From fa07c7e719da11125ae375225d801f75f636ab6c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 15:12:34 +0200 Subject: [PATCH 01/28] feat(dpp): IdentityCreateFromShieldedPool transition foundation (type 20) Stage 1 of #3813. Adds the DPP layer for a new shielded-pool-funded identity-creation state transition (StateTransitionType = 20), gated to protocol v12 like the rest of the shielded family. - New transition tree under state_transitions/shielded/ identity_create_from_shielded_pool_transition/ (V0 struct: variable public_keys + denomination + Orchard spend + derived identity_id). - identity_id = double_sha256(sorted nullifiers), shared derivation fn. - extra_sighash_data helper binding id + denomination + full key set into the Orchard sighash (anti-redirection), in shielded/mod.rs. - compute_shielded_identity_create_fee predictor + storage-byte constants. - StateTransitionType::IdentityCreateFromShieldedPool = 20 (TAIL) and all StateTransition enum/macro arms. - Versioned denomination set {0.1,0.3,0.5,1.0} DASH in event_constants (v8), empty pre-v12; serialization version field across v1/v2. - New basic consensus error ShieldedInvalidDenominationError (code 10827). cargo check -p dpp (default + all-features + tests) green; 15 new unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/errors/consensus/basic/basic_error.rs | 12 +- .../consensus/basic/state_transition/mod.rs | 2 + .../shielded_invalid_denomination_error.rs | 41 +++ packages/rs-dpp/src/errors/consensus/codes.rs | 3 +- .../compute_minimum_shielded_fee/mod.rs | 33 +++ .../compute_minimum_shielded_fee/v0/mod.rs | 61 +++++ packages/rs-dpp/src/shielded/mod.rs | 65 ++++- packages/rs-dpp/src/state_transition/mod.rs | 39 ++- .../state_transition_types.rs | 9 +- .../accessors/mod.rs | 36 +++ .../accessors/v0/mod.rs | 26 ++ .../methods/mod.rs | 53 ++++ .../methods/v0/mod.rs | 31 +++ .../mod.rs | 175 +++++++++++++ ...ate_transition_estimated_fee_validation.rs | 18 ++ .../state_transition_like.rs | 38 +++ .../state_transition_validation.rs | 17 ++ .../v0/mod.rs | 174 +++++++++++++ .../v0/state_transition_like.rs | 42 +++ .../v0/state_transition_validation.rs | 243 ++++++++++++++++++ .../v0/types.rs | 16 ++ .../v0/v0_methods.rs | 41 +++ .../v0/version.rs | 9 + .../version.rs | 11 + .../state_transitions/shielded/mod.rs | 1 + .../mod.rs | 1 + .../v1.rs | 5 + .../v2.rs | 5 + .../drive_abci_validation_versions/mod.rs | 7 + .../drive_abci_validation_versions/v1.rs | 1 + .../drive_abci_validation_versions/v2.rs | 1 + .../drive_abci_validation_versions/v3.rs | 1 + .../drive_abci_validation_versions/v4.rs | 1 + .../drive_abci_validation_versions/v5.rs | 1 + .../drive_abci_validation_versions/v6.rs | 1 + .../drive_abci_validation_versions/v7.rs | 1 + .../drive_abci_validation_versions/v8.rs | 7 + 37 files changed, 1219 insertions(+), 9 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_invalid_denomination_error.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/types.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/version.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/version.rs diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 29713a500f8..2708ee1ff79 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -80,10 +80,11 @@ use crate::consensus::basic::state_transition::{ MissingStateTransitionTypeError, OutputAddressAlsoInputError, OutputBelowMinimumError, OutputsNotGreaterThanInputsError, ShieldedEmptyProofError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, - ShieldedInvalidValueBalanceError, ShieldedNoActionsError, ShieldedTooManyActionsError, - ShieldedZeroAnchorError, StateTransitionMaxSizeExceededError, StateTransitionNotActiveError, - TransitionNoInputsError, TransitionNoOutputsError, TransitionOverMaxInputsError, - TransitionOverMaxOutputsError, WithdrawalBalanceMismatchError, WithdrawalBelowMinAmountError, + ShieldedInvalidDenominationError, ShieldedInvalidValueBalanceError, ShieldedNoActionsError, + ShieldedTooManyActionsError, ShieldedZeroAnchorError, StateTransitionMaxSizeExceededError, + StateTransitionNotActiveError, TransitionNoInputsError, TransitionNoOutputsError, + TransitionOverMaxInputsError, TransitionOverMaxOutputsError, WithdrawalBalanceMismatchError, + WithdrawalBelowMinAmountError, }; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, @@ -688,6 +689,9 @@ pub enum BasicError { // (codes.rs) is independent of variant order. #[error(transparent)] ShieldedImplicitFeeCapExceededError(ShieldedImplicitFeeCapExceededError), + + #[error(transparent)] + ShieldedInvalidDenominationError(ShieldedInvalidDenominationError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs index 20f152f0e26..b20d1d53f27 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs @@ -16,6 +16,7 @@ mod outputs_not_greater_than_inputs_error; mod shielded_empty_proof_error; mod shielded_encrypted_note_size_mismatch_error; mod shielded_implicit_fee_cap_exceeded_error; +mod shielded_invalid_denomination_error; mod shielded_invalid_value_balance_error; mod shielded_no_actions_error; mod shielded_too_many_actions_error; @@ -47,6 +48,7 @@ pub use outputs_not_greater_than_inputs_error::*; pub use shielded_empty_proof_error::*; pub use shielded_encrypted_note_size_mismatch_error::*; pub use shielded_implicit_fee_cap_exceeded_error::*; +pub use shielded_invalid_denomination_error::*; pub use shielded_invalid_value_balance_error::*; pub use shielded_no_actions_error::*; pub use shielded_too_many_actions_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_invalid_denomination_error.rs b/packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_invalid_denomination_error.rs new file mode 100644 index 00000000000..99122c6862b --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_invalid_denomination_error.rs @@ -0,0 +1,41 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Invalid shielded identity-create denomination {denomination}: must be one of the allowed exit denominations")] +#[platform_serialize(unversioned)] +pub struct ShieldedInvalidDenominationError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + /// The rejected denomination (in credits). `IdentityCreateFromShieldedPool` may only exit one of + /// a small versioned set of fixed denominations so every exit of a given size is indistinguishable + /// on-chain (maximizing the anonymity set). Any other value — including a non-member amount or a + /// `value_balance` that does not equal the declared denomination — is rejected with this error + /// (consensus error code 10827). + denomination: u64, +} + +impl ShieldedInvalidDenominationError { + pub fn new(denomination: u64) -> Self { + Self { denomination } + } + + pub fn denomination(&self) -> u64 { + self.denomination + } +} + +impl From for ConsensusError { + fn from(err: ShieldedInvalidDenominationError) -> Self { + Self::BasicError(BasicError::ShieldedInvalidDenominationError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index a7424f3c121..a11e58a8611 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -233,7 +233,7 @@ impl ErrorWithCode for BasicError { Self::OutputAddressAlsoInputError(_) => 10816, Self::InvalidRemainderOutputCountError(_) => 10817, Self::WithdrawalBelowMinAmountError(_) => 10818, - // Shielded transition errors (10819-10826) + // Shielded transition errors (10819-10827) Self::ShieldedNoActionsError(_) => 10819, Self::ShieldedEmptyProofError(_) => 10820, Self::ShieldedZeroAnchorError(_) => 10821, @@ -241,6 +241,7 @@ impl ErrorWithCode for BasicError { Self::ShieldedEncryptedNoteSizeMismatchError(_) => 10823, Self::ShieldedTooManyActionsError(_) => 10825, Self::ShieldedImplicitFeeCapExceededError(_) => 10826, + Self::ShieldedInvalidDenominationError(_) => 10827, } } } diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs index 77f93cba69d..a3a7bbab57c 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs @@ -4,6 +4,7 @@ use crate::fee::Credits; use crate::ProtocolError; use platform_version::version::PlatformVersion; use v0::compute_minimum_shielded_fee_v0; +use v0::compute_shielded_identity_create_fee_v0; use v0::compute_shielded_unshield_fee_v0; use v0::compute_shielded_verification_fee_v0; use v0::compute_shielded_withdrawal_fee_v0; @@ -144,3 +145,35 @@ pub fn compute_shielded_verification_fee( }), } } + +/// Computes the **IdentityCreateFromShieldedPool** fee (in credits): [`compute_minimum_shielded_fee`] +/// PLUS the variable storage cost of the `AddNewIdentity` write (identity record + balance + +/// revision + N key subtrees), which scales with the number of public keys. +/// +/// Unlike the flat per-transition components of [`compute_shielded_unshield_fee`] / +/// [`compute_shielded_withdrawal_fee`], the identity write grows monotonically with the key count. +/// This is the **client-side predictor** + the **cheap floor** the `denomination >= min_fee` gate +/// uses; the authoritative consensus fee is METERED by GroveDB at execution (the transition's +/// `ExecutionEvent` meters its ops and adds only the compute fee via `additional_fixed_fee_cost`). +/// +/// Dispatches on the SAME version key (`dpp.methods.compute_minimum_shielded_fee`) as +/// [`compute_minimum_shielded_fee`] so the formulas evolve together across protocol versions. +/// +/// # Parameters +/// - `num_actions` — number of Orchard actions in the bundle +/// - `num_keys` — number of public keys the new identity is created with +/// - `platform_version` — protocol version (determines the formula version and fee constants) +pub fn compute_shielded_identity_create_fee( + num_actions: usize, + num_keys: usize, + platform_version: &PlatformVersion, +) -> Result { + match platform_version.dpp.methods.compute_minimum_shielded_fee { + 0 => compute_shielded_identity_create_fee_v0(num_actions, num_keys, platform_version), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "compute_shielded_identity_create_fee".to_string(), + known_versions: vec![0], + received: version, + }), + } +} diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index 57f4b4e56cf..b7f92ad0d17 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -1,5 +1,6 @@ use crate::fee::Credits; use crate::shielded::{ + SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES, SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES, SHIELDED_STORAGE_BYTES_PER_ACTION, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, }; @@ -188,6 +189,66 @@ pub fn compute_shielded_unshield_fee_v0( .ok_or(ProtocolError::Overflow("shielded unshield fee overflow")) } +/// v0 of the shielded **identity-create** fee formula: +/// +/// `identity_create_fee = compute_minimum_shielded_fee_v0(num_actions) +/// + (SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES +/// + num_keys × SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES) +/// × (disk + processing) credits/byte` +/// +/// This is [`compute_minimum_shielded_fee_v0`] (the per-action note/nullifier storage estimate + +/// the per-bundle ZK compute) PLUS one VARIABLE storage component for the `AddNewIdentity` write an +/// `IdentityCreateFromShieldedPool` performs (the identity record + balance + revision + N key +/// subtrees). Unlike the flat per-transition components of `Unshield`/`ShieldedWithdrawal`, this +/// component grows monotonically with the key count, which is why the flat pool-paid model does not +/// fit and the transition's execution meters its writes against the new identity's balance instead. +/// +/// This function is NOT the authoritative consensus fee (execution meters the real GroveDB cost and +/// adds only the compute fee on top). It is the **client-side predictor** — so a client can size its +/// bundle and pick a denomination that comfortably covers the fee — and the **cheap floor** the +/// `denomination >= min_fee` gate uses to reject obviously-underfunded denominations before metering. +/// It is sized as a conservative upper bound on the real metered cost so it never under-predicts. +/// +/// All arithmetic is checked: an overflow (only reachable via pathological fee constants or key +/// counts) surfaces as `ProtocolError::Overflow` instead of silently wrapping. +pub fn compute_shielded_identity_create_fee_v0( + num_actions: usize, + num_keys: usize, + platform_version: &PlatformVersion, +) -> Result { + let storage = &platform_version.fee_version.storage; + + let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; + + let per_byte_rate = storage + .storage_disk_usage_credit_per_byte + .checked_add(storage.storage_processing_credit_per_byte) + .ok_or(ProtocolError::Overflow( + "shielded storage per-byte rate overflow", + ))?; + let per_key_bytes = (num_keys as u64) + .checked_mul(SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES) + .ok_or(ProtocolError::Overflow( + "shielded identity create per-key bytes overflow", + ))?; + let identity_bytes = SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES + .checked_add(per_key_bytes) + .ok_or(ProtocolError::Overflow( + "shielded identity create bytes overflow", + ))?; + let identity_storage_fee = + identity_bytes + .checked_mul(per_byte_rate) + .ok_or(ProtocolError::Overflow( + "shielded identity create storage fee overflow", + ))?; + base_fee + .checked_add(identity_storage_fee) + .ok_or(ProtocolError::Overflow( + "shielded identity create fee overflow", + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index cb9d32ac9e7..2c459c39b04 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -13,10 +13,14 @@ use crate::withdrawal::Pooling; // Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the // module and the function share a name but live in different namespaces). pub use compute_minimum_shielded_fee::{ - compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_verification_fee, + compute_minimum_shielded_fee, compute_shielded_identity_create_fee, + compute_shielded_unshield_fee, compute_shielded_verification_fee, compute_shielded_withdrawal_fee, }; +use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + /// Permanent storage bytes per shielded action: 312 bytes total. /// /// - 280 bytes in the BulkAppendTree: 32 (`cmx`, the note commitment) + 32 @@ -91,6 +95,23 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; +/// Calibrated effective storage-byte cost of the flat (key-independent) portion of the +/// `AddNewIdentity` write an `IdentityCreateFromShieldedPool` performs: the identity record, +/// balance, revision, and the empty key-tree scaffolding. Sized as a conservative upper bound on +/// the metered storage so the client-side fee predictor never under-estimates the real cost (the +/// authoritative consensus fee is metered by GroveDB at execution; this constant only feeds the +/// offline predictor and the cheap `denomination >= min_fee` gate). The per-key component is added +/// separately via [`SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES`]. +pub const SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES: u64 = 1200; + +/// Calibrated effective storage-byte cost of EACH `IdentityPublicKey` the `AddNewIdentity` write +/// inserts (the key entry in the identity key subtree plus its key-hash → key-id index entry and +/// tree overhead). Because the metered identity write grows monotonically with the key count, the +/// predictor scales this per-key term by the number of keys. Priced at the SAME per-byte storage +/// rate as the per-action note storage, so it tracks the storage rate as it evolves. See +/// [`compute_minimum_shielded_fee::compute_shielded_identity_create_fee`]. +pub const SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES: u64 = 350; + /// Domain separator for Platform sighash computation. const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash"; @@ -165,6 +186,48 @@ pub fn unshield_extra_sighash_data(output_address: &[u8], unshielding_amount: u6 data } +/// Builds the transparent `extra_data` bound into an `IdentityCreateFromShieldedPool`'s platform +/// sighash, with the byte layout +/// `identity_id (32) || denomination (u64 LE) || num_keys (u16 LE) +/// || for each key in supplied order: key_id (u32 LE) || purpose (u8) || security_level (u8) +/// || key_type (u8) || key_data_len (u16 LE) || key_data`. +/// +/// `IdentityCreateFromShieldedPool` carries NO platform identity signature: authorization is 100% +/// the Orchard proof + per-action spend-auth signatures + binding signature over this sighash. The +/// transparent, state-determining fields — the new identity id, the exit denomination, and the +/// FULL public-key set — must therefore be committed into the Orchard sighash, exactly as the +/// `surplus_output` field is committed into `ShieldFromAssetLock`'s ECDSA signature. Without this +/// binding a relay or block proposer could take a valid bundle exiting a denomination and re-point +/// it at a DIFFERENT identity id, or swap in DIFFERENT keys they control, stealing the credited +/// balance (the per-key proofs-of-possession alone do NOT prevent this — a relayer keeps valid PoP +/// sigs for their own keys while swapping the bundle). Binding `(this spend → these exact keys → +/// this id → this denomination)` here makes the redirection atomic-or-invalid. +/// +/// The signing (client/builder) and verifying (consensus) sides MUST produce identical bytes, so +/// both call this single function. Unlike the fixed-length withdrawal/unshield helpers, the +/// variable-length key list is fully length-prefixed (both the key count and each key's data) so +/// the preimage is unambiguous for any key set. +pub fn identity_create_from_shielded_extra_sighash_data( + identity_id: &[u8; 32], + denomination: u64, + public_keys: &[IdentityPublicKeyInCreation], +) -> Vec { + let mut data = Vec::with_capacity(32 + 8 + 2 + public_keys.len() * 41); + data.extend_from_slice(identity_id); + data.extend_from_slice(&denomination.to_le_bytes()); + data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); + for key in public_keys { + data.extend_from_slice(&key.id().to_le_bytes()); + data.push(key.purpose() as u8); + data.push(key.security_level() as u8); + data.push(key.key_type() as u8); + let key_data = key.data().as_slice(); + data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); + data.extend_from_slice(key_data); + } + data +} + /// Common Orchard bundle parameters shared across all shielded transition types. /// /// Groups the fields that every shielded transition carries identically: diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 43d7d0fb6c1..92bf4a4e583 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -112,6 +112,9 @@ use crate::state_transition::errors::{ use crate::state_transition::identity_create_from_addresses_transition::{ IdentityCreateFromAddressesTransition, IdentityCreateFromAddressesTransitionSignable, }; +use crate::state_transition::identity_create_from_shielded_pool_transition::{ + IdentityCreateFromShieldedPoolTransition, IdentityCreateFromShieldedPoolTransitionSignable, +}; use crate::state_transition::identity_create_transition::{ IdentityCreateTransition, IdentityCreateTransitionSignable, }; @@ -180,6 +183,7 @@ macro_rules! call_method { StateTransition::Unshield(st) => st.$method($args), StateTransition::ShieldFromAssetLock(st) => st.$method($args), StateTransition::ShieldedWithdrawal(st) => st.$method($args), + StateTransition::IdentityCreateFromShieldedPool(st) => st.$method($args), } }; ($state_transition:expr, $method:ident ) => { @@ -204,6 +208,7 @@ macro_rules! call_method { StateTransition::Unshield(st) => st.$method(), StateTransition::ShieldFromAssetLock(st) => st.$method(), StateTransition::ShieldedWithdrawal(st) => st.$method(), + StateTransition::IdentityCreateFromShieldedPool(st) => st.$method(), } }; } @@ -231,6 +236,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::Unshield(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, + StateTransition::IdentityCreateFromShieldedPool(_) => None, } }; ($state_transition:expr, $method:ident ) => { @@ -255,6 +261,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::Unshield(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, + StateTransition::IdentityCreateFromShieldedPool(_) => None, } }; } @@ -282,6 +289,7 @@ macro_rules! call_method_identity_signed { StateTransition::Unshield(_) => {} StateTransition::ShieldFromAssetLock(_) => {} StateTransition::ShieldedWithdrawal(_) => {} + StateTransition::IdentityCreateFromShieldedPool(_) => {} } }; ($state_transition:expr, $method:ident ) => { @@ -306,6 +314,7 @@ macro_rules! call_method_identity_signed { StateTransition::Unshield(_) => {} StateTransition::ShieldFromAssetLock(_) => {} StateTransition::ShieldedWithdrawal(_) => {} + StateTransition::IdentityCreateFromShieldedPool(_) => {} } }; } @@ -358,6 +367,9 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::ShieldedWithdrawal(_) => Err(ProtocolError::CorruptedCodeExecution( "shielded withdrawal transition can not be called for identity signing".to_string(), )), + StateTransition::IdentityCreateFromShieldedPool(_) => Err(ProtocolError::CorruptedCodeExecution( + "identity create from shielded pool transition can not be called for identity signing".to_string(), + )), } }; ($state_transition:expr, $method:ident) => { @@ -406,6 +418,9 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::ShieldedWithdrawal(_) => Err(ProtocolError::CorruptedCodeExecution( "shielded withdrawal transition can not be called for identity signing".to_string(), )), + StateTransition::IdentityCreateFromShieldedPool(_) => Err(ProtocolError::CorruptedCodeExecution( + "identity create from shielded pool transition can not be called for identity signing".to_string(), + )), } }; } @@ -449,6 +464,7 @@ pub enum StateTransition { Unshield(UnshieldTransition), ShieldFromAssetLock(ShieldFromAssetLockTransition), ShieldedWithdrawal(ShieldedWithdrawalTransition), + IdentityCreateFromShieldedPool(IdentityCreateFromShieldedPoolTransition), } impl OptionallyAssetLockProved for StateTransition { @@ -536,7 +552,8 @@ impl StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => 12..=LATEST_VERSION, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => 12..=LATEST_VERSION, } } @@ -550,6 +567,7 @@ impl StateTransition { | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) ) } @@ -654,6 +672,7 @@ impl StateTransition { Self::Unshield(_) => "Unshield".to_string(), Self::ShieldFromAssetLock(_) => "ShieldFromAssetLock".to_string(), Self::ShieldedWithdrawal(_) => "ShieldedWithdrawal".to_string(), + Self::IdentityCreateFromShieldedPool(_) => "IdentityCreateFromShieldedPool".to_string(), } } @@ -680,6 +699,7 @@ impl StateTransition { StateTransition::Unshield(_) => None, StateTransition::ShieldFromAssetLock(st) => Some(st.signature()), StateTransition::ShieldedWithdrawal(_) => None, + StateTransition::IdentityCreateFromShieldedPool(_) => None, } } @@ -695,6 +715,7 @@ impl StateTransition { StateTransition::Unshield(_) => 0, StateTransition::ShieldFromAssetLock(_) => 0, StateTransition::ShieldedWithdrawal(_) => 0, + StateTransition::IdentityCreateFromShieldedPool(_) => 0, _ => 1, } } @@ -723,6 +744,7 @@ impl StateTransition { StateTransition::ShieldedTransfer(_) => 0, StateTransition::Unshield(_) => 0, StateTransition::ShieldedWithdrawal(_) => 0, + StateTransition::IdentityCreateFromShieldedPool(_) => 0, } } @@ -790,6 +812,7 @@ impl StateTransition { StateTransition::Unshield(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, + StateTransition::IdentityCreateFromShieldedPool(_) => None, } } @@ -816,6 +839,7 @@ impl StateTransition { StateTransition::Unshield(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, + StateTransition::IdentityCreateFromShieldedPool(_) => None, } } @@ -878,7 +902,8 @@ impl StateTransition { | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, StateTransition::AddressFundingFromAssetLock(st) => { st.set_signature(signature); true @@ -931,6 +956,7 @@ impl StateTransition { StateTransition::ShieldedTransfer(_) => {} StateTransition::Unshield(_) => {} StateTransition::ShieldedWithdrawal(_) => {} + StateTransition::IdentityCreateFromShieldedPool(_) => {} } } @@ -1106,6 +1132,12 @@ impl StateTransition { .to_string(), )) } + StateTransition::IdentityCreateFromShieldedPool(_) => { + return Err(ProtocolError::CorruptedCodeExecution( + "identity create from shielded pool transition can not be called for identity signing" + .to_string(), + )) + } } let data = self.signable_bytes()?; self.set_signature(signer.sign(identity_public_key, data.as_slice()).await?); @@ -1612,6 +1644,9 @@ impl StateTransitionStructureValidation for StateTransition { StateTransition::ShieldedWithdrawal(transition) => { transition.validate_structure(platform_version) } + StateTransition::IdentityCreateFromShieldedPool(transition) => { + transition.validate_structure(platform_version) + } } } } diff --git a/packages/rs-dpp/src/state_transition/state_transition_types.rs b/packages/rs-dpp/src/state_transition/state_transition_types.rs index 7b6008943bb..8dbe3883029 100644 --- a/packages/rs-dpp/src/state_transition/state_transition_types.rs +++ b/packages/rs-dpp/src/state_transition/state_transition_types.rs @@ -40,6 +40,7 @@ pub enum StateTransitionType { Unshield = 17, ShieldFromAssetLock = 18, ShieldedWithdrawal = 19, + IdentityCreateFromShieldedPool = 20, } impl std::fmt::Display for StateTransitionType { @@ -118,6 +119,10 @@ mod tests { StateTransitionType::ShieldedWithdrawal, "ShieldedWithdrawal", ), + ( + StateTransitionType::IdentityCreateFromShieldedPool, + "IdentityCreateFromShieldedPool", + ), ]; for (variant, expected) in cases { assert_eq!( @@ -152,6 +157,7 @@ mod tests { (17, StateTransitionType::Unshield), (18, StateTransitionType::ShieldFromAssetLock), (19, StateTransitionType::ShieldedWithdrawal), + (20, StateTransitionType::IdentityCreateFromShieldedPool), ]; for (val, expected) in pairs { let result = StateTransitionType::try_from(val).unwrap(); @@ -161,7 +167,7 @@ mod tests { #[test] fn test_try_from_u8_invalid() { - assert!(StateTransitionType::try_from(20u8).is_err()); + assert!(StateTransitionType::try_from(21u8).is_err()); assert!(StateTransitionType::try_from(255u8).is_err()); } @@ -188,6 +194,7 @@ mod tests { StateTransitionType::Unshield, StateTransitionType::ShieldFromAssetLock, StateTransitionType::ShieldedWithdrawal, + StateTransitionType::IdentityCreateFromShieldedPool, ]; for variant in all_variants { let val: u8 = variant.into(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs new file mode 100644 index 00000000000..fc8183a07cb --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs @@ -0,0 +1,36 @@ +mod v0; + +pub use v0::*; + +use crate::shielded::SerializedAction; +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use platform_value::Identifier; + +impl IdentityCreateFromShieldedPoolTransitionAccessorsV0 + for IdentityCreateFromShieldedPoolTransition +{ + fn actions(&self) -> &[SerializedAction] { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => &v0.actions, + } + } + + fn public_keys(&self) -> &[IdentityPublicKeyInCreation] { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => &v0.public_keys, + } + } + + fn denomination(&self) -> u64 { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => v0.denomination, + } + } + + fn identity_id(&self) -> Identifier { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => v0.identity_id, + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs new file mode 100644 index 00000000000..f27c36c537f --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs @@ -0,0 +1,26 @@ +use crate::shielded::SerializedAction; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use platform_value::Identifier; + +pub trait IdentityCreateFromShieldedPoolTransitionAccessorsV0 { + /// Get the serialized Orchard actions (spend/output pairs). + fn actions(&self) -> &[SerializedAction]; + + /// Get the public keys of the new identity. + fn public_keys(&self) -> &[IdentityPublicKeyInCreation]; + + /// Get the fixed exit denomination (in credits). + fn denomination(&self) -> u64; + + /// Get the id of the new identity (derived from the spend nullifiers). + fn identity_id(&self) -> Identifier; + + /// Extract nullifier bytes from each action. + /// Generic over the element type: use `Vec` or `[u8; 32]` as needed. + fn nullifiers>(&self) -> Vec { + self.actions() + .iter() + .map(|a| T::from(a.nullifier)) + .collect() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs new file mode 100644 index 00000000000..27d872cce14 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs @@ -0,0 +1,53 @@ +mod v0; + +pub use v0::*; + +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +#[cfg(feature = "state-transition-signing")] +use crate::{ + state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0, + state_transition::StateTransition, ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl IdentityCreateFromShieldedPoolTransitionMethodsV0 + for IdentityCreateFromShieldedPoolTransition +{ + #[cfg(feature = "state-transition-signing")] + fn try_from_bundle( + public_keys: Vec, + denomination: u64, + actions: Vec, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transition_serialization_versions + .identity_create_from_shielded_pool_state_transition + .default_current_version + { + 0 => IdentityCreateFromShieldedPoolTransitionV0::try_from_bundle( + public_keys, + denomination, + actions, + anchor, + proof, + binding_signature, + platform_version, + ), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "IdentityCreateFromShieldedPoolTransition::try_from_bundle".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs new file mode 100644 index 00000000000..6b1063051e4 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs @@ -0,0 +1,31 @@ +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use crate::state_transition::StateTransitionType; +#[cfg(feature = "state-transition-signing")] +use crate::{state_transition::StateTransition, ProtocolError}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +pub trait IdentityCreateFromShieldedPoolTransitionMethodsV0 { + /// Builds the (unsigned-by-identity) transition from a pre-built Orchard bundle and the new + /// identity's public keys. The identity id is derived from the spend nullifiers. The per-key + /// proof-of-possession signatures are filled separately (mirroring `IdentityCreate`). + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + fn try_from_bundle( + public_keys: Vec, + denomination: u64, + actions: Vec, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + platform_version: &PlatformVersion, + ) -> Result; + + /// Get State Transition Type + fn get_type() -> StateTransitionType { + StateTransitionType::IdentityCreateFromShieldedPool + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs new file mode 100644 index 00000000000..c8fbb1f7bd4 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs @@ -0,0 +1,175 @@ +pub mod accessors; +pub mod methods; +mod state_transition_estimated_fee_validation; +mod state_transition_like; +mod state_transition_validation; +pub mod v0; +mod version; + +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0Signable; +use crate::state_transition::StateTransitionFieldTypes; + +pub type IdentityCreateFromShieldedPoolTransitionLatest = + IdentityCreateFromShieldedPoolTransitionV0; + +use crate::identity::state_transition::OptionallyAssetLockProved; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::shielded::SerializedAction; +use crate::util::hash::hash_double; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use derive_more::From; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize, PlatformSignable}; +use platform_value::Identifier; +use platform_versioning::PlatformVersioned; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformDeserialize, + PlatformSerialize, + PlatformSignable, + PlatformVersioned, + From, + PartialEq, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(unversioned)] //versioned directly, no need to use platform_version +#[platform_version_path_bounds( + "dpp.state_transition_serialization_versions.identity_create_from_shielded_pool_state_transition" +)] +pub enum IdentityCreateFromShieldedPoolTransition { + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(IdentityCreateFromShieldedPoolTransitionV0), +} + +/// Derives the new identity's id from a set of spend nullifiers as +/// `double_sha256(nullifier_0 || nullifier_1 || …)` over the SORTED nullifier set. +/// +/// Nullifiers are globally-unique one-time spend tags (enforced by `validate_nullifiers`), so the +/// derived id is unique by construction and single-use. Sorting makes the id independent of +/// action ordering (non-malleable). The same derivation runs at consensus to re-derive and check +/// the supplied id, and the id is committed into the Orchard `extra_sighash_data`, so the bundle +/// cannot be redirected to a different identity. +pub fn identity_id_from_nullifiers(nullifiers: &[[u8; 32]]) -> Identifier { + let mut sorted: Vec<[u8; 32]> = nullifiers.to_vec(); + sorted.sort_unstable(); + let mut buf = Vec::with_capacity(sorted.len() * 32); + for nullifier in &sorted { + buf.extend_from_slice(nullifier); + } + Identifier::new(hash_double(buf)) +} + +/// Convenience wrapper around [`identity_id_from_nullifiers`] that extracts the nullifiers from a +/// slice of serialized Orchard actions. Shared by the SDK builder and the consensus re-derivation +/// check so both compute the id identically. +pub fn derive_identity_id_from_actions(actions: &[SerializedAction]) -> Identifier { + let nullifiers: Vec<[u8; 32]> = actions.iter().map(|a| a.nullifier).collect(); + identity_id_from_nullifiers(&nullifiers) +} + +// `IdentityCreateFromShieldedPool` funds the new identity from the shielded pool, not an asset +// lock, so it proves no asset lock (the default `None`). +impl OptionallyAssetLockProved for IdentityCreateFromShieldedPoolTransition {} + +impl StateTransitionFieldTypes for IdentityCreateFromShieldedPoolTransition { + fn signature_property_paths() -> Vec<&'static str> { + vec![] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + use crate::shielded::SerializedAction; + + fn mk_action(nullifier_byte: u8) -> SerializedAction { + SerializedAction { + nullifier: [nullifier_byte; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } + } + + #[test] + fn id_derivation_is_order_independent() { + let a = derive_identity_id_from_actions(&[mk_action(0x11), mk_action(0x22)]); + let b = derive_identity_id_from_actions(&[mk_action(0x22), mk_action(0x11)]); + assert_eq!(a, b, "id must not depend on action ordering"); + } + + #[test] + fn id_derivation_differs_for_different_nullifiers() { + let a = derive_identity_id_from_actions(&[mk_action(0x11)]); + let b = derive_identity_id_from_actions(&[mk_action(0x12)]); + assert_ne!(a, b); + } + + #[test] + fn serialization_round_trip() { + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use platform_value::BinaryData; + + let actions = vec![mk_action(0x11)]; + let identity_id = derive_identity_id_from_actions(&actions); + let key = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![0xAB; 33]), + signature: BinaryData::new(vec![0xCD; 65]), + }); + let transition: IdentityCreateFromShieldedPoolTransition = + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![key], + denomination: 10_000_000_000, + actions, + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + identity_id, + } + .into(); + + let bytes = transition.serialize_to_bytes().expect("serialize"); + let restored = IdentityCreateFromShieldedPoolTransition::deserialize_from_bytes(&bytes) + .expect("deserialize"); + assert_eq!(transition, restored); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs new file mode 100644 index 00000000000..7ab0ad9be7e --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs @@ -0,0 +1,18 @@ +use crate::fee::Credits; +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use crate::state_transition::StateTransitionEstimatedFeeValidation; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl StateTransitionEstimatedFeeValidation for IdentityCreateFromShieldedPoolTransition { + fn calculate_min_required_fee( + &self, + _platform_version: &PlatformVersion, + ) -> Result { + // Like the other pool-spend shielded transitions, the fee is carved from the exit + // denomination and validated on-chain (the denomination must cover the metered + compute + // fee). The client-side predictor lives in `compute_shielded_identity_create_fee`; this + // mempool pre-check estimate returns 0 (mirroring `Unshield`). + Ok(0) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_like.rs new file mode 100644 index 00000000000..f8506d61aaf --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_like.rs @@ -0,0 +1,38 @@ +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use crate::state_transition::{StateTransitionLike, StateTransitionType}; +use crate::version::FeatureVersion; +use platform_value::Identifier; + +impl StateTransitionLike for IdentityCreateFromShieldedPoolTransition { + /// Returns the id of the newly created identity (the only modified data). + fn modified_data_ids(&self) -> Vec { + match self { + IdentityCreateFromShieldedPoolTransition::V0(transition) => { + transition.modified_data_ids() + } + } + } + + fn state_transition_protocol_version(&self) -> FeatureVersion { + match self { + IdentityCreateFromShieldedPoolTransition::V0(_) => 0, + } + } + + /// returns the type of State Transition + fn state_transition_type(&self) -> StateTransitionType { + match self { + IdentityCreateFromShieldedPoolTransition::V0(transition) => { + transition.state_transition_type() + } + } + } + + fn unique_identifiers(&self) -> Vec { + match self { + IdentityCreateFromShieldedPoolTransition::V0(transition) => { + transition.unique_identifiers() + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_validation.rs new file mode 100644 index 00000000000..6ecf9a16f58 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/state_transition_validation.rs @@ -0,0 +1,17 @@ +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl StateTransitionStructureValidation for IdentityCreateFromShieldedPoolTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => { + v0.validate_structure(platform_version) + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs new file mode 100644 index 00000000000..da15ed06769 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs @@ -0,0 +1,174 @@ +mod state_transition_like; +mod state_transition_validation; +mod types; +pub(super) mod v0_methods; +mod version; + +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +use crate::shielded::SerializedAction; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreationSignable; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::PlatformSignable; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive(Debug, Clone, PartialEq, Encode, Decode, PlatformSignable)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +// As with `IdentityCreateTransitionV0`, deriving bincode for the `#[platform_signable(into = ...)]` +// borrowed key vector is done manually inside the PlatformSignable proc macro instead of via the +// bincode derive. +#[platform_signable(derive_bincode_with_borrowed_vec)] +pub struct IdentityCreateFromShieldedPoolTransitionV0 { + /// The public keys of the new identity (VARIABLE: 1..=max_public_keys_in_creation), exactly as + /// `IdentityCreate` carries them. When signing, the per-key proof-of-possession signatures are + /// NOT part of the sighash (the `Signable` form excludes them); the keys themselves ARE, and + /// are additionally bound into the Orchard sighash via `extra_sighash_data`. + #[platform_signable(into = "Vec")] + pub public_keys: Vec, + /// The fixed exit denomination (in credits) leaving the shielded pool. MUST equal the Orchard + /// bundle's `value_balance` EXACTLY and MUST be a member of the versioned denomination set. + pub denomination: u64, + /// Orchard actions (spend-output pairs). The spend nullifiers fund the exit; any change + /// re-enters the pool as an ordinary output note. + pub actions: Vec, + /// Sinsemilla root of the note commitment tree (Orchard Anchor) + pub anchor: [u8; 32], + /// Halo2 proof bytes + pub proof: Vec, + /// RedPallas binding signature + pub binding_signature: [u8; 64], + /// The id of the new identity, derived as `double_sha256(sorted nullifiers)`. It is committed + /// into the Orchard `extra_sighash_data` (so the bundle cannot be redirected to a different id) + /// and re-derived + checked at consensus. Excluded from the platform sighash because it is fully + /// determined by the nullifiers in `actions`, which are already covered. + #[platform_signable(exclude_from_sig_hash)] + pub identity_id: Identifier, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::serialization::Signable; + use crate::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use platform_value::BinaryData; + + fn mk_action(nullifier_byte: u8) -> SerializedAction { + SerializedAction { + nullifier: [nullifier_byte; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } + } + + fn mk_key(id: u32, data_byte: u8) -> IdentityPublicKeyInCreation { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![data_byte; 33]), + signature: BinaryData::new(vec![]), + }) + } + + fn make_v0() -> IdentityCreateFromShieldedPoolTransitionV0 { + let actions = vec![mk_action(0x11)]; + let identity_id = derive_identity_id_from_actions(&actions); + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![mk_key(0, 0xAA)], + denomination: 10_000_000_000, + actions, + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + identity_id, + } + } + + #[test] + fn test_state_transition_type() { + use crate::state_transition::{StateTransitionLike, StateTransitionType}; + let t = make_v0(); + assert_eq!( + t.state_transition_type(), + StateTransitionType::IdentityCreateFromShieldedPool + ); + } + + #[test] + fn test_modified_data_ids_is_identity_id() { + use crate::state_transition::StateTransitionLike; + let t = make_v0(); + assert_eq!(t.modified_data_ids(), vec![t.identity_id]); + } + + #[test] + fn test_unique_identifiers_from_nullifiers() { + use crate::state_transition::StateTransitionLike; + let mut t = make_v0(); + t.actions = vec![mk_action(0x11), mk_action(0x22)]; + let ids = t.unique_identifiers(); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], hex::encode([0x11u8; 32])); + assert_eq!(ids[1], hex::encode([0x22u8; 32])); + } + + #[test] + fn test_feature_versioned() { + use crate::state_transition::FeatureVersioned; + assert_eq!(make_v0().feature_version(), 0); + } + + /// The per-key proof-of-possession signs the transition's signable bytes, so the signable bytes + /// MUST change when the public-key set or the denomination changes — otherwise a relayer could + /// replay valid PoP signatures against a swapped key set / amount. (The Orchard + /// `extra_sighash_data` binding is the primary defense; this asserts the platform-level sighash + /// also commits to these fields.) + #[test] + fn test_signable_bytes_commit_to_keys_and_denomination() { + let base = make_v0(); + let base_bytes = base.signable_bytes().expect("signable bytes"); + + let mut other_keys = base.clone(); + other_keys.public_keys = vec![mk_key(0, 0xBB)]; + assert_ne!( + base_bytes, + other_keys.signable_bytes().expect("signable bytes"), + "changing the public-key set must change the signable bytes" + ); + + let mut other_denom = base.clone(); + other_denom.denomination = 30_000_000_000; + assert_ne!( + base_bytes, + other_denom.signable_bytes().expect("signable bytes"), + "changing the denomination must change the signable bytes" + ); + + // identity_id is excluded from the sighash (it is derived from the nullifiers), so changing + // it alone must NOT change the signable bytes. + let mut other_id = base.clone(); + other_id.identity_id = Identifier::new([0xCC; 32]); + assert_eq!( + base_bytes, + other_id.signable_bytes().expect("signable bytes"), + "identity_id is excluded from the sighash, so it must not change the signable bytes" + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_like.rs new file mode 100644 index 00000000000..9e48e06e908 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_like.rs @@ -0,0 +1,42 @@ +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use crate::{ + prelude::Identifier, + state_transition::{StateTransitionLike, StateTransitionType}, +}; + +use crate::state_transition::StateTransition; +use crate::state_transition::StateTransitionType::IdentityCreateFromShieldedPool; +use crate::version::FeatureVersion; + +impl From for StateTransition { + fn from(value: IdentityCreateFromShieldedPoolTransitionV0) -> Self { + let transition: IdentityCreateFromShieldedPoolTransition = value.into(); + transition.into() + } +} + +impl StateTransitionLike for IdentityCreateFromShieldedPoolTransitionV0 { + fn state_transition_protocol_version(&self) -> FeatureVersion { + 0 + } + + /// returns the type of State Transition + fn state_transition_type(&self) -> StateTransitionType { + IdentityCreateFromShieldedPool + } + + /// Returns the id of the newly created identity (the only modified data). + fn modified_data_ids(&self) -> Vec { + vec![self.identity_id] + } + + /// For ZK pool-spend transitions, uniqueness comes from the nullifiers in the actions. + /// Each nullifier can only be used once, making them natural unique identifiers. + fn unique_identifiers(&self) -> Vec { + self.actions + .iter() + .map(|action| hex::encode(action.nullifier)) + .collect() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs new file mode 100644 index 00000000000..29bc22bf039 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs @@ -0,0 +1,243 @@ +use crate::consensus::basic::identity::MissingMasterPublicKeyError; +use crate::consensus::basic::state_transition::ShieldedInvalidDenominationError; +use crate::consensus::basic::BasicError; +use crate::consensus::state::identity::max_identity_public_key_limit_reached_error::MaxIdentityPublicKeyLimitReachedError; +use crate::consensus::state::state_error::StateError; +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use crate::state_transition::state_transitions::shielded::common_validation::{ + validate_actions_count, validate_anchor_not_zero, validate_encrypted_note_sizes, + validate_proof_not_empty, +}; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl StateTransitionStructureValidation for IdentityCreateFromShieldedPoolTransitionV0 { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + // Actions count must be in [1, max] + let result = validate_actions_count( + &self.actions, + platform_version + .system_limits + .max_shielded_transition_actions, + ); + if !result.is_valid() { + return result; + } + + // Each action's encrypted_note must be exactly ENCRYPTED_NOTE_SIZE bytes + let result = validate_encrypted_note_sizes(&self.actions); + if !result.is_valid() { + return result; + } + + // The denomination MUST be a member of the versioned exit-denomination set. Restricting the + // exit to a small fixed set is what makes every identity-creation exit of a given size + // indistinguishable on-chain (maximizing the anonymity set). An empty set (pre-v12) rejects + // every denomination, but the transition is already gated off pre-v12 by `is_allowed`. + let denominations = platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_identity_create_denominations; + if !denominations.contains(&self.denomination) { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidDenominationError( + ShieldedInvalidDenominationError::new(self.denomination), + ) + .into(), + ); + } + + // Proof must not be empty + let result = validate_proof_not_empty(&self.proof); + if !result.is_valid() { + return result; + } + + // Anchor must not be all zeros + let result = validate_anchor_not_zero(&self.anchor); + if !result.is_valid() { + return result; + } + + // At least one public key (the master key requirement and full key-structure validation — + // duplicates, security levels, proofs-of-possession — run in drive-abci, mirroring + // `IdentityCreate`). + if self.public_keys.is_empty() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::MissingMasterPublicKeyError(MissingMasterPublicKeyError::new()).into(), + ); + } + + // At most `max_public_keys_in_creation` public keys. + let max_keys = platform_version + .dpp + .state_transitions + .identities + .max_public_keys_in_creation as usize; + if self.public_keys.len() > max_keys { + return SimpleConsensusValidationResult::new_with_error( + StateError::MaxIdentityPublicKeyLimitReachedError( + MaxIdentityPublicKeyLimitReachedError::new(max_keys), + ) + .into(), + ); + } + + SimpleConsensusValidationResult::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::ConsensusError; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::SerializedAction; + use crate::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use assert_matches::assert_matches; + use platform_value::BinaryData; + + fn dummy_action() -> SerializedAction { + SerializedAction { + nullifier: [1u8; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } + } + + fn master_key() -> IdentityPublicKeyInCreation { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![0u8; 33]), + signature: BinaryData::new(vec![0u8; 65]), + }) + } + + fn valid_transition() -> IdentityCreateFromShieldedPoolTransitionV0 { + let actions = vec![dummy_action()]; + let identity_id = derive_identity_id_from_actions(&actions); + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![master_key()], + denomination: 10_000_000_000, + actions, + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + identity_id, + } + } + + #[test] + fn should_validate_a_valid_transition() { + let platform_version = PlatformVersion::latest(); + let result = valid_transition().validate_structure(platform_version); + assert!( + result.is_valid(), + "expected valid result, got: {:?}", + result.errors + ); + } + + #[test] + fn should_reject_non_member_denomination() { + let platform_version = PlatformVersion::latest(); + let mut t = valid_transition(); + t.denomination = 12_345; // not a member of the set + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedInvalidDenominationError(_) + )] + ); + } + + #[test] + fn should_accept_each_member_denomination() { + let platform_version = PlatformVersion::latest(); + for denomination in [ + 10_000_000_000u64, + 30_000_000_000, + 50_000_000_000, + 100_000_000_000, + ] { + let mut t = valid_transition(); + t.denomination = denomination; + assert!( + t.validate_structure(platform_version).is_valid(), + "denomination {denomination} should be accepted" + ); + } + } + + #[test] + fn should_reject_empty_actions() { + let platform_version = PlatformVersion::latest(); + let mut t = valid_transition(); + t.actions.clear(); + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedNoActionsError(_) + )] + ); + } + + #[test] + fn should_reject_empty_public_keys() { + let platform_version = PlatformVersion::latest(); + let mut t = valid_transition(); + t.public_keys.clear(); + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::MissingMasterPublicKeyError(_) + )] + ); + } + + #[test] + fn should_reject_zero_anchor() { + let platform_version = PlatformVersion::latest(); + let mut t = valid_transition(); + t.anchor = [0u8; 32]; + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedZeroAnchorError(_) + )] + ); + } + + #[test] + fn should_reject_empty_proof() { + let platform_version = PlatformVersion::latest(); + let mut t = valid_transition(); + t.proof.clear(); + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedEmptyProofError(_) + )] + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/types.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/types.rs new file mode 100644 index 00000000000..aac6ca065e1 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/types.rs @@ -0,0 +1,16 @@ +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use crate::state_transition::StateTransitionFieldTypes; + +impl StateTransitionFieldTypes for IdentityCreateFromShieldedPoolTransitionV0 { + fn signature_property_paths() -> Vec<&'static str> { + vec![] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..0ed13e5b528 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs @@ -0,0 +1,41 @@ +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; +use crate::state_transition::identity_create_from_shielded_pool_transition::methods::IdentityCreateFromShieldedPoolTransitionMethodsV0; +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +#[cfg(feature = "state-transition-signing")] +use crate::{state_transition::StateTransition, ProtocolError}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl IdentityCreateFromShieldedPoolTransitionMethodsV0 + for IdentityCreateFromShieldedPoolTransitionV0 +{ + #[cfg(feature = "state-transition-signing")] + fn try_from_bundle( + public_keys: Vec, + denomination: u64, + actions: Vec, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + _platform_version: &PlatformVersion, + ) -> Result { + // The identity id is deterministically derived from the (sorted) spend nullifiers, so it is + // unique by construction and is committed into the Orchard sighash via `extra_sighash_data`. + let identity_id = derive_identity_id_from_actions(&actions); + let transition = IdentityCreateFromShieldedPoolTransitionV0 { + public_keys, + denomination, + actions, + anchor, + proof, + binding_signature, + identity_id, + }; + Ok(transition.into()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/version.rs new file mode 100644 index 00000000000..61891b6edb4 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/version.rs @@ -0,0 +1,9 @@ +use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for IdentityCreateFromShieldedPoolTransitionV0 { + fn feature_version(&self) -> FeatureVersion { + 0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/version.rs new file mode 100644 index 00000000000..89e47ecad64 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/version.rs @@ -0,0 +1,11 @@ +use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for IdentityCreateFromShieldedPoolTransition { + fn feature_version(&self) -> FeatureVersion { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => v0.feature_version(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs index 6a8cafc1932..7167cb6a81c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs @@ -1,4 +1,5 @@ pub mod common_validation; +pub mod identity_create_from_shielded_pool_transition; pub mod shield_from_asset_lock_transition; pub mod shield_transition; pub mod shielded_transfer_transition; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs index 5b6a458dc38..87798639464 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs @@ -33,6 +33,7 @@ pub struct DPPStateTransitionSerializationVersions { pub unshield_state_transition: FeatureVersionBounds, pub shield_from_asset_lock_state_transition: FeatureVersionBounds, pub shielded_withdrawal_state_transition: FeatureVersionBounds, + pub identity_create_from_shielded_pool_state_transition: FeatureVersionBounds, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs index 478f87dee89..53608fc14c9 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs @@ -157,4 +157,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V1: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + identity_create_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs index b2b12cae1af..d1baed9e530 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs @@ -157,4 +157,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V2: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + identity_create_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 8059700e873..104fc2cc4df 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -50,6 +50,13 @@ pub struct DriveAbciValidationConstants { /// cap the transition is rejected so a client cannot accidentally forfeit a /// large asset-lock remainder. 20,000,000,000 credits = 0.2 Dash. pub shielded_implicit_fee_cap: u64, + /// Allowed exit denominations (in credits) for `IdentityCreateFromShieldedPool`. + /// 0.1, 0.3, 0.5, 1.0 DASH = {10, 30, 50, 100} × 10^9 credits. The exit amount is + /// restricted to this small fixed set so every identity-creation exit of a given size + /// is indistinguishable on-chain, maximizing the anonymity set (mirroring the exact-fee + /// uniformity already enforced for `ShieldedTransfer`). Empty pre-v12 so the transition + /// is gated off until the shielded family activates. + pub shielded_identity_create_denominations: &'static [u64], } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 32ca6a7a44b..3cb6b8b7330 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -268,5 +268,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index c8d261ae03f..1e15e788ccb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -268,5 +268,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 622b01c96ef..585e330eb1a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -268,5 +268,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index 80aac501f21..f01e5be12e1 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -271,5 +271,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index d0d6f3ab5e3..6024d029908 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -272,5 +272,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index 579e6f6b675..54cf7e56eed 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -275,5 +275,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index 105c04949aa..13edd80fb0a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -269,5 +269,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, shielded_implicit_fee_cap: 20_000_000_000, + shielded_identity_create_denominations: &[], }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index bf832b4bbbf..d8b249cc9d0 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -326,5 +326,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = // tracks the per-action cost and the margin stays uniform as actions grow. shielded_per_action_processing_fee: 22_000_000, shielded_implicit_fee_cap: 20_000_000_000, + // 0.1, 0.3, 0.5, 1.0 DASH in credits (1 DASH = 10^8 duffs, CREDITS_PER_DUFF = 1000). + shielded_identity_create_denominations: &[ + 10_000_000_000, + 30_000_000_000, + 50_000_000_000, + 100_000_000_000, + ], }, }; From 17b47c4a01d4a87ed2ed72b4b6fc14af3c272bcf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 15:29:50 +0200 Subject: [PATCH 02/28] feat(drive): IdentityCreateFromShieldedPool action, converter, prove/verify (type 20) Stage 2 + 4 of #3813. rs-drive action + booking: - New action tree state_transition_action/shielded/ identity_create_from_shielded_pool/ (carries built Identity, notes, denomination, fee_amount, pool balance). Transformer builds the new identity (id + keys + balance = denomination). - StateTransitionAction::IdentityCreateFromShieldedPoolAction (TAIL) + user_fee_increase + dispatch arms. - Converter emits insert_nullifiers + AddNewIdentity{balance=denomination} + AddToSystemCredits(denomination) + insert_notes(change) + UpdateTotalBalance(pool - denomination). Conservation: pool debit == system credit (net 0); fee moved from identity at execution. - Converter method-version key across struct/v1/v2. rs-drive strict merged prove/verify (built strict from day one, cf #3812): - prove arm: merge nullifier PathQuery + full_identity_query (limits cleared before merge). - verify arm: verify_query_with_absence_proof (limit u16::MAX), partition by PATH (nullifier tree vs identity subtrees), reconstruct the Identity, require all nullifiers spent. - New StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers. cargo check -p drive (default + --features verify) green; 6 converter conservation tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/state_transition/proof_result.rs | 5 + .../prove/prove_state_transition/v0/mod.rs | 28 ++ .../action_convert_to_operations/mod.rs | 4 + ...ty_create_from_shielded_pool_transition.rs | 252 ++++++++++++++++++ .../shielded/mod.rs | 1 + .../src/state_transition_action/mod.rs | 6 + .../identity_create_from_shielded_pool/mod.rs | 72 +++++ .../transformer.rs | 28 ++ .../v0/mod.rs | 27 ++ .../v0/transformer.rs | 50 ++++ .../state_transition_action/shielded/mod.rs | 2 + .../v0/mod.rs | 146 ++++++++++ .../mod.rs | 1 + .../v1.rs | 1 + .../v2.rs | 1 + 15 files changed, 624 insertions(+) create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs diff --git a/packages/rs-dpp/src/state_transition/proof_result.rs b/packages/rs-dpp/src/state_transition/proof_result.rs index 4732764e0b3..927f7be95d6 100644 --- a/packages/rs-dpp/src/state_transition/proof_result.rs +++ b/packages/rs-dpp/src/state_transition/proof_result.rs @@ -77,4 +77,9 @@ pub enum StateTransitionProofResult { StoredAssetLockInfo, BTreeMap>, ), + /// Returned by `IdentityCreateFromShieldedPool`. Carries the newly-created [`Identity`] AND the + /// presence of each spent nullifier (`(nullifier_bytes, present)`), proven together in a single + /// STRICT merged multi-root GroveDB proof. A light/SDK client can cryptographically confirm both + /// that the identity was created and that the funding nullifiers were consumed. + VerifiedIdentityWithShieldedNullifiers(Identity, Vec<(Vec, bool)>), } diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 535745056df..eae18704747 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -433,6 +433,34 @@ impl Drive { None => outpoint_pq, } } + StateTransition::IdentityCreateFromShieldedPool(st) => { + use crate::drive::shielded::paths::shielded_credit_pool_nullifiers_path_vec; + use dpp::state_transition::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + + // Prove BOTH the spent nullifiers AND the newly-created identity in a single merged + // multi-root proof. Built STRICT from day one (per #3812): the verifier rebuilds this + // exact merged query and verifies it with `verify_query_with_absence_proof`, so the + // proof cannot carry any branch beyond {nullifiers, identity}. + let nullifier_keys: Vec> = st.nullifiers(); + let mut nf_query = grovedb::Query::new(); + nf_query.insert_keys(nullifier_keys); + // `PathQuery::merge` rejects sub-queries that carry a limit, so leave it None. + let nullifier_pq = PathQuery::new( + shielded_credit_pool_nullifiers_path_vec(), + grovedb::SizedQuery::new(nf_query, None, None), + ); + + let mut identity_pq = Drive::full_identity_query( + &st.identity_id().to_buffer(), + &platform_version.drive.grove_version, + )?; + identity_pq.query.limit = None; + + PathQuery::merge( + vec![&nullifier_pq, &identity_pq], + &platform_version.drive.grove_version, + )? + } }; let proof = self.grove_get_proved_path_query( diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index 1ed7ba86664..7db2c8d5390 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs @@ -123,6 +123,10 @@ impl DriveHighLevelOperationConverter for StateTransitionAction { StateTransitionAction::ShieldedWithdrawalAction(shielded_withdrawal_action) => { shielded_withdrawal_action.into_high_level_drive_operations(epoch, platform_version) } + StateTransitionAction::IdentityCreateFromShieldedPoolAction( + identity_create_from_shielded_pool_action, + ) => identity_create_from_shielded_pool_action + .into_high_level_drive_operations(epoch, platform_version), } } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs new file mode 100644 index 00000000000..a1bd990f512 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs @@ -0,0 +1,252 @@ +use super::{insert_notes, insert_nullifiers, update_balance}; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use crate::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use crate::util::batch::DriveOperation; +use crate::util::batch::DriveOperation::{IdentityOperation, SystemOperation}; +use crate::util::batch::{IdentityOperationType, SystemOperationType}; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; + +impl DriveHighLevelOperationConverter for IdentityCreateFromShieldedPoolTransitionAction { + fn into_high_level_drive_operations<'a>( + self, + _epoch: &Epoch, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .identity_create_from_shielded_pool_transition + { + 0 => match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(v0) => { + let mut ops: Vec> = Vec::new(); + + // 1. Insert each nullifier (validated to not already exist) — double-spend + // prevention. These also serve as the id-derivation preimage. + insert_nullifiers(&mut ops, &v0.notes); + + // 2. Create the new identity holding the FULL denomination. The fee is moved out + // of this balance into the fee pools at execution, so the identity ends with + // `denomination - fee_amount` and credits are conserved. + ops.push(IdentityOperation(IdentityOperationType::AddNewIdentity { + identity: v0.identity, + is_masternode_identity: false, + })); + + // 3. The credits backing the new identity come from the shielded pool, which is + // decremented in step 5. Because the identity is freshly created (it was not + // already in circulation, unlike an Unshield's transparent recipient), the + // system-credits total must be incremented by the SAME amount so the global + // credit supply is unchanged. This MUST equal `denomination`, NOT + // `denomination - fee_amount` (the fee never leaves the supply — it is moved + // from the identity balance into the fee pools). Getting this wrong mints or + // burns credits and halts the chain. + ops.push(SystemOperation(SystemOperationType::AddToSystemCredits { + amount: v0.denomination, + })); + + // 4. Insert each action's output note into the CommitmentTree (change re-enters + // the pool as an ordinary, indistinguishable Orchard output). + insert_notes(&mut ops, &v0.notes); + + // 5. Decrement the shielded pool by exactly `denomination` (= the Orchard + // value_balance leaving the pool; change stays internal to the bundle). + let new_total_balance = v0 + .current_total_balance + .checked_sub(v0.denomination) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "shielded pool total balance underflow when subtracting identity-create denomination" + .to_string(), + )) + })?; + update_balance(&mut ops, new_total_balance); + + Ok(ops) + } + }, + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: + "IdentityCreateFromShieldedPoolTransitionAction::into_high_level_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state_transition_action::shielded::identity_create_from_shielded_pool::v0::IdentityCreateFromShieldedPoolTransitionActionV0; + use crate::state_transition_action::shielded::ShieldedActionNote; + use crate::util::batch::drive_op_batch::ShieldedPoolOperationType; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::Identity; + use dpp::platform_value::Identifier; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + fn make_note(i: u8) -> ShieldedActionNote { + ShieldedActionNote { + nullifier: [i; 32], + cmx: [i.wrapping_add(100); 32], + encrypted_note: vec![0x77; 216], + } + } + + fn make_identity(balance: u64) -> Identity { + let platform_version = PlatformVersion::latest(); + let mut identity = Identity::new_with_id_and_keys( + Identifier::from([0xAA; 32]), + BTreeMap::new(), + platform_version, + ) + .expect("identity"); + use dpp::identity::accessors::IdentitySettersV0; + identity.set_balance(balance); + identity + } + + fn make_action( + denomination: u64, + fee_amount: u64, + pool: u64, + ) -> IdentityCreateFromShieldedPoolTransitionAction { + IdentityCreateFromShieldedPoolTransitionAction::V0( + IdentityCreateFromShieldedPoolTransitionActionV0 { + identity: make_identity(denomination), + notes: vec![make_note(1)], + anchor: [0xAA; 32], + denomination, + fee_amount, + current_total_balance: pool, + }, + ) + } + + #[test] + fn test_produces_expected_ops() { + let action = make_action(10_000_000_000, 500_000_000, 50_000_000_000); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("ops"); + // InsertNullifiers + AddNewIdentity + AddToSystemCredits + InsertNote(1) + UpdateTotalBalance + assert_eq!(ops.len(), 5); + } + + #[test] + fn test_add_to_system_credits_equals_denomination_not_net() { + // CRITICAL conservation invariant: AddToSystemCredits must equal the FULL denomination, + // not denomination - fee. The identity is created with the full denomination; the fee is + // moved from its balance into the fee pools at execution. + let denomination = 10_000_000_000u64; + let action = make_action(denomination, 500_000_000, 50_000_000_000); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("ops"); + + let mut found = false; + for op in &ops { + if let SystemOperation(SystemOperationType::AddToSystemCredits { amount }) = op { + assert_eq!( + *amount, denomination, + "AddToSystemCredits must equal the full denomination" + ); + found = true; + } + } + assert!(found, "expected an AddToSystemCredits op"); + } + + #[test] + fn test_identity_balance_is_full_denomination() { + let denomination = 30_000_000_000u64; + let action = make_action(denomination, 500_000_000, 50_000_000_000); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("ops"); + for op in &ops { + if let IdentityOperation(IdentityOperationType::AddNewIdentity { identity, .. }) = op { + assert_eq!(identity.balance(), denomination); + } + } + } + + #[test] + fn test_pool_decrements_by_denomination() { + let denomination = 10_000_000_000u64; + let pool = 50_000_000_000u64; + let action = make_action(denomination, 500_000_000, pool); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("ops"); + match ops.last().unwrap() { + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::UpdateTotalBalance { new_total_balance }, + ) => assert_eq!(*new_total_balance, pool - denomination), + other => panic!("expected UpdateTotalBalance, got {:?}", other), + } + } + + /// Op-level conservation: the shielded pool loses `denomination` and the system-credits total + /// gains `denomination`, so the net credit-supply change from the converter ops is ZERO. (The + /// fee is later moved from the identity balance into the fee pools at execution — a transfer + /// within the supply, not a mint/burn.) + #[test] + fn test_conservation_pool_debit_equals_system_credit() { + let denomination = 10_000_000_000u64; + let pool = 50_000_000_000u64; + let action = make_action(denomination, 500_000_000, pool); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("ops"); + + let mut system_delta: i128 = 0; + let mut pool_delta: i128 = 0; + for op in &ops { + match op { + SystemOperation(SystemOperationType::AddToSystemCredits { amount }) => { + system_delta += *amount as i128 + } + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::UpdateTotalBalance { new_total_balance }, + ) => pool_delta = *new_total_balance as i128 - pool as i128, + _ => {} + } + } + assert_eq!(system_delta, denomination as i128); + assert_eq!(pool_delta, -(denomination as i128)); + assert_eq!( + system_delta + pool_delta, + 0, + "credit supply must be conserved" + ); + } + + #[test] + fn test_pool_underflow_errors() { + let action = make_action(50_000_000_000, 500_000_000, 10_000_000_000); // pool < denomination + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::latest(); + assert!(action + .into_high_level_drive_operations(&epoch, platform_version) + .is_err()); + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs index 51e0976c1f8..94d382a9c43 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs @@ -1,3 +1,4 @@ +mod identity_create_from_shielded_pool_transition; mod shield_from_asset_lock_transition; mod shield_transition; mod shielded_transfer_transition; diff --git a/packages/rs-drive/src/state_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/mod.rs index e14aba510c7..51baa617dcb 100644 --- a/packages/rs-drive/src/state_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/mod.rs @@ -29,6 +29,7 @@ use crate::state_transition_action::identity::identity_topup::IdentityTopUpTrans use crate::state_transition_action::identity::identity_topup_from_addresses::IdentityTopUpFromAddressesTransitionAction; use crate::state_transition_action::identity::identity_update::IdentityUpdateTransitionAction; use crate::state_transition_action::identity::masternode_vote::MasternodeVoteTransitionAction; +use crate::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; use crate::state_transition_action::shielded::shield::ShieldTransitionAction; use crate::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; use crate::state_transition_action::shielded::shielded_transfer::ShieldedTransferTransitionAction; @@ -107,6 +108,8 @@ pub enum StateTransitionAction { ShieldFromAssetLockAction(ShieldFromAssetLockTransitionAction), /// shielded withdrawal (shielded pool -> L1 core address) ShieldedWithdrawalAction(ShieldedWithdrawalTransitionAction), + /// identity create from shielded pool (shielded pool -> new identity) + IdentityCreateFromShieldedPoolAction(IdentityCreateFromShieldedPoolTransitionAction), } impl StateTransitionAction { @@ -165,6 +168,9 @@ impl StateTransitionAction { StateTransitionAction::ShieldedWithdrawalAction(_) => { UserFeeIncrease::default() // 0 (fee is locked by Orchard binding signature) } + StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) => { + UserFeeIncrease::default() // 0 (fee is locked by Orchard binding signature) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/mod.rs new file mode 100644 index 00000000000..438a883da84 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/mod.rs @@ -0,0 +1,72 @@ +/// transformer +pub mod transformer; +/// v0 +pub mod v0; + +use crate::state_transition_action::shielded::identity_create_from_shielded_pool::v0::IdentityCreateFromShieldedPoolTransitionActionV0; +use crate::state_transition_action::shielded::ShieldedActionNote; +use derive_more::From; +use dpp::fee::Credits; +use dpp::identity::Identity; +use dpp::prelude::Identifier; + +/// IdentityCreateFromShieldedPool transition action +#[derive(Debug, Clone, From)] +pub enum IdentityCreateFromShieldedPoolTransitionAction { + /// v0 + V0(IdentityCreateFromShieldedPoolTransitionActionV0), +} + +impl IdentityCreateFromShieldedPoolTransitionAction { + /// Get the built identity (balance = denomination, before fee deduction). + pub fn identity(&self) -> &Identity { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => &transition.identity, + } + } + /// Take ownership of the built identity. + pub fn identity_owned(self) -> Identity { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => transition.identity, + } + } + /// Get the id of the new identity. + pub fn identity_id(&self) -> Identifier { + use dpp::identity::accessors::IdentityGettersV0; + self.identity().id() + } + /// Get notes. + pub fn notes(&self) -> &[ShieldedActionNote] { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => &transition.notes, + } + } + /// Get anchor. + pub fn anchor(&self) -> &[u8; 32] { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => &transition.anchor, + } + } + /// Get the exit denomination (in credits). + pub fn denomination(&self) -> Credits { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => { + transition.denomination + } + } + } + /// Total fee moved from the new identity's balance into the fee pools at execution. + pub fn fee_amount(&self) -> Credits { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => transition.fee_amount, + } + } + /// Current total balance of the shielded pool (before this transition). + pub fn current_total_balance(&self) -> Credits { + match self { + IdentityCreateFromShieldedPoolTransitionAction::V0(transition) => { + transition.current_total_balance + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/transformer.rs new file mode 100644 index 00000000000..5c577581cea --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/transformer.rs @@ -0,0 +1,28 @@ +use crate::state_transition_action::shielded::identity_create_from_shielded_pool::v0::IdentityCreateFromShieldedPoolTransitionActionV0; +use crate::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use dpp::fee::Credits; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; + +impl IdentityCreateFromShieldedPoolTransitionAction { + /// Transforms the state transition into an action. + pub fn try_from_transition( + value: &IdentityCreateFromShieldedPoolTransition, + current_total_balance: Credits, + fee_amount: Credits, + platform_version: &PlatformVersion, + ) -> Result { + match value { + IdentityCreateFromShieldedPoolTransition::V0(v0) => { + let action = IdentityCreateFromShieldedPoolTransitionActionV0::try_from_transition( + v0, + current_total_balance, + fee_amount, + platform_version, + )?; + Ok(action.into()) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs new file mode 100644 index 00000000000..09f7835b43a --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs @@ -0,0 +1,27 @@ +mod transformer; + +use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::fee::Credits; +use dpp::identity::Identity; + +/// IdentityCreateFromShieldedPool transition action v0 +#[derive(Debug, Clone)] +pub struct IdentityCreateFromShieldedPoolTransitionActionV0 { + /// The fully-built new identity (id derived from the spend nullifiers, keys from the + /// transition's `public_keys`). Its balance is the FULL `denomination`; the fee is moved + /// from this balance into the fee pools at execution, so the identity ends with + /// `denomination - fee_amount`. + pub identity: Identity, + /// Notes from the orchard bundle actions (nullifiers to insert + change notes to append). + pub notes: Vec, + /// The anchor used for verification. + pub anchor: [u8; 32], + /// The fixed exit denomination (in credits) leaving the shielded pool. Equals the new + /// identity's initial balance and the `AddToSystemCredits` amount. + pub denomination: Credits, + /// Total fee (metered GroveDB write cost + flat shielded verification/compute fee) moved from + /// the new identity's balance into the fee pools at execution. MUST be `< denomination`. + pub fee_amount: Credits, + /// Current total balance of the shielded pool (decremented by `denomination`). + pub current_total_balance: Credits, +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs new file mode 100644 index 00000000000..a1fdd7069b0 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs @@ -0,0 +1,50 @@ +use crate::state_transition_action::shielded::identity_create_from_shielded_pool::v0::IdentityCreateFromShieldedPoolTransitionActionV0; +use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID}; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use std::collections::BTreeMap; + +impl IdentityCreateFromShieldedPoolTransitionActionV0 { + /// Transforms the identity-create-from-shielded-pool transition into an action, building the new + /// identity (id + keys + balance = `denomination`) from the transition payload. + pub fn try_from_transition( + value: &IdentityCreateFromShieldedPoolTransitionV0, + current_total_balance: Credits, + fee_amount: Credits, + platform_version: &PlatformVersion, + ) -> Result { + let public_keys: BTreeMap = value + .public_keys + .iter() + .map(|key| { + let public_key: IdentityPublicKey = key.into(); + (public_key.id(), public_key) + }) + .collect(); + + // The id was re-derived and checked against the spend nullifiers during validation, so it is + // authoritative here. + let mut identity = + Identity::new_with_id_and_keys(value.identity_id, public_keys, platform_version)?; + // The identity is created holding the FULL denomination. The fee is moved out of this + // balance into the fee pools at execution (so the credit supply is conserved). + identity.set_balance(value.denomination); + + let notes: Vec = + value.actions.iter().map(ShieldedActionNote::from).collect(); + + Ok(IdentityCreateFromShieldedPoolTransitionActionV0 { + identity, + notes, + anchor: value.anchor, + denomination: value.denomination, + fee_amount, + current_total_balance, + }) + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/mod.rs index f3c267a36d9..47c3ff213ee 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/mod.rs @@ -1,3 +1,5 @@ +/// IdentityCreateFromShieldedPool transition action +pub mod identity_create_from_shielded_pool; /// Shield transition action pub mod shield; /// Shield from asset lock transition action diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 3b8caf885cc..002d95db653 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1505,6 +1505,152 @@ impl Drive { } } } + StateTransition::IdentityCreateFromShieldedPool(st) => { + use crate::drive::balances::balance_path; + use crate::drive::identity::IdentityRootStructure::IdentityTreeRevision; + use crate::drive::identity::{identity_key_tree_path, identity_path}; + use crate::drive::shielded::paths::shielded_credit_pool_nullifiers_path_vec; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{IdentityPublicKey, IdentityV0, KeyID}; + use dpp::prelude::Revision; + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + use dpp::state_transition::proof_result::StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers; + + let identity_id = st.identity_id().to_buffer(); + let nullifier_keys: Vec> = st.nullifiers(); + + // Rebuild the BYTE-IDENTICAL merged query the prove side built: the nullifier + // sub-query over the nullifier tree + the full-identity sub-query, each with its + // limit cleared (PathQuery::merge rejects limited sub-queries). + let mut nf_query = grovedb::Query::new(); + nf_query.insert_keys(nullifier_keys.clone()); + let nullifier_pq = grovedb::PathQuery::new( + shielded_credit_pool_nullifiers_path_vec(), + grovedb::SizedQuery::new(nf_query, None, None), + ); + + let mut identity_pq = Drive::full_identity_query( + &identity_id, + &platform_version.drive.grove_version, + )?; + identity_pq.query.limit = None; + + let mut merged_pq = grovedb::PathQuery::merge( + vec![&nullifier_pq, &identity_pq], + &platform_version.drive.grove_version, + )?; + + // STRICT verification: `verify_query_with_absence_proof` requires a limit, but + // `merge` leaves it None. Use an unreachable `u16::MAX` so the per-layer succinctness + // check (which rejects extra proof branches — the whole point of building this strict + // from day one, cf. #3812) runs fully on every layer; a smaller limit could break the + // result loop early and falsely reject honest proofs. The limit does NOT relax + // extra-data rejection. + merged_pq.query.limit = Some(u16::MAX); + + let (root_hash, proved_key_values) = + grovedb::GroveDb::verify_query_with_absence_proof( + proof, + &merged_pq, + &platform_version.drive.grove_version, + )?; + + // Partition the proved key/values by PATH (NOT key length — nullifier keys and the + // identity id are both 32 bytes): nullifier-tree entries vs the identity subtrees + // (balance / revision / keys). Reconstruct the identity exactly as + // `verify_full_identity_by_identity_id_v0` does. + let nullifier_path = shielded_credit_pool_nullifiers_path_vec(); + let balance_path = balance_path(); + let identity_path = identity_path(identity_id.as_slice()); + let identity_keys_path = identity_key_tree_path(identity_id.as_slice()); + + let mut statuses: Vec<(Vec, bool)> = Vec::new(); + let mut balance: Option = None; + let mut revision: Option = None; + let mut keys = BTreeMap::::new(); + + for (path, key, maybe_element) in proved_key_values { + if path == nullifier_path { + statuses.push((key, maybe_element.is_some())); + } else if path == balance_path && key == identity_id { + let element = maybe_element.ok_or_else(|| { + Error::Proof(ProofError::IncompleteProof( + "balance wasn't provided for the created identity", + )) + })?; + let signed_balance = element.as_sum_item_value().map_err(Error::from)?; + if signed_balance < 0 { + return Err(Error::Proof(ProofError::Overflow( + "balance can't be negative", + ))); + } + balance = Some(signed_balance as Credits); + } else if path == identity_path && key == vec![IdentityTreeRevision as u8] { + let element = maybe_element.ok_or_else(|| { + Error::Proof(ProofError::IncompleteProof( + "revision wasn't provided for the created identity", + )) + })?; + let item_bytes = element.into_item_bytes().map_err(Error::from)?; + revision = Some(Revision::from_be_bytes(item_bytes.try_into().map_err( + |_| { + Error::Proof(ProofError::IncorrectValueSize( + "revision should be 8 bytes", + )) + }, + )?)); + } else if path == identity_keys_path { + let element = maybe_element.ok_or_else(|| { + Error::Proof(ProofError::CorruptedProof( + "received an absence proof for a key but didn't request one" + .to_string(), + )) + })?; + let item_bytes = element.into_item_bytes().map_err(Error::from)?; + let public_key = IdentityPublicKey::deserialize_from_bytes(&item_bytes)?; + keys.insert(public_key.id(), public_key); + } else { + return Err(Error::Proof(ProofError::TooManyElements( + "identity create from shielded pool proof contains an element outside \ + the nullifier tree and the created identity", + ))); + } + } + + // Every funding nullifier must be present (spent) in the post-execution state. + for (nf, is_spent) in &statuses { + if !is_spent { + return Err(Error::Proof(ProofError::IncorrectProof(format!( + "nullifier {} was not found as spent in the identity-create-from-shielded-pool proof", + hex::encode(nf) + )))); + } + } + + // The created identity MUST be fully present. + let (balance, revision) = match (balance, revision, keys.is_empty()) { + (Some(balance), Some(revision), false) => (balance, revision), + _ => { + return Err(Error::Proof(ProofError::IncompleteProof( + "identity create from shielded pool was executed but the created identity is absent or incomplete in the proof", + ))) + } + }; + + let identity: dpp::prelude::Identity = IdentityV0 { + id: Identifier::from(identity_id), + public_keys: keys, + balance, + revision, + } + .into(); + + Ok(( + root_hash, + VerifiedIdentityWithShieldedNullifiers(identity, statuses), + )) + } } } } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index 9c1ec2942ba..29e1cd7c71d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -53,6 +53,7 @@ pub struct DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions pub shielded_transfer_transition: FeatureVersion, pub unshield_transition: FeatureVersion, pub shielded_withdrawal_transition: FeatureVersion, + pub identity_create_from_shielded_pool_transition: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index 0a9930b133b..204d3058747 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -54,5 +54,6 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV shielded_transfer_transition: 0, unshield_transition: 0, shielded_withdrawal_transition: 0, + identity_create_from_shielded_pool_transition: 0, }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index 7bc67bbbbde..babf9bc6fd1 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -55,5 +55,6 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV shielded_transfer_transition: 0, unshield_transition: 0, shielded_withdrawal_transition: 0, + identity_create_from_shielded_pool_transition: 0, }, }; From 0bcb9def10b964723501dae8b8229331ce351d08 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 15:52:14 +0200 Subject: [PATCH 03/28] feat(drive-abci): IdentityCreateFromShieldedPool validation + metered booking (type 20) Stage 3 of #3813. - New validation module identity_create_from_shielded_pool/ with transform_into_action: stateful checks (pool balance, anchor exists, nullifier replay) + key-structure validation + per-key proof-of-possession over the transition signable bytes + builds the action (id re-derived from nullifiers, canonical). - shielded_proof trait: proof verification arm binding identity id + denomination + full key set into the Orchard extra_sighash_data (anti-redirection), value_balance == denomination; new ShieldedMinFeeKind::IdentityCreate{num_keys} min-fee gate (denomination >= compute_shielded_identity_create_fee). - New ExecutionEvent::PaidFromShieldedPoolToNewIdentity (create-then- deduct, funded from the pool): meters the GroveDB write + adds the flat compute fee as additional_fixed_fee_cost, moves total_fee from the new identity into the fee pools. validate_fees_of_event affordability gate (denomination >= total_fee -> IdentityInsufficientBalanceError). - All processor predicate/dispatch trait arms (is_allowed gated on SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, basic_structure, state, identity_based_signature, identity_nonces, address_*, identity_balance) + transformer dispatch. - rs-platform-version: validation-version field across v1-v8 (Some(0) basic_structure in v8, None pre-v12). cargo check -p drive-abci green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../execute_event/v0/mod.rs | 47 +++++- .../validate_fees_of_event/v0/mod.rs | 55 +++++++ .../execution/types/execution_event/mod.rs | 53 ++++++ .../traits/address_balances_and_nonces.rs | 6 +- .../processor/traits/address_witnesses.rs | 6 +- .../traits/addresses_minimum_balance.rs | 6 +- .../processor/traits/basic_structure.rs | 33 ++++ .../processor/traits/identity_balance.rs | 5 +- .../traits/identity_based_signature.rs | 11 +- .../processor/traits/identity_nonces.rs | 8 +- .../processor/traits/is_allowed.rs | 6 +- .../processor/traits/shielded_proof.rs | 62 +++++++ .../processor/traits/state.rs | 8 +- .../identity_create_from_shielded_pool/mod.rs | 65 ++++++++ .../tests.rs | 5 + .../transform_into_action/mod.rs | 2 + .../transform_into_action/v0/mod.rs | 153 ++++++++++++++++++ .../state_transition/state_transitions/mod.rs | 2 + .../state_transition/transformer/mod.rs | 12 ++ .../v0/transformer.rs | 8 +- .../drive_abci_validation_versions/mod.rs | 2 + .../drive_abci_validation_versions/v1.rs | 9 ++ .../drive_abci_validation_versions/v2.rs | 9 ++ .../drive_abci_validation_versions/v3.rs | 9 ++ .../drive_abci_validation_versions/v4.rs | 9 ++ .../drive_abci_validation_versions/v5.rs | 9 ++ .../drive_abci_validation_versions/v6.rs | 9 ++ .../drive_abci_validation_versions/v7.rs | 9 ++ .../drive_abci_validation_versions/v8.rs | 9 ++ 29 files changed, 602 insertions(+), 25 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 10d5490222d..11c5d010c64 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -368,13 +368,16 @@ where ExecutionEvent::PaidFromAssetLock { .. } | ExecutionEvent::Paid { .. } | ExecutionEvent::PaidFromAddressInputs { .. } - | ExecutionEvent::PaidFromAssetLockToPool { .. } => Some(self.validate_fees_of_event( - &event, - block_info, - Some(transaction), - platform_version, - previous_fee_versions, - )?), + | ExecutionEvent::PaidFromAssetLockToPool { .. } + | ExecutionEvent::PaidFromShieldedPoolToNewIdentity { .. } => { + Some(self.validate_fees_of_event( + &event, + block_info, + Some(transaction), + platform_version, + previous_fee_versions, + )?) + } ExecutionEvent::PaidFromAssetLockWithoutIdentity { .. } | ExecutionEvent::PaidFixedCost { .. } | ExecutionEvent::PaidFromShieldedPool { .. } @@ -622,6 +625,36 @@ where Ok(UnpaidConsensusExecutionError(all_errors)) } } + ExecutionEvent::PaidFromShieldedPoolToNewIdentity { + identity, + operations, + execution_operations, + additional_fixed_fee_cost, + .. + } => { + // Reuse the create-then-deduct machinery: `paid_from_identity_function` applies the + // ops (which create the identity holding the full `denomination`, credit the system + // total, and debit the pool), then deducts the metered fee + the + // `additional_fixed_fee_cost` (the shielded compute fee) from the new identity's + // balance and books it to the fee pools — so the identity ends with + // `denomination - total_fee`. Conservation holds by the standard machinery, exactly + // as for `PaidFromAssetLock`. Shielded transitions have no fee bidding, so + // `user_fee_increase` is 0. + let fee_validation_result = maybe_fee_validation_result.unwrap(); + self.paid_from_identity_function( + fee_validation_result, + identity, + operations, + execution_operations, + 0, + additional_fixed_fee_cost, + block_info, + consensus_errors, + transaction, + platform_version, + previous_fee_versions, + ) + } ExecutionEvent::Free { operations } => { self.drive .apply_drive_operations( diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs index fe0670737e3..cb4750dd892 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs @@ -281,6 +281,61 @@ where )) } } + ExecutionEvent::PaidFromShieldedPoolToNewIdentity { + identity, + operations, + execution_operations, + denomination, + additional_fixed_fee_cost, + } => { + // Affordability gate mirroring `PaidFromAssetLock`: the new identity is created + // holding `denomination`, and the metered fee + the flat compute fee must not exceed + // it (otherwise the identity would be credited <= 0). Estimate the metered cost + // (apply = false), fold the compute fee into processing for gas-wanted parity, and + // reject with `IdentityInsufficientBalanceError` if `denomination < total_fee`. + let mut estimated_fee_result = self + .drive + .apply_drive_operations( + operations.clone(), + false, + block_info, + transaction, + platform_version, + Some(previous_fee_versions), + ) + .map_err(Error::Drive)?; + + ValidationOperation::add_many_to_fee_result( + execution_operations, + &mut estimated_fee_result, + platform_version, + )?; + + if let Some(additional_fixed_fee_cost) = additional_fixed_fee_cost { + estimated_fee_result.processing_fee = estimated_fee_result + .processing_fee + .saturating_add(*additional_fixed_fee_cost); + } + + let total_fee = estimated_fee_result.total_base_fee(); + if *denomination >= total_fee { + Ok(ConsensusValidationResult::new_with_data( + estimated_fee_result, + )) + } else { + Ok(ConsensusValidationResult::new_with_data_and_errors( + estimated_fee_result, + vec![StateError::IdentityInsufficientBalanceError( + IdentityInsufficientBalanceError::new( + identity.id, + *denomination, + total_fee, + ), + ) + .into()], + )) + } + } ExecutionEvent::PaidFixedCost { .. } | ExecutionEvent::PaidFromShieldedPool { .. } | ExecutionEvent::Free { .. } diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index b64aede39e7..dc9d62e9cbd 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -122,6 +122,28 @@ pub(in crate::execution) enum ExecutionEvent<'a> { /// the execution operations that we must also pay for execution_operations: Vec, }, + /// A drive event for `IdentityCreateFromShieldedPool`: the new identity is created holding the + /// full `denomination` (debited from the shielded pool by the converter), then the metered + /// GroveDB write cost PLUS the flat shielded compute fee (`additional_fixed_fee_cost`) is MOVED + /// from the new identity's balance into the fee pools — so the identity ends with + /// `denomination - total_fee` and the credit supply is conserved. Mirrors `PaidFromAssetLock` + /// (create-then-deduct-from-the-new-identity) but funded from the shielded pool instead of an + /// asset lock. The metered write grows with the key count, which is why this transition meters + /// rather than carving a flat pool fee like the other pool-paid shielded transitions. + PaidFromShieldedPoolToNewIdentity { + /// The new identity (id derived from the spend nullifiers, balance = `denomination`). + identity: PartialIdentity, + /// the operations that should be performed + operations: Vec>, + /// the execution operations that we must also pay for (per-key signature verifications) + execution_operations: Vec, + /// The exit denomination = the new identity's initial balance = the affordability ceiling + /// the fee must not exceed. + denomination: Credits, + /// The flat shielded COMPUTE fee (Halo 2 proof verification + per-action processing) added + /// to the metered processing fee — GroveDB cannot meter the ZK work. + additional_fixed_fee_cost: Option, + }, /// A drive event that is free #[allow(dead_code)] // TODO investigate why `variant `Free` is never constructed` Free { @@ -561,6 +583,37 @@ impl ExecutionEvent<'_> { fees_to_add_to_pool: fee_amount, }) } + StateTransitionAction::IdentityCreateFromShieldedPoolAction(ref action_ref) => { + use std::collections::{BTreeMap, BTreeSet}; + // The new identity is created holding the full denomination; the fee is the metered + // GroveDB write cost (from `operations`) PLUS the flat shielded COMPUTE fee + // (proof verification + per-action processing) GroveDB cannot meter, added as + // `additional_fixed_fee_cost` — exactly the transparent `Shield` model. That total is + // then moved out of the new identity's balance into the fee pools at execution. + let denomination = action_ref.denomination(); + let compute_fee = dpp::shielded::compute_shielded_verification_fee( + action_ref.notes().len(), + platform_version, + )?; + // Only `id` (for the fee balance-change) and `balance` (for the affordability gate) + // are needed; the keys themselves are written by the `AddNewIdentity` operation. + let partial_identity = PartialIdentity { + id: action_ref.identity_id(), + loaded_public_keys: BTreeMap::new(), + balance: Some(denomination), + revision: None, + not_found_public_keys: BTreeSet::new(), + }; + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromShieldedPoolToNewIdentity { + identity: partial_identity, + operations, + execution_operations: execution_context.operations_consume(), + denomination, + additional_fixed_fee_cost: Some(compute_fee), + }) + } _ => { let user_fee_increase = action.user_fee_increase(); let operations = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs index 31f15fdaf4c..0cd70a2dd7c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs @@ -181,7 +181,8 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, } } @@ -249,7 +250,8 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { Ok(ConsensusValidationResult::new_with_data(BTreeMap::new())) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs index 7d4c21f6918..78cdb5ec1c6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs @@ -83,7 +83,8 @@ impl StateTransitionAddressWitnessValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { return Ok(SimpleConsensusValidationResult::new()); } }; @@ -200,7 +201,8 @@ impl StateTransitionHasAddressWitnessValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, }; Ok(has_address_witness_validation) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs index cf91e383b88..85d1ab03600 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs @@ -75,7 +75,8 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { return Ok(SimpleConsensusValidationResult::new()); } }?; @@ -107,7 +108,8 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs index d25cfbfec31..1a2cf4c3c0e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs @@ -353,6 +353,32 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { })), } } + StateTransition::IdentityCreateFromShieldedPool(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_create_from_shielded_pool_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: + "identity create from shielded pool transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: + "identity create from shielded pool transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + })), + } + } } } fn has_basic_structure_validation(&self, platform_version: &PlatformVersion) -> bool { @@ -424,6 +450,13 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { .shielded_withdrawal_state_transition .basic_structure .is_some(), + StateTransition::IdentityCreateFromShieldedPool(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_create_from_shielded_pool_state_transition + .basic_structure + .is_some(), StateTransition::MasternodeVote(_) => false, } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index eb92aba9b32..390a141f834 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -81,7 +81,10 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => Ok(SimpleConsensusValidationResult::new()), + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { + Ok(SimpleConsensusValidationResult::new()) + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 63b8840a1b4..1df1759bada 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -135,7 +135,10 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => Ok(ConsensusValidationResult::new()), + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { + Ok(ConsensusValidationResult::new()) + } } } @@ -175,7 +178,8 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) @@ -203,7 +207,8 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs index d9a4bd04ba2..7dbd3980ef8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs @@ -119,7 +119,10 @@ impl StateTransitionIdentityNonceValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => Ok(SimpleConsensusValidationResult::new()), + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { + Ok(SimpleConsensusValidationResult::new()) + } } } } @@ -170,7 +173,8 @@ impl StateTransitionHasIdentityNonceValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, }; Ok(has_nonce_validation) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 190afb512fb..619b25d8265 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -36,7 +36,8 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => Ok(true), + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => Ok(true), StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::IdentityCreate(_) @@ -78,7 +79,8 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { if platform_version.protocol_version >= SHIELDED_POOL_INITIAL_PROTOCOL_VERSION { Ok(ConsensusValidationResult::new()) } else { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index c31966ffa96..3197051971f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -52,6 +52,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) ) } @@ -63,6 +64,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) ) } } @@ -117,6 +119,10 @@ enum ShieldedMinFeeKind { Unshield, /// `compute_shielded_withdrawal_fee` — ShieldedWithdrawal (base + the flat withdrawal-document cost). Withdrawal, + /// `compute_shielded_identity_create_fee` — IdentityCreateFromShieldedPool (base + the VARIABLE + /// `AddNewIdentity` write whose cost grows with the key count). Carries `num_keys` because the + /// fee scales with it, unlike the other (fixed) per-transition components. + IdentityCreate { num_keys: usize }, } impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { @@ -190,6 +196,25 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { ) } }, + // IdentityCreateFromShieldedPool: `denomination` is the TOTAL leaving the pool + // (new-identity balance + fee). We check it against `min_fee` so the net + // (`denomination - compute_shielded_identity_create_fee`) the new identity keeps + // at execution is non-negative. It is NOT pure fee (`>=` model); the exact + // `value_balance == denomination` equality is enforced by the proof verifier + // (which passes `value_balance = denomination`). The fee scales with the key + // count, so the `IdentityCreate` flavor carries `num_keys`. + StateTransition::IdentityCreateFromShieldedPool(st) => match st { + dpp::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition::V0(v0) => { + ( + v0.denomination as i64, + v0.actions.len(), + 0, + u64::MAX, + false, + ShieldedMinFeeKind::IdentityCreate { num_keys: v0.public_keys.len() }, + ) + } + }, // Other transitions don't go through shielded fee validation. _ => return Ok(SimpleConsensusValidationResult::new()), }; @@ -243,6 +268,13 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { platform_version, )? } + ShieldedMinFeeKind::IdentityCreate { num_keys } => { + dpp::shielded::compute_shielded_identity_create_fee( + num_actions, + num_keys, + platform_version, + )? + } }; if (validated_amount as u64) < minimum_shielded_fee { @@ -403,6 +435,36 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { ) } }, + StateTransition::IdentityCreateFromShieldedPool(st) => match st { + dpp::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition::V0(v0) => { + // Bind the new identity id + denomination + FULL public-key set into the + // Orchard sighash so the bundle cannot be redirected to a different + // identity/keys (the surplus_output binding analog). The id is re-derived + // from the spend nullifiers — the canonical value — so the binding holds + // regardless of any (separately-validated) wire `identity_id`. + let identity_id = + dpp::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions(&v0.actions) + .to_buffer(); + let extra_sighash_data = + dpp::shielded::identity_create_from_shielded_extra_sighash_data( + &identity_id, + v0.denomination, + &v0.public_keys, + ); + // value_balance = denomination EXACTLY (the ShieldedTransfer exact-equality + // model): the binding signature proves the value commitments sum to exactly + // the denomination leaving the pool. + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_SPENDS_AND_OUTPUTS, + v0.denomination as i64, + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &extra_sighash_data, + ) + } + }, // ShieldFromAssetLock retains proof verification in transform_into_action // (penalty comes from the asset lock, which is safe) _ => return Ok(SimpleConsensusValidationResult::new()), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 2db15868e62..dce166748f4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -206,6 +206,11 @@ impl StateTransitionStateValidation for StateTransition { "shielded withdrawal should not have state validation", ))) } + StateTransition::IdentityCreateFromShieldedPool(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "identity create from shielded pool should not have state validation", + ))) + } } } @@ -230,7 +235,8 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => false, + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => false, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs new file mode 100644 index 00000000000..a8067eb2e16 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -0,0 +1,65 @@ +mod transform_into_action; + +#[cfg(test)] +mod tests; + +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; + +/// A trait to transform into an action for the identity-create-from-shielded-pool transition. +pub trait StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer { + /// Transform into an action. + fn transform_into_action_for_identity_create_from_shielded_pool_transition( + &self, + platform: &PlatformRef, + signable_bytes: Vec, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer + for IdentityCreateFromShieldedPoolTransition +{ + fn transform_into_action_for_identity_create_from_shielded_pool_transition( + &self, + platform: &PlatformRef, + signable_bytes: Vec, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_create_from_shielded_pool_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform.drive, + signable_bytes, + execution_context, + tx, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "identity create from shielded pool transition: transform_into_action" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs new file mode 100644 index 00000000000..a82a9fc4edf --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -0,0 +1,5 @@ +//! Integration tests for the `IdentityCreateFromShieldedPool` state transition live in the +//! strategy-test / full-block-pipeline suites (credit conservation, denomination boundaries, +//! `total_fee >= denomination` rejection, prove/verify roundtrip, malleability). This module is a +//! placeholder so the `#[cfg(test)] mod tests;` declaration resolves; unit-level coverage of the +//! structural checks lives in the dpp `validate_structure` tests and the drive converter tests. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/mod.rs new file mode 100644 index 00000000000..a1a6fdd3a6b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/mod.rs @@ -0,0 +1,2 @@ +/// v0 +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..ddf561f8fda --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs @@ -0,0 +1,153 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, + validate_nullifiers, +}; +use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; +use dpp::consensus::state::state_error::StateError; +use dpp::prelude::ConsensusValidationResult; +use dpp::serialization::PlatformMessageSignable; +use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_shielded_pool) trait IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + signable_bytes: Vec, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0 + for IdentityCreateFromShieldedPoolTransition +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + signable_bytes: Vec, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let IdentityCreateFromShieldedPoolTransition::V0(v0) = self; + + let anchor: [u8; 32] = v0.anchor; + let nullifiers: Vec<[u8; 32]> = v0.actions.iter().map(|a| a.nullifier).collect(); + + // Read the current shielded pool state (read-your-own-writes within the block transaction). + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + // Minimum-notes anonymity-set threshold for outgoing transitions. + if let Some(consensus_error) = validate_minimum_pool_notes( + drive, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // The anchor must exist in the recorded anchors tree. + if let Some(consensus_error) = validate_anchor_exists( + drive, + &anchor, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Nullifiers must be unspent in state and not duplicated intra-bundle (read-your-own-writes). + if let Some(consensus_error) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Validate the new identity's key structure: a master key is required (in_create = true), + // no duplicate key ids or key data, and each key's security level matches its purpose — + // identical to `IdentityCreate`. + let key_structure_result = + IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &v0.public_keys, + true, + platform_version, + )?; + if !key_structure_result.is_valid() { + return Ok(ConsensusValidationResult::new_with_errors( + key_structure_result.errors, + )); + } + + // Per-key proof-of-possession: each key must sign the transition's signable bytes, proving + // the creator controls every key being registered (mirrors `IdentityCreate`'s + // identity-and-signatures check). The Orchard `extra_sighash_data` binding already pins the + // exact key set to this spend, so a relayer cannot swap keys; this additionally proves the + // creator holds them. + for key in v0.public_keys.iter() { + let result = signable_bytes.as_slice().verify_signature( + key.key_type(), + key.data().as_slice(), + key.signature().as_slice(), + ); + execution_context.add_operation(ValidationOperation::SignatureVerification( + SignatureVerificationOperation::new(key.key_type()), + )); + if !result.is_valid() { + return Ok(ConsensusValidationResult::new_with_errors(result.errors)); + } + } + + // The pool must hold at least the full denomination leaving it. + if current_total_balance < v0.denomination { + return Ok(ConsensusValidationResult::new_with_error( + StateError::InvalidShieldedProofError(InvalidShieldedProofError::new(format!( + "shielded pool has insufficient balance: pool has {} but identity-create exit requires {}", + current_total_balance, v0.denomination + ))) + .into(), + )); + } + + // The action carries the client-predicted fee for reference; the authoritative fee is + // METERED at execution and moved from the new identity's balance into the fee pools. + let fee_amount = dpp::shielded::compute_shielded_identity_create_fee( + v0.actions.len(), + v0.public_keys.len(), + platform_version, + )?; + + let action = IdentityCreateFromShieldedPoolTransitionAction::try_from_transition( + self, + current_total_balance, + fee_amount, + platform_version, + )?; + + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromShieldedPoolAction(action), + )) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index ec3d9731baf..bf2db56b270 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -41,6 +41,8 @@ pub mod address_credit_withdrawal; pub mod address_funds_transfer; mod identity_top_up_from_addresses; +/// Module for identity-create-from-shielded-pool transition validation +pub mod identity_create_from_shielded_pool; /// Module for shield transition validation pub mod shield; /// Module for shield from asset lock transition validation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index f2c02594f1d..35e539c7e08 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -6,6 +6,7 @@ use crate::execution::validation::state_transition::address_funding_from_asset_l use crate::execution::validation::state_transition::address_funds_transfer::StateTransitionAddressFundsTransferTransitionActionTransformer; use crate::execution::validation::state_transition::identity_create::StateTransitionActionTransformerForIdentityCreateTransitionV0; use crate::execution::validation::state_transition::identity_create_from_addresses::StateTransitionActionTransformerForIdentityCreateFromAddressesTransitionV0; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer; use crate::execution::validation::state_transition::identity_top_up::StateTransitionIdentityTopUpTransitionActionTransformer; use crate::execution::validation::state_transition::shield::StateTransitionShieldTransitionActionTransformer; use crate::execution::validation::state_transition::shield_from_asset_lock::StateTransitionShieldFromAssetLockTransitionActionTransformer; @@ -268,6 +269,17 @@ impl StateTransitionActionTransformer for StateTransition { } StateTransition::ShieldedWithdrawal(st) => st .transform_into_action_for_shielded_withdrawal_transition(platform, block_info, tx), + StateTransition::IdentityCreateFromShieldedPool(st) => { + // Bind the per-key proofs-of-possession to the full transition by passing its + // signable bytes (the per-key signatures are validated in transform_into_action). + let signable_bytes = self.signable_bytes()?; + st.transform_into_action_for_identity_create_from_shielded_pool_transition( + platform, + signable_bytes, + execution_context, + tx, + ) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs index a1fdd7069b0..4bbb8756eec 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs @@ -27,10 +27,12 @@ impl IdentityCreateFromShieldedPoolTransitionActionV0 { }) .collect(); - // The id was re-derived and checked against the spend nullifiers during validation, so it is - // authoritative here. + // Re-derive the id from the spend nullifiers (the canonical value). The wire `identity_id` + // is advisory; consensus always uses the derived value so a malformed/malicious wire id + // cannot redirect the created identity (the Orchard sighash also binds the derived id). + let identity_id = dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions(&value.actions); let mut identity = - Identity::new_with_id_and_keys(value.identity_id, public_keys, platform_version)?; + Identity::new_with_id_and_keys(identity_id, public_keys, platform_version)?; // The identity is created holding the FULL denomination. The fee is moved out of this // balance into the fee pools at execution (so the credit supply is conserved). identity.set_balance(value.denomination); diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 104fc2cc4df..3acea0c769a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -98,6 +98,8 @@ pub struct DriveAbciStateTransitionValidationVersions { pub unshield_state_transition: DriveAbciStateTransitionValidationVersion, pub shield_from_asset_lock_state_transition: DriveAbciStateTransitionValidationVersion, pub shielded_withdrawal_state_transition: DriveAbciStateTransitionValidationVersion, + pub identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 3cb6b8b7330..dbb998a6ac7 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -243,6 +243,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 1e15e788ccb..ef7ab0f538a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -243,6 +243,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 585e330eb1a..002bee61637 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -243,6 +243,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index f01e5be12e1..d263b638254 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -246,6 +246,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, // <---- changed this has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index 6024d029908..420624ae1ba 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -247,6 +247,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index 54cf7e56eed..3b0d5f4af92 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -250,6 +250,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index 13edd80fb0a..e983eb5e025 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -244,6 +244,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index d8b249cc9d0..c44d1a20c29 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -298,6 +298,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, From 968e86c9b7cab7ada34bab8ce71f0a4b5b085cf0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 15:58:37 +0200 Subject: [PATCH 04/28] feat(wasm): wire IdentityCreateFromShieldedPool into wasm-dpp/wasm-dpp2 (type 20) Stage 5 (part 1) of #3813. - wasm-dpp2 state_transition.rs: action-type-number => 20 + all five grouping matches (owner-id, identity-nonce, set-owner, set-identity-contract-nonce, set-identity-nonce). - wasm-dpp2 proof_result: new VerifiedIdentityWithShieldedNullifiersWasm wrapper + convert arm + TS union entry. - wasm-dpp (legacy): BasicError::ShieldedInvalidDenominationError mapping + state_transition_factory arm. cargo check --workspace green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/errors/consensus/consensus_error.rs | 5 +- .../state_transition_factory.rs | 3 +- .../base/state_transition.rs | 16 ++++-- .../state_transitions/proof_result/convert.rs | 15 +++++- .../proof_result/shielded.rs | 50 +++++++++++++++++++ 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index f7c96ff8fb1..e4fd48143b8 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -95,7 +95,7 @@ use dpp::consensus::state::shielded::insufficient_shielded_fee_error::Insufficie use dpp::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; -use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError}; +use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, ShieldedInvalidDenominationError}; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -967,6 +967,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ShieldedImplicitFeeCapExceededError(e) => { generic_consensus_error!(ShieldedImplicitFeeCapExceededError, e).into() } + BasicError::ShieldedInvalidDenominationError(e) => { + generic_consensus_error!(ShieldedInvalidDenominationError, e).into() + } } } diff --git a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs index 2b929d17a15..cac1c8925aa 100644 --- a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs +++ b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs @@ -83,7 +83,8 @@ impl StateTransitionFactoryWasm { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) => { todo!("shielded transitions not yet implemented in state_transition_factory") } }, diff --git a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs index d2dbe0a927e..cd90cdc68b4 100644 --- a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs @@ -313,6 +313,7 @@ impl StateTransitionWasm { Unshield(_) => 17, ShieldFromAssetLock(_) => 18, ShieldedWithdrawal(_) => 19, + IdentityCreateFromShieldedPool(_) => 20, } } @@ -401,7 +402,8 @@ impl StateTransitionWasm { | ShieldedTransfer(_) | Unshield(_) | ShieldFromAssetLock(_) - | ShieldedWithdrawal(_) => None, + | ShieldedWithdrawal(_) + | IdentityCreateFromShieldedPool(_) => None, } } @@ -428,7 +430,8 @@ impl StateTransitionWasm { | ShieldedTransfer(_) | Unshield(_) | ShieldFromAssetLock(_) - | ShieldedWithdrawal(_) => None, + | ShieldedWithdrawal(_) + | IdentityCreateFromShieldedPool(_) => None, } } @@ -570,7 +573,8 @@ impl StateTransitionWasm { | ShieldedTransfer(_) | Unshield(_) | ShieldFromAssetLock(_) - | ShieldedWithdrawal(_) => { + | ShieldedWithdrawal(_) + | IdentityCreateFromShieldedPool(_) => { return Err(WasmDppError::invalid_argument( "Cannot set owner for shielded transition", )); @@ -647,7 +651,8 @@ impl StateTransitionWasm { | ShieldedTransfer(_) | Unshield(_) | ShieldFromAssetLock(_) - | ShieldedWithdrawal(_) => { + | ShieldedWithdrawal(_) + | IdentityCreateFromShieldedPool(_) => { return Err(WasmDppError::invalid_argument( "Cannot set identity contract nonce for shielded transition", )); @@ -744,7 +749,8 @@ impl StateTransitionWasm { | ShieldedTransfer(_) | Unshield(_) | ShieldFromAssetLock(_) - | ShieldedWithdrawal(_) => { + | ShieldedWithdrawal(_) + | IdentityCreateFromShieldedPool(_) => { return Err(WasmDppError::invalid_argument( "Cannot set identity nonce for shielded transition", )); diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs index 3859416d039..9c3a7503631 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs @@ -16,7 +16,8 @@ use super::identity::{ }; use super::shielded::{ VerifiedAssetLockConsumedWasm, VerifiedAssetLockConsumedWithAddressInfosWasm, - VerifiedShieldedNullifiersWasm, VerifiedShieldedNullifiersWithAddressInfosWasm, + VerifiedIdentityWithShieldedNullifiersWasm, VerifiedShieldedNullifiersWasm, + VerifiedShieldedNullifiersWithAddressInfosWasm, VerifiedShieldedNullifiersWithWithdrawalDocumentWasm, }; use super::token::{ @@ -67,7 +68,8 @@ export type StateTransitionProofResultType = | VerifiedAssetLockConsumedWithAddressInfos | VerifiedShieldedNullifiers | VerifiedShieldedNullifiersWithAddressInfos - | VerifiedShieldedNullifiersWithWithdrawalDocument; + | VerifiedShieldedNullifiersWithWithdrawalDocument + | VerifiedIdentityWithShieldedNullifiers; "#; #[wasm_bindgen] @@ -308,6 +310,15 @@ pub fn convert_proof_result( ) .into() } + + StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers( + identity, + nullifiers, + ) => VerifiedIdentityWithShieldedNullifiersWasm::new( + identity.into(), + build_nullifier_map(nullifiers), + ) + .into(), }; Ok(js_value.into()) diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs index d2da7bbfcc6..aa34fc1a3fc 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs @@ -7,6 +7,7 @@ use super::helpers::js_obj; use crate::error::{WasmDppError, WasmDppResult}; use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; +use crate::IdentityWasm; use crate::serialization::conversions::normalize_js_value_for_json; use js_sys::{BigInt, Map}; use serde::{Deserialize, Serialize}; @@ -417,3 +418,52 @@ impl_wasm_type_info!( VerifiedShieldedNullifiersWithWithdrawalDocumentWasm, VerifiedShieldedNullifiersWithWithdrawalDocument ); + +// --- VerifiedIdentityWithShieldedNullifiers --- + +/// Returned by `IdentityCreateFromShieldedPool`: the newly-created identity plus the presence of +/// each spent funding nullifier, proven together in a single STRICT merged GroveDB proof. +#[wasm_bindgen(js_name = "VerifiedIdentityWithShieldedNullifiers")] +#[derive(Clone)] +pub struct VerifiedIdentityWithShieldedNullifiersWasm { + #[wasm_bindgen(getter_with_clone)] + pub identity: IdentityWasm, + nullifiers: Map, +} + +#[wasm_bindgen(js_class = VerifiedIdentityWithShieldedNullifiers)] +impl VerifiedIdentityWithShieldedNullifiersWasm { + #[wasm_bindgen(getter)] + pub fn nullifiers(&self) -> Map { + self.nullifiers.clone() + } + + #[wasm_bindgen(js_name = toObject)] + pub fn to_object(&self) -> JsValue { + js_obj(&[ + ("identity", self.identity.clone().into()), + ("nullifiers", self.nullifiers.clone().into()), + ]) + } + + /// Returns a `JSON.stringify`-friendly form: the `Map` is normalised to a plain object so its + /// entries survive serialisation. + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> WasmDppResult { + normalize_js_value_for_json(&self.to_object()) + } +} + +impl VerifiedIdentityWithShieldedNullifiersWasm { + pub fn new(identity: IdentityWasm, nullifiers: Map) -> Self { + Self { + identity, + nullifiers, + } + } +} + +impl_wasm_type_info!( + VerifiedIdentityWithShieldedNullifiersWasm, + VerifiedIdentityWithShieldedNullifiers +); From 31980ef1a99b3c44559f1313ae8c52045a010b2a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 16:01:02 +0200 Subject: [PATCH 05/28] feat(sdk): IdentityCreateFromShieldedPool broadcast helper (type 20) Stage 5 (part 2) of #3813. New `IdentityCreateFromShieldedPool` SDK trait on `Sdk` (gated by the `shielded` feature): builds the transition from a pre-built Orchard bundle + the new identity's public keys (with per-key PoP signatures), validates structure, and broadcasts. Mirrors `unshield`. cargo check -p dash-sdk --features shielded green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/platform/transition.rs | 2 + .../identity_create_from_shielded_pool.rs | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index a1b58e7135d..5913a47a7e1 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -14,6 +14,8 @@ pub mod put_document; pub mod put_identity; pub mod put_settings; #[cfg(feature = "shielded")] +pub mod identity_create_from_shielded_pool; +#[cfg(feature = "shielded")] pub mod shield; #[cfg(feature = "shielded")] pub mod shield_from_asset_lock; diff --git a/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs new file mode 100644 index 00000000000..64be75a4c51 --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs @@ -0,0 +1,63 @@ +use super::broadcast::BroadcastStateTransition; +use super::put_settings::PutSettings; +use super::validation::ensure_valid_state_transition_structure; +use crate::{Error, Sdk}; +use dpp::shielded::OrchardBundleParams; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::methods::IdentityCreateFromShieldedPoolTransitionMethodsV0; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + +/// Helper trait to create a brand-new Platform identity funded directly from the shielded pool. +#[async_trait::async_trait] +pub trait IdentityCreateFromShieldedPool { + /// Create a new identity funded by spending shielded-pool notes. + /// + /// The exit amount is a fixed `denomination` (a member of the versioned denomination set), and + /// authorization is 100% the Orchard proof + per-action spend-auth signatures + binding + /// signature (no platform identity signature). The new identity's id is derived from the spend + /// nullifiers and is bound — together with the denomination and the full public-key set — into + /// the Orchard sighash, so the bundle cannot be redirected. + /// + /// `public_keys` MUST already carry their per-key proof-of-possession signatures over the + /// transition's signable bytes (the wallet/builder fills them before broadcast). The new + /// identity is created holding `denomination - total_fee`. + async fn identity_create_from_shielded_pool( + &self, + public_keys: Vec, + denomination: u64, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result<(), Error>; +} + +#[async_trait::async_trait] +impl IdentityCreateFromShieldedPool for Sdk { + async fn identity_create_from_shielded_pool( + &self, + public_keys: Vec, + denomination: u64, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result<(), Error> { + let OrchardBundleParams { + actions, + anchor, + proof, + binding_signature, + } = bundle; + + let state_transition = IdentityCreateFromShieldedPoolTransition::try_from_bundle( + public_keys, + denomination, + actions, + anchor, + proof, + binding_signature, + self.version(), + )?; + ensure_valid_state_transition_structure(&state_transition, self.version())?; + + state_transition.broadcast(self, settings).await?; + Ok(()) + } +} From aab37354c17bbca0a0747dff9cbbd8ea116b504c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 16:04:40 +0200 Subject: [PATCH 06/28] test(dpp)+docs: IdentityCreateFromShieldedPool sighash/fee tests + book docs (#3813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 6 (part 1) of #3813. - dpp tests: extra_sighash_data layout + binding (id / denomination / full key set are all committed → anti-redirection/anti-key-swap), and compute_shielded_identity_create_fee monotonic growth with key count. - book: shielded-fees.md fee-extraction row (metered + moved-from-identity model, fixed denomination, exact value_balance) + error-codes.md 10827 (ShieldedInvalidDenominationError). Co-Authored-By: Claude Opus 4.8 (1M context) --- book/src/error-handling/error-codes.md | 2 +- book/src/fees/shielded-fees.md | 1 + .../compute_minimum_shielded_fee/v0/mod.rs | 40 +++++++++ packages/rs-dpp/src/shielded/mod.rs | 81 +++++++++++++++++++ packages/rs-sdk/src/platform/transition.rs | 4 +- .../proof_result/shielded.rs | 2 +- 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 1d4f60406b6..018dd014fd0 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -58,7 +58,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) | | 10700-10700 | General | `OverflowError` (10700) | | 10800-10818 | Address | `TransitionOverMaxInputsError` (10800), `WithdrawalBelowMinAmountError` (10818) | -| 10819-10826 | Shielded | `ShieldedNoActionsError` (10819), `ShieldedTooManyActionsError` (10825), `ShieldedImplicitFeeCapExceededError` (10826) | +| 10819-10827 | Shielded | `ShieldedNoActionsError` (10819), `ShieldedTooManyActionsError` (10825), `ShieldedImplicitFeeCapExceededError` (10826), `ShieldedInvalidDenominationError` (10827 — `IdentityCreateFromShieldedPool` exit amount not a member of the versioned denomination set) | ### SignatureError codes (20000-20012) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 53c895232a6..152a75f88bf 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -42,6 +42,7 @@ The fee is derived differently depending on the shielded transition type: | **Unshield** | `fee = compute_minimum_shielded_fee(num_actions) + unshield_address_storage_fee` | `value_balance` (the transition's `unshielding_amount`) is the **gross** amount leaving the pool. The output address receives `unshielding_amount − fee`; validation requires `unshielding_amount ≥ fee`. Unshield also writes the net to the output platform address (`AddBalanceToAddress`), a real storage write priced on top of the base shielded minimum (`unshield_address_storage_fee = 222 × per_byte_rate`, ≈6.08M credits, flat regardless of action count — 222 bytes is the *storage* portion of the ≈6.24M metered address write) so the address write is covered and the proof fee isn't diverted to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | | **ShieldedWithdrawal** | `fee = compute_minimum_shielded_fee(num_actions) + withdrawal_document_storage_fee` | `value_balance` (`unshielding_amount`) is the **gross** amount leaving the pool. The Core withdrawal document receives `unshielding_amount − fee` (which must also clear `MIN_WITHDRAWAL_AMOUNT`). Unlike the other pool-paid transitions, ShieldedWithdrawal also **writes a Core withdrawal document** — a real document insert into the withdrawals contract plus its index entries (`AddWithdrawalDocument`), with a real metered cost of ≈110M credits that is **flat regardless of action count**. That cost is priced on top of the base shielded minimum as a flat ~4,100-byte storage component (`withdrawal_document_storage_fee = 4100 × per_byte_rate`), so the document write is covered and the proof-verification fee isn't diverted from the proposer to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | | **ShieldFromAssetLock** | `pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost`, paid from the asset lock | The flat shielded minimum plus the asset-lock processing base cost is routed to the fee pools. Any remaining asset-lock value (the *surplus*) goes to an optional signed `surplus_output` platform address, or — if none is set — folds into the fee pools up to `shielded_implicit_fee_cap`. See [Entry-Transition Fees](#entry-transition-fees-shield-and-shieldfromassetlock). | +| **IdentityCreateFromShieldedPool** | `total_fee = metered(insert_nullifiers + AddNewIdentity(identity + N keys)) + shielded_verification_fee`, **moved from the new identity's balance** | `value_balance` is a **fixed `denomination`** (a member of the versioned set `{0.1, 0.3, 0.5, 1.0}` DASH) and must equal it EXACTLY. The new identity is created holding the full `denomination` (debited from the pool, mirrored by `AddToSystemCredits(denomination)`); the fee is then **moved** from that balance into the fee pools, so the identity ends with `denomination − total_fee`. Unlike the flat pool-paid transitions, the `AddNewIdentity` write grows with the key count, so the cost is **metered** (not a flat carve) — only the ZK compute fee (`compute_shielded_verification_fee`) is added on top, exactly like the transparent `Shield`. The client predicts it offline with `compute_shielded_identity_create_fee(num_actions, num_keys)`; consensus rejects `denomination < total_fee` with `IdentityInsufficientBalanceError`. | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index b7f92ad0d17..83dd2ff6f76 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -329,6 +329,46 @@ mod tests { } } + /// The identity-create fee MUST equal the base shielded fee plus the variable identity-write + /// component `(BASE + num_keys × PER_KEY) × per_byte_rate`, and it MUST grow strictly with the + /// key count (a larger key set is a larger `AddNewIdentity` write). This pins the formula so the + /// `denomination >= min_fee` gate and the client predictor stay aligned with the metered write. + #[test] + fn compute_shielded_identity_create_fee_v0_scales_with_keys() { + let platform_version = PlatformVersion::latest(); + let storage = &platform_version.fee_version.storage; + let per_byte_rate = + storage.storage_disk_usage_credit_per_byte + storage.storage_processing_credit_per_byte; + + for num_actions in [1usize, 2, 5] { + let base = compute_minimum_shielded_fee_v0(num_actions, platform_version) + .expect("minimum shielded fee"); + let mut previous = None; + for num_keys in [1usize, 2, 5, 10] { + let fee = compute_shielded_identity_create_fee_v0( + num_actions, + num_keys, + platform_version, + ) + .expect("identity create fee"); + let expected_identity_bytes = SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES + + num_keys as u64 * SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES; + assert_eq!( + fee, + base + expected_identity_bytes * per_byte_rate, + "identity create fee must equal base + (BASE + num_keys×PER_KEY)×rate" + ); + if let Some(prev) = previous { + assert!( + fee > prev, + "identity create fee must grow strictly with the key count" + ); + } + previous = Some(fee); + } + } + } + /// Pin the exact relationship between the Unshield fee and the base shielded fee: /// the unshield fee MUST be `compute_minimum_shielded_fee_v0(n)` plus exactly one flat /// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES × per_byte_rate` address-write component (the same diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 2c459c39b04..f4dfbc5fb6a 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -372,4 +372,85 @@ mod tests { assert_eq!(&d[0..3], &[0xAA, 0xBB, 0xCC]); assert_eq!(&d[3..11], &5u64.to_le_bytes()); } + + mod identity_create_sighash { + use super::*; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use platform_value::BinaryData; + + fn mk_key(id: u32, data_byte: u8) -> IdentityPublicKeyInCreation { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![data_byte; 33]), + signature: BinaryData::new(vec![]), + }) + } + + #[test] + fn layout_is_length_prefixed() { + // identity_id(32) || denomination(8) || num_keys(2) || [key_id(4)|purpose|sec|type|len(2)|data] + let id = [0x11u8; 32]; + let keys = vec![mk_key(7, 0xAB)]; + let d = identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &keys); + assert_eq!(&d[0..32], &id); + assert_eq!(&d[32..40], &10_000_000_000u64.to_le_bytes()); + assert_eq!(&d[40..42], &1u16.to_le_bytes()); + assert_eq!(&d[42..46], &7u32.to_le_bytes()); + assert_eq!(d[46], Purpose::AUTHENTICATION as u8); + assert_eq!(d[47], SecurityLevel::MASTER as u8); + assert_eq!(d[48], KeyType::ECDSA_SECP256K1 as u8); + assert_eq!(&d[49..51], &33u16.to_le_bytes()); + assert_eq!(&d[51..84], &[0xAB; 33]); + assert_eq!(d.len(), 32 + 8 + 2 + (4 + 1 + 1 + 1 + 2 + 33)); + } + + #[test] + fn binds_identity_id_denomination_and_keys() { + let id_a = [0x11u8; 32]; + let id_b = [0x22u8; 32]; + let keys = vec![mk_key(0, 0xAA)]; + let base = + identity_create_from_shielded_extra_sighash_data(&id_a, 10_000_000_000, &keys); + + // Changing the identity id changes the preimage (anti-redirection to a different id). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data(&id_b, 10_000_000_000, &keys), + "identity id must be bound" + ); + // Changing the denomination changes the preimage. + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data(&id_a, 30_000_000_000, &keys), + "denomination must be bound" + ); + // Swapping in a different key changes the preimage (anti-key-swap). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &[mk_key(0, 0xBB)] + ), + "key data must be bound" + ); + // Adding a key changes the preimage (the full set is bound, not just the count). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &[mk_key(0, 0xAA), mk_key(1, 0xCC)] + ), + "the full key set must be bound" + ); + } + } } diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index 5913a47a7e1..7d14e6c6b57 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -8,14 +8,14 @@ pub use address_inputs::fetch_inputs_with_nonce; pub mod broadcast; pub(crate) mod broadcast_identity; pub mod broadcast_request; +#[cfg(feature = "shielded")] +pub mod identity_create_from_shielded_pool; pub mod purchase_document; pub mod put_contract; pub mod put_document; pub mod put_identity; pub mod put_settings; #[cfg(feature = "shielded")] -pub mod identity_create_from_shielded_pool; -#[cfg(feature = "shielded")] pub mod shield; #[cfg(feature = "shielded")] pub mod shield_from_asset_lock; diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs index aa34fc1a3fc..a7c0ba71775 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs @@ -4,10 +4,10 @@ //! code in its own module. use super::helpers::js_obj; +use crate::IdentityWasm; use crate::error::{WasmDppError, WasmDppResult}; use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; -use crate::IdentityWasm; use crate::serialization::conversions::normalize_js_value_for_json; use js_sys::{BigInt, Map}; use serde::{Deserialize, Serialize}; From 8d288adb1cb24776cad194b0c3c1f087c3d67951 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 16:06:29 +0200 Subject: [PATCH 07/28] test(drive): IdentityCreateFromShieldedPool strict-verify empty-proof rejection (#3813) Adds a negative test exercising the new strict merged-query verify arm: an empty proof cannot satisfy verify_query_with_absence_proof over the merged {nullifier-tree, identity} query, so the verifier rejects. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v0/mod.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 002d95db653..e05f8b729e8 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -3037,6 +3037,64 @@ mod tests { ); } + // --- IdentityCreateFromShieldedPool: empty proof returns error. + // + // Exercises the STRICT merged-query verify arm: an empty proof cannot satisfy + // `verify_query_with_absence_proof` over the merged {nullifier-tree, identity} query, so the + // verifier must reject (rather than silently accepting). The positive prove→verify roundtrip and + // the padded-proof (extra-branch) rejection are covered by the full-block integration suite. + #[test] + fn verify_identity_create_from_shielded_pool_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + use dpp::shielded::SerializedAction; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + + let actions = vec![SerializedAction { + nullifier: [0x11; 32], + rk: [0x22; 32], + cmx: [0x33; 32], + encrypted_note: vec![0x44; 216], + cv_net: [0x55; 32], + spend_auth_sig: [0x66; 64], + }]; + let identity_id = derive_identity_id_from_actions(&actions); + + let st = StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 10_000_000_000, + actions, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id, + }, + ), + ); + + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + assert!( + matches!( + result, + Err(crate::error::Error::Proof(_)) | Err(crate::error::Error::GroveDB(_)) + ), + "expected error for identity create from shielded pool with empty proof, got: {:?}", + result + ); + } + // --- IdentityCreditTransferToAddresses: empty proof returns error. #[test] fn verify_identity_credit_transfer_to_addresses_empty_proof_returns_error() { From 7e1734e1309b96fd3812731cbe4d2755b4912c7d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 19:37:50 +0200 Subject: [PATCH 08/28] feat(swift-sdk): IdentityCreateFromShieldedPool wallet + FFI + Swift surface (#3813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 5 (part 3) of #3813 — the client-wallet surface (built by the swift-rust-ffi-engineer agent, integrated + compile-verified here). - rs-dpp: new shielded `builder/identity_create_from_shielded_pool.rs` (Orchard spend bundle binding the identity payload via extra_sighash_data, exact-equality value_balance == denomination, change note back to pool, per-key proof-of-possession signing) + builder/mod.rs registration. - rs-platform-wallet: `identity_create_from_shielded_pool` operation + `ShieldedFeeKind::IdentityCreate { num_keys }` note-selection variant (denomination − fee reservation, exact-equality model). - rs-platform-wallet-ffi: `platform_wallet_manager_shielded_identity_create_from_pool` extern "C" fn marshalling to the wallet operation. - swift-sdk: `shieldedIdentityCreateFromPool(...)` wrapper bridging to the FFI (persist/load/bridge only; all logic stays in Rust). cargo check -p platform-wallet-ffi green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../identity_create_from_shielded_pool.rs | 232 ++++++++++++++++++ packages/rs-dpp/src/shielded/builder/mod.rs | 4 + .../src/shielded_send.rs | 130 +++++++++- .../src/wallet/platform_wallet.rs | 53 ++++ .../src/wallet/shielded/note_selection.rs | 144 ++++++++++- .../src/wallet/shielded/operations.rs | 180 +++++++++++++- .../ManagedPlatformWallet.swift | 5 +- .../PlatformWalletManagerShieldedSync.swift | 95 +++++++ 8 files changed, 835 insertions(+), 8 deletions(-) create mode 100644 packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs diff --git a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs new file mode 100644 index 00000000000..b2ab7a588a2 --- /dev/null +++ b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs @@ -0,0 +1,232 @@ +use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey}; + +use crate::address_funds::OrchardAddress; +use crate::fee::Credits; +use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use crate::identity::signer::Signer; +use crate::identity::IdentityPublicKey; +use crate::serialization::Signable; +use crate::shielded::compute_shielded_identity_create_fee; +use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use crate::shielded::OrchardBundleParams; +use crate::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::methods::IdentityCreateFromShieldedPoolTransitionMethodsV0; +use crate::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::{ + derive_identity_id_from_actions, identity_id_from_nullifiers, + IdentityCreateFromShieldedPoolTransition, +}; +use crate::state_transition::StateTransition; +use crate::ProtocolError; +use platform_value::Identifier; +use platform_version::version::PlatformVersion; + +use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, SpendableNote}; + +/// Output of [`build_identity_create_from_shielded_pool_transition`]: everything the SDK's +/// `IdentityCreateFromShieldedPool::identity_create_from_shielded_pool` broadcast helper needs. +/// +/// The split (PoP-signed keys + bundle params, rather than a fully-built `StateTransition`) lets +/// the wallet feed the SDK helper directly — the helper re-assembles the transition via +/// `try_from_bundle`, which preserves the per-key proof-of-possession signatures already filled here. +pub struct IdentityCreateFromShieldedPoolBuildResult { + /// The new identity's public keys with their per-key proof-of-possession signatures filled. + pub public_keys: Vec, + /// The serialized, authorized Orchard bundle (actions / anchor / proof / binding signature). + pub bundle: OrchardBundleParams, + /// The new identity's id (`double_sha256(sorted nullifiers)`), surfaced so the host can persist + /// / display it without re-deriving. + pub identity_id: Identifier, + /// The client-predicted fee (in credits). The authoritative fee is metered at consensus. + pub predicted_fee: Credits, +} + +/// Builds an `IdentityCreateFromShieldedPool` (Type 20) state transition: spend shielded-pool +/// notes to fund a brand-new Platform identity. +/// +/// The `denomination` (a member of the versioned exit-denomination set) leaves the pool EXACTLY — +/// the bundle's `value_balance` equals `denomination` (the ShieldedTransfer exact-equality model). +/// Any spent value above the denomination re-enters the pool as a single change note to +/// `change_address`. The metered fee is taken from the denomination at execution, so the new +/// identity is created holding `denomination - total_fee` (the fee is NOT subtracted from the +/// bundle here — only predicted for the caller's note-reservation math). +/// +/// # Authorization +/// +/// `IdentityCreateFromShieldedPool` carries NO platform identity signature. Authorization is 100%: +/// 1. the Orchard proof + per-action spend-auth signatures (the spender controls the spent notes), +/// 2. the RedPallas binding signature over the platform sighash, which commits the new identity id, +/// the denomination, and the FULL public-key set via +/// [`crate::shielded::identity_create_from_shielded_extra_sighash_data`] — so a relayer cannot +/// redirect the bundle to a different id or swap in keys it controls, and +/// 3. a per-key proof-of-possession signature over the transition's `signable_bytes`, proving the +/// creator holds every key being registered (mirrors `IdentityCreate`). +/// +/// The new identity id is derived from the SORTED spend nullifiers +/// ([`derive_identity_id_from_actions`]) — fully determined by which notes are spent, so it is +/// known before the bundle is built and the same value is re-derived and checked at consensus. +/// +/// # Parameters +/// - `public_keys` — the new identity's public keys, each paired with its +/// [`IdentityPublicKeyInCreation`] form (the latter goes into the transition; the former is used +/// only to look up the private key in `identity_signer`). The per-key proof-of-possession +/// signatures are filled by this function. +/// - `denomination` — the fixed exit amount (in credits) leaving the pool. +/// - `spends` — notes to spend with their Merkle paths. Their total MUST be `>= denomination`. +/// - `change_address` — Orchard address that receives the change note (`total_spent - denomination`). +/// - `fvk` / `ask` — the spender's full viewing key and spend-authorizing key (Orchard side). +/// - `anchor` — Sinsemilla root of the note commitment tree (Orchard Anchor). +/// - `prover` — Orchard prover (holds the Halo 2 proving key). +/// - `identity_signer` — produces each new key's proof-of-possession signature over the transition's +/// signable bytes. +/// - `memo` — 36-byte structured memo for the change output. +/// - `platform_version` — protocol version. +/// +/// Returns the PoP-signed keys, the serialized Orchard bundle, the derived identity id, and the +/// client-predicted fee (in credits) — ready to feed the SDK's +/// `IdentityCreateFromShieldedPool::identity_create_from_shielded_pool` broadcast helper. The +/// authoritative fee is metered at consensus. +#[allow(clippy::too_many_arguments)] +pub async fn build_identity_create_from_shielded_pool_transition( + public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, + denomination: u64, + spends: Vec, + change_address: &OrchardAddress, + fvk: &FullViewingKey, + ask: &SpendAuthorizingKey, + anchor: Anchor, + prover: &P, + identity_signer: &S, + memo: [u8; 36], + platform_version: &PlatformVersion, +) -> Result +where + P: OrchardProver, + S: Signer, +{ + if denomination > i64::MAX as u64 { + return Err(ProtocolError::ShieldedBuildError(format!( + "denomination {} exceeds maximum allowed value {}", + denomination, + i64::MAX as u64 + ))); + } + if public_keys.is_empty() { + return Err(ProtocolError::ShieldedBuildError( + "identity-create-from-shielded-pool requires at least one public key".to_string(), + )); + } + + let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum(); + if denomination > total_spent { + return Err(ProtocolError::ShieldedBuildError(format!( + "denomination {} exceeds total spendable value {}", + denomination, total_spent + ))); + } + + // The whole denomination leaves the pool; the excess re-enters as a single change note. There + // is NO shielded recipient — the value funds the (transparent) new identity, not another note. + let change_amount = total_spent - denomination; + + // Orchard's BundleType::DEFAULT pads single-spend bundles to a 2-action minimum, matching the + // other spend-side builders. The fee predictor is only informational here (the metered fee at + // execution is authoritative); we report it so the caller's reservation math lines up. + let num_actions = spends.len().max(2); + let fee = + compute_shielded_identity_create_fee(num_actions, public_keys.len(), platform_version)?; + + // The id is derived from the SORTED spend nullifiers, which must be known BEFORE signing + // because the id is part of the Orchard sighash. The nullifier of a spend is + // `Note::nullifier(fvk)`, independent of bundle randomness, so compute them directly from the + // spent notes — the same values the bundle will publish and consensus will re-derive from. + let nullifiers: Vec<[u8; 32]> = spends + .iter() + .map(|s| s.note.nullifier(fvk).to_bytes()) + .collect(); + let identity_id = identity_id_from_nullifiers(&nullifiers); + + // Build the in-creation key list (transition order) and bind it — together with the id and the + // denomination — into the Orchard sighash. + let in_creation_keys: Vec = + public_keys.iter().map(|(_, c)| c.clone()).collect(); + let extra_sighash_data = crate::shielded::identity_create_from_shielded_extra_sighash_data( + &identity_id.to_buffer(), + denomination, + &in_creation_keys, + ); + + let bundle = build_spend_bundle( + spends, + change_address, + change_amount, + memo, + fvk, + ask, + anchor, + prover, + &extra_sighash_data, + )?; + + let sb = serialize_authorized_bundle(&bundle); + + // The consensus binding re-derives the id from the on-wire action nullifiers. Assert the + // bundle's published nullifiers reduce to the same id we bound, so a mismatch is caught here + // (cheap) rather than as an opaque InvalidShieldedProofError after the ~30 s proof. + debug_assert_eq!( + identity_id, + derive_identity_id_from_actions(&sb.actions), + "bound identity id must match the id re-derived from the bundle's published nullifiers" + ); + + // Build the transition (denomination == value_balance EXACTLY) with the unsigned key set, purely + // to obtain the canonical signable bytes the per-key proofs-of-possession must sign. + let mut state_transition = IdentityCreateFromShieldedPoolTransition::try_from_bundle( + in_creation_keys, + denomination, + sb.actions.clone(), + sb.anchor, + sb.proof.clone(), + sb.binding_signature, + platform_version, + )?; + + // Per-key proof-of-possession: each unique-type key signs the transition's signable bytes. The + // signable form excludes the per-key signatures themselves (and the derived identity id), so the + // bytes are stable across the signing loop — compute them once, mirroring `IdentityCreate`. + let key_signable_bytes = state_transition.signable_bytes()?; + + let StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0(v0), + ) = &mut state_transition + else { + return Err(ProtocolError::ShieldedBuildError( + "unexpected state transition variant after try_from_bundle".to_string(), + )); + }; + + for (key_with_witness, (original_key, _)) in v0.public_keys.iter_mut().zip(public_keys.iter()) { + if original_key.key_type().is_unique_key_type() { + let signature = identity_signer + .sign(original_key, &key_signable_bytes) + .await?; + key_with_witness.set_signature(signature); + } + } + + // Hand the PoP-signed keys + the bundle params back to the caller (the wallet), which feeds them + // to the SDK broadcast helper. The helper re-assembles the transition via `try_from_bundle`, + // preserving these signatures. + let signed_public_keys = std::mem::take(&mut v0.public_keys); + + Ok(IdentityCreateFromShieldedPoolBuildResult { + public_keys: signed_public_keys, + bundle: OrchardBundleParams { + actions: sb.actions, + anchor: sb.anchor, + proof: sb.proof, + binding_signature: sb.binding_signature, + }, + identity_id, + predicted_fee: fee, + }) +} diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 288c1b7e4ce..431e558ff09 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -28,6 +28,7 @@ //! )?; //! ``` +mod identity_create_from_shielded_pool; mod shield; mod shield_from_asset_lock; mod shielded_transfer; @@ -35,6 +36,9 @@ mod shielded_withdrawal; mod unshield; pub use self::shield::build_shield_transition; +pub use identity_create_from_shielded_pool::{ + build_identity_create_from_shielded_pool_transition, IdentityCreateFromShieldedPoolBuildResult, +}; pub use shield_from_asset_lock::build_shield_from_asset_lock_transition; #[cfg(feature = "core_key_wallet")] pub use shield_from_asset_lock::build_shield_from_asset_lock_transition_with_signer; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 398b5e57e10..e355054663a 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1,5 +1,6 @@ //! FFI bindings for the shielded spend pipeline (transitions -//! 15/16/17/19 — shield, transfer, unshield, withdraw). +//! 15/16/17/19/20 — shield, transfer, unshield, withdraw, +//! identity-create-from-pool). //! //! Transitions 16/17/19 sign with the bound shielded wallet's //! Orchard `SpendAuthorizingKey`, which lives on the @@ -9,6 +10,14 @@ //! withdrawal) and the resulting Halo 2 proof + state transition //! is built and broadcast on the Rust side. //! +//! Transition 20 (`identity_create_from_pool` — Shielded→new +//! identity) additionally takes the new identity's public keys plus +//! a host-supplied `Signer` for the per-key +//! proofs-of-possession (mirroring address-funded identity +//! registration). The Orchard spend authority is still the bound +//! wallet's own `SpendAuthorizingKey`; only the new identity keys' +//! PoP signatures come from the host signer. +//! //! Transition 15 (`shield` — Platform→Shielded) additionally //! takes a host-supplied `Signer` because the //! input addresses' ECDSA signatures live in the host keychain. @@ -35,6 +44,7 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; @@ -43,6 +53,7 @@ use crate::check_ptr; use crate::core_wallet_types::OutPointFFI; use crate::error::*; use crate::handle::*; +use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; use crate::runtime::{block_on_worker, runtime}; /// Parse an optional surplus-output platform address supplied as raw @@ -289,6 +300,123 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( PlatformWalletFFIResult::ok() } +/// IdentityCreateFromShieldedPool (Type 20): spend `account`'s shielded notes to fund a brand-new +/// Platform identity. +/// +/// The host supplies the new identity's public keys (`identity_pubkeys` rows, same +/// [`IdentityPubkeyFFI`] shape as address-funded registration) and a chosen `denomination` (a +/// member of the versioned exit-denomination set, in credits). The whole denomination leaves the +/// pool and the metered fee is taken from it, so the new identity is created holding +/// `denomination - total_fee`; any spent value above the denomination re-enters the pool as a +/// change note to `account`'s default Orchard address. +/// +/// Authorization is 100% the Orchard proof + per-action spend-auth signatures (from the bound +/// wallet's own `SpendAuthorizingKey`) + the binding signature (which commits the derived id + +/// denomination + full key set) + a per-key proof-of-possession produced via +/// `signer_identity_handle`. There is NO platform identity signature. +/// +/// On success the 32-byte new identity id (`double_sha256(sorted nullifiers)`) is written to +/// `out_identity_id`. The id is deterministic in the spent notes, so the host can also predict it +/// independently if needed. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `identity_pubkeys` must point to `identity_pubkeys_count` contiguous [`IdentityPubkeyFFI`] +/// rows that outlive this call (each row's pointers per the [`IdentityPubkeyFFI`] contract). +/// - `signer_identity_handle` must be a valid, non-destroyed `*const SignerHandle` (a +/// `VTableSigner` with the callback variant) that outlives this call; the caller retains +/// ownership. +/// - `out_identity_id` must point to 32 writable bytes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_pool( + handle: Handle, + wallet_id_bytes: *const u8, + account: u32, + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, + denomination: u64, + signer_identity_handle: *const SignerHandle, + out_identity_id: *mut [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(identity_pubkeys); + check_ptr!(signer_identity_handle); + check_ptr!(out_identity_id); + if identity_pubkeys_count == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "`identity_pubkeys_count` must be >= 1", + ); + } + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + // Decode the host-supplied identity keys into the + // `Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>` shape the wallet builder consumes. + // Reuses the shared registration decoder (key_type / purpose / security_level / contract-bounds + // validation) so this path can't drift from the address-funded registration path. + let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { + Ok(m) => m, + Err(result) => return result, + }; + let public_keys: Vec<( + dpp::identity::IdentityPublicKey, + IdentityPublicKeyInCreation, + )> = keys_map + .into_values() + .map(|k| { + let in_creation: IdentityPublicKeyInCreation = (&k).into(); + (k, in_creation) + }) + .collect(); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + // Round-trip the signer pointer through `usize` so the worker future captures only plain + // `Send + 'static` data and re-materializes the borrow INSIDE the task — never a fabricated + // `&'static` borrow of a host-owned vtable across the FFI boundary. The caller's contract is + // that the handle outlives this call, and `block_on_worker` blocks the calling frame until the + // task completes, so the borrow is valid for the task's whole lifetime. + let signer_identity_addr = signer_identity_handle as usize; + + // Run the proof on a worker thread (8 MB stack). Halo 2 circuit synthesis recurses past the + // ~512 KB iOS dispatch-thread stack and crashes with EXC_BAD_ACCESS when polled on the calling + // thread. + let result = block_on_worker(async move { + // SAFETY: re-materialize the borrow under the caller's documented lifetime contract; valid + // for the duration of this synchronously-awaited task. `VTableSigner` impls + // `Signer`. + let identity_signer: &VTableSigner = &*(signer_identity_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + wallet + .shielded_identity_create_from_pool( + &coordinator, + account, + public_keys, + denomination, + identity_signer, + &prover, + ) + .await + }); + + match result { + Ok(identity_id) => { + *out_identity_id = identity_id.to_buffer(); + PlatformWalletFFIResult::ok() + } + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded identity-create-from-pool failed: {e}"), + ), + } +} + /// Shield: spend credits from a Platform Payment account into /// the bound shielded sub-wallet's pool. /// diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index fe4b93d58c1..dbcbe3d4fe3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -701,6 +701,59 @@ impl PlatformWallet { .await } + /// Create a brand-new Platform identity funded directly from `account`'s shielded notes. + /// + /// Spends notes covering a fixed `denomination` (a member of the versioned exit-denomination + /// set); the whole denomination leaves the pool and the metered fee is taken from it, so the + /// new identity is created holding `denomination - total_fee`. Any excess re-enters the pool as + /// a change note to `account`'s default Orchard address. + /// + /// `public_keys` is the new identity's key set (each entry pairs the `IdentityPublicKey` with + /// its `IdentityPublicKeyInCreation` form); `identity_signer` produces each key's + /// proof-of-possession signature. The Orchard spend authority comes from the wallet's own + /// `OrchardKeySet` (the ASK never crosses to the coordinator). Returns the new identity's id. + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn shielded_identity_create_from_pool( + &self, + coordinator: &Arc, + account: u32, + public_keys: Vec<( + dpp::identity::IdentityPublicKey, + dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation, + )>, + denomination: u64, + identity_signer: &IS, + prover: P, + ) -> Result + where + P: dpp::shielded::builder::OrchardProver, + IS: dpp::identity::signer::Signer + Send + Sync, + { + let guard = self.shielded_keys.read().await; + let keys = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; + super::shielded::operations::identity_create_from_shielded_pool( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + public_keys, + denomination, + identity_signer, + &prover, + ) + .await + } + /// Shield credits from a Platform Payment account into the /// wallet's shielded pool, with the resulting note assigned /// to `shielded_account`'s default Orchard address. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs index 05e0f3d9e4b..4836ebbe78b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -8,7 +8,8 @@ use super::store::ShieldedNote; use crate::error::PlatformWalletError; use dpp::fee::Credits; use dpp::shielded::{ - compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, + compute_minimum_shielded_fee, compute_shielded_identity_create_fee, + compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, }; use dpp::version::PlatformVersion; use dpp::ProtocolError; @@ -22,6 +23,14 @@ use dpp::ProtocolError; /// Core withdrawal-document storage cost). Note selection must reserve against the SAME formula the /// builder/consensus will charge, otherwise it under-funds the spend (the builder then rejects /// it, or — in debug — the `fee_used == exact_fee` assertion fails). +/// +/// `IdentityCreate` is the odd one out: it uses the EXACT-EQUALITY model (like ShieldedTransfer's +/// `value_balance`), where the whole `denomination` leaves the pool and the metered fee is taken +/// FROM the denomination at execution — so its fee is *not* added on top of the amount during note +/// selection. The variant still carries the fee formula so the offline `denomination >= fee` gate +/// (the only way the new identity ends up with a non-negative balance) can be checked. Its fee +/// additionally depends on the number of identity public keys, so the variant carries `num_keys`. +/// Use [`select_notes_for_denomination`] (not [`select_notes_with_fee`]) for this kind. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ShieldedFeeKind { /// `compute_minimum_shielded_fee` — ShieldedTransfer (the base). @@ -30,6 +39,13 @@ pub enum ShieldedFeeKind { Unshield, /// `compute_shielded_withdrawal_fee` — ShieldedWithdrawal (adds the flat withdrawal-document cost). Withdrawal, + /// `compute_shielded_identity_create_fee` — IdentityCreateFromShieldedPool. Exact-equality + /// model: the fee is metered FROM the denomination, not added to the selection target. Carries + /// the identity's public-key count (the fee scales with it). + IdentityCreate { + /// Number of public keys in the new identity (the fee scales per key). + num_keys: usize, + }, } impl ShieldedFeeKind { @@ -47,6 +63,9 @@ impl ShieldedFeeKind { ShieldedFeeKind::Withdrawal => { compute_shielded_withdrawal_fee(num_actions, platform_version) } + ShieldedFeeKind::IdentityCreate { num_keys } => { + compute_shielded_identity_create_fee(num_actions, num_keys, platform_version) + } } } } @@ -165,6 +184,48 @@ pub fn select_notes_with_fee<'a>( Ok((selected, total, exact_fee)) } +/// Select notes for an `IdentityCreateFromShieldedPool` exit of exactly `denomination` credits. +/// +/// Unlike [`select_notes_with_fee`], this uses the EXACT-EQUALITY model: the whole `denomination` +/// leaves the pool as the bundle's `value_balance`, and the metered fee is taken FROM the +/// denomination at execution (the new identity is created holding `denomination - fee`). So the +/// selection target is `denomination` itself — the fee is NOT added on top. +/// +/// The function still computes the predicted `compute_shielded_identity_create_fee` (using the +/// resulting action count and `num_keys`) and rejects up-front if `denomination < predicted_fee`, +/// since that would create an identity with a negative/zero balance (consensus rejects +/// `total_fee >= denomination`). The predicted fee is informational — the authoritative fee is +/// metered at consensus — so a small drift between the predictor and the metered amount does not +/// affect selection (the full denomination is reserved regardless). +/// +/// Returns the selected notes, total input value, and the predicted fee. +pub fn select_notes_for_denomination<'a>( + unspent: &'a [ShieldedNote], + denomination: u64, + min_actions: usize, + num_keys: usize, + platform_version: &PlatformVersion, +) -> Result<(Vec<&'a ShieldedNote>, u64, u64), PlatformWalletError> { + // Target the denomination exactly — no fee added on top (exact-equality model). + let selected = select_notes(unspent, denomination, 0)?; + let total: u64 = selected.iter().map(|n| n.value).sum(); + let num_actions = selected.len().max(min_actions); + let predicted_fee = ShieldedFeeKind::IdentityCreate { num_keys } + .compute(num_actions, platform_version) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + // The denomination must cover the metered fee, or the new identity would be created with a + // non-positive balance (consensus rejects `total_fee >= denomination`). Gate on the predictor. + if denomination <= predicted_fee { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "denomination {denomination} does not exceed the predicted identity-create fee \ + {predicted_fee}; the new identity would be created with a non-positive balance" + ))); + } + + Ok((selected, total, predicted_fee)) +} + #[cfg(test)] mod tests { use super::*; @@ -364,4 +425,85 @@ mod tests { "unshield note selection must reserve the unshield-inclusive fee" ); } + + #[test] + fn test_select_notes_for_denomination_targets_denomination_only() { + // IdentityCreateFromShieldedPool uses the exact-equality model: the whole denomination + // leaves the pool and the fee is metered FROM it, so selection targets the denomination + // itself (NOT denomination + fee). A single note worth exactly the denomination must + // therefore satisfy the selection. + let platform_version = PlatformVersion::latest(); + let denomination = 10_000_000_000u64; // 0.1 DASH in credits (a member of the set) + let notes = vec![test_note(denomination, 0)]; + + let (selected, total, predicted_fee) = + select_notes_for_denomination(¬es, denomination, 2, 1, platform_version) + .expect("selection ok"); + + assert_eq!(selected.len(), 1); + assert_eq!(total, denomination); + // The predicted fee is informational and must be strictly below the denomination (otherwise + // the gate rejects). It equals the consensus identity-create fee for this action/key count. + let expected_fee = compute_shielded_identity_create_fee(2, 1, platform_version) + .expect("fee computation should not overflow"); + assert_eq!(predicted_fee, expected_fee); + assert!( + predicted_fee < denomination, + "predicted fee must be below the denomination" + ); + } + + #[test] + fn test_select_notes_for_denomination_fee_scales_with_keys() { + // The identity-create fee scales with the number of keys, so a larger key set yields a + // larger predicted fee for the same denomination + action count. + let platform_version = PlatformVersion::latest(); + let denomination = 100_000_000_000u64; // 1 DASH in credits + let notes = vec![test_note(denomination, 0)]; + + let (_, _, fee_1_key) = + select_notes_for_denomination(¬es, denomination, 2, 1, platform_version) + .expect("selection ok"); + let (_, _, fee_5_keys) = + select_notes_for_denomination(¬es, denomination, 2, 5, platform_version) + .expect("selection ok"); + + assert!( + fee_5_keys > fee_1_key, + "more identity keys must predict a higher fee ({fee_5_keys} > {fee_1_key})" + ); + } + + #[test] + fn test_select_notes_for_denomination_rejects_denomination_below_fee() { + // If the denomination doesn't exceed the predicted fee, the new identity would be created + // with a non-positive balance — the selection must reject up-front. + let platform_version = PlatformVersion::latest(); + let predicted_fee = compute_shielded_identity_create_fee(2, 1, platform_version) + .expect("fee computation should not overflow"); + // A denomination equal to the fee (the boundary) must be rejected (`denomination <= fee`). + let denomination = predicted_fee; + let notes = vec![test_note(denomination, 0)]; + + let result = select_notes_for_denomination(¬es, denomination, 2, 1, platform_version); + assert!( + matches!(result, Err(PlatformWalletError::ShieldedBuildError(_))), + "denomination == fee must reject (non-positive resulting balance)" + ); + } + + #[test] + fn test_select_notes_for_denomination_insufficient_balance() { + // Not enough unspent value to cover the denomination → insufficient-balance error + // (the denomination is the selection target). + let platform_version = PlatformVersion::latest(); + let denomination = 10_000_000_000u64; + let notes = vec![test_note(denomination - 1, 0)]; + + let result = select_notes_for_denomination(¬es, denomination, 2, 1, platform_version); + assert!(matches!( + result, + Err(PlatformWalletError::ShieldedInsufficientBalance { .. }) + )); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 7e2588ad8c4..9a51b1eaef3 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1,4 +1,4 @@ -//! Shielded transaction operations (5 transition types), multi-account. +//! Shielded transaction operations (6 transition types), multi-account. //! //! Each operation is a free function taking the //! (sdk, store, persister, wallet_id, keys, account, …) tuple @@ -10,15 +10,19 @@ //! Spends never cross account boundaries — note selection reads //! only the given account's unspent notes. //! -//! The five transition types are: +//! The six transition types are: //! - **Shield** (Type 15): transparent platform addresses → shielded pool //! - **ShieldFromAssetLock** (Type 18): Core L1 asset lock → shielded pool //! - **Unshield** (Type 17): shielded pool → transparent platform address //! - **Transfer** (Type 16): shielded pool → shielded pool (private) //! - **Withdraw** (Type 19): shielded pool → Core L1 address +//! - **IdentityCreateFromShieldedPool** (Type 20): shielded pool → a brand-new Platform identity +//! funded by a fixed denomination leaving the pool (any excess re-enters as a change note) use super::keys::OrchardKeySet; -use super::note_selection::{select_notes_with_fee, ShieldedFeeKind}; +use super::note_selection::{ + select_notes_for_denomination, select_notes_with_fee, ShieldedFeeKind, +}; use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::PlatformWalletError; @@ -29,18 +33,23 @@ use std::collections::BTreeMap; use std::sync::Arc; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::identity_create_from_shielded_pool::IdentityCreateFromShieldedPool; use dpp::address_funds::{ AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, }; use dpp::fee::Credits; use dpp::identity::core_script::CoreScript; use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::prelude::Identifier; use dpp::shielded::builder::{ - build_shield_transition, build_shielded_transfer_transition, - build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, + build_identity_create_from_shielded_pool_transition, build_shield_transition, + build_shielded_transfer_transition, build_shielded_withdrawal_transition, + build_unshield_transition, OrchardProver, SpendableNote, }; use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tokio::sync::RwLock; @@ -640,6 +649,133 @@ pub async fn withdraw( } } +// ------------------------------------------------------------------------- +// IdentityCreateFromShieldedPool: shielded pool -> brand-new identity (Type 20) +// ------------------------------------------------------------------------- + +/// Create a brand-new Platform identity funded directly from `account`'s shielded notes. +/// +/// Spends notes covering `denomination` (a member of the versioned exit-denomination set); the whole +/// denomination leaves the pool (`value_balance == denomination` EXACTLY, the ShieldedTransfer +/// exact-equality model) and the metered fee is taken FROM the denomination at execution, so the new +/// identity is created holding `denomination - total_fee`. Any spent value above the denomination +/// re-enters the pool as a single change note to `account`'s default Orchard address. +/// +/// `public_keys` is the new identity's key set (each entry is the `IdentityPublicKey` and its +/// `IdentityPublicKeyInCreation` form); `identity_signer` produces each key's proof-of-possession +/// signature over the transition's signable bytes. Authorization is 100% the Orchard proof + +/// per-action spend-auth signatures + binding signature (which commits the derived id + denomination +/// + full key set) + the per-key PoP — there is NO platform identity signature. +/// +/// Returns the new identity's id (`double_sha256(sorted nullifiers)`), derived deterministically +/// from the spent notes' nullifiers. +#[allow(clippy::too_many_arguments)] +pub async fn identity_create_from_shielded_pool( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, + denomination: u64, + identity_signer: &IS, + prover: &P, +) -> Result +where + S: ShieldedStore, + P: OrchardProver, + IS: Signer, +{ + if public_keys.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "identity-create-from-shielded-pool requires at least one public key".to_string(), + )); + } + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + let num_keys = public_keys.len(); + + // Exact-equality model: reserve notes covering the denomination itself (NOT denomination + fee + // — the fee is metered FROM the denomination at execution). The reservation also gates on + // `denomination > predicted_fee` so the new identity can't be created with a non-positive + // balance. Orchard's BundleType::DEFAULT pads single-spend bundles to a 2-action floor. + let (selected_notes, total_input, predicted_fee) = + reserve_unspent_notes_for_denomination(sdk, store, id, denomination, 2, num_keys).await?; + + info!( + account, + denomination, + predicted_fee, + inputs = selected_notes.len(), + total_input, + keys = num_keys, + "IdentityCreateFromShieldedPool" + ); + + // From here on every error path must release the reservation taken above. + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + + let build = build_identity_create_from_shielded_pool_transition( + public_keys, + denomination, + spends, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + identity_signer, + [0u8; 36], + sdk.version(), + ) + .await + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + let identity_id = build.identity_id; + + trace!("IdentityCreateFromShieldedPool: built, broadcasting via SDK helper..."); + // Broadcast through the SDK helper, which re-assembles the transition from the PoP-signed + // keys + bundle params (preserving the per-key signatures) and waits for proven execution. + sdk.identity_create_from_shielded_pool(build.public_keys, denomination, build.bundle, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + Ok::(identity_id) + } + .await; + + match result { + Ok(identity_id) => { + // Best-effort post-broadcast bookkeeping (see `unshield`): mark the spent notes so the + // local balance reflects the exit immediately; any drift heals on the next nullifier + // sync. The on-chain nullifier set — not this local mark — is the authoritative + // no-reuse guarantee. + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "IdentityCreateFromShieldedPool broadcast succeeded but local spent-state \ + update failed; will heal on next sync" + ); + } + info!( + account, + denomination, + identity_id = %identity_id, + "IdentityCreateFromShieldedPool broadcast succeeded" + ); + Ok(identity_id) + } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) + } + } +} + // ------------------------------------------------------------------------- // Internal helpers (free fns) // ------------------------------------------------------------------------- @@ -800,6 +936,40 @@ async fn reserve_unspent_notes( Ok((selected, total_input, exact_fee)) } +/// Exact-equality sibling of [`reserve_unspent_notes`] for +/// `IdentityCreateFromShieldedPool`: select + reserve notes covering exactly `denomination` +/// (the fee is metered FROM the denomination, not added to the target) in one write-locked +/// critical section, gating on `denomination > predicted_fee` via +/// [`select_notes_for_denomination`]. Returns the selected notes, total input value, and the +/// predicted fee. Callers must pair this with [`finalize_pending`] / [`cancel_pending`]. +async fn reserve_unspent_notes_for_denomination( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + denomination: u64, + min_actions: usize, + num_keys: usize, +) -> Result<(Vec, u64, u64), PlatformWalletError> { + let mut store = store.write().await; + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let (selected, total_input, predicted_fee) = select_notes_for_denomination( + &unspent, + denomination, + min_actions, + num_keys, + sdk.version(), + )? + .into_owned(); + for note in &selected { + store + .mark_pending(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + Ok((selected, total_input, predicted_fee)) +} + /// Promote a successful broadcast: mark the notes spent (which /// also clears any matching pending reservation, see /// [`SubwalletState::mark_spent`]) and queue the changeset for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index e43ca6be594..bd7b5cb929b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -563,7 +563,10 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// pre-extracted `[Data]` of the same `pubkeyBytes` values, kept /// separately so the recursive helper doesn't need to see the /// full Swift wrapper struct). - fileprivate static func withPubkeyFFIArray( + // `internal` (not `fileprivate`) so the shielded identity-create-from-pool wrapper in + // `PlatformWalletManagerShieldedSync.swift` can reuse this exact `[IdentityPubkeyFFI]` pinning + // helper rather than duplicating the recursive lifetime dance. + static func withPubkeyFFIArray( _ pubkeys: [IdentityPubkey], buffers: [Data], _ body: (UnsafePointer?, Int) -> R diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 11186008cf8..5b91a7b07e5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -610,6 +610,101 @@ extension PlatformWalletManager { }.value } + /// Shielded → new identity (Type 20). Spends notes from + /// `walletId`'s shielded balance to fund a brand-new Platform + /// identity. The whole `denomination` (a member of the versioned + /// exit-denomination set, in credits) leaves the pool and the + /// metered fee is taken from it, so the new identity is created + /// holding `denomination - totalFee`; any excess re-enters the + /// pool as a change note. + /// + /// `identityPubkeys` is the new identity's key set (the first row + /// should be the MASTER key). `identitySigner` is the host-side + /// `KeychainSigner` whose `.handle` produces each key's + /// proof-of-possession signature; the Orchard spend authority is + /// the bound wallet's own key. Returns the 32-byte new identity id + /// (`double_sha256(sorted nullifiers)`). + /// + /// Heavy CPU work (Halo 2 proof + per-key signing) runs on a + /// detached task so the caller's actor isn't blocked. + public func shieldedIdentityCreateFromPool( + walletId: Data, + account: UInt32 = 0, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + denomination: UInt64, + identitySigner: KeychainSigner + ) async throws -> Data { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + guard !identityPubkeys.isEmpty else { + throw PlatformWalletError.invalidParameter( + "identityPubkeys is empty" + ) + } + + let handle = self.handle + let identitySignerHandle = identitySigner.handle + + return try await Task.detached(priority: .userInitiated) { () -> Data in + // Keepalive — KeychainSigner uses `passUnretained`, so the + // Rust ctx pointer dangles unless the Swift owner stays + // alive across this detached work (see + // `registerIdentityFromAddresses`). + _ = identitySigner + + var outIdentityId: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + ) + + // Pin every pubkey buffer simultaneously (and the + // wallet-id bytes), then hand the pinned + // `[IdentityPubkeyFFI]` rows + signer handle to the FFI. + // Reuses the same marshalling helper the address-funded + // registration path uses so the two can't drift. + let pubkeyBuffers: [Data] = identityPubkeys.map { $0.pubkeyBytes } + let result = try walletId.withUnsafeBytes { widRaw -> PlatformWalletFFIResult in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + return ManagedPlatformWallet.withPubkeyFFIArray( + identityPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_manager_shielded_identity_create_from_pool( + handle, + widPtr, + account, + ffiRowsPtr, + UInt(ffiRowsCount), + denomination, + identitySignerHandle, + &outIdentityId + ) + } + } + + try result.check() + return withUnsafeBytes(of: outIdentityId) { Data($0) } + }.value + } + public func syncShieldedWalletNow(walletId: Data) async throws { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( From 08679ffce18bea3d9314ba0e5fb75efa13e9544d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 20:16:07 +0200 Subject: [PATCH 09/28] =?UTF-8?q?fix(dpp,drive-abci,sdk):=20address=20revi?= =?UTF-8?q?ew=20=E2=80=94=20id=20malleability,=20PoP-before-Halo2,=20broad?= =?UTF-8?q?cast-and-wait=20(#3816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses thepastaclaw review on PR #3816: - BLOCKING: enforce `identity_id == derive_identity_id_from_actions(&actions)` in `validate_structure` (basic, InvalidIdentifierError), making the wire id authoritative consensus-wide so prove/verify/modified_data_ids (which trust the wire field) can never desync from the canonical derived id. Was: wire id excluded from sighash + re-derived only in the action transformer, so a relayer could mutate it and make an honestly-executed transition unverifiable through the SDK proof API. + rejection test. - DoS hardening: run the cheap key-structure + per-key proof-of-possession checks inside `validate_shielded_proof` BEFORE Halo2 (the PoP sigs aren't covered by the Orchard proof, so a flipped PoP byte previously wasted full proof verification before rejection in transform_into_action). Removed the now-redundant checks from transform_into_action (which no longer needs signable_bytes/execution_context). - SDK: identity_create_from_shielded_pool now broadcast_and_wait::< StateTransitionProofResult> for parity with unshield/transfer/withdraw, so the wallet's finalize_pending only runs on proven inclusion (not relay-ACK) and the cancel_pending fallback fires on a post-relay Platform rejection. cargo check --workspace green; 16 dpp transition tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v0/state_transition_validation.rs | 37 +++++++++++++ .../processor/traits/shielded_proof.rs | 37 +++++++++++++ .../identity_create_from_shielded_pool/mod.rs | 17 ++---- .../transform_into_action/v0/mod.rs | 52 +++---------------- .../state_transition/transformer/mod.rs | 11 ++-- .../identity_create_from_shielded_pool.rs | 19 +++++-- 6 files changed, 104 insertions(+), 69 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs index 29bc22bf039..64a8d87a488 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs @@ -1,8 +1,10 @@ use crate::consensus::basic::identity::MissingMasterPublicKeyError; +use crate::consensus::basic::invalid_identifier_error::InvalidIdentifierError; use crate::consensus::basic::state_transition::ShieldedInvalidDenominationError; use crate::consensus::basic::BasicError; use crate::consensus::state::identity::max_identity_public_key_limit_reached_error::MaxIdentityPublicKeyLimitReachedError; use crate::consensus::state::state_error::StateError; +use crate::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; use crate::state_transition::state_transitions::shielded::common_validation::{ validate_actions_count, validate_anchor_not_zero, validate_encrypted_note_sizes, @@ -34,6 +36,25 @@ impl StateTransitionStructureValidation for IdentityCreateFromShieldedPoolTransi return result; } + // The wire `identity_id` MUST equal the value derived from the spend nullifiers. It is + // excluded from the platform sighash and is NOT what the Orchard bundle binds (the bundle's + // `extra_sighash_data` commits to the *derived* id). Without this check a relayer/proposer + // could overwrite the field with arbitrary bytes: consensus would still create the identity + // at the derived id, but every downstream consumer that trusts the wire field — + // `modified_data_ids` (block events / indexers) and the SDK prove/verify path (which build + // their merged path-query from `identity_id`) — would desync from the canonical state. + // Rejecting a mismatch here makes the wire id authoritative consensus-wide, exactly as + // `IdentityCreate` re-derives and checks the id from its asset-lock outpoint. + if self.identity_id != derive_identity_id_from_actions(&self.actions) { + return SimpleConsensusValidationResult::new_with_error( + BasicError::InvalidIdentifierError(InvalidIdentifierError::new( + "identity_id".to_string(), + "does not match the value derived from the spend nullifiers".to_string(), + )) + .into(), + ); + } + // The denomination MUST be a member of the versioned exit-denomination set. Restricting the // exit to a small fixed set is what makes every identity-creation exit of a given size // indistinguishable on-chain (maximizing the anonymity set). An empty set (pre-v12) rejects @@ -153,6 +174,22 @@ mod tests { ); } + #[test] + fn should_reject_mismatched_wire_identity_id() { + // A relayer-mutated `identity_id` (not matching the value derived from the spend nullifiers) + // must be rejected so the wire field stays authoritative for prove/verify/modified_data_ids. + let platform_version = PlatformVersion::latest(); + let mut t = valid_transition(); + t.identity_id = platform_value::Identifier::new([0xFF; 32]); + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidIdentifierError(_) + )] + ); + } + #[test] fn should_reject_non_member_denomination() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 3197051971f..5a1e68e3c9a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -10,6 +10,10 @@ use dpp::consensus::basic::state_transition::{ use dpp::consensus::basic::BasicError; use dpp::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; use dpp::consensus::state::state_error::StateError; +use dpp::serialization::{PlatformMessageSignable, Signable}; +use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::state_transition::StateTransition; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; @@ -371,6 +375,39 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { .validate_shielded_proof { 0 => { + // `IdentityCreateFromShieldedPool` is the only shielded transition carrying separate + // per-key proof-of-possession signatures that are NOT covered by the Orchard proof + // (they sign the platform signable bytes, and only id+denomination+keys — not the PoP + // sigs — are bound into `extra_sighash_data`). Validate the CHEAP key structure + + // per-key PoP here, BEFORE the expensive Halo 2 bundle verification, so a relayer + // who flips a PoP byte on an observed transition is rejected without the node paying + // for proof verification (DoS hardening). Same `signable_bytes` the transformer uses. + if let StateTransition::IdentityCreateFromShieldedPool(st) = self { + let IdentityCreateFromShieldedPoolTransition::V0(v0) = st; + + let key_structure_result = + IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &v0.public_keys, + true, + platform_version, + )?; + if !key_structure_result.is_valid() { + return Ok(key_structure_result); + } + + let signable_bytes = self.signable_bytes()?; + for key in v0.public_keys.iter() { + let pop_result = signable_bytes.as_slice().verify_signature( + key.key_type(), + key.data().as_slice(), + key.signature().as_slice(), + ); + if !pop_result.is_valid() { + return Ok(pop_result); + } + } + } + let result = match self { StateTransition::Shield(st) => match st { dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs index a8067eb2e16..531a51d94b1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -10,7 +10,6 @@ use drive::state_transition_action::StateTransitionAction; use crate::error::execution::ExecutionError; use crate::error::Error; -use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -19,11 +18,13 @@ use crate::rpc::core::CoreRPCLike; /// A trait to transform into an action for the identity-create-from-shielded-pool transition. pub trait StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer { /// Transform into an action. + /// + /// The key structure + per-key proof-of-possession are validated earlier (in + /// `validate_shielded_proof`, before Halo 2), so this only does the stateful pool checks — no + /// `signable_bytes` / execution context are needed here. fn transform_into_action_for_identity_create_from_shielded_pool_transition( &self, platform: &PlatformRef, - signable_bytes: Vec, - execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; } @@ -34,8 +35,6 @@ impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer fn transform_into_action_for_identity_create_from_shielded_pool_transition( &self, platform: &PlatformRef, - signable_bytes: Vec, - execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -47,13 +46,7 @@ impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer .identity_create_from_shielded_pool_state_transition .transform_into_action { - 0 => self.transform_into_action_v0( - platform.drive, - signable_bytes, - execution_context, - tx, - platform_version, - ), + 0 => self.transform_into_action_v0(platform.drive, tx, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from shielded pool transition: transform_into_action" .to_string(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs index ddf561f8fda..c126cdaca5d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs @@ -1,9 +1,4 @@ use crate::error::Error; -use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; -use crate::execution::types::execution_operation::ValidationOperation; -use crate::execution::types::state_transition_execution_context::{ - StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, -}; use crate::execution::validation::state_transition::state_transitions::shielded_common::{ read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, validate_nullifiers, @@ -11,9 +6,6 @@ use crate::execution::validation::state_transition::state_transitions::shielded_ use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::state_error::StateError; use dpp::prelude::ConsensusValidationResult; -use dpp::serialization::PlatformMessageSignable; -use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; -use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::version::PlatformVersion; use drive::drive::Drive; @@ -26,8 +18,6 @@ pub(in crate::execution::validation::state_transition::state_transitions::identi fn transform_into_action_v0( &self, drive: &Drive, - signable_bytes: Vec, - execution_context: &mut StateTransitionExecutionContext, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error>; @@ -39,8 +29,6 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV fn transform_into_action_v0( &self, drive: &Drive, - signable_bytes: Vec, - execution_context: &mut StateTransitionExecutionContext, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { @@ -49,6 +37,12 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV let anchor: [u8; 32] = v0.anchor; let nullifiers: Vec<[u8; 32]> = v0.actions.iter().map(|a| a.nullifier).collect(); + // The (stateless) key structure, per-key proof-of-possession, denomination membership, and + // id re-derivation are all validated earlier — basic structure (`validate_structure`) and + // `validate_shielded_proof` (the latter runs the PoP + key structure BEFORE Halo 2 so a + // malformed PoP cannot make the node pay for proof verification). Here we only do the + // STATEFUL checks against the shielded pool. + // Read the current shielded pool state (read-your-own-writes within the block transaction). let mut drive_operations = vec![]; let current_total_balance = @@ -86,40 +80,6 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV return Ok(consensus_error); } - // Validate the new identity's key structure: a master key is required (in_create = true), - // no duplicate key ids or key data, and each key's security level matches its purpose — - // identical to `IdentityCreate`. - let key_structure_result = - IdentityPublicKeyInCreation::validate_identity_public_keys_structure( - &v0.public_keys, - true, - platform_version, - )?; - if !key_structure_result.is_valid() { - return Ok(ConsensusValidationResult::new_with_errors( - key_structure_result.errors, - )); - } - - // Per-key proof-of-possession: each key must sign the transition's signable bytes, proving - // the creator controls every key being registered (mirrors `IdentityCreate`'s - // identity-and-signatures check). The Orchard `extra_sighash_data` binding already pins the - // exact key set to this spend, so a relayer cannot swap keys; this additionally proves the - // creator holds them. - for key in v0.public_keys.iter() { - let result = signable_bytes.as_slice().verify_signature( - key.key_type(), - key.data().as_slice(), - key.signature().as_slice(), - ); - execution_context.add_operation(ValidationOperation::SignatureVerification( - SignatureVerificationOperation::new(key.key_type()), - )); - if !result.is_valid() { - return Ok(ConsensusValidationResult::new_with_errors(result.errors)); - } - } - // The pool must hold at least the full denomination leaving it. if current_total_balance < v0.denomination { return Ok(ConsensusValidationResult::new_with_error( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index 35e539c7e08..466e3860011 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -270,14 +270,11 @@ impl StateTransitionActionTransformer for StateTransition { StateTransition::ShieldedWithdrawal(st) => st .transform_into_action_for_shielded_withdrawal_transition(platform, block_info, tx), StateTransition::IdentityCreateFromShieldedPool(st) => { - // Bind the per-key proofs-of-possession to the full transition by passing its - // signable bytes (the per-key signatures are validated in transform_into_action). - let signable_bytes = self.signable_bytes()?; + // Key structure + per-key proof-of-possession are validated earlier (in + // `validate_shielded_proof`, ahead of Halo 2), so the transformer only needs the + // stateful pool checks. st.transform_into_action_for_identity_create_from_shielded_pool_transition( - platform, - signable_bytes, - execution_context, - tx, + platform, tx, ) } } diff --git a/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs index 64be75a4c51..338aa911f1e 100644 --- a/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs @@ -3,6 +3,7 @@ use super::put_settings::PutSettings; use super::validation::ensure_valid_state_transition_structure; use crate::{Error, Sdk}; use dpp::shielded::OrchardBundleParams; +use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::methods::IdentityCreateFromShieldedPoolTransitionMethodsV0; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; @@ -21,13 +22,18 @@ pub trait IdentityCreateFromShieldedPool { /// `public_keys` MUST already carry their per-key proof-of-possession signatures over the /// transition's signable bytes (the wallet/builder fills them before broadcast). The new /// identity is created holding `denomination - total_fee`. + /// + /// Like the other shielded spends, this **waits for proven execution** (not just relay-ACK) and + /// returns the `StateTransitionProofResult` (a `VerifiedIdentityWithShieldedNullifiers`), so a + /// caller's post-broadcast bookkeeping (e.g. the wallet marking notes spent) only runs after the + /// transition is cryptographically proven included. async fn identity_create_from_shielded_pool( &self, public_keys: Vec, denomination: u64, bundle: OrchardBundleParams, settings: Option, - ) -> Result<(), Error>; + ) -> Result; } #[async_trait::async_trait] @@ -38,7 +44,7 @@ impl IdentityCreateFromShieldedPool for Sdk { denomination: u64, bundle: OrchardBundleParams, settings: Option, - ) -> Result<(), Error> { + ) -> Result { let OrchardBundleParams { actions, anchor, @@ -57,7 +63,12 @@ impl IdentityCreateFromShieldedPool for Sdk { )?; ensure_valid_state_transition_structure(&state_transition, self.version())?; - state_transition.broadcast(self, settings).await?; - Ok(()) + // Wait for proven inclusion (parity with `unshield`/`shielded_transfer`/`withdraw`), so the + // wallet's post-broadcast `finalize_pending` only runs once the spend is proven — a + // Platform-level rejection after relay then correctly triggers the `cancel_pending` fallback. + let proof_result = state_transition + .broadcast_and_wait::(self, settings) + .await?; + Ok(proof_result) } } From febc057f25794de05dc95c0069fd6f828371134a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 20:38:52 +0200 Subject: [PATCH 10/28] docs(book): add PaidFromShieldedPoolToNewIdentity to the ExecutionEvent table (#3816) The fees/overview.md ExecutionEvent table enumerates each variant and the transitions that use it; add the new IdentityCreateFromShieldedPool event (eighth variant) and update the count. Co-Authored-By: Claude Opus 4.8 (1M context) --- book/src/fees/overview.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 5b11b998c55..c26820e77ea 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -159,7 +159,7 @@ fn apply_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { ## ExecutionEvent Variants The `ExecutionEvent` enum (in `rs-drive-abci`) determines how fees are collected -for each state transition. There are seven variants: +for each state transition. There are eight variants: | Variant | Fee Source | Used By | |---|---|---| @@ -170,6 +170,7 @@ for each state transition. There are seven variants: | `PaidFromAddressInputs` | Platform address balances | All address-based transitions; `Shield` (metered + a ZK compute fee via `additional_fixed_fee_cost`) | | `PaidFixedCost` | Fixed fee to pool | MasternodeVote | | `PaidFromShieldedPool` | Shielded pool value_balance | ShieldedTransfer, Unshield, ShieldedWithdrawal | +| `PaidFromShieldedPoolToNewIdentity` | Shielded pool (the fixed `denomination`); the metered write + ZK compute fee is moved from the new identity's balance into the fee pools | IdentityCreateFromShieldedPool | Each variant carries the operations to execute and enough context for the fee validation and execution pipeline to deduct the correct amount from the correct From 52bb6d7ed61d749287d26c1df6e2eb12565d53ab Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 20:48:44 +0200 Subject: [PATCH 11/28] fix(drive-abci,wallet): restore per-key PoP fee accounting + checked note sum + test fixtures (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses thepastaclaw follow-up review on 08679ff: - BLOCKING: the PoP-before-Halo2 move dropped the per-key SignatureVerification fee accounting (validate_shielded_proof has no execution context). Restore it: keep the early PoP *rejection* in validate_shielded_proof, and add a thin accounting-only pass in transform_into_action_v0 (success path) that records one SignatureVerification op per key WITHOUT re-verifying — so a Type 20 transition is charged the same signature-verification CPU as a plain IdentityCreate. Re-threads execution_context into the transformer. - Carried-forward: checked u64 accumulation in shielded note selection (try_fold + checked_add; legitimate values are supply-bounded, but never trust the store blindly). - Carried-forward: add IdentityCreateFromShieldedPool to the has_shielded_proof_validation / has_shielded_minimum_fee_validation true-list test fixtures. cargo check --workspace green; 11 shielded_proof tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../processor/traits/shielded_proof.rs | 25 +++++++++++++++++++ .../identity_create_from_shielded_pool/mod.rs | 17 ++++++++++--- .../transform_into_action/v0/mod.rs | 24 +++++++++++++++++- .../state_transition/transformer/mod.rs | 10 +++++--- .../src/wallet/shielded/note_selection.rs | 18 +++++++++++-- 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 5a1e68e3c9a..8190fd595a4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -615,6 +615,23 @@ mod tests { }, )) } + fn make_identity_create_from_shielded_pool() -> StateTransition { + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 0, + actions: vec![], + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ) + } mod has_shielded_proof_validation { use super::*; @@ -626,6 +643,10 @@ mod tests { ("ShieldedTransfer", make_shielded_transfer()), ("Unshield", make_unshield()), ("ShieldedWithdrawal", make_shielded_withdrawal()), + ( + "IdentityCreateFromShieldedPool", + make_identity_create_from_shielded_pool(), + ), ]; for (name, st) in transitions { assert!( @@ -671,6 +692,10 @@ mod tests { ("ShieldedTransfer", make_shielded_transfer()), ("Unshield", make_unshield()), ("ShieldedWithdrawal", make_shielded_withdrawal()), + ( + "IdentityCreateFromShieldedPool", + make_identity_create_from_shielded_pool(), + ), ]; for (name, st) in transitions { assert!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs index 531a51d94b1..c455f8a261d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -10,6 +10,7 @@ use drive::state_transition_action::StateTransitionAction; use crate::error::execution::ExecutionError; use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -19,12 +20,14 @@ use crate::rpc::core::CoreRPCLike; pub trait StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer { /// Transform into an action. /// - /// The key structure + per-key proof-of-possession are validated earlier (in - /// `validate_shielded_proof`, before Halo 2), so this only does the stateful pool checks — no - /// `signable_bytes` / execution context are needed here. + /// The key structure + per-key proof-of-possession are *verified* earlier (in + /// `validate_shielded_proof`, before Halo 2). This does the stateful pool checks and records the + /// per-key signature-verification operations into the execution context for fee accounting (no + /// `signable_bytes` are needed — the verification already happened). fn transform_into_action_for_identity_create_from_shielded_pool_transition( &self, platform: &PlatformRef, + execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; } @@ -35,6 +38,7 @@ impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer fn transform_into_action_for_identity_create_from_shielded_pool_transition( &self, platform: &PlatformRef, + execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -46,7 +50,12 @@ impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer .identity_create_from_shielded_pool_state_transition .transform_into_action { - 0 => self.transform_into_action_v0(platform.drive, tx, platform_version), + 0 => self.transform_into_action_v0( + platform.drive, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from shielded pool transition: transform_into_action" .to_string(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs index c126cdaca5d..7cb858d2218 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs @@ -1,4 +1,9 @@ use crate::error::Error; +use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; use crate::execution::validation::state_transition::state_transitions::shielded_common::{ read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, validate_nullifiers, @@ -6,6 +11,7 @@ use crate::execution::validation::state_transition::state_transitions::shielded_ use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::state_error::StateError; use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::version::PlatformVersion; use drive::drive::Drive; @@ -18,6 +24,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::identi fn transform_into_action_v0( &self, drive: &Drive, + execution_context: &mut StateTransitionExecutionContext, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error>; @@ -29,6 +36,7 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV fn transform_into_action_v0( &self, drive: &Drive, + execution_context: &mut StateTransitionExecutionContext, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { @@ -41,7 +49,7 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV // id re-derivation are all validated earlier — basic structure (`validate_structure`) and // `validate_shielded_proof` (the latter runs the PoP + key structure BEFORE Halo 2 so a // malformed PoP cannot make the node pay for proof verification). Here we only do the - // STATEFUL checks against the shielded pool. + // STATEFUL checks against the shielded pool, then account for the per-key PoP verifications. // Read the current shielded pool state (read-your-own-writes within the block transaction). let mut drive_operations = vec![]; @@ -91,6 +99,20 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV )); } + // Account for the per-key proof-of-possession signature verifications on the SUCCESS path so + // the metered fee includes their CPU cost — exactly as `IdentityCreate`'s identity-and- + // signatures stage does. The signatures themselves are verified earlier (in + // `validate_shielded_proof`, ahead of Halo 2); this records one `SignatureVerification` + // operation per key WITHOUT re-verifying, so a Type 20 transition is charged for the same + // signature-verification work as a plain `IdentityCreate`. (Only reached once the bundle + // proof + PoP have passed, so no nullifier is consumed and no fee charged for a rejected + // transition.) + for key in v0.public_keys.iter() { + execution_context.add_operation(ValidationOperation::SignatureVerification( + SignatureVerificationOperation::new(key.key_type()), + )); + } + // The action carries the client-predicted fee for reference; the authoritative fee is // METERED at execution and moved from the new identity's balance into the fee pools. let fee_amount = dpp::shielded::compute_shielded_identity_create_fee( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index 466e3860011..10033b84242 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -270,11 +270,13 @@ impl StateTransitionActionTransformer for StateTransition { StateTransition::ShieldedWithdrawal(st) => st .transform_into_action_for_shielded_withdrawal_transition(platform, block_info, tx), StateTransition::IdentityCreateFromShieldedPool(st) => { - // Key structure + per-key proof-of-possession are validated earlier (in - // `validate_shielded_proof`, ahead of Halo 2), so the transformer only needs the - // stateful pool checks. + // Key structure + per-key proof-of-possession are *verified* earlier (in + // `validate_shielded_proof`, ahead of Halo 2); the transformer does the stateful + // pool checks and records the per-key signature-verification ops for fee accounting. st.transform_into_action_for_identity_create_from_shielded_pool_transition( - platform, tx, + platform, + execution_context, + tx, ) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs index 4836ebbe78b..36c37d8637c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -97,7 +97,16 @@ pub fn select_notes( PlatformWalletError::ShieldedBuildError("amount + fee overflows u64".to_string()) })?; - let total_available: u64 = unspent_only.iter().map(|n| n.value).sum(); + // Checked accumulation: a corrupt/crafted store could otherwise overflow u64 (legitimate note + // values sum to at most the bounded credit supply, but never trust the store blindly). + let total_available = unspent_only + .iter() + .try_fold(0u64, |acc, n| acc.checked_add(n.value)) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "shielded note values sum overflows u64".to_string(), + ) + })?; if total_available < required { return Err(PlatformWalletError::ShieldedInsufficientBalance { available: total_available, @@ -113,8 +122,13 @@ pub fn select_notes( let mut accumulated = 0u64; for note in sorted { + // Cannot overflow (the full-set sum above already succeeded), but stay checked for clarity. + accumulated = accumulated.checked_add(note.value).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "selected shielded note values sum overflows u64".to_string(), + ) + })?; selected.push(note); - accumulated += note.value; if accumulated >= required { break; } From 408568394d9058ee26fa5dc0b95d2c4af41f1f1a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 20:52:16 +0200 Subject: [PATCH 12/28] =?UTF-8?q?fix(drive-abci):=20IdentityCreateFromShie?= =?UTF-8?q?ldedPool=20=E2=80=94=20add=20identity-absence=20+=20unique-key-?= =?UTF-8?q?hash=20state=20checks=20(#3816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (codex): the transition creates an identity but was grouped with the non-creating shielded transitions, so it skipped the identity-create state checks. A public key already registered to another identity (or, vanishingly, a pre-existing identity at the derived id) would then fail INSIDE the AddNewIdentity write at execution as an internal Drive error instead of a clean consensus rejection. Add both checks to transform_into_action (where this transition does all its stateful validation, like Unshield), mirroring IdentityCreate's validate_state: - IdentityAlreadyExistsError if an identity already exists at the derived id (double_sha256(sorted nullifiers) — collision-resistant + single-use, so practically unreachable, but a clean rejection beats an internal error). - validate_unique_identity_public_key_hashes_not_in_state over the new keys (a failure is a plain rejection — no asset lock to penalize). The lookups are accounted into the execution context for fee metering. cargo check --workspace green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../transform_into_action/v0/mod.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs index 7cb858d2218..9d2fe61ab36 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs @@ -4,14 +4,17 @@ use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; use crate::execution::validation::state_transition::state_transitions::shielded_common::{ read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, validate_nullifiers, }; +use dpp::consensus::state::identity::IdentityAlreadyExistsError; use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::state_error::StateError; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::version::PlatformVersion; use drive::drive::Drive; @@ -99,6 +102,40 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV )); } + // Identity-creation state checks (mirroring `IdentityCreate`'s `validate_state`), so the + // `AddNewIdentity` write can't fail as an internal Drive error during execution: + // + // 1. The new identity must not already exist. The id is `double_sha256(sorted nullifiers)` — + // collision-resistant and derived from single-use spend tags — so this is practically + // unreachable, but check explicitly to return a clean consensus rejection. (No asset lock + // here, so a failure is a plain rejection, not a partial-asset-lock penalty.) + let identity_id = derive_identity_id_from_actions(&v0.actions); + if drive + .fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)? + .is_some() + { + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id).into(), + )); + } + + // 2. None of the new identity's public-key hashes may already be registered to another + // identity (platform enforces globally-unique key hashes for unique key types). Without + // this, a duplicate unique key would fail inside the `AddNewIdentity` write at execution + // as an internal Drive error instead of a clean consensus rejection. + let unique_keys_result = validate_unique_identity_public_key_hashes_not_in_state( + &v0.public_keys, + drive, + execution_context, + transaction, + platform_version, + )?; + if !unique_keys_result.is_valid() { + return Ok(ConsensusValidationResult::new_with_errors( + unique_keys_result.errors, + )); + } + // Account for the per-key proof-of-possession signature verifications on the SUCCESS path so // the metered fee includes their CPU cost — exactly as `IdentityCreate`'s identity-and- // signatures stage does. The signatures themselves are verified earlier (in From a9c8e13e9a9353de6a9205747915b2a06bfd589a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 21:29:38 +0200 Subject: [PATCH 13/28] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20verify=20binding,=20checked=20arithmetic,=20denomin?= =?UTF-8?q?ation=20gate,=20recoverable=20errors=20(#3816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify arm: recompute identity_id from actions (reject wire mismatch) and bind the proof to the transition's declared public-key set, so a tampered transition can't reuse a valid {nullifiers,identity} proof with a different id/keys. (Balance is NOT checked vs denomination — the identity holds denomination − metered total_fee, not recoverable here; denomination is bound via the Orchard extra_sighash_data at consensus.) - builder: checked u64 sum of spend values (overflow → ShieldedBuildError). - converter: assert identity.balance == denomination before emitting ops (defense against the two sources of truth diverging → mint/burn). - note_selection: reject a non-member denomination up-front (before the Orchard prove) + regression tests. - wasm-dpp factory: replace todo!() panic with a recoverable Err(JsValue). - swift: withExtendedLifetime(identitySigner) keepalive (the `_ = signer` folklore can be elided in -O builds → use-after-free of the passUnretained ctx). - tests: validate_minimum_shielded_fee IdentityCreate gate (below-min reject, boundary accept, key-count scaling); is_allowed + identity_balance classifier fixtures for the new transition. cargo check --workspace green; new + affected tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../identity_create_from_shielded_pool.rs | 11 +- .../processor/traits/identity_balance.rs | 20 +++ .../processor/traits/is_allowed.rs | 19 +++ .../processor/traits/shielded_proof.rs | 132 ++++++++++++++++++ ...ty_create_from_shielded_pool_transition.rs | 15 ++ .../v0/mod.rs | 42 +++++- .../src/wallet/shielded/note_selection.rs | 42 ++++++ .../PlatformWalletManagerShieldedSync.swift | 50 +++---- .../state_transition_factory.rs | 6 +- 9 files changed, 306 insertions(+), 31 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs index b2ab7a588a2..ea2cec31f7b 100644 --- a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs +++ b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs @@ -116,7 +116,15 @@ where )); } - let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum(); + // Checked: a large spend set could otherwise overflow u64 (release builds wrap silently). + let total_spent = spends + .iter() + .try_fold(0u64, |acc, s| acc.checked_add(s.note.value().inner())) + .ok_or_else(|| { + ProtocolError::ShieldedBuildError( + "identity-create-from-shielded-pool total spent value overflows u64".to_string(), + ) + })?; if denomination > total_spent { return Err(ProtocolError::ShieldedBuildError(format!( "denomination {} exceeds total spendable value {}", @@ -126,6 +134,7 @@ where // The whole denomination leaves the pool; the excess re-enters as a single change note. There // is NO shielded recipient — the value funds the (transparent) new identity, not another note. + // Cannot underflow: the `denomination > total_spent` guard above already rejected that case. let change_amount = total_spent - denomination; // Orchard's BundleType::DEFAULT pads single-spend bundles to a 2-action minimum, matching the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index 390a141f834..ab87a0429dd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -219,6 +219,26 @@ mod tests { IdentityTopUpTransitionV0::default(), )), ), + { + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + ( + "IdentityCreateFromShieldedPool", + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 0, + actions: vec![], + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ), + ) + }, ]; for (name, st) in transitions { assert!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 619b25d8265..f02882b2a51 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -234,6 +234,24 @@ mod tests { )) } + fn make_identity_create_from_shielded_pool_transition() -> StateTransition { + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 0, + actions: vec![], + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ) + } + /// Returns all state transitions grouped by expected `has_is_allowed_validation` result. fn transitions_requiring_allowed_validation() -> Vec { vec![ @@ -267,6 +285,7 @@ mod tests { make_unshield_transition(), make_shield_from_asset_lock_transition(), make_shielded_withdrawal_transition(), + make_identity_create_from_shielded_pool_transition(), ] } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 8190fd595a4..ce1dc819006 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -875,6 +875,138 @@ mod tests { result.errors ); } + + /// Build an `IdentityCreateFromShieldedPool` with `num_actions` actions, `num_keys` keys, and + /// the given `denomination` (the min-fee gate only reads those three). + fn identity_create_from_shielded_pool( + denomination: u64, + num_actions: usize, + num_keys: usize, + ) -> StateTransition { + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::shielded::SerializedAction; + use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + let actions = (0..num_actions as u8) + .map(|i| SerializedAction { + nullifier: [i; 32], + rk: [0u8; 32], + cmx: [0u8; 32], + encrypted_note: vec![0u8; 216], + cv_net: [0u8; 32], + spend_auth_sig: [0u8; 64], + }) + .collect(); + let public_keys = (0..num_keys as u32) + .map(|i| { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: i, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![i as u8; 33]), + signature: BinaryData::new(vec![]), + }) + }) + .collect(); + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys, + denomination, + actions, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ) + } + + #[test] + fn should_reject_identity_create_denomination_below_min_fee() { + let platform_version = PlatformVersion::latest(); + let (num_actions, num_keys) = (2usize, 1usize); + let min_fee = dpp::shielded::compute_shielded_identity_create_fee( + num_actions, + num_keys, + platform_version, + ) + .expect("fee"); + let st = identity_create_from_shielded_pool(min_fee - 1, num_actions, num_keys); + let result = st + .validate_minimum_shielded_fee(platform_version) + .expect("no error"); + assert!(!result.is_valid()); + assert!( + matches!( + result.errors.first(), + Some(ConsensusError::StateError( + StateError::InsufficientShieldedFeeError(_) + )) + ), + "a denomination below the min fee must reject with InsufficientShieldedFeeError; got {:?}", + result.errors + ); + } + + #[test] + fn should_accept_identity_create_denomination_at_min_fee() { + let platform_version = PlatformVersion::latest(); + let (num_actions, num_keys) = (2usize, 1usize); + let min_fee = dpp::shielded::compute_shielded_identity_create_fee( + num_actions, + num_keys, + platform_version, + ) + .expect("fee"); + let st = identity_create_from_shielded_pool(min_fee, num_actions, num_keys); + assert!( + st.validate_minimum_shielded_fee(platform_version) + .expect("no error") + .is_valid(), + "a denomination equal to the min fee must be accepted" + ); + } + + #[test] + fn should_scale_identity_create_min_fee_with_key_count() { + let platform_version = PlatformVersion::latest(); + let num_actions = 2usize; + let one_key_fee = dpp::shielded::compute_shielded_identity_create_fee( + num_actions, + 1, + platform_version, + ) + .expect("fee"); + let five_key_fee = dpp::shielded::compute_shielded_identity_create_fee( + num_actions, + 5, + platform_version, + ) + .expect("fee"); + assert!(five_key_fee > one_key_fee, "more keys must cost more"); + // A 1-key-sized denomination must be REJECTED for a 5-key identity (the fee scaled up). + let st = identity_create_from_shielded_pool(one_key_fee, num_actions, 5); + assert!( + !st.validate_minimum_shielded_fee(platform_version) + .expect("no error") + .is_valid(), + "a 1-key-sized denomination must be rejected once the identity has 5 keys" + ); + // ...and accepted once the denomination covers the scaled fee. + let st_ok = identity_create_from_shielded_pool(five_key_fee, num_actions, 5); + assert!(st_ok + .validate_minimum_shielded_fee(platform_version) + .expect("no error") + .is_valid()); + } } mod validate_shielded_proof { diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs index a1bd990f512..19f0244d2f4 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs @@ -7,6 +7,7 @@ use crate::util::batch::DriveOperation; use crate::util::batch::DriveOperation::{IdentityOperation, SystemOperation}; use crate::util::batch::{IdentityOperationType, SystemOperationType}; use dpp::block::epoch::Epoch; +use dpp::identity::accessors::IdentityGettersV0; use dpp::version::PlatformVersion; impl DriveHighLevelOperationConverter for IdentityCreateFromShieldedPoolTransitionAction { @@ -26,6 +27,20 @@ impl DriveHighLevelOperationConverter for IdentityCreateFromShieldedPoolTransiti IdentityCreateFromShieldedPoolTransitionAction::V0(v0) => { let mut ops: Vec> = Vec::new(); + // Defense in depth: the new identity is created holding the full denomination + // (`AddNewIdentity{ balance }`) AND the system-credits / pool accounting is keyed + // off `denomination`. If those two ever diverged, the emitted ops would mint or + // burn credits. The transformer builds the identity with `balance = denomination`, + // so this can't happen — but assert it before emitting any op rather than trust + // two separate sources of truth. + if v0.identity.balance() != v0.denomination { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "identity balance {} must equal the shielded exit denomination {}", + v0.identity.balance(), + v0.denomination + )))); + } + // 1. Insert each nullifier (validated to not already exist) — double-spend // prevention. These also serve as the id-derivation preimage. insert_nullifiers(&mut ops, &v0.notes); diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index e05f8b729e8..fd68dcc1afd 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1515,9 +1515,23 @@ impl Drive { use dpp::prelude::Revision; use dpp::serialization::PlatformDeserializable; use dpp::state_transition::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + use dpp::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; use dpp::state_transition::proof_result::StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers; - - let identity_id = st.identity_id().to_buffer(); + use std::collections::BTreeMap; + + // Recompute the id from the actions (the canonical value) instead of trusting the + // wire field, and reject a tampered transition whose wire id doesn't match — so a + // client verifying a proof cannot be fed a transition that reuses these nullifiers + // while pointing `identity_id` at a different identity. (Consensus enforces the same + // equality in `validate_structure`; this independently re-checks it here so the + // SDK proof path is sound even on a hand-constructed transition object.) + let derived_id = derive_identity_id_from_actions(st.actions()); + if st.identity_id() != derived_id { + return Err(Error::Proof(ProofError::IncorrectProof( + "identity create from shielded pool: identity_id does not match the value derived from the spend nullifiers".to_string(), + ))); + } + let identity_id = derived_id.to_buffer(); let nullifier_keys: Vec> = st.nullifiers(); // Rebuild the BYTE-IDENTICAL merged query the prove side built: the nullifier @@ -1638,6 +1652,30 @@ impl Drive { } }; + // Bind the proof to the transition's declared key set: the proven identity must hold + // EXACTLY the keys the transition created (the same conversion the action transformer + // used to build the identity). This stops a tampered transition from swapping in a + // different key set while reusing a valid {nullifiers, identity} proof. + // + // The balance is deliberately NOT checked against `denomination`: the identity holds + // `denomination - total_fee`, and `total_fee` is metered at execution and not + // recoverable here, so a balance/denomination equality check would reject every + // honest proof. (`denomination` is bound into the Orchard `extra_sighash_data` at + // consensus, which is where that binding is enforced.) + let expected_keys: BTreeMap = st + .public_keys() + .iter() + .map(|key| { + let public_key: IdentityPublicKey = key.into(); + (public_key.id(), public_key) + }) + .collect(); + if keys != expected_keys { + return Err(Error::Proof(ProofError::IncorrectProof( + "identity create from shielded pool: the proven identity's keys do not match the transition's declared public keys".to_string(), + ))); + } + let identity: dpp::prelude::Identity = IdentityV0 { id: Identifier::from(identity_id), public_keys: keys, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs index 36c37d8637c..99c11e46ec4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -220,6 +220,20 @@ pub fn select_notes_for_denomination<'a>( num_keys: usize, platform_version: &PlatformVersion, ) -> Result<(Vec<&'a ShieldedNote>, u64, u64), PlatformWalletError> { + // Reject a non-member denomination up-front: consensus only accepts the versioned exit set, so + // an unsupported value is rejected at `validate_structure` — but that happens AFTER the + // (expensive) Orchard build/prove in the current flow, so gating here avoids burning that work. + let allowed = platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_identity_create_denominations; + if !allowed.contains(&denomination) { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "denomination {denomination} is not a member of the allowed exit-denomination set {allowed:?}" + ))); + } + // Target the denomination exactly — no fee added on top (exact-equality model). let selected = select_notes(unspent, denomination, 0)?; let total: u64 = selected.iter().map(|n| n.value).sum(); @@ -268,6 +282,34 @@ mod tests { assert_eq!(result[0].value, 300); } + #[test] + fn test_select_for_denomination_rejects_non_member_before_proof() { + // A non-member denomination must fail fast (before any note selection / Orchard prove), + // even when the wallet holds more than enough value — consensus would reject it anyway at + // validate_structure, so burning the prove path on it is pure waste. + let platform_version = PlatformVersion::latest(); + let notes = vec![test_note(u64::MAX / 2, 0)]; + let err = select_notes_for_denomination(¬es, 12_345, 2, 1, platform_version) + .expect_err("a non-member denomination must be rejected"); + assert!( + matches!(err, PlatformWalletError::ShieldedBuildError(ref m) if m.contains("not a member")), + "expected a not-a-member ShieldedBuildError, got: {err:?}" + ); + } + + #[test] + fn test_select_for_denomination_accepts_member() { + // A member denomination with enough value selects successfully. + let platform_version = PlatformVersion::latest(); + let denomination = 10_000_000_000u64; // 0.1 DASH — a member of the v12 set. + let notes = vec![test_note(denomination + 1, 0)]; + let (selected, total, _fee) = + select_notes_for_denomination(¬es, denomination, 2, 1, platform_version) + .expect("a member denomination with enough value must select"); + assert_eq!(selected.len(), 1); + assert!(total >= denomination); + } + #[test] fn test_select_needs_multiple() { let notes = vec![test_note(100, 0), test_note(200, 1), test_note(150, 2)]; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 5b91a7b07e5..2fbeaa37caf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -654,12 +654,6 @@ extension PlatformWalletManager { let identitySignerHandle = identitySigner.handle return try await Task.detached(priority: .userInitiated) { () -> Data in - // Keepalive — KeychainSigner uses `passUnretained`, so the - // Rust ctx pointer dangles unless the Swift owner stays - // alive across this detached work (see - // `registerIdentityFromAddresses`). - _ = identitySigner - var outIdentityId: ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -678,25 +672,31 @@ extension PlatformWalletManager { // Reuses the same marshalling helper the address-funded // registration path uses so the two can't drift. let pubkeyBuffers: [Data] = identityPubkeys.map { $0.pubkeyBytes } - let result = try walletId.withUnsafeBytes { widRaw -> PlatformWalletFFIResult in - guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) - else { - throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") - } - return ManagedPlatformWallet.withPubkeyFFIArray( - identityPubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - platform_wallet_manager_shielded_identity_create_from_pool( - handle, - widPtr, - account, - ffiRowsPtr, - UInt(ffiRowsCount), - denomination, - identitySignerHandle, - &outIdentityId - ) + // KeychainSigner is passed to Rust via `passUnretained`, so the Rust ctx pointer dangles + // unless the Swift owner is kept alive across the FFI call. `_ = identitySigner` is + // folklore that the optimizer may elide in -O builds; `withExtendedLifetime` is the + // guaranteed keepalive (matches this module's signer-lifetime guidance). + let result = try withExtendedLifetime(identitySigner) { + try walletId.withUnsafeBytes { widRaw -> PlatformWalletFFIResult in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + return ManagedPlatformWallet.withPubkeyFFIArray( + identityPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_manager_shielded_identity_create_from_pool( + handle, + widPtr, + account, + ffiRowsPtr, + UInt(ffiRowsCount), + denomination, + identitySignerHandle, + &outIdentityId + ) + } } } diff --git a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs index cac1c8925aa..26a439121de 100644 --- a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs +++ b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs @@ -84,9 +84,9 @@ impl StateTransitionFactoryWasm { | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) - | StateTransition::IdentityCreateFromShieldedPool(_) => { - todo!("shielded transitions not yet implemented in state_transition_factory") - } + | StateTransition::IdentityCreateFromShieldedPool(_) => Err(JsValue::from_str( + "shielded transitions are not yet supported in wasm-dpp StateTransitionFactory", + )), }, Err(dpp::ProtocolError::StateTransitionError(e)) => match e { StateTransitionError::InvalidStateTransitionError { From ed377d56872a086c10b663147ba1ccd69af7c83d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 21:40:32 +0200 Subject: [PATCH 14/28] test(drive-abci): targeted transformer tests for IdentityCreateFromShieldedPool state checks + classifier fixture (#3816) Addresses thepastaclaw suggestions (the is_allowed fixture + the validate_minimum_shielded_fee IdentityCreate tests were already added in a9c8e13): - New transformer rejection tests (tests.rs was a placeholder): seed pool balance/anchor/min-notes, then assert transform_into_action_v0 returns a clean consensus error with no action for (1) an identity already existing at the derived id (IdentityAlreadyExistsError) and (2) a public-key hash already registered to another identity (DuplicatedIdentityPublicKeyIdStateError). These pin the exact branches the codex P1 added (which otherwise would come back as internal Drive errors during AddNewIdentity execution). - Add IdentityCreateFromShieldedPool to the identity_based_signature transitions_not_using_identity_in_state() classifier fixture. cargo check --workspace green; new tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../traits/identity_based_signature.rs | 20 ++ .../tests.rs | 229 +++++++++++++++++- 2 files changed, 244 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 1df1759bada..9b05cb1ba86 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -392,6 +392,26 @@ mod tests { ("Unshield", make_unshield()), ("ShieldFromAssetLock", make_shield_from_asset_lock()), ("ShieldedWithdrawal", make_shielded_withdrawal()), + { + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + ( + "IdentityCreateFromShieldedPool", + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 0, + actions: vec![], + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ), + ) + }, ] } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index a82a9fc4edf..2e5175fd43d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -1,5 +1,224 @@ -//! Integration tests for the `IdentityCreateFromShieldedPool` state transition live in the -//! strategy-test / full-block-pipeline suites (credit conservation, denomination boundaries, -//! `total_fee >= denomination` rejection, prove/verify roundtrip, malleability). This module is a -//! placeholder so the `#[cfg(test)] mod tests;` declaration resolves; unit-level coverage of the -//! structural checks lives in the dpp `validate_structure` tests and the drive converter tests. +//! Unit tests for the `IdentityCreateFromShieldedPool` transformer's identity-creation state +//! checks. The structural checks are covered in the dpp `validate_structure` tests, the op/ +//! conservation invariants in the drive converter tests, and the full happy-path (real Orchard +//! proof, credit conservation, prove/verify roundtrip) in the strategy-test / full-block suites. +//! +//! These tests pin the two consensus-facing early-out branches added to `transform_into_action_v0` +//! (mirroring `IdentityCreate::validate_state`): an identity already existing at the derived id, and +//! a public-key hash already registered to another identity. Both convert what would otherwise be an +//! internal Drive error during `AddNewIdentity` execution into a clean consensus rejection, so they +//! get targeted coverage here. + +use super::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + insert_anchor_into_state, insert_dummy_encrypted_notes, set_pool_total_balance, setup_platform, +}; +use assert_matches::assert_matches; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::shielded::SerializedAction; +use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::{DefaultForPlatformVersion, PlatformVersion}; +use rand::SeedableRng; + +const DENOMINATION: u64 = 10_000_000_000; +const ANCHOR: [u8; 32] = [7u8; 32]; + +fn action(nullifier_seed: u8) -> SerializedAction { + SerializedAction { + nullifier: [nullifier_seed; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } +} + +fn master_key() -> IdentityPublicKeyInCreation { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![0u8; 33]), + signature: BinaryData::default(), + }) +} + +fn transition( + public_keys: Vec, + actions: Vec, +) -> IdentityCreateFromShieldedPoolTransition { + let identity_id = derive_identity_id_from_actions(&actions); + IdentityCreateFromShieldedPoolTransition::V0(IdentityCreateFromShieldedPoolTransitionV0 { + public_keys, + denomination: DENOMINATION, + actions, + anchor: ANCHOR, + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + identity_id, + }) +} + +#[test] +fn transform_rejects_when_identity_already_exists_at_derived_id() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Seed enough pool state (balance, the anchor, the minimum note count) that the transformer + // gets past the pool/anchor/nullifier/balance checks and reaches the identity-creation checks. + set_pool_total_balance(&platform, DENOMINATION * 10); + insert_anchor_into_state(&platform, &ANCHOR); + let min_notes = platform_version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing; + insert_dummy_encrypted_notes(&platform, min_notes.max(1)); + + let actions = vec![action(1), action(2)]; + let derived_id = derive_identity_id_from_actions(&actions); + + // Pre-create an identity AT the derived id (with valid random keys, so add_new_identity + // succeeds) — the (cryptographically unreachable, but defended) collision case. + let (random_identity, _): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + 2, + &mut rand::rngs::StdRng::seed_from_u64(7), + platform_version, + ) + .expect("random identity"); + let existing = Identity::new_with_id_and_keys( + derived_id, + random_identity.public_keys().clone(), + platform_version, + ) + .expect("identity at derived id"); + platform + .drive + .add_new_identity( + existing, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("should add the pre-existing identity"); + + let st = transition(vec![master_key()], actions); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let result = st + .transform_into_action_v0( + &platform.drive, + &mut execution_context, + None, + platform_version, + ) + .expect("transform should not error"); + + assert!(!result.is_valid(), "expected a consensus rejection"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::IdentityAlreadyExistsError(_) + )], + "got: {:?}", + result.errors + ); +} + +#[test] +fn transform_rejects_when_a_public_key_hash_is_already_registered() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + set_pool_total_balance(&platform, DENOMINATION * 10); + insert_anchor_into_state(&platform, &ANCHOR); + let min_notes = platform_version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing; + insert_dummy_encrypted_notes(&platform, min_notes.max(1)); + + // Pre-register a different identity that owns an ECDSA_SECP256K1 key. + let (existing_identity, keys_with_private): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + 3, + &mut rand::rngs::StdRng::seed_from_u64(50), + platform_version, + ) + .expect("random identity"); + let existing_key = keys_with_private + .iter() + .find(|(k, _)| k.key_type() == KeyType::ECDSA_SECP256K1) + .map(|(k, _)| k.clone()) + .expect("an ECDSA_SECP256K1 key"); + platform + .drive + .add_new_identity( + existing_identity, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("should add the key-owning identity"); + + // The new identity's key DUPLICATES that already-registered key's hash. Its derived id (from + // these nullifiers) is free, so the identity-absence check passes and the unique-key-hash check + // is the one that must reject. + let dup_key = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: existing_key.key_type(), + purpose: existing_key.purpose(), + security_level: existing_key.security_level(), + contract_bounds: None, + read_only: false, + data: existing_key.data().clone(), + signature: BinaryData::default(), + }); + + let st = transition(vec![dup_key], vec![action(10), action(11)]); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let result = st + .transform_into_action_v0( + &platform.drive, + &mut execution_context, + None, + platform_version, + ) + .expect("transform should not error"); + + assert!(!result.is_valid(), "expected a consensus rejection"); + // The latest platform version dispatches the v1 unique-key-hash check, which reports the + // collision as a StateError (v0 reported it as a BasicError). + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DuplicatedIdentityPublicKeyIdStateError(_) + )], + "got: {:?}", + result.errors + ); +} From d879f4c995d0fbd32c82a3f760d49b93451f454a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 22:52:11 +0200 Subject: [PATCH 15/28] fix(drive)!: remove AddToSystemCredits over-mint in IdentityCreateFromShieldedPool converter + bind read_only/contract_bounds + fixes (#3816) BLOCKING (chain halt): the converter emitted AddToSystemCredits{denomination} for a pool->new-identity move. Since v12 the shielded pool balance is itself a right-hand-side term of the credit-conservation equation, so this move is RHS-internal (AddNewIdentity +denom offset by the pool decrement -denom), exactly like Unshield (pool->address, which emits NO AddToSystemCredits). AddToSystemCredits writes the LHS scalar, so it over-minted by `denomination` => end-of-block CorruptedCreditsNotBalanced => deterministic chain halt on the first type-20 block. Remove the op (the transition now mirrors Unshield). Rewrite the two converter unit tests that asserted the mint as correct to model the real equation (no LHS change; identity balance offset by the pool decrement). Fix the now-wrong AddToSystemCredits wording in the action doc and book/fees/shielded-fees.md. Also in this commit: - Fix the strategy_tests build break (CI: macOS Rust workspace tests, E0004): add IdentityCreateFromShieldedPoolAction to the shielded no-op catch-all in verify_state_transitions.rs (the harness generates no shielded transitions). - MEDIUM (malleability): bind each key's read_only + contract_bounds into the Orchard extra_sighash_data (single shared helper => builder/verifier stay in lockstep), so they can't be flipped on an all-hash-key transition whose per-key PoP binds nothing. + preimage-binding test. - Review nits: FFI signer_identity_handle *const -> *mut SignerHandle (crate convention / cbindgen header parity); wasm VerifiedIdentityWithShieldedNullifiers toObject now emits a plain identity object and gains fromObject/fromJSON (mirrors the address-funded sibling). cargo check --workspace + drive-abci --tests green; dpp (166) + drive converter (6) + sighash (3) + drive-abci classifier/transformer (20) tests pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- book/src/fees/shielded-fees.md | 2 +- packages/rs-dpp/src/shielded/mod.rs | 69 ++++++++++- .../verify_state_transitions.rs | 9 +- ...ty_create_from_shielded_pool_transition.rs | 116 ++++++++++-------- .../v0/mod.rs | 3 +- .../src/shielded_send.rs | 4 +- .../proof_result/shielded.rs | 49 +++++++- 7 files changed, 185 insertions(+), 67 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 152a75f88bf..e8aec3c3183 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -42,7 +42,7 @@ The fee is derived differently depending on the shielded transition type: | **Unshield** | `fee = compute_minimum_shielded_fee(num_actions) + unshield_address_storage_fee` | `value_balance` (the transition's `unshielding_amount`) is the **gross** amount leaving the pool. The output address receives `unshielding_amount − fee`; validation requires `unshielding_amount ≥ fee`. Unshield also writes the net to the output platform address (`AddBalanceToAddress`), a real storage write priced on top of the base shielded minimum (`unshield_address_storage_fee = 222 × per_byte_rate`, ≈6.08M credits, flat regardless of action count — 222 bytes is the *storage* portion of the ≈6.24M metered address write) so the address write is covered and the proof fee isn't diverted to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | | **ShieldedWithdrawal** | `fee = compute_minimum_shielded_fee(num_actions) + withdrawal_document_storage_fee` | `value_balance` (`unshielding_amount`) is the **gross** amount leaving the pool. The Core withdrawal document receives `unshielding_amount − fee` (which must also clear `MIN_WITHDRAWAL_AMOUNT`). Unlike the other pool-paid transitions, ShieldedWithdrawal also **writes a Core withdrawal document** — a real document insert into the withdrawals contract plus its index entries (`AddWithdrawalDocument`), with a real metered cost of ≈110M credits that is **flat regardless of action count**. That cost is priced on top of the base shielded minimum as a flat ~4,100-byte storage component (`withdrawal_document_storage_fee = 4100 × per_byte_rate`), so the document write is covered and the proof-verification fee isn't diverted from the proposer to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | | **ShieldFromAssetLock** | `pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost`, paid from the asset lock | The flat shielded minimum plus the asset-lock processing base cost is routed to the fee pools. Any remaining asset-lock value (the *surplus*) goes to an optional signed `surplus_output` platform address, or — if none is set — folds into the fee pools up to `shielded_implicit_fee_cap`. See [Entry-Transition Fees](#entry-transition-fees-shield-and-shieldfromassetlock). | -| **IdentityCreateFromShieldedPool** | `total_fee = metered(insert_nullifiers + AddNewIdentity(identity + N keys)) + shielded_verification_fee`, **moved from the new identity's balance** | `value_balance` is a **fixed `denomination`** (a member of the versioned set `{0.1, 0.3, 0.5, 1.0}` DASH) and must equal it EXACTLY. The new identity is created holding the full `denomination` (debited from the pool, mirrored by `AddToSystemCredits(denomination)`); the fee is then **moved** from that balance into the fee pools, so the identity ends with `denomination − total_fee`. Unlike the flat pool-paid transitions, the `AddNewIdentity` write grows with the key count, so the cost is **metered** (not a flat carve) — only the ZK compute fee (`compute_shielded_verification_fee`) is added on top, exactly like the transparent `Shield`. The client predicts it offline with `compute_shielded_identity_create_fee(num_actions, num_keys)`; consensus rejects `denomination < total_fee` with `IdentityInsufficientBalanceError`. | +| **IdentityCreateFromShieldedPool** | `total_fee = metered(insert_nullifiers + AddNewIdentity(identity + N keys)) + shielded_verification_fee`, **moved from the new identity's balance** | `value_balance` is a **fixed `denomination`** (a member of the versioned set `{0.1, 0.3, 0.5, 1.0}` DASH) and must equal it EXACTLY. The new identity is created holding the full `denomination`, funded by decrementing the shielded pool by exactly that amount — a move *between* two balance trees (like `Unshield`'s pool→address), so the global system-credit supply is unchanged (**no** `AddToSystemCredits`); the fee is then **moved** from that balance into the fee pools, so the identity ends with `denomination − total_fee`. Unlike the flat pool-paid transitions, the `AddNewIdentity` write grows with the key count, so the cost is **metered** (not a flat carve) — only the ZK compute fee (`compute_shielded_verification_fee`) is added on top, exactly like the transparent `Shield`. The client predicts it offline with `compute_shielded_identity_create_fee(num_actions, num_keys)`; consensus rejects `denomination < total_fee` with `IdentityInsufficientBalanceError`. | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index f4dfbc5fb6a..a10bd69dd00 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -18,6 +18,7 @@ pub use compute_minimum_shielded_fee::{ compute_shielded_withdrawal_fee, }; +use crate::identity::identity_public_key::contract_bounds::ContractBounds; use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -190,7 +191,9 @@ pub fn unshield_extra_sighash_data(output_address: &[u8], unshielding_amount: u6 /// sighash, with the byte layout /// `identity_id (32) || denomination (u64 LE) || num_keys (u16 LE) /// || for each key in supplied order: key_id (u32 LE) || purpose (u8) || security_level (u8) -/// || key_type (u8) || key_data_len (u16 LE) || key_data`. +/// || key_type (u8) || key_data_len (u16 LE) || key_data || read_only (u8) +/// || contract_bounds (tag u8: 0=None, 1=SingleContract id(32), 2=SingleContractDocumentType +/// id(32) name_len(u16 LE) name)`. /// /// `IdentityCreateFromShieldedPool` carries NO platform identity signature: authorization is 100% /// the Orchard proof + per-action spend-auth signatures + binding signature over this sighash. The @@ -212,7 +215,7 @@ pub fn identity_create_from_shielded_extra_sighash_data( denomination: u64, public_keys: &[IdentityPublicKeyInCreation], ) -> Vec { - let mut data = Vec::with_capacity(32 + 8 + 2 + public_keys.len() * 41); + let mut data = Vec::with_capacity(32 + 8 + 2 + public_keys.len() * 44); data.extend_from_slice(identity_id); data.extend_from_slice(&denomination.to_le_bytes()); data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); @@ -224,6 +227,29 @@ pub fn identity_create_from_shielded_extra_sighash_data( let key_data = key.data().as_slice(); data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); data.extend_from_slice(key_data); + // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that + // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind + // them for hash-based key types (which accept an empty signature). Committing them into the + // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer + // cannot flip `read_only` or alter `contract_bounds` on an observed transition. + data.push(key.read_only() as u8); + match key.contract_bounds() { + None => data.push(0u8), + Some(ContractBounds::SingleContract { id }) => { + data.push(1u8); + data.extend_from_slice(id.as_bytes()); + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + data.push(2u8); + data.extend_from_slice(id.as_bytes()); + let name = document_type_name.as_bytes(); + data.extend_from_slice(&(name.len() as u16).to_le_bytes()); + data.extend_from_slice(name); + } + } } data } @@ -395,7 +421,8 @@ mod tests { #[test] fn layout_is_length_prefixed() { - // identity_id(32) || denomination(8) || num_keys(2) || [key_id(4)|purpose|sec|type|len(2)|data] + // identity_id(32) || denomination(8) || num_keys(2) + // || [key_id(4)|purpose|sec|type|len(2)|data|read_only(1)|contract_bounds_tag(1)] let id = [0x11u8; 32]; let keys = vec![mk_key(7, 0xAB)]; let d = identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &keys); @@ -408,7 +435,9 @@ mod tests { assert_eq!(d[48], KeyType::ECDSA_SECP256K1 as u8); assert_eq!(&d[49..51], &33u16.to_le_bytes()); assert_eq!(&d[51..84], &[0xAB; 33]); - assert_eq!(d.len(), 32 + 8 + 2 + (4 + 1 + 1 + 1 + 2 + 33)); + assert_eq!(d[84], 0u8, "read_only=false"); + assert_eq!(d[85], 0u8, "contract_bounds=None tag"); + assert_eq!(d.len(), 32 + 8 + 2 + (4 + 1 + 1 + 1 + 2 + 33 + 1 + 1)); } #[test] @@ -452,5 +481,37 @@ mod tests { "the full key set must be bound" ); } + + #[test] + fn binds_read_only_and_contract_bounds() { + use crate::identity::identity_public_key::contract_bounds::ContractBounds; + use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; + let id = [0x11u8; 32]; + let base = identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &[mk_key(0, 0xAA)], + ); + + // Flipping read_only changes the preimage (un-malleable for every key type). + let mut ro_key = mk_key(0, 0xAA); + ro_key.set_read_only(true); + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &[ro_key]), + "read_only must be bound" + ); + + // Attaching contract_bounds changes the preimage. + let mut cb_key = mk_key(0, 0xAA); + cb_key.set_contract_bounds(Some(ContractBounds::SingleContract { + id: platform_value::Identifier::new([0x33; 32]), + })); + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &[cb_key]), + "contract_bounds must be bound" + ); + } } } diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index b42bb00cc67..b416ede8222 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -1453,8 +1453,13 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( | StateTransitionAction::ShieldedTransferAction(_) | StateTransitionAction::UnshieldAction(_) | StateTransitionAction::ShieldFromAssetLockAction(_) - | StateTransitionAction::ShieldedWithdrawalAction(_) => { - // Shielded transitions don't support proof verification yet + | StateTransitionAction::ShieldedWithdrawalAction(_) + | StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) => { + // The strategy harness does not generate shielded transitions (no shielded + // `OperationType`), so their proof-verification roundtrip isn't exercised here. + // IdentityCreateFromShieldedPool's strict prove/verify is covered by the unit + // test in rs-drive's verify module; its credit conservation is pinned by the + // converter conservation unit test (no AddToSystemCredits / RHS-internal). } } } else { diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs index 19f0244d2f4..401a739da17 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs @@ -4,8 +4,8 @@ use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; use crate::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; use crate::util::batch::DriveOperation; -use crate::util::batch::DriveOperation::{IdentityOperation, SystemOperation}; -use crate::util::batch::{IdentityOperationType, SystemOperationType}; +use crate::util::batch::DriveOperation::IdentityOperation; +use crate::util::batch::IdentityOperationType; use dpp::block::epoch::Epoch; use dpp::identity::accessors::IdentityGettersV0; use dpp::version::PlatformVersion; @@ -45,32 +45,32 @@ impl DriveHighLevelOperationConverter for IdentityCreateFromShieldedPoolTransiti // prevention. These also serve as the id-derivation preimage. insert_nullifiers(&mut ops, &v0.notes); - // 2. Create the new identity holding the FULL denomination. The fee is moved out - // of this balance into the fee pools at execution, so the identity ends with - // `denomination - fee_amount` and credits are conserved. + // 2. Create the new identity holding the FULL denomination, funded by the pool + // decrement in step 4. This is a move BETWEEN two right-hand-side terms of the + // credit-conservation equation (shielded-pool balance -> identity balance), + // exactly like Unshield (pool -> address): the credits already exist in the + // supply (the shielded pool is itself a counted balance tree), so the global + // system-credits scalar (the conservation equation's LHS) must NOT change. + // `AddToSystemCredits`/`RemoveFromSystemCredits` are correct ONLY when credits + // cross the platform boundary (asset-lock inflow: IdentityCreate / + // ShieldFromAssetLock; Core-withdrawal outflow: ShieldedWithdrawal) — NOT for + // this pool-internal move. Emitting one here would over-mint by `denomination` + // and halt the chain at the end-of-block sum-tree check. The fee is later moved + // from this balance into the fee pools at execution (also RHS-internal), so the + // identity ends with `denomination - fee_amount` and credits are conserved. ops.push(IdentityOperation(IdentityOperationType::AddNewIdentity { identity: v0.identity, is_masternode_identity: false, })); - // 3. The credits backing the new identity come from the shielded pool, which is - // decremented in step 5. Because the identity is freshly created (it was not - // already in circulation, unlike an Unshield's transparent recipient), the - // system-credits total must be incremented by the SAME amount so the global - // credit supply is unchanged. This MUST equal `denomination`, NOT - // `denomination - fee_amount` (the fee never leaves the supply — it is moved - // from the identity balance into the fee pools). Getting this wrong mints or - // burns credits and halts the chain. - ops.push(SystemOperation(SystemOperationType::AddToSystemCredits { - amount: v0.denomination, - })); - - // 4. Insert each action's output note into the CommitmentTree (change re-enters + // 3. Insert each action's output note into the CommitmentTree (change re-enters // the pool as an ordinary, indistinguishable Orchard output). insert_notes(&mut ops, &v0.notes); - // 5. Decrement the shielded pool by exactly `denomination` (= the Orchard - // value_balance leaving the pool; change stays internal to the bundle). + // 4. Decrement the shielded pool by exactly `denomination` (= the Orchard + // value_balance leaving the pool; change stays internal to the bundle). This + // RHS decrement exactly offsets the new identity's balance (step 2), so the + // converter is conservation-neutral with no change to the system-credits scalar. let new_total_balance = v0 .current_total_balance .checked_sub(v0.denomination) @@ -102,6 +102,8 @@ mod tests { use crate::state_transition_action::shielded::identity_create_from_shielded_pool::v0::IdentityCreateFromShieldedPoolTransitionActionV0; use crate::state_transition_action::shielded::ShieldedActionNote; use crate::util::batch::drive_op_batch::ShieldedPoolOperationType; + use crate::util::batch::DriveOperation::SystemOperation; + use crate::util::batch::SystemOperationType; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::platform_value::Identifier; @@ -154,34 +156,34 @@ mod tests { let ops = action .into_high_level_drive_operations(&epoch, platform_version) .expect("ops"); - // InsertNullifiers + AddNewIdentity + AddToSystemCredits + InsertNote(1) + UpdateTotalBalance - assert_eq!(ops.len(), 5); + // InsertNullifiers + AddNewIdentity + InsertNote(1) + UpdateTotalBalance + assert_eq!(ops.len(), 4); } #[test] - fn test_add_to_system_credits_equals_denomination_not_net() { - // CRITICAL conservation invariant: AddToSystemCredits must equal the FULL denomination, - // not denomination - fee. The identity is created with the full denomination; the fee is - // moved from its balance into the fee pools at execution. - let denomination = 10_000_000_000u64; - let action = make_action(denomination, 500_000_000, 50_000_000_000); + fn test_does_not_touch_system_credits() { + // CRITICAL conservation invariant: this is a pool -> identity move BETWEEN two RHS balance + // trees (like Unshield), so it must NOT emit AddToSystemCredits / RemoveFromSystemCredits. + // The shielded-pool credits already exist in the supply; re-minting them on the LHS scalar + // over-counts by `denomination` and halts the chain at the end-of-block sum-tree check. + let action = make_action(10_000_000_000, 500_000_000, 50_000_000_000); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); let ops = action .into_high_level_drive_operations(&epoch, platform_version) .expect("ops"); - let mut found = false; - for op in &ops { - if let SystemOperation(SystemOperationType::AddToSystemCredits { amount }) = op { - assert_eq!( - *amount, denomination, - "AddToSystemCredits must equal the full denomination" - ); - found = true; - } - } - assert!(found, "expected an AddToSystemCredits op"); + let touches_system_credits = ops.iter().any(|op| { + matches!( + op, + SystemOperation(SystemOperationType::AddToSystemCredits { .. }) + | SystemOperation(SystemOperationType::RemoveFromSystemCredits { .. }) + ) + }); + assert!( + !touches_system_credits, + "a pool -> identity move must NOT mint/burn system credits (the LHS scalar)" + ); } #[test] @@ -218,12 +220,15 @@ mod tests { } } - /// Op-level conservation: the shielded pool loses `denomination` and the system-credits total - /// gains `denomination`, so the net credit-supply change from the converter ops is ZERO. (The - /// fee is later moved from the identity balance into the fee pools at execution — a transfer - /// within the supply, not a mint/burn.) + /// Op-level conservation modeling the REAL sum-tree equation + /// `total_credits_in_platform (LHS) == pools + identity_balances + specialized + addresses + + /// shielded_balances (RHS)`. This converter must leave the LHS scalar unchanged (no + /// AddToSystemCredits/RemoveFromSystemCredits) and offset the new identity's balance (an RHS + /// `Balances` credit) exactly against the shielded-pool decrement (an RHS `ShieldedBalances` + /// debit), so the net credit-supply change is ZERO. (The fee is later moved from the identity + /// balance into the fee pools at execution — an RHS-internal transfer, not a mint/burn.) #[test] - fn test_conservation_pool_debit_equals_system_credit() { + fn test_conservation_rhs_internal_no_lhs_change() { let denomination = 10_000_000_000u64; let pool = 50_000_000_000u64; let action = make_action(denomination, 500_000_000, pool); @@ -233,12 +238,19 @@ mod tests { .into_high_level_drive_operations(&epoch, platform_version) .expect("ops"); - let mut system_delta: i128 = 0; - let mut pool_delta: i128 = 0; + let mut lhs_delta: i128 = 0; // AddToSystemCredits / RemoveFromSystemCredits (the LHS scalar) + let mut identity_balance_delta: i128 = 0; // AddNewIdentity balance (RHS Balances) + let mut pool_delta: i128 = 0; // pool UpdateTotalBalance (RHS ShieldedBalances) for op in &ops { match op { SystemOperation(SystemOperationType::AddToSystemCredits { amount }) => { - system_delta += *amount as i128 + lhs_delta += *amount as i128 + } + SystemOperation(SystemOperationType::RemoveFromSystemCredits { amount }) => { + lhs_delta -= *amount as i128 + } + IdentityOperation(IdentityOperationType::AddNewIdentity { identity, .. }) => { + identity_balance_delta += identity.balance() as i128 } DriveOperation::ShieldedPoolOperation( ShieldedPoolOperationType::UpdateTotalBalance { new_total_balance }, @@ -246,12 +258,14 @@ mod tests { _ => {} } } - assert_eq!(system_delta, denomination as i128); - assert_eq!(pool_delta, -(denomination as i128)); assert_eq!( - system_delta + pool_delta, + lhs_delta, 0, + "a pool -> identity move must NOT change the system-credits scalar (LHS)" + ); + assert_eq!( + identity_balance_delta + pool_delta, 0, - "credit supply must be conserved" + "the new identity balance (RHS) must be exactly funded by the shielded-pool decrement (RHS)" ); } diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs index 09f7835b43a..92152189618 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs @@ -17,7 +17,8 @@ pub struct IdentityCreateFromShieldedPoolTransitionActionV0 { /// The anchor used for verification. pub anchor: [u8; 32], /// The fixed exit denomination (in credits) leaving the shielded pool. Equals the new - /// identity's initial balance and the `AddToSystemCredits` amount. + /// identity's initial balance and the amount the shielded pool is decremented by (a move + /// between two balance trees — no change to the system-credit supply). pub denomination: Credits, /// Total fee (metered GroveDB write cost + flat shielded verification/compute fee) moved from /// the new identity's balance into the fee pools at execution. MUST be `< denomination`. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index e355054663a..19f84411e5d 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -323,7 +323,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( /// - `wallet_id_bytes` must point to 32 readable bytes. /// - `identity_pubkeys` must point to `identity_pubkeys_count` contiguous [`IdentityPubkeyFFI`] /// rows that outlive this call (each row's pointers per the [`IdentityPubkeyFFI`] contract). -/// - `signer_identity_handle` must be a valid, non-destroyed `*const SignerHandle` (a +/// - `signer_identity_handle` must be a valid, non-destroyed `*mut SignerHandle` (a /// `VTableSigner` with the callback variant) that outlives this call; the caller retains /// ownership. /// - `out_identity_id` must point to 32 writable bytes. @@ -336,7 +336,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p identity_pubkeys: *const IdentityPubkeyFFI, identity_pubkeys_count: usize, denomination: u64, - signer_identity_handle: *const SignerHandle, + signer_identity_handle: *mut SignerHandle, out_identity_id: *mut [u8; 32], ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs index a7c0ba71775..d8b3d80f4c3 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs @@ -439,18 +439,55 @@ impl VerifiedIdentityWithShieldedNullifiersWasm { } #[wasm_bindgen(js_name = toObject)] - pub fn to_object(&self) -> JsValue { - js_obj(&[ - ("identity", self.identity.clone().into()), - ("nullifiers", self.nullifiers.clone().into()), - ]) + pub fn to_object(&self) -> WasmDppResult { + // Use the identity's own `toObject` so consumers get a plain JS object (not the exported + // `IdentityWasm` class instance), matching the address-funded sibling wrapper. + let id = self.identity.to_object()?; + let nullifiers_js: JsValue = self.nullifiers.clone().into(); + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &"identity".into(), &id.into()).unwrap(); + js_sys::Reflect::set(&obj, &"nullifiers".into(), &nullifiers_js).unwrap(); + Ok(obj.into()) } /// Returns a `JSON.stringify`-friendly form: the `Map` is normalised to a plain object so its /// entries survive serialisation. #[wasm_bindgen(js_name = toJSON)] pub fn to_json(&self) -> WasmDppResult { - normalize_js_value_for_json(&self.to_object()) + let id = self.identity.to_json()?; + let nullifiers_js: JsValue = self.nullifiers.clone().into(); + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &"identity".into(), &id.into()).unwrap(); + js_sys::Reflect::set(&obj, &"nullifiers".into(), &nullifiers_js).unwrap(); + normalize_js_value_for_json(&obj.into()) + } + + #[wasm_bindgen(js_name = fromObject)] + pub fn from_object( + value: JsValue, + ) -> WasmDppResult { + let identity_val = js_sys::Reflect::get(&value, &"identity".into()) + .map_err(|_| WasmDppError::generic("Missing property: identity"))?; + let identity: IdentityWasm = crate::serialization::conversions::from_object(identity_val)?; + let nullifiers_val = js_sys::Reflect::get(&value, &"nullifiers".into()) + .map_err(|_| WasmDppError::generic("Missing property: nullifiers"))?; + Ok(VerifiedIdentityWithShieldedNullifiersWasm { + identity, + nullifiers: nullifiers_val.unchecked_into(), + }) + } + + #[wasm_bindgen(js_name = fromJSON)] + pub fn from_json(value: JsValue) -> WasmDppResult { + let identity_val = js_sys::Reflect::get(&value, &"identity".into()) + .map_err(|_| WasmDppError::generic("Missing property: identity"))?; + let identity: IdentityWasm = crate::serialization::conversions::from_json(identity_val)?; + let nullifiers_val = js_sys::Reflect::get(&value, &"nullifiers".into()) + .map_err(|_| WasmDppError::generic("Missing property: nullifiers"))?; + Ok(VerifiedIdentityWithShieldedNullifiersWasm { + identity, + nullifiers: nullifiers_val.unchecked_into(), + }) } } From e04dbafcbc795afcc0dbd9d768bd9d3ada4de86a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 23:14:19 +0200 Subject: [PATCH 16/28] fix(wasm-dpp2): rebuild nullifiers Map on fromObject/fromJSON + cover type 20 in the signature-negative classifier fixture (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WASM round-trip (codex): the new VerifiedIdentityWithShieldedNullifiers fromObject/fromJSON unchecked-cast the nullifiers property as js_sys::Map, but toJSON normalizes the Map to a plain object — so after JSON.parse(JSON.stringify(toJSON())) the getter advertised a Map that was really a plain object (.size/.get()/iteration broken). Use read_map_property (the existing Map-or-plain-object helper the sibling wrappers use) to rebuild a real Map. - Carried-forward classifier fixture: add IdentityCreateFromShieldedPool to the second negative fixture (transitions_without_sig_validation) backing should_return_false_for_non_identity_signed_transitions (the first fixture was already covered). cargo check green; identity_based_signature (4) tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../traits/identity_based_signature.rs | 20 +++++++++++++++++++ .../proof_result/shielded.rs | 16 +++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 9b05cb1ba86..19561271209 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -605,6 +605,26 @@ mod tests { ("Unshield", make_unshield()), ("ShieldFromAssetLock", make_shield_from_asset_lock()), ("ShieldedWithdrawal", make_shielded_withdrawal()), + { + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + ( + "IdentityCreateFromShieldedPool", + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 0, + actions: vec![], + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ), + ) + }, ]; for (name, st) in transitions_without_sig_validation { assert!( diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs index d8b3d80f4c3..62779cb5d3d 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs @@ -469,11 +469,13 @@ impl VerifiedIdentityWithShieldedNullifiersWasm { let identity_val = js_sys::Reflect::get(&value, &"identity".into()) .map_err(|_| WasmDppError::generic("Missing property: identity"))?; let identity: IdentityWasm = crate::serialization::conversions::from_object(identity_val)?; - let nullifiers_val = js_sys::Reflect::get(&value, &"nullifiers".into()) - .map_err(|_| WasmDppError::generic("Missing property: nullifiers"))?; + // `toJSON` normalizes the `Map` to a plain object so it survives `JSON.stringify`; rebuild a + // real `Map` (accepting either form) so `nullifiers()` behaves like a Map after a + // `JSON.parse(JSON.stringify(...))` round-trip — same boundary the sibling wrappers handle. + let nullifiers = read_map_property(&value, "nullifiers")?; Ok(VerifiedIdentityWithShieldedNullifiersWasm { identity, - nullifiers: nullifiers_val.unchecked_into(), + nullifiers, }) } @@ -482,11 +484,13 @@ impl VerifiedIdentityWithShieldedNullifiersWasm { let identity_val = js_sys::Reflect::get(&value, &"identity".into()) .map_err(|_| WasmDppError::generic("Missing property: identity"))?; let identity: IdentityWasm = crate::serialization::conversions::from_json(identity_val)?; - let nullifiers_val = js_sys::Reflect::get(&value, &"nullifiers".into()) - .map_err(|_| WasmDppError::generic("Missing property: nullifiers"))?; + // `toJSON` normalizes the `Map` to a plain object so it survives `JSON.stringify`; rebuild a + // real `Map` (accepting either form) so `nullifiers()` behaves like a Map after a + // `JSON.parse(JSON.stringify(...))` round-trip — same boundary the sibling wrappers handle. + let nullifiers = read_map_property(&value, "nullifiers")?; Ok(VerifiedIdentityWithShieldedNullifiersWasm { identity, - nullifiers: nullifiers_val.unchecked_into(), + nullifiers, }) } } From 4e13526d17ae32729c1154b82124c04e1e6710b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 8 Jun 2026 23:48:13 +0200 Subject: [PATCH 17/28] test(drive-abci): sum-tree credit-conservation regression for IdentityCreateFromShieldedPool (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the strongest tractable guard for the chain-halt class the blocking AddToSystemCredits over-mint belonged to: seed a balanced funded shielded pool, apply the Type 20 converter's high-level drive operations through a REAL Drive, and assert the end-of-block invariant `calculate_total_credits_balance().ok()` still balances. Verified it FAILS (off by `denomination`) if AddToSystemCredits is reintroduced, and passes with the fix. No Orchard proof is needed — credit conservation is independent of proof verification (the converter only books balances). Re the reviewer's full build->prove->execute->prove/verify happy-path request: that depends on the shared shielded-strategy harness, which is a pre-existing repo-wide TODO disabled for EVERY shielded transition (the shielded `OperationType` build handlers are commented out in strategy.rs and the module is commented out in test_cases/mod.rs / feature-gated). Building it for Type 20 alone is out of scope; this conservation test + the existing op-level converter test + the strict-verify empty-proof unit test cover the consensus-critical composition points reachable without that harness. 3 transition tests pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests.rs | 106 ++++++++++++++++-- 1 file changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 2e5175fd43d..e9f1e982be7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -1,13 +1,19 @@ -//! Unit tests for the `IdentityCreateFromShieldedPool` transformer's identity-creation state -//! checks. The structural checks are covered in the dpp `validate_structure` tests, the op/ -//! conservation invariants in the drive converter tests, and the full happy-path (real Orchard -//! proof, credit conservation, prove/verify roundtrip) in the strategy-test / full-block suites. +//! Drive-backed tests for the `IdentityCreateFromShieldedPool` transition. Structural checks are +//! covered in the dpp `validate_structure` tests and op-level checks in the drive converter tests. //! -//! These tests pin the two consensus-facing early-out branches added to `transform_into_action_v0` -//! (mirroring `IdentityCreate::validate_state`): an identity already existing at the derived id, and -//! a public-key hash already registered to another identity. Both convert what would otherwise be an -//! internal Drive error during `AddNewIdentity` execution into a clean consensus rejection, so they -//! get targeted coverage here. +//! Covered here: +//! - The two consensus-facing early-out branches in `transform_into_action_v0` (mirroring +//! `IdentityCreate::validate_state`): an identity already existing at the derived id, and a +//! public-key hash already registered to another identity — both convert what would otherwise be +//! an internal Drive error during `AddNewIdentity` execution into a clean consensus rejection. +//! - Sum-tree credit conservation: the converter ops applied through a real Drive keep +//! `calculate_total_credits_balance().ok()` balanced (the end-of-block invariant that halts the +//! chain) — the regression guard for the `AddToSystemCredits` over-mint. +//! +//! The full build->prove->execute->prove/verify happy path (real Orchard proof + the strict merged +//! nullifier+identity proof roundtrip) is deferred to the shared shielded-strategy harness, a +//! pre-existing repo-wide TODO that is disabled for every shielded transition (the shielded +//! `OperationType` build handlers are commented out in `strategy.rs`). use super::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; @@ -222,3 +228,85 @@ fn transform_rejects_when_a_public_key_hash_is_already_registered() { result.errors ); } + +/// Sum-tree credit-conservation regression for the pool->new-identity exit. +/// +/// Applies the converter's high-level drive operations through a REAL Drive and asserts the +/// end-of-block invariant `calculate_total_credits_balance().ok()` — the exact check that halts the +/// chain — still balances. This is the regression guard for the `AddToSystemCredits` over-mint: +/// with that op present the balance is off by `denomination` and `.ok()` is false. It needs no +/// Orchard proof because credit conservation is independent of proof verification (the converter +/// only books balances). The full build->prove->execute->prove/verify happy path additionally needs +/// the shared shielded-strategy harness, which is a pre-existing repo-wide TODO disabled for every +/// shielded transition (the `OperationType` build handlers are commented out in strategy.rs). +#[test] +fn converter_ops_preserve_sum_tree_credit_conservation() { + use dpp::block::epoch::Epoch; + use dpp::identity::accessors::IdentitySettersV0; + use dpp::platform_value::Identifier; + use drive::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; + use drive::state_transition_action::shielded::identity_create_from_shielded_pool::v0::IdentityCreateFromShieldedPoolTransitionActionV0; + use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; + use drive::state_transition_action::shielded::ShieldedActionNote; + use std::collections::BTreeMap; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let drive = &platform.drive; + let block_info = BlockInfo::default(); + let seed = 50_000_000_000u64; + + // Seed a BALANCED funded pool: `set_pool_total_balance` raises the shielded pool (an RHS balance + // tree) AND the system-credit scalar (the conservation equation's LHS) by `seed` together, + // mirroring a prior shield-in, so the starting state is balanced. + set_pool_total_balance(&platform, seed); + assert!( + drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("calc") + .ok() + .expect("ok"), + "precondition: the seeded pool+system-credits state must be balanced" + ); + + // A new identity holding the full denomination, funded by the pool (no Orchard proof needed — + // the converter only books balances). + let mut identity = Identity::new_with_id_and_keys( + Identifier::from([0xCD; 32]), + BTreeMap::new(), + platform_version, + ) + .expect("identity"); + identity.set_balance(DENOMINATION); + let action = IdentityCreateFromShieldedPoolTransitionAction::V0( + IdentityCreateFromShieldedPoolTransitionActionV0 { + identity, + notes: vec![ShieldedActionNote { + nullifier: [0x10; 32], + cmx: [0x20; 32], + encrypted_note: vec![0x77; 216], + }], + anchor: [0x07; 32], + denomination: DENOMINATION, + fee_amount: 500_000_000, + current_total_balance: seed, + }, + ); + + let ops = action + .into_high_level_drive_operations(&Epoch::new(0).unwrap(), platform_version) + .expect("converter ops"); + drive + .apply_drive_operations(ops, true, &block_info, None, platform_version, None) + .expect("apply converter ops"); + + // The end-of-block conservation invariant must still hold — this FAILS (off by `denomination`) + // if the converter re-mints via AddToSystemCredits. + let balance = drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("calc"); + assert!( + balance.ok().expect("ok"), + "credit supply must be conserved after a pool->identity exit; got {balance}" + ); +} From 56881e8854c004430f9a43d5826531d8df81c957 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 02:15:05 +0200 Subject: [PATCH 18/28] feat: fallback-on-failure for IdentityCreateFromShieldedPool (charge instead of free-reject) (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type 20 was the only identity-create that free-rejected on a stateful failure; the asset-lock and address-funded creates charge a penalty (PartiallyUseAssetLock / BumpAddressInputNonces) because they have a chargeable collateral. Give Type 20 the same: a new wire field `send_to_address_on_creation_failure: PlatformAddress` is the collateral. On a unique-public-key-hash collision the spend is finalized and the value is credited to that address minus a penalty, instead of a free reject (so the spend is deterministic/final and the failure is anti-grief). Design: - New field bound into BOTH the platform sighash (per-key PoP signs it) and the Orchard `extra_sighash_data` (so a relayer cannot redirect the fallback). - The state checks (identity-exists + unique-key-hash) move from transform_into_action into a real `validate_state` (Type 20 now has_state_validation=true; advanced_structure_with_state stays false so the cheap PoP/key checks remain BEFORE Halo2). The processor delivers no pre-built action, so the state.rs dispatch builds the success action via transform exactly once, then branches. - The failure does NOT add a new StateTransitionAction variant: it produces an `UnshieldAction` (pool->address minus fee) — topologically identical — reusing Unshield's converter, `PaidFromShieldedPool` event, and conservation. The penalty (unique_key_already_present + processing fee) is capped at the denomination so the Unshield converter cannot underflow. Mirrors how IdentityCreate produces a PartiallyUseAssetLock action on failure. Threaded the fallback through the sighash, try_from_bundle, the builder, the SDK broadcast helper, the wallet op, the FFI (required 21-byte PlatformAddress), and the Swift wrapper. Verified: validate_state mirrors IdentityCreateFromAddresses; the processor runs transform exactly once (no double sig-verification accounting); the failure path conserves (new failure-path sum-tree conservation test + reuses Unshield's proven booking). cargo check --workspace green; clippy clean; dpp shielded (166) + drive converter (7) + the 4 transition tests (incl. the Unshield-fallback branch + both conservation tests) pass. Follow-up (non-consensus): the wallet should treat a fallback outcome as a spend (finalize the reservation, learn the credit went to the fallback) rather than cancel; that needs the SDK to surface executed-with-fallback distinctly from rejected. Tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../identity_create_from_shielded_pool.rs | 4 + packages/rs-dpp/src/shielded/mod.rs | 121 +++++-- .../accessors/mod.rs | 9 + .../accessors/v0/mod.rs | 4 + .../methods/mod.rs | 4 + .../methods/v0/mod.rs | 3 + .../mod.rs | 3 + .../v0/mod.rs | 12 + .../v0/state_transition_validation.rs | 3 + .../v0/v0_methods.rs | 4 + .../processor/traits/identity_balance.rs | 2 + .../traits/identity_based_signature.rs | 4 + .../processor/traits/is_allowed.rs | 3 + .../processor/traits/shielded_proof.rs | 6 + .../processor/traits/state.rs | 44 ++- .../identity_create_from_shielded_pool/mod.rs | 50 +++ .../state/mod.rs | 1 + .../state/v0/mod.rs | 118 +++++++ .../tests.rs | 300 ++++++++++++++++-- .../transform_into_action/v0/mod.rs | 43 +-- .../v0/mod.rs | 1 + .../src/shielded_send.rs | 30 ++ .../src/wallet/platform_wallet.rs | 2 + .../src/wallet/shielded/operations.rs | 14 +- .../identity_create_from_shielded_pool.rs | 4 + .../PlatformWalletManagerShieldedSync.swift | 57 +++- 26 files changed, 747 insertions(+), 99 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs diff --git a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs index ea2cec31f7b..9e38aca2a8b 100644 --- a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs +++ b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs @@ -1,6 +1,7 @@ use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey}; use crate::address_funds::OrchardAddress; +use crate::address_funds::PlatformAddress; use crate::fee::Credits; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use crate::identity::signer::Signer; @@ -89,6 +90,7 @@ pub struct IdentityCreateFromShieldedPoolBuildResult { pub async fn build_identity_create_from_shielded_pool_transition( public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, spends: Vec, change_address: &OrchardAddress, fvk: &FullViewingKey, @@ -161,6 +163,7 @@ where let extra_sighash_data = crate::shielded::identity_create_from_shielded_extra_sighash_data( &identity_id.to_buffer(), denomination, + &send_to_address_on_creation_failure, &in_creation_keys, ); @@ -192,6 +195,7 @@ where let mut state_transition = IdentityCreateFromShieldedPoolTransition::try_from_bundle( in_creation_keys, denomination, + send_to_address_on_creation_failure, sb.actions.clone(), sb.anchor, sb.proof.clone(), diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index a10bd69dd00..efa89030b13 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -18,6 +18,7 @@ pub use compute_minimum_shielded_fee::{ compute_shielded_withdrawal_fee, }; +use crate::address_funds::PlatformAddress; use crate::identity::identity_public_key::contract_bounds::ContractBounds; use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -189,7 +190,9 @@ pub fn unshield_extra_sighash_data(output_address: &[u8], unshielding_amount: u6 /// Builds the transparent `extra_data` bound into an `IdentityCreateFromShieldedPool`'s platform /// sighash, with the byte layout -/// `identity_id (32) || denomination (u64 LE) || num_keys (u16 LE) +/// `identity_id (32) || denomination (u64 LE) +/// || send_to_address_on_creation_failure (tag u8: 0=P2pkh, 1=P2sh || hash 20) +/// || num_keys (u16 LE) /// || for each key in supplied order: key_id (u32 LE) || purpose (u8) || security_level (u8) /// || key_type (u8) || key_data_len (u16 LE) || key_data || read_only (u8) /// || contract_bounds (tag u8: 0=None, 1=SingleContract id(32), 2=SingleContractDocumentType @@ -213,11 +216,24 @@ pub fn unshield_extra_sighash_data(output_address: &[u8], unshielding_amount: u6 pub fn identity_create_from_shielded_extra_sighash_data( identity_id: &[u8; 32], denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, public_keys: &[IdentityPublicKeyInCreation], ) -> Vec { - let mut data = Vec::with_capacity(32 + 8 + 2 + public_keys.len() * 44); + let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); data.extend_from_slice(identity_id); data.extend_from_slice(&denomination.to_le_bytes()); + // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the + // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address. + match send_to_address_on_creation_failure { + PlatformAddress::P2pkh(hash) => { + data.push(0u8); + data.extend_from_slice(hash); + } + PlatformAddress::P2sh(hash) => { + data.push(1u8); + data.extend_from_slice(hash); + } + } data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); for key in public_keys { data.extend_from_slice(&key.id().to_le_bytes()); @@ -421,23 +437,34 @@ mod tests { #[test] fn layout_is_length_prefixed() { - // identity_id(32) || denomination(8) || num_keys(2) + // identity_id(32) || denomination(8) + // || send_to_address_on_creation_failure (tag(1) || hash(20)) + // || num_keys(2) // || [key_id(4)|purpose|sec|type|len(2)|data|read_only(1)|contract_bounds_tag(1)] let id = [0x11u8; 32]; let keys = vec![mk_key(7, 0xAB)]; - let d = identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &keys); + let fallback = PlatformAddress::P2pkh([0x5Cu8; 20]); + let d = identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &keys, + ); assert_eq!(&d[0..32], &id); assert_eq!(&d[32..40], &10_000_000_000u64.to_le_bytes()); - assert_eq!(&d[40..42], &1u16.to_le_bytes()); - assert_eq!(&d[42..46], &7u32.to_le_bytes()); - assert_eq!(d[46], Purpose::AUTHENTICATION as u8); - assert_eq!(d[47], SecurityLevel::MASTER as u8); - assert_eq!(d[48], KeyType::ECDSA_SECP256K1 as u8); - assert_eq!(&d[49..51], &33u16.to_le_bytes()); - assert_eq!(&d[51..84], &[0xAB; 33]); - assert_eq!(d[84], 0u8, "read_only=false"); - assert_eq!(d[85], 0u8, "contract_bounds=None tag"); - assert_eq!(d.len(), 32 + 8 + 2 + (4 + 1 + 1 + 1 + 2 + 33 + 1 + 1)); + // Fallback address: tag(0=P2pkh) at offset 40, 20-byte hash at 41..61. + assert_eq!(d[40], 0u8, "fallback address P2pkh tag"); + assert_eq!(&d[41..61], &[0x5Cu8; 20], "fallback address hash"); + assert_eq!(&d[61..63], &1u16.to_le_bytes()); + assert_eq!(&d[63..67], &7u32.to_le_bytes()); + assert_eq!(d[67], Purpose::AUTHENTICATION as u8); + assert_eq!(d[68], SecurityLevel::MASTER as u8); + assert_eq!(d[69], KeyType::ECDSA_SECP256K1 as u8); + assert_eq!(&d[70..72], &33u16.to_le_bytes()); + assert_eq!(&d[72..105], &[0xAB; 33]); + assert_eq!(d[105], 0u8, "read_only=false"); + assert_eq!(d[106], 0u8, "contract_bounds=None tag"); + assert_eq!(d.len(), 32 + 8 + 21 + 2 + (4 + 1 + 1 + 1 + 2 + 33 + 1 + 1)); } #[test] @@ -445,27 +472,68 @@ mod tests { let id_a = [0x11u8; 32]; let id_b = [0x22u8; 32]; let keys = vec![mk_key(0, 0xAA)]; - let base = - identity_create_from_shielded_extra_sighash_data(&id_a, 10_000_000_000, &keys); + let fallback = PlatformAddress::P2pkh([0x01u8; 20]); + let base = identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &fallback, + &keys, + ); // Changing the identity id changes the preimage (anti-redirection to a different id). assert_ne!( base, - identity_create_from_shielded_extra_sighash_data(&id_b, 10_000_000_000, &keys), + identity_create_from_shielded_extra_sighash_data( + &id_b, + 10_000_000_000, + &fallback, + &keys + ), "identity id must be bound" ); // Changing the denomination changes the preimage. assert_ne!( base, - identity_create_from_shielded_extra_sighash_data(&id_a, 30_000_000_000, &keys), + identity_create_from_shielded_extra_sighash_data( + &id_a, + 30_000_000_000, + &fallback, + &keys + ), "denomination must be bound" ); + // Changing the fallback failure address changes the preimage (anti-redirection of the + // failure credit: a relayer cannot point the penalty-charged spend at a different + // address than the one each key's proof-of-possession signed). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &PlatformAddress::P2pkh([0x02u8; 20]), + &keys + ), + "fallback failure address hash must be bound" + ); + // Changing only the fallback address TYPE (P2pkh -> P2sh, same hash) changes the + // preimage too (the type tag is bound, not just the hash). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &PlatformAddress::P2sh([0x01u8; 20]), + &keys + ), + "fallback failure address type tag must be bound" + ); // Swapping in a different key changes the preimage (anti-key-swap). assert_ne!( base, identity_create_from_shielded_extra_sighash_data( &id_a, 10_000_000_000, + &fallback, &[mk_key(0, 0xBB)] ), "key data must be bound" @@ -476,6 +544,7 @@ mod tests { identity_create_from_shielded_extra_sighash_data( &id_a, 10_000_000_000, + &fallback, &[mk_key(0, 0xAA), mk_key(1, 0xCC)] ), "the full key set must be bound" @@ -487,9 +556,11 @@ mod tests { use crate::identity::identity_public_key::contract_bounds::ContractBounds; use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; let id = [0x11u8; 32]; + let fallback = PlatformAddress::P2pkh([0x01u8; 20]); let base = identity_create_from_shielded_extra_sighash_data( &id, 10_000_000_000, + &fallback, &[mk_key(0, 0xAA)], ); @@ -498,7 +569,12 @@ mod tests { ro_key.set_read_only(true); assert_ne!( base, - identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &[ro_key]), + identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &[ro_key] + ), "read_only must be bound" ); @@ -509,7 +585,12 @@ mod tests { })); assert_ne!( base, - identity_create_from_shielded_extra_sighash_data(&id, 10_000_000_000, &[cb_key]), + identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &[cb_key] + ), "contract_bounds must be bound" ); } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs index fc8183a07cb..68715016c64 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs @@ -2,6 +2,7 @@ mod v0; pub use v0::*; +use crate::address_funds::PlatformAddress; use crate::shielded::SerializedAction; use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -28,6 +29,14 @@ impl IdentityCreateFromShieldedPoolTransitionAccessorsV0 } } + fn send_to_address_on_creation_failure(&self) -> &PlatformAddress { + match self { + IdentityCreateFromShieldedPoolTransition::V0(v0) => { + &v0.send_to_address_on_creation_failure + } + } + } + fn identity_id(&self) -> Identifier { match self { IdentityCreateFromShieldedPoolTransition::V0(v0) => v0.identity_id, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs index f27c36c537f..bf0c2c0b62f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::address_funds::PlatformAddress; use crate::shielded::SerializedAction; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_value::Identifier; @@ -12,6 +13,9 @@ pub trait IdentityCreateFromShieldedPoolTransitionAccessorsV0 { /// Get the fixed exit denomination (in credits). fn denomination(&self) -> u64; + /// Get the fallback address credited (minus penalty) if identity creation fails a stateful check. + fn send_to_address_on_creation_failure(&self) -> &PlatformAddress; + /// Get the id of the new identity (derived from the spend nullifiers). fn identity_id(&self) -> Identifier; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs index 27d872cce14..683528ccb4c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs @@ -2,6 +2,8 @@ mod v0; pub use v0::*; +#[cfg(feature = "state-transition-signing")] +use crate::address_funds::PlatformAddress; #[cfg(feature = "state-transition-signing")] use crate::shielded::SerializedAction; use crate::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; @@ -22,6 +24,7 @@ impl IdentityCreateFromShieldedPoolTransitionMethodsV0 fn try_from_bundle( public_keys: Vec, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, actions: Vec, anchor: [u8; 32], proof: Vec, @@ -37,6 +40,7 @@ impl IdentityCreateFromShieldedPoolTransitionMethodsV0 0 => IdentityCreateFromShieldedPoolTransitionV0::try_from_bundle( public_keys, denomination, + send_to_address_on_creation_failure, actions, anchor, proof, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs index 6b1063051e4..1d434311923 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/v0/mod.rs @@ -1,4 +1,6 @@ #[cfg(feature = "state-transition-signing")] +use crate::address_funds::PlatformAddress; +#[cfg(feature = "state-transition-signing")] use crate::shielded::SerializedAction; #[cfg(feature = "state-transition-signing")] use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -17,6 +19,7 @@ pub trait IdentityCreateFromShieldedPoolTransitionMethodsV0 { fn try_from_bundle( public_keys: Vec, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, actions: Vec, anchor: [u8; 32], proof: Vec, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs index c8fbb1f7bd4..1e503a85ca3 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs @@ -163,6 +163,9 @@ mod tests { anchor: [7u8; 32], proof: vec![8u8; 100], binding_signature: [9u8; 64], + send_to_address_on_creation_failure: crate::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), identity_id, } .into(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs index da15ed06769..7c1a3777714 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs @@ -4,6 +4,7 @@ mod types; pub(super) mod v0_methods; mod version; +use crate::address_funds::PlatformAddress; #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; use crate::shielded::SerializedAction; @@ -46,6 +47,14 @@ pub struct IdentityCreateFromShieldedPoolTransitionV0 { pub proof: Vec, /// RedPallas binding signature pub binding_signature: [u8; 64], + /// Fallback platform address credited if identity creation FAILS a stateful check (a + /// public-key hash already registered to another identity). On failure the spend is still final + /// — the denomination leaves the pool — and is credited here minus a penalty, exactly like the + /// `PartiallyUseAssetLock` / `BumpAddressInputNonces` penalty the asset-lock and address-funded + /// identity creates use. It IS part of the platform sighash (so each key's proof-of-possession + /// signs it) and is additionally committed into the Orchard `extra_sighash_data`, so a relayer + /// cannot redirect the fallback. + pub send_to_address_on_creation_failure: PlatformAddress, /// The id of the new identity, derived as `double_sha256(sorted nullifiers)`. It is committed /// into the Orchard `extra_sighash_data` (so the bundle cannot be redirected to a different id) /// and re-derived + checked at consensus. Excluded from the platform sighash because it is fully @@ -97,6 +106,9 @@ mod tests { anchor: [7u8; 32], proof: vec![8u8; 100], binding_signature: [9u8; 64], + send_to_address_on_creation_failure: crate::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), identity_id, } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs index 64a8d87a488..f0a11d4b27c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/state_transition_validation.rs @@ -159,6 +159,9 @@ mod tests { anchor: [7u8; 32], proof: vec![8u8; 100], binding_signature: [9u8; 64], + send_to_address_on_creation_failure: crate::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), identity_id, } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs index 0ed13e5b528..8b083f01dcf 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/v0_methods.rs @@ -1,4 +1,6 @@ #[cfg(feature = "state-transition-signing")] +use crate::address_funds::PlatformAddress; +#[cfg(feature = "state-transition-signing")] use crate::shielded::SerializedAction; #[cfg(feature = "state-transition-signing")] use crate::state_transition::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; @@ -18,6 +20,7 @@ impl IdentityCreateFromShieldedPoolTransitionMethodsV0 fn try_from_bundle( public_keys: Vec, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, actions: Vec, anchor: [u8; 32], proof: Vec, @@ -30,6 +33,7 @@ impl IdentityCreateFromShieldedPoolTransitionMethodsV0 let transition = IdentityCreateFromShieldedPoolTransitionV0 { public_keys, denomination, + send_to_address_on_creation_failure, actions, anchor, proof, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index ab87a0429dd..61745a0af30 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -233,6 +233,8 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), identity_id: Default::default(), }, ), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 19561271209..bdf5da140d3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -406,6 +406,8 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), identity_id: Default::default(), }, ), @@ -619,6 +621,8 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), identity_id: Default::default(), }, ), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index f02882b2a51..60e752130d5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -246,6 +246,9 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), identity_id: Default::default(), }, ), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index ce1dc819006..2f2b212fd70 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -486,6 +486,7 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { dpp::shielded::identity_create_from_shielded_extra_sighash_data( &identity_id, v0.denomination, + &v0.send_to_address_on_creation_failure, &v0.public_keys, ); // value_balance = denomination EXACTLY (the ShieldedTransfer exact-equality @@ -627,6 +628,9 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), identity_id: Default::default(), }, ), @@ -923,6 +927,8 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), identity_id: Default::default(), }, ), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index dce166748f4..80af348230f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -3,6 +3,8 @@ use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::identity_create::StateTransitionStateValidationForIdentityCreateTransitionV0; use crate::execution::validation::state_transition::identity_create_from_addresses::StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; @@ -206,10 +208,40 @@ impl StateTransitionStateValidation for StateTransition { "shielded withdrawal should not have state validation", ))) } - StateTransition::IdentityCreateFromShieldedPool(_) => { - Err(Error::Execution(ExecutionError::CorruptedCodeExecution( - "identity create from shielded pool should not have state validation", - ))) + StateTransition::IdentityCreateFromShieldedPool(st) => { + // Type 20 does NOT use `has_advanced_structure_validation_with_state` (the cheap + // PoP/key-structure checks stay in `validate_shielded_proof`, ahead of Halo 2), so + // the processor does not pre-build the action — it always arrives here as `None`. + // Build the optimistic SUCCESS action now (the stateless + pool/anchor/nullifier/ + // balance checks). If that already rejects, forward the rejection; otherwise hand + // the success action to `validate_state`, which branches success-vs-Unshield-fallback + // on the identity-creation state checks. + let action = if let Some(action) = action { + action + } else { + let transform_result = st + .transform_into_action_for_identity_create_from_shielded_pool_transition( + platform, + execution_context, + tx, + )?; + if !transform_result.is_valid_with_data() { + return Ok(transform_result); + } + transform_result.into_data()? + }; + let StateTransitionAction::IdentityCreateFromShieldedPoolAction(action) = action + else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "action must be an identity create from shielded pool transition action", + ))); + }; + st.validate_state_for_identity_create_from_shielded_pool_transition( + action, + platform, + execution_context, + tx, + ) } } } @@ -217,6 +249,7 @@ impl StateTransitionStateValidation for StateTransition { fn has_state_validation(&self) -> bool { match self { StateTransition::IdentityCreateFromAddresses(_) + | StateTransition::IdentityCreateFromShieldedPool(_) | StateTransition::DataContractCreate(_) | StateTransition::IdentityCreate(_) | StateTransition::DataContractUpdate(_) @@ -235,8 +268,7 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) - | StateTransition::IdentityCreateFromShieldedPool(_) => false, + | StateTransition::ShieldedWithdrawal(_) => false, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs index c455f8a261d..c295362bbdc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -1,3 +1,4 @@ +mod state; mod transform_into_action; #[cfg(test)] @@ -6,11 +7,13 @@ mod tests; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::validation::ConsensusValidationResult; use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; use drive::state_transition_action::StateTransitionAction; use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -65,3 +68,50 @@ impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer } } } + +/// A trait for state validation for the identity-create-from-shielded-pool transition. +/// +/// `transform_into_action` builds the SUCCESS action (after the stateless + pool checks); this +/// runs the identity-creation state checks that branch the outcome: on success it forwards the +/// success action unchanged, and on a unique-public-key-hash collision it returns an +/// `UnshieldAction` that finalizes the spend and credits the fallback address minus a penalty +/// (mirroring how `IdentityCreateFromAddresses` returns a `BumpAddressInputNonces` action and +/// asset-lock `IdentityCreate` returns a `PartiallyUseAssetLock` action on failure). +pub trait StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 { + /// Validate state. + fn validate_state_for_identity_create_from_shielded_pool_transition( + &self, + action: IdentityCreateFromShieldedPoolTransitionAction, + platform: &PlatformRef, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 + for IdentityCreateFromShieldedPoolTransition +{ + fn validate_state_for_identity_create_from_shielded_pool_transition( + &self, + action: IdentityCreateFromShieldedPoolTransitionAction, + platform: &PlatformRef, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_create_from_shielded_pool_state_transition + .state + { + 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "identity create from shielded pool transition: validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs new file mode 100644 index 00000000000..506f09f1185 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs @@ -0,0 +1,118 @@ +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use drive::state_transition_action::shielded::unshield::v0::UnshieldTransitionActionV0; +use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_shielded_pool) trait IdentityCreateFromShieldedPoolStateTransitionStateValidationV0 +{ + fn validate_state_v0( + &self, + platform: &PlatformRef, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV0 + for IdentityCreateFromShieldedPoolTransition +{ + fn validate_state_v0( + &self, + platform: &PlatformRef, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + // 1. The new identity must not already exist. The id is `double_sha256(sorted nullifiers)` — + // collision-resistant and derived from single-use spend tags — so this is practically + // unreachable, but check explicitly to return a clean consensus rejection. There is no + // chargeable fallback for this case (it cannot be triggered by a relayer choosing a + // colliding id), so a failure is a plain free rejection, mirroring the identity-exists + // check in `IdentityCreateFromAddresses`'s `validate_state`. + let identity_id = derive_identity_id_from_actions(self.actions()); + if drive + .fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)? + .is_some() + { + // Since the id comes entirely from the spend nullifiers this should never be reachable. + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id).into(), + )); + } + + // 2. None of the new identity's public-key hashes may already be registered to another + // identity (platform enforces globally-unique key hashes for unique key types). Unlike the + // identity-exists check above, this CAN be triggered by an attacker re-using a victim's + // public-key hash, so it gets a chargeable fallback instead of a free rejection: on + // failure the spend is still final and the value is credited to + // `send_to_address_on_creation_failure` minus a penalty. This is topologically identical + // to an `Unshield` (pool -> address minus fee), so we reuse `UnshieldTransitionAction` + // wholesale (its converter, `PaidFromShieldedPool` execution event, and conservation). + let unique_public_key_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + if unique_public_key_validation_result.is_valid() { + // We just pass the success action that was built by `transform_into_action`. + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromShieldedPoolAction(action), + )) + } else { + // A unique-key-hash collision: finalize the spend and credit the fallback address minus a + // penalty. The penalty is the flat `unique_key_already_present` amount plus the metered + // processing fee accumulated so far, exactly like `IdentityCreateFromAddresses`'s + // `BumpAddressInputNonces` penalty. We then CAP it at the denomination so the Unshield + // converter's `amount.checked_sub(fee)` cannot underflow (a net-zero credit is the worst + // case: the whole spend is consumed by the penalty and flows to the fee pools). + let denomination = action.denomination(); + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow( + "identity create from shielded pool failure penalty overflow", + ))? + .min(denomination); + + let failure_action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { + output_address: self.send_to_address_on_creation_failure().clone(), + amount: denomination, + notes: action.notes().to_vec(), + anchor: *action.anchor(), + fee_amount: penalty, + current_total_balance: action.current_total_balance(), + }); + + Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::UnshieldAction(failure_action), + unique_public_key_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index e9f1e982be7..17489662581 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -2,24 +2,28 @@ //! covered in the dpp `validate_structure` tests and op-level checks in the drive converter tests. //! //! Covered here: -//! - The two consensus-facing early-out branches in `transform_into_action_v0` (mirroring -//! `IdentityCreate::validate_state`): an identity already existing at the derived id, and a -//! public-key hash already registered to another identity — both convert what would otherwise be -//! an internal Drive error during `AddNewIdentity` execution into a clean consensus rejection. -//! - Sum-tree credit conservation: the converter ops applied through a real Drive keep -//! `calculate_total_credits_balance().ok()` balanced (the end-of-block invariant that halts the -//! chain) — the regression guard for the `AddToSystemCredits` over-mint. +//! - The two identity-creation state checks in `validate_state` (the success/failure branch that +//! mirrors `IdentityCreateFromAddresses`): an identity already existing at the derived id is a +//! free rejection, while a public-key hash already registered to another identity is NOT a free +//! reject — the spend is finalized and the value is credited to +//! `send_to_address_on_creation_failure` minus a penalty via a fallback `UnshieldAction`. +//! - Sum-tree credit conservation on BOTH the success path (pool->new-identity) and the failure +//! path (the fallback Unshield, pool->address minus penalty): the converter ops applied through a +//! real Drive keep `calculate_total_credits_balance().ok()` balanced (the end-of-block invariant +//! that halts the chain) — the regression guard for the `AddToSystemCredits` over-mint. //! //! The full build->prove->execute->prove/verify happy path (real Orchard proof + the strict merged //! nullifier+identity proof roundtrip) is deferred to the shared shielded-strategy harness, a //! pre-existing repo-wide TODO that is disabled for every shielded transition (the shielded //! `OperationType` build handlers are commented out in `strategy.rs`). +use super::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; use super::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::state_transitions::test_helpers::{ insert_anchor_into_state, insert_dummy_encrypted_notes, set_pool_total_balance, setup_platform, }; +use crate::platform_types::platform::PlatformRef; use assert_matches::assert_matches; use dpp::block::block_info::BlockInfo; use dpp::consensus::state::state_error::StateError; @@ -35,10 +39,15 @@ use dpp::state_transition::state_transitions::shielded::identity_create_from_shi use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::version::{DefaultForPlatformVersion, PlatformVersion}; +use drive::state_transition_action::StateTransitionAction; use rand::SeedableRng; const DENOMINATION: u64 = 10_000_000_000; const ANCHOR: [u8; 32] = [7u8; 32]; +/// Fallback platform address credited (minus a penalty) when the unique-public-key-hash state +/// check fails. On that failure the transition produces an `UnshieldAction` paying this address. +const FALLBACK_ADDRESS: dpp::address_funds::PlatformAddress = + dpp::address_funds::PlatformAddress::P2pkh([0x5C; 20]); fn action(nullifier_seed: u8) -> SerializedAction { SerializedAction { @@ -76,17 +85,44 @@ fn transition( anchor: ANCHOR, proof: vec![0u8; 100], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: FALLBACK_ADDRESS, identity_id, }) } +/// Build the optimistic SUCCESS action via `transform_into_action_v0` (the stateless + pool/anchor/ +/// nullifier/balance checks). Used by the `validate_state` tests, which then run the success/failure +/// identity-creation branch against it. Requires the pool state to already be seeded so the +/// transform succeeds. +fn build_success_action( + platform: &crate::test::helpers::setup::TempPlatform, + st: &IdentityCreateFromShieldedPoolTransition, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction +{ + let result = st + .transform_into_action_v0(&platform.drive, execution_context, None, platform_version) + .expect("transform should not error"); + assert!( + result.is_valid_with_data(), + "transform should build a valid success action; got {:?}", + result.errors + ); + match result.into_data().expect("success action data") { + StateTransitionAction::IdentityCreateFromShieldedPoolAction(action) => action, + other => panic!("expected IdentityCreateFromShieldedPoolAction, got {other:?}"), + } +} + #[test] -fn transform_rejects_when_identity_already_exists_at_derived_id() { +fn validate_state_rejects_when_identity_already_exists_at_derived_id() { let platform_version = PlatformVersion::latest(); let platform = setup_platform(); - // Seed enough pool state (balance, the anchor, the minimum note count) that the transformer - // gets past the pool/anchor/nullifier/balance checks and reaches the identity-creation checks. + // Seed enough pool state (balance, the anchor, the minimum note count) that `transform` gets past + // the pool/anchor/nullifier/balance checks and builds the success action; `validate_state` then + // runs the identity-creation state checks. set_pool_total_balance(&platform, DENOMINATION * 10); insert_anchor_into_state(&platform, &ANCHOR); let min_notes = platform_version @@ -130,15 +166,27 @@ fn transform_rejects_when_identity_already_exists_at_derived_id() { let mut execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version) .expect("execution context"); + let action = build_success_action(&platform, &st, &mut execution_context, platform_version); + + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; let result = st - .transform_into_action_v0( - &platform.drive, + .validate_state_v0( + &platform_ref, + action, &mut execution_context, None, platform_version, ) - .expect("transform should not error"); + .expect("validate_state should not error"); + // The identity-exists case is a FREE rejection (no chargeable fallback): an attacker cannot + // choose a colliding derived id, so there is no spend to finalize. assert!(!result.is_valid(), "expected a consensus rejection"); assert_matches!( result.errors.as_slice(), @@ -151,7 +199,7 @@ fn transform_rejects_when_identity_already_exists_at_derived_id() { } #[test] -fn transform_rejects_when_a_public_key_hash_is_already_registered() { +fn validate_state_returns_unshield_fallback_on_duplicate_key_hash() { let platform_version = PlatformVersion::latest(); let platform = setup_platform(); @@ -191,7 +239,7 @@ fn transform_rejects_when_a_public_key_hash_is_already_registered() { // The new identity's key DUPLICATES that already-registered key's hash. Its derived id (from // these nullifiers) is free, so the identity-absence check passes and the unique-key-hash check - // is the one that must reject. + // is the one that fails — triggering the chargeable fallback rather than a free rejection. let dup_key = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { id: 0, key_type: existing_key.key_type(), @@ -207,16 +255,40 @@ fn transform_rejects_when_a_public_key_hash_is_already_registered() { let mut execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version) .expect("execution context"); + let action = build_success_action(&platform, &st, &mut execution_context, platform_version); + let expected_current_total_balance = action.current_total_balance(); + + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; let result = st - .transform_into_action_v0( - &platform.drive, + .validate_state_v0( + &platform_ref, + action, &mut execution_context, None, platform_version, ) - .expect("transform should not error"); + .expect("validate_state should not error"); - assert!(!result.is_valid(), "expected a consensus rejection"); + // The fallback path is NOT a free rejection: it returns a result that CARRIES an action (the + // chargeable `UnshieldAction` — the spend is finalized, crediting the fallback address minus a + // penalty) WITH the unique-key-hash collision errors attached for surfacing to the submitter. + // (Errors-present means `is_valid()` is false, exactly like `IdentityCreateFromAddresses`'s + // `new_with_data_and_errors` bump action; the processor still books the carried action.) + assert!( + result.has_data(), + "duplicate-key-hash must return a chargeable fallback action, not a free reject; got {:?}", + result.errors + ); + assert!( + !result.is_valid(), + "the fallback action must still carry the collision errors for the submitter" + ); // The latest platform version dispatches the v1 unique-key-hash check, which reports the // collision as a StateError (v0 reported it as a BasicError). assert_matches!( @@ -224,9 +296,44 @@ fn transform_rejects_when_a_public_key_hash_is_already_registered() { [ConsensusError::StateError( StateError::DuplicatedIdentityPublicKeyIdStateError(_) )], - "got: {:?}", + "expected the collision errors attached to the fallback action; got: {:?}", result.errors ); + + let fallback = result.into_data().expect("fallback action data"); + let StateTransitionAction::UnshieldAction(unshield) = fallback else { + panic!("expected a fallback UnshieldAction, got {fallback:?}"); + }; + + use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; + let UnshieldTransitionAction::V0(unshield_v0) = unshield; + // The fallback Unshield is topologically identical to the would-be success exit: it spends the + // full denomination from the pool, credits the bound fallback address (the recipient receives + // `denomination - penalty`), reuses the same notes/anchor, and reads the same pool balance. + assert_eq!(unshield_v0.output_address, FALLBACK_ADDRESS); + assert_eq!(unshield_v0.amount, DENOMINATION); + assert_eq!(unshield_v0.anchor, ANCHOR); + assert_eq!( + unshield_v0.current_total_balance, + expected_current_total_balance + ); + assert_eq!( + unshield_v0.notes.len(), + 2, + "the fallback must carry the original 2 spend notes" + ); + // The penalty is the flat `unique_key_already_present` plus metered processing, capped at the + // denomination so the converter's `amount - fee` can never underflow. + let penalty_floor = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + assert!( + unshield_v0.fee_amount >= penalty_floor && unshield_v0.fee_amount <= DENOMINATION, + "penalty {} must be >= the flat floor {penalty_floor} and capped at the denomination {DENOMINATION}", + unshield_v0.fee_amount + ); } /// Sum-tree credit-conservation regression for the pool->new-identity exit. @@ -310,3 +417,156 @@ fn converter_ops_preserve_sum_tree_credit_conservation() { "credit supply must be conserved after a pool->identity exit; got {balance}" ); } + +/// Sum-tree credit-conservation regression for the FAILURE path (the fallback `UnshieldAction`). +/// +/// On a unique-public-key-hash collision, `validate_state` finalizes the spend as an +/// `UnshieldAction` that decrements the pool by `denomination` and credits the fallback address with +/// `denomination - penalty`; the `penalty` is reconciled into the fee (storage) pool at block +/// finalization (the `PaidFromShieldedPool` execution event). This test exercises the whole booking: +/// it runs `validate_state` with a pre-registered colliding key to obtain the real fallback action, +/// applies that action's converter ops through a REAL Drive, books the penalty into the storage fee +/// pool (standing in for the end-of-block fee distribution), and asserts the end-of-block invariant +/// `calculate_total_credits_balance().ok()` — the exact check that halts the chain — still balances: +/// pool −denom, address +(denom−penalty), pools +penalty nets to zero against the unchanged system +/// credits. Mirrors `converter_ops_preserve_sum_tree_credit_conservation` (the success path). +#[test] +fn failure_path_unshield_converter_ops_preserve_sum_tree_credit_conservation() { + use dpp::block::epoch::Epoch; + use drive::drive::credit_pools::operations::update_storage_fee_distribution_pool_operation; + use drive::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; + use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; + use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + use drive::util::batch::GroveDbOpBatch; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let drive = &platform.drive; + let block_info = BlockInfo::default(); + // The pool must hold at least the denomination plus the minimum-notes headroom; seed generously. + let seed = DENOMINATION * 10; + + // Seed a BALANCED funded pool (raises the shielded pool AND system credits by `seed` together). + set_pool_total_balance(&platform, seed); + insert_anchor_into_state(&platform, &ANCHOR); + let min_notes = platform_version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing; + insert_dummy_encrypted_notes(&platform, min_notes.max(1)); + + // Pre-register a different identity that owns an ECDSA_SECP256K1 key, then make the new + // identity's key DUPLICATE that key's hash so the unique-key-hash state check fails and + // `validate_state` returns the fallback Unshield. The pre-registered identity is added with a + // ZERO balance so it doesn't perturb the credit-conservation precondition (a non-zero identity + // balance would write to the Balances sum tree without a matching system-credit increment). + use dpp::identity::accessors::IdentitySettersV0; + let (mut existing_identity, keys_with_private): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + 3, + &mut rand::rngs::StdRng::seed_from_u64(99), + platform_version, + ) + .expect("random identity"); + existing_identity.set_balance(0); + let existing_key = keys_with_private + .iter() + .find(|(k, _)| k.key_type() == KeyType::ECDSA_SECP256K1) + .map(|(k, _)| k.clone()) + .expect("an ECDSA_SECP256K1 key"); + platform + .drive + .add_new_identity( + existing_identity, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("should add the key-owning identity"); + let dup_key = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: existing_key.key_type(), + purpose: existing_key.purpose(), + security_level: existing_key.security_level(), + contract_bounds: None, + read_only: false, + data: existing_key.data().clone(), + signature: BinaryData::default(), + }); + + // Precondition: the seeded pool + system-credits + the pre-registered identity are balanced. + assert!( + drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("calc") + .ok() + .expect("ok"), + "precondition: the seeded state must be balanced" + ); + + let st = transition(vec![dup_key], vec![action(20), action(21)]); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let action = build_success_action(&platform, &st, &mut execution_context, platform_version); + + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let result = st + .validate_state_v0( + &platform_ref, + action, + &mut execution_context, + None, + platform_version, + ) + .expect("validate_state should not error"); + let fallback = result + .into_data() + .expect("fallback action data on the failure path"); + let StateTransitionAction::UnshieldAction(unshield) = fallback else { + panic!("expected a fallback UnshieldAction"); + }; + let penalty = unshield.fee_amount(); + + // Apply the fallback Unshield's converter ops (pool −denom, address +(denom−penalty)). + let ops = unshield + .into_high_level_drive_operations(&Epoch::new(0).unwrap(), platform_version) + .expect("converter ops"); + drive + .apply_drive_operations(ops, true, &block_info, None, platform_version, None) + .expect("apply converter ops"); + + // Book the penalty into the storage fee (Pools) tree — the end-of-block reconciliation the + // `PaidFromShieldedPool` execution event performs (`fees_to_add_to_pool`). Without this the + // total is short by exactly `penalty` (the carved fee), which is the point: the fee is conserved + // into the pools, not burned. + let existing_pool = drive + .get_storage_fees_from_distribution_pool(None, platform_version) + .expect("read storage fee pool"); + let mut batch = GroveDbOpBatch::new(); + batch.push( + update_storage_fee_distribution_pool_operation(existing_pool + penalty) + .expect("storage fee pool op"), + ); + drive + .grove_apply_batch(batch, false, None, &platform_version.drive) + .expect("apply storage fee pool booking"); + + // The end-of-block conservation invariant must still hold for the failure path. + let balance = drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("calc"); + assert!( + balance.ok().expect("ok"), + "credit supply must be conserved after the fallback pool->address unshield; got {balance}" + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs index 9d2fe61ab36..d252b11baed 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/transform_into_action/v0/mod.rs @@ -4,17 +4,14 @@ use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; -use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; use crate::execution::validation::state_transition::state_transitions::shielded_common::{ read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, validate_nullifiers, }; -use dpp::consensus::state::identity::IdentityAlreadyExistsError; use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::state_error::StateError; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; -use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; use dpp::version::PlatformVersion; use drive::drive::Drive; @@ -102,39 +99,13 @@ impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV )); } - // Identity-creation state checks (mirroring `IdentityCreate`'s `validate_state`), so the - // `AddNewIdentity` write can't fail as an internal Drive error during execution: - // - // 1. The new identity must not already exist. The id is `double_sha256(sorted nullifiers)` — - // collision-resistant and derived from single-use spend tags — so this is practically - // unreachable, but check explicitly to return a clean consensus rejection. (No asset lock - // here, so a failure is a plain rejection, not a partial-asset-lock penalty.) - let identity_id = derive_identity_id_from_actions(&v0.actions); - if drive - .fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)? - .is_some() - { - return Ok(ConsensusValidationResult::new_with_error( - IdentityAlreadyExistsError::new(identity_id).into(), - )); - } - - // 2. None of the new identity's public-key hashes may already be registered to another - // identity (platform enforces globally-unique key hashes for unique key types). Without - // this, a duplicate unique key would fail inside the `AddNewIdentity` write at execution - // as an internal Drive error instead of a clean consensus rejection. - let unique_keys_result = validate_unique_identity_public_key_hashes_not_in_state( - &v0.public_keys, - drive, - execution_context, - transaction, - platform_version, - )?; - if !unique_keys_result.is_valid() { - return Ok(ConsensusValidationResult::new_with_errors( - unique_keys_result.errors, - )); - } + // The identity-creation state checks (the new identity must not already exist, and none of + // its public-key hashes may already be registered to another identity) are NOT done here. + // They live in `validate_state`, which branches the outcome: it forwards the success action + // built below when the checks pass, or returns an `UnshieldAction` that finalizes the spend + // and credits the fallback address minus a penalty when the unique-key-hash check fails + // (mirroring `IdentityCreateFromAddresses`). `transform_into_action` always produces the + // optimistic SUCCESS action. // Account for the per-key proof-of-possession signature verifications on the SUCCESS path so // the metered fee includes their CPU cost — exactly as `IdentityCreate`'s identity-and- diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index fd68dcc1afd..c95a8e9d536 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -3108,6 +3108,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + send_to_address_on_creation_failure: PlatformAddress::P2pkh([0u8; 20]), identity_id, }, ), diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 19f84411e5d..e4237318b67 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -319,10 +319,20 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( /// `out_identity_id`. The id is deterministic in the spent notes, so the host can also predict it /// independently if needed. /// +/// `send_to_address_on_creation_failure_bytes` is the REQUIRED fallback platform address, supplied +/// as raw `PlatformAddress` storage bytes (21 bytes: 1-byte variant tag + 20-byte hash — the +/// encoding `PlatformAddress::to_bytes()` produces and `PlatformAddressWasm`/the Swift wrapper +/// expose). If identity creation fails a stateful check (a public-key hash already registered to +/// another identity) the spend is still finalized and the value is credited to this address minus a +/// penalty, exactly like the asset-lock / address-funded identity-create penalties. It is bound into +/// the transition sighash, so it cannot be redirected after signing. +/// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. /// - `identity_pubkeys` must point to `identity_pubkeys_count` contiguous [`IdentityPubkeyFFI`] /// rows that outlive this call (each row's pointers per the [`IdentityPubkeyFFI`] contract). +/// - `send_to_address_on_creation_failure_bytes` must point to exactly 21 readable bytes for the +/// duration of this call. /// - `signer_identity_handle` must be a valid, non-destroyed `*mut SignerHandle` (a /// `VTableSigner` with the callback variant) that outlives this call; the caller retains /// ownership. @@ -336,11 +346,13 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p identity_pubkeys: *const IdentityPubkeyFFI, identity_pubkeys_count: usize, denomination: u64, + send_to_address_on_creation_failure_bytes: *const u8, signer_identity_handle: *mut SignerHandle, out_identity_id: *mut [u8; 32], ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(identity_pubkeys); + check_ptr!(send_to_address_on_creation_failure_bytes); check_ptr!(signer_identity_handle); check_ptr!(out_identity_id); if identity_pubkeys_count == 0 { @@ -350,6 +362,23 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p ); } + // Decode the REQUIRED fallback failure address (21 raw `PlatformAddress` bytes: 1-byte variant + // tag + 20-byte hash). Reuses the same strict decoder as `surplus_output`, but here a null / + // malformed address is a hard error (the fallback is mandatory for Type 20). + let send_to_address_on_creation_failure = match parse_optional_surplus_output( + send_to_address_on_creation_failure_bytes, + 21, + ) { + Ok(Some(addr)) => addr, + Ok(None) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "`send_to_address_on_creation_failure_bytes` is required (21 PlatformAddress bytes)", + ); + } + Err(result) => return result, + }; + let mut wallet_id = [0u8; 32]; std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); @@ -399,6 +428,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p account, public_keys, denomination, + send_to_address_on_creation_failure, identity_signer, &prover, ) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index dbcbe3d4fe3..f99aa8e4bac 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -723,6 +723,7 @@ impl PlatformWallet { dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation, )>, denomination: u64, + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress, identity_signer: &IS, prover: P, ) -> Result @@ -748,6 +749,7 @@ impl PlatformWallet { account, public_keys, denomination, + send_to_address_on_creation_failure, identity_signer, &prover, ) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9a51b1eaef3..bc29b43cd5d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -679,6 +679,7 @@ pub async fn identity_create_from_shielded_pool( account: u32, public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, identity_signer: &IS, prover: &P, ) -> Result @@ -720,6 +721,7 @@ where let build = build_identity_create_from_shielded_pool_transition( public_keys, denomination, + send_to_address_on_creation_failure, spends, &change_addr, &keys.full_viewing_key, @@ -738,9 +740,15 @@ where trace!("IdentityCreateFromShieldedPool: built, broadcasting via SDK helper..."); // Broadcast through the SDK helper, which re-assembles the transition from the PoP-signed // keys + bundle params (preserving the per-key signatures) and waits for proven execution. - sdk.identity_create_from_shielded_pool(build.public_keys, denomination, build.bundle, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + sdk.identity_create_from_shielded_pool( + build.public_keys, + denomination, + send_to_address_on_creation_failure, + build.bundle, + None, + ) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; Ok::(identity_id) } diff --git a/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs index 338aa911f1e..7586526b088 100644 --- a/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs @@ -2,6 +2,7 @@ use super::broadcast::BroadcastStateTransition; use super::put_settings::PutSettings; use super::validation::ensure_valid_state_transition_structure; use crate::{Error, Sdk}; +use dpp::address_funds::PlatformAddress; use dpp::shielded::OrchardBundleParams; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -31,6 +32,7 @@ pub trait IdentityCreateFromShieldedPool { &self, public_keys: Vec, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, bundle: OrchardBundleParams, settings: Option, ) -> Result; @@ -42,6 +44,7 @@ impl IdentityCreateFromShieldedPool for Sdk { &self, public_keys: Vec, denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, bundle: OrchardBundleParams, settings: Option, ) -> Result { @@ -55,6 +58,7 @@ impl IdentityCreateFromShieldedPool for Sdk { let state_transition = IdentityCreateFromShieldedPoolTransition::try_from_bundle( public_keys, denomination, + send_to_address_on_creation_failure, actions, anchor, proof, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 2fbeaa37caf..b578a24263a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -625,6 +625,15 @@ extension PlatformWalletManager { /// the bound wallet's own key. Returns the 32-byte new identity id /// (`double_sha256(sorted nullifiers)`). /// + /// `sendToAddressOnCreationFailure` is the REQUIRED fallback + /// platform address as raw `PlatformAddress` storage bytes (21 + /// bytes: 1-byte variant tag + 20-byte hash, the encoding + /// `PlatformAddress.toBytes()` produces). If creation fails a + /// stateful check (a public-key hash already registered to another + /// identity) the spend is still finalized and the value is credited + /// to this address minus a penalty. It is bound into the transition + /// sighash, so it cannot be redirected after signing. + /// /// Heavy CPU work (Halo 2 proof + per-key signing) runs on a /// detached task so the caller's actor isn't blocked. public func shieldedIdentityCreateFromPool( @@ -632,6 +641,7 @@ extension PlatformWalletManager { account: UInt32 = 0, identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], denomination: UInt64, + sendToAddressOnCreationFailure: Data, identitySigner: KeychainSigner ) async throws -> Data { guard isConfigured, handle != NULL_HANDLE else { @@ -649,9 +659,15 @@ extension PlatformWalletManager { "identityPubkeys is empty" ) } + guard sendToAddressOnCreationFailure.count == 21 else { + throw PlatformWalletError.invalidParameter( + "sendToAddressOnCreationFailure must be exactly 21 PlatformAddress bytes" + ) + } let handle = self.handle let identitySignerHandle = identitySigner.handle + let fallbackAddressBytes = sendToAddressOnCreationFailure return try await Task.detached(priority: .userInitiated) { () -> Data in var outIdentityId: ( @@ -682,20 +698,33 @@ extension PlatformWalletManager { else { throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") } - return ManagedPlatformWallet.withPubkeyFFIArray( - identityPubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - platform_wallet_manager_shielded_identity_create_from_pool( - handle, - widPtr, - account, - ffiRowsPtr, - UInt(ffiRowsCount), - denomination, - identitySignerHandle, - &outIdentityId - ) + // Pin the 21-byte fallback `PlatformAddress` bytes for the whole FFI call so the + // pointer handed to Rust stays valid (validated `== 21` above). + return try fallbackAddressBytes.withUnsafeBytes { + fallbackRaw -> PlatformWalletFFIResult in + guard let fallbackPtr = fallbackRaw.baseAddress?.assumingMemoryBound( + to: UInt8.self + ) else { + throw PlatformWalletError.invalidParameter( + "sendToAddressOnCreationFailure baseAddress is nil" + ) + } + return ManagedPlatformWallet.withPubkeyFFIArray( + identityPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_manager_shielded_identity_create_from_pool( + handle, + widPtr, + account, + ffiRowsPtr, + UInt(ffiRowsCount), + denomination, + fallbackPtr, + identitySignerHandle, + &outIdentityId + ) + } } } } From e636a06982c9f9e4371d29506490411069b00091 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 02:25:33 +0200 Subject: [PATCH 19/28] =?UTF-8?q?fix(drive-abci):=20clippy=20=E2=80=94=20P?= =?UTF-8?q?latformAddress=20is=20Copy,=20don't=20clone=20the=20fallback=20?= =?UTF-8?q?(#3816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI `clippy --all-features -- -D warnings` failed on `self .send_to_address_on_creation_failure().clone()` (PlatformAddress derives Copy). Use a deref copy. Verified the exact CI invocation now exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../identity_create_from_shielded_pool/state/v0/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs index 506f09f1185..b01da431b64 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs @@ -101,7 +101,7 @@ impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV0 .min(denomination); let failure_action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { - output_address: self.send_to_address_on_creation_failure().clone(), + output_address: *self.send_to_address_on_creation_failure(), amount: denomination, notes: action.notes().to_vec(), anchor: *action.anchor(), From 86591a4d42923fbc6b8bea836f140184de1455e7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 02:35:27 +0200 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20address=20CodeRabbit=20on=20the=20?= =?UTF-8?q?fallback=20feature=20=E2=80=94=20builder=20fail-fast,=20FFI=20p?= =?UTF-8?q?aram=20name,=20test=20coverage=20(#3816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - builder (rs-dpp): reject a non-member denomination and `fee >= denomination` BEFORE the ~30s Orchard prove (consensus rejects both later); make the bound-id-vs-bundle-nullifier consistency check a real runtime error instead of a debug-only `debug_assert_eq!`. - FFI: rename `parse_optional_surplus_output` -> `parse_optional_platform_address` with a `field_name` arg, so a malformed fallback address reports `send_to_address_on_creation_failure_bytes` rather than `surplus_output`. - tests: assert the identity-id collision stays a free rejection (`!has_data()`); add a fallback-address mutation to the signable-bytes non-redirectability test; cover IdentityCreateFromShieldedPool in the has_state_validation true-list. CI clippy gate (--all-features -D warnings) exits 0; dpp + drive-abci tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../identity_create_from_shielded_pool.rs | 35 ++++++++++++++++--- .../v0/mod.rs | 9 +++++ .../processor/traits/state.rs | 22 ++++++++++++ .../tests.rs | 4 +++ .../src/shielded_send.rs | 24 ++++++++----- 5 files changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs index 9e38aca2a8b..ddae9a4de66 100644 --- a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs +++ b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs @@ -118,6 +118,20 @@ where )); } + // Reject a non-member denomination before any (expensive) proving — Type 20 exits are a + // protocol-versioned fixed set, so an unsupported value would be rejected at `validate_structure` + // after the Orchard proof anyway. Fail fast. + let allowed_denominations = platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_identity_create_denominations; + if !allowed_denominations.contains(&denomination) { + return Err(ProtocolError::ShieldedBuildError(format!( + "denomination {denomination} is not a member of the allowed exit-denomination set {allowed_denominations:?}" + ))); + } + // Checked: a large spend set could otherwise overflow u64 (release builds wrap silently). let total_spent = spends .iter() @@ -146,6 +160,15 @@ where let fee = compute_shielded_identity_create_fee(num_actions, public_keys.len(), platform_version)?; + // The metered fee is carved from the denomination at execution; if the predicted fee already + // meets/exceeds it, the new identity could not be created with a positive balance (consensus + // rejects `total_fee >= denomination`). Fail fast rather than after proving. + if fee >= denomination { + return Err(ProtocolError::ShieldedBuildError(format!( + "predicted fee {fee} is not less than the denomination {denomination}; the new identity would have a non-positive balance" + ))); + } + // The id is derived from the SORTED spend nullifiers, which must be known BEFORE signing // because the id is part of the Orchard sighash. The nullifier of a spend is // `Note::nullifier(fvk)`, independent of bundle randomness, so compute them directly from the @@ -184,11 +207,13 @@ where // The consensus binding re-derives the id from the on-wire action nullifiers. Assert the // bundle's published nullifiers reduce to the same id we bound, so a mismatch is caught here // (cheap) rather than as an opaque InvalidShieldedProofError after the ~30 s proof. - debug_assert_eq!( - identity_id, - derive_identity_id_from_actions(&sb.actions), - "bound identity id must match the id re-derived from the bundle's published nullifiers" - ); + if identity_id != derive_identity_id_from_actions(&sb.actions) { + return Err(ProtocolError::ShieldedBuildError( + "bound identity id does not match the id re-derived from the bundle's published \ + nullifiers" + .to_string(), + )); + } // Build the transition (denomination == value_balance EXACTLY) with the unsigned key set, purely // to obtain the canonical signable bytes the per-key proofs-of-possession must sign. diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs index 7c1a3777714..7de79fd1073 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs @@ -173,6 +173,15 @@ mod tests { "changing the denomination must change the signable bytes" ); + let mut other_failure_address = base.clone(); + other_failure_address.send_to_address_on_creation_failure = + PlatformAddress::P2pkh([1u8; 20]); + assert_ne!( + base_bytes, + other_failure_address.signable_bytes().expect("signable bytes"), + "changing the failure-fallback address must change the signable bytes (non-redirectable)" + ); + // identity_id is excluded from the sighash (it is derived from the nullifiers), so changing // it alone must NOT change the signable bytes. let mut other_id = base.clone(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 80af348230f..73f3a9dffd4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -375,6 +375,28 @@ mod tests { MasternodeVoteTransitionV0::default(), )), ), + { + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + ( + "IdentityCreateFromShieldedPool", + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 0, + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), + actions: vec![], + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + identity_id: Default::default(), + }, + ), + ), + ) + }, ]; for (name, st) in transitions { assert!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 17489662581..615e9cbcdf1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -188,6 +188,10 @@ fn validate_state_rejects_when_identity_already_exists_at_derived_id() { // The identity-exists case is a FREE rejection (no chargeable fallback): an attacker cannot // choose a colliding derived id, so there is no spend to finalize. assert!(!result.is_valid(), "expected a consensus rejection"); + assert!( + !result.has_data(), + "an identity-id collision must stay a free rejection — no fallback action" + ); assert_matches!( result.errors.as_slice(), [ConsensusError::StateError( diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index e4237318b67..10d61cef5ea 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -70,9 +70,10 @@ use crate::runtime::{block_on_worker, runtime}; /// # Safety /// When `ptr` is non-null it must point to at least `len` readable /// bytes for the duration of this call. -unsafe fn parse_optional_surplus_output( +unsafe fn parse_optional_platform_address( ptr: *const u8, len: usize, + field_name: &str, ) -> Result, PlatformWalletFFIResult> { const PLATFORM_ADDRESS_LEN: usize = 21; if ptr.is_null() || len == 0 { @@ -86,7 +87,7 @@ unsafe fn parse_optional_surplus_output( if len != PLATFORM_ADDRESS_LEN { return Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("surplus_output must be exactly {PLATFORM_ADDRESS_LEN} bytes, got {len}"), + format!("{field_name} must be exactly {PLATFORM_ADDRESS_LEN} bytes, got {len}"), )); } let bytes = std::slice::from_raw_parts(ptr, len); @@ -94,7 +95,7 @@ unsafe fn parse_optional_surplus_output( Ok(addr) => Ok(Some(addr)), Err(e) => Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("invalid surplus_output platform address: {e}"), + format!("invalid {field_name} platform address: {e}"), )), } } @@ -365,9 +366,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p // Decode the REQUIRED fallback failure address (21 raw `PlatformAddress` bytes: 1-byte variant // tag + 20-byte hash). Reuses the same strict decoder as `surplus_output`, but here a null / // malformed address is a hard error (the fallback is mandatory for Type 20). - let send_to_address_on_creation_failure = match parse_optional_surplus_output( + let send_to_address_on_creation_failure = match parse_optional_platform_address( send_to_address_on_creation_failure_bytes, 21, + "send_to_address_on_creation_failure_bytes", ) { Ok(Some(addr)) => addr, Ok(None) => { @@ -602,8 +604,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( } }; - let surplus_output = match parse_optional_surplus_output(surplus_output_ptr, surplus_output_len) - { + let surplus_output = match parse_optional_platform_address( + surplus_output_ptr, + surplus_output_len, + "surplus_output", + ) { Ok(s) => s, Err(result) => return result, }; @@ -738,8 +743,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset } }; - let surplus_output = match parse_optional_surplus_output(surplus_output_ptr, surplus_output_len) - { + let surplus_output = match parse_optional_platform_address( + surplus_output_ptr, + surplus_output_len, + "surplus_output", + ) { Ok(s) => s, Err(result) => return result, }; From e0804c4919d9216c4f6bb1bfd27bd7434fe14c7e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 02:36:23 +0200 Subject: [PATCH 21/28] docs(ffi): generalize parse_optional_platform_address doc (shared helper) (#3816) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-platform-wallet-ffi/src/shielded_send.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 10d61cef5ea..e21a0493229 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -56,12 +56,14 @@ use crate::handle::*; use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; use crate::runtime::{block_on_worker, runtime}; -/// Parse an optional surplus-output platform address supplied as raw -/// `PlatformAddress` storage bytes (21 bytes: 1-byte variant tag + -/// 20-byte hash — the encoding `PlatformAddress::to_bytes()` produces -/// and `PlatformAddressWasm`/the Swift wrapper expose). +/// Parse an optional platform address supplied as raw `PlatformAddress` +/// storage bytes (21 bytes: 1-byte variant tag + 20-byte hash — the +/// encoding `PlatformAddress::to_bytes()` produces and +/// `PlatformAddressWasm`/the Swift wrapper expose). Shared by the +/// `surplus_output` and `send_to_address_on_creation_failure` params; +/// `field_name` names the parameter in any error message. /// -/// `ptr == null` (or `len == 0`) means "no surplus output" → `Ok(None)`. +/// `ptr == null` (or `len == 0`) means "no address" → `Ok(None)`. /// A non-null pointer is read for `len` bytes and decoded; a malformed /// address is surfaced as an `Err(PlatformWalletFFIResult)` so the /// caller fails fast rather than building a transition the wallet would From c85f0d8a5f09da0cfb8522124fe59d0b10a09f0a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 04:20:58 +0200 Subject: [PATCH 22/28] fix(drive-abci)!: execute the IdentityCreateFromShieldedPool fallback charge despite collision errors (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKING (codex): the duplicate-key fallback returned an UnshieldAction WITH the collision errors (new_with_data_and_errors), but PaidFromShieldedPool's execute arm only applied ops when consensus_errors.is_empty() — so the fallback NEVER executed: nullifiers not consumed, pool not debited, fallback address not credited, penalty not booked, and the result was reported as InternalError. An attacker with a valid Halo2 proof against an already-registered key hash could force repeated (expensive) proof verification for free (nullifiers never burn). The conservation unit test missed it by applying converter ops directly, bypassing execute_event. Fix: PaidFromShieldedPool now applies the ops REGARDLESS of attached consensus errors (the ordinary shielded spends never carry data+errors, so they are unaffected), then reports SuccessfulPaidExecution (no errors) or UnsuccessfulPaidExecution (errors) — mirroring PaidFromAddressInputs' UnsuccessfulPaidExecution path for BumpAddressInputNonces. So the fallback now charges and reports a PaidConsensusError. Added a regression test that drives the REAL execute_event path and asserts UnsuccessfulPaidExecution + the pool debited by the denomination (verified it FAILS — UnpaidConsensusExecutionError — without the fix). Also addressed in this commit: - Fee parity (codex): fold compute_shielded_verification_fee into the failure penalty floor — the proposer ran the same Halo2 verification on the failure path that the success path charges via additional_fixed_fee_cost. - Nitpick: debug_assert!(action.is_none()) in the Type 20 validate_state dispatch so the (currently dead) pre-built-action branch fails loudly if a future change starts pre-building the action and would route around transform's checks. cargo check --workspace + clippy (-D warnings) green; 5 transition tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../execute_event/v0/mod.rs | 65 +++++--- .../processor/traits/state.rs | 9 + .../state/v0/mod.rs | 17 +- .../tests.rs | 157 ++++++++++++++++++ 4 files changed, 219 insertions(+), 29 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 11c5d010c64..78e914615c2 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -550,35 +550,50 @@ where fees_to_add_to_pool, .. } => { - if consensus_errors.is_empty() { - let applied_fees = self - .drive - .apply_drive_operations( - operations, - true, - block_info, - Some(transaction), - platform_version, - Some(previous_fee_versions), - ) - .map_err(Error::Drive)?; + // Apply the ops REGARDLESS of attached consensus errors. The ordinary shielded + // spends (Unshield / ShieldedTransfer / ShieldedWithdrawal) only ever reach here + // with NO errors (they are data-only on success, error-only on rejection, so they + // never carry data+errors). The IdentityCreateFromShieldedPool FALLBACK is the only + // event that arrives WITH errors, and it is a CHARGEABLE failure: the spend must + // still be finalized (nullifiers consumed, pool debited, fallback address credited) + // and the penalty booked to the fee pools — exactly like `PaidFromAddressInputs`' + // `UnsuccessfulPaidExecution` path for the `BumpAddressInputNonces` penalty. If the + // ops were skipped (the previous `errors.is_empty()` gate), the nullifiers would NOT + // be consumed, letting an attacker force repeated (expensive) Halo 2 verification of + // the same valid-proof-but-colliding-key transition for free. + let applied_fees = self + .drive + .apply_drive_operations( + operations, + true, + block_info, + Some(transaction), + platform_version, + Some(previous_fee_versions), + ) + .map_err(Error::Drive)?; - // Split the carved fee like every other transition: the real storage - // cost of the (permanent) shielded writes goes to the storage pool, so it - // is amortised to the validators that store it over time and picks up the - // epoch fee multiplier at payout; the remainder (proof verification + - // per-action processing) is the processing fee paid to the current - // proposer. Conservation: storage + processing == fees_to_add_to_pool - // (what was carved from the shielded pool). - let storage_fee = applied_fees.storage_fee.min(fees_to_add_to_pool); - let processing_fee = fees_to_add_to_pool - storage_fee; + // Split the carved fee like every other transition: the real storage + // cost of the (permanent) shielded writes goes to the storage pool, so it + // is amortised to the validators that store it over time and picks up the + // epoch fee multiplier at payout; the remainder (proof verification + + // per-action processing) is the processing fee paid to the current + // proposer. Conservation: storage + processing == fees_to_add_to_pool + // (what was carved from the shielded pool). + let storage_fee = applied_fees.storage_fee.min(fees_to_add_to_pool); + let processing_fee = fees_to_add_to_pool - storage_fee; + let fee_result = FeeResult::default_with_fees(storage_fee, processing_fee); - Ok(SuccessfulPaidExecution( + if consensus_errors.is_empty() { + Ok(SuccessfulPaidExecution(None, fee_result)) + } else { + // The fallback charged its penalty but the identity was NOT created — report it + // as a chargeable consensus failure (the ops above are committed regardless). + Ok(UnsuccessfulPaidExecution( None, - FeeResult::default_with_fees(storage_fee, processing_fee), + fee_result, + consensus_errors, )) - } else { - Ok(UnpaidConsensusExecutionError(consensus_errors)) } } ExecutionEvent::PaidFromAssetLockToPool { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 73f3a9dffd4..93a394b77c5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -216,6 +216,15 @@ impl StateTransitionStateValidation for StateTransition { // balance checks). If that already rejects, forward the rejection; otherwise hand // the success action to `validate_state`, which branches success-vs-Unshield-fallback // on the identity-creation state checks. + // Type 20 keeps `has_advanced_structure_validation_with_state() == false`, so the + // processor never pre-builds the action — it always arrives `None`. Assert that + // invariant so a future change that starts pre-building it fails loudly in tests + // rather than silently routing around `transform`'s pool/anchor/nullifier checks. + debug_assert!( + action.is_none(), + "IdentityCreateFromShieldedPool must not be pre-built by the processor \ + (advanced_structure_with_state is false)" + ); let action = if let Some(action) = action { action } else { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs index b01da431b64..5a003c59171 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs @@ -84,17 +84,26 @@ impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV0 } else { // A unique-key-hash collision: finalize the spend and credit the fallback address minus a // penalty. The penalty is the flat `unique_key_already_present` amount plus the metered - // processing fee accumulated so far, exactly like `IdentityCreateFromAddresses`'s - // `BumpAddressInputNonces` penalty. We then CAP it at the denomination so the Unshield - // converter's `amount.checked_sub(fee)` cannot underflow (a net-zero credit is the worst - // case: the whole spend is consumed by the penalty and flows to the fee pools). + // processing fee accumulated so far (like `IdentityCreateFromAddresses`'s + // `BumpAddressInputNonces` penalty) PLUS the flat shielded compute fee + // (`compute_shielded_verification_fee`): the proposer ran the same Halo 2 verification on + // the failure path that the success path charges via `additional_fixed_fee_cost`, so the + // penalty floor must cover it too (fee parity with the success / other shielded paths). We + // then CAP it at the denomination so the Unshield converter's `amount.checked_sub(fee)` + // cannot underflow (a net-zero credit is the worst case: the whole spend is consumed by + // the penalty and flows to the fee pools). let denomination = action.denomination(); + let compute_fee = dpp::shielded::compute_shielded_verification_fee( + action.notes().len(), + platform_version, + )?; let penalty = platform_version .drive_abci .validation_and_processing .penalties .unique_key_already_present .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .and_then(|v| v.checked_add(compute_fee)) .ok_or(ProtocolError::Overflow( "identity create from shielded pool failure penalty overflow", ))? diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 615e9cbcdf1..c019d04e650 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -340,6 +340,163 @@ fn validate_state_returns_unshield_fallback_on_duplicate_key_hash() { ); } +/// BLOCKING regression: the fallback charge must EXECUTE through `execute_event` despite the +/// attached collision errors. +/// +/// `validate_state` returns the fallback `UnshieldAction` WITH the unique-key-hash collision errors +/// (`new_with_data_and_errors`). That routes to `ExecutionEvent::PaidFromShieldedPool`, whose +/// execution arm MUST apply the ops (consume nullifiers, debit the pool, credit the fallback, book +/// the penalty) and report `UnsuccessfulPaidExecution` — NOT skip the ops and return +/// `UnpaidConsensusExecutionError`. If the ops were skipped (the old `errors.is_empty()` gate), the +/// nullifiers would never be consumed, letting an attacker force repeated (expensive) Halo 2 +/// verification of a valid-proof-but-colliding-key transition for free. This drives the REAL +/// `execute_event` path (the prior conservation test bypassed it by applying converter ops directly). +#[test] +fn failure_path_charge_executes_through_execute_event() { + use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; + use crate::platform_types::event_execution_result::EventExecutionResult; + use dpp::block::epoch::Epoch; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::identity::accessors::IdentitySettersV0; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let block_info = BlockInfo::default(); + let seed = DENOMINATION * 10; + + set_pool_total_balance(&platform, seed); + insert_anchor_into_state(&platform, &ANCHOR); + let min_notes = platform_version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing; + insert_dummy_encrypted_notes(&platform, min_notes.max(1)); + + // Pre-register a colliding identity (balance 0 so it doesn't perturb anything we read). + let (mut existing_identity, keys_with_private): (Identity, Vec<(IdentityPublicKey, [u8; 32])>) = + Identity::random_identity_with_main_keys_with_private_key( + 3, + &mut rand::rngs::StdRng::seed_from_u64(77), + platform_version, + ) + .expect("random identity"); + existing_identity.set_balance(0); + let existing_key = keys_with_private + .iter() + .find(|(k, _)| k.key_type() == KeyType::ECDSA_SECP256K1) + .map(|(k, _)| k.clone()) + .expect("an ECDSA_SECP256K1 key"); + platform + .drive + .add_new_identity( + existing_identity, + false, + &block_info, + true, + None, + platform_version, + ) + .expect("add identity"); + let dup_key = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: existing_key.key_type(), + purpose: existing_key.purpose(), + security_level: existing_key.security_level(), + contract_bounds: None, + read_only: false, + data: existing_key.data().clone(), + signature: BinaryData::default(), + }); + + // Run validate_state to obtain the real fallback UnshieldAction + the collision errors. + let st = transition(vec![dup_key], vec![action(30), action(31)]); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let success_action = + build_success_action(&platform, &st, &mut execution_context, platform_version); + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let result = st + .validate_state_v0( + &platform_ref, + success_action, + &mut execution_context, + None, + platform_version, + ) + .expect("validate_state"); + let errors = result.errors.clone(); + assert!( + !errors.is_empty(), + "the fallback must carry the collision errors" + ); + let fallback_action = result.into_data().expect("fallback action"); + + // Build the execution event from the fallback action and EXECUTE it through the real path. + let event = + crate::execution::types::execution_event::ExecutionEvent::create_from_state_transition_action( + fallback_action, + None, + &Epoch::new(0).unwrap(), + execution_context, + platform_version, + ) + .expect("create execution event"); + + let transaction = platform.drive.grove.start_transaction(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + let exec_result = platform + .platform + .execute_event( + event, + errors, + &block_info, + &transaction, + None, + platform_version, + &fee_versions, + ) + .expect("execute_event should not error"); + + // THE FIX: the charge executes (`UnsuccessfulPaidExecution`), NOT `UnpaidConsensusExecutionError`. + let booked_fee = match exec_result { + EventExecutionResult::UnsuccessfulPaidExecution(_, fee_result, _) => { + fee_result.total_base_fee() + } + other => panic!( + "the fallback charge must execute despite the collision errors; expected \ + UnsuccessfulPaidExecution, got {other:?}" + ), + }; + assert!( + booked_fee > 0, + "the penalty fee must be booked into the fee pools" + ); + + // The ops were APPLIED: the pool is debited by the full denomination (nullifiers/notes inserted, + // pool decremented) — proving the spend was finalized, not skipped. + let mut ops = vec![]; + let pool_after = read_pool_total_balance( + &platform.drive, + Some(&transaction), + &mut ops, + platform_version, + ) + .expect("read pool balance"); + assert_eq!( + pool_after, + seed - DENOMINATION, + "the pool must be debited by the full denomination when the fallback charge executes" + ); +} + /// Sum-tree credit-conservation regression for the pool->new-identity exit. /// /// Applies the converter's high-level drive operations through a REAL Drive and asserts the From 37de2543e00ea54dc5dc229ac9ee41d6b25d8bfe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 05:04:12 +0200 Subject: [PATCH 23/28] fix: type-enforce the shielded-pool apply-despite-errors invariant + review nits (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses thepastaclaw's non-blocking findings on the fallback feature: - SUGGESTION (type-enforcement): the PaidFromShieldedPool execute arm applied ops regardless of attached errors, relying on a COMMENT that only the Type 20 fallback ever carries data+errors. Make it mechanical: add `chargeable_failure: bool` to UnshieldTransitionActionV0 (false for ordinary Unshield, true only for the IdentityCreateFromShieldedPool fallback) + to the PaidFromShieldedPool event. The execute arm now applies-despite-errors ONLY when chargeable_failure is set; an error-bearing event WITHOUT the flag (a future misuse of new_with_data_and_errors on an ordinary shielded spend) fails SAFE — no side-effectful spend, no proposer payment — and debug_asserts so it surfaces in tests. Also clarifies the UnshieldTransitionActionV0::fee_amount doc (the fallback uses it as the penalty, not the unshield fee). - NITPICK: the dead pre-built-action branch in the Type 20 validate_state dispatch now returns Err(CorruptedCodeExecution) explicitly (runtime-enforced in release too) instead of a debug-only assertion. - NITPICK: FFI fallback decode uses a self-documenting parse_required_platform_- address(ptr, field_name) (no-length-arg case) + a module-level PLATFORM_ADDRESS_LEN, instead of hardcoding the literal 21. cargo check --workspace + clippy (-D warnings) green; the 5 transition tests (incl. failure_path_charge_executes_through_execute_event) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../execute_event/v0/mod.rs | 35 ++++++++++------ .../validate_fees_of_event/v0/mod.rs | 1 + .../execution/types/execution_event/mod.rs | 13 ++++++ .../processor/traits/state.rs | 42 ++++++++++--------- .../state/v0/mod.rs | 4 ++ .../shielded/unshield/mod.rs | 9 ++++ .../shielded/unshield/v0/mod.rs | 16 +++++-- .../shielded/unshield/v0/transformer.rs | 2 + .../src/shielded_send.rs | 40 ++++++++++++------ 9 files changed, 114 insertions(+), 48 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 78e914615c2..d628f6a4b36 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -548,19 +548,30 @@ where ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool, - .. + chargeable_failure, } => { - // Apply the ops REGARDLESS of attached consensus errors. The ordinary shielded - // spends (Unshield / ShieldedTransfer / ShieldedWithdrawal) only ever reach here - // with NO errors (they are data-only on success, error-only on rejection, so they - // never carry data+errors). The IdentityCreateFromShieldedPool FALLBACK is the only - // event that arrives WITH errors, and it is a CHARGEABLE failure: the spend must - // still be finalized (nullifiers consumed, pool debited, fallback address credited) - // and the penalty booked to the fee pools — exactly like `PaidFromAddressInputs`' - // `UnsuccessfulPaidExecution` path for the `BumpAddressInputNonces` penalty. If the - // ops were skipped (the previous `errors.is_empty()` gate), the nullifiers would NOT - // be consumed, letting an attacker force repeated (expensive) Halo 2 verification of - // the same valid-proof-but-colliding-key transition for free. + // An error-bearing `PaidFromShieldedPool` is legitimate ONLY for the + // `IdentityCreateFromShieldedPool` chargeable fallback (`chargeable_failure == true`): + // the spend must still be finalized (nullifiers consumed, pool debited, fallback + // address credited) and the penalty booked — exactly like `PaidFromAddressInputs`' + // `UnsuccessfulPaidExecution` path for the `BumpAddressInputNonces` penalty. If those + // ops were skipped, the nullifiers would NOT be consumed, letting an attacker force + // repeated (expensive) Halo 2 verification of the same valid-proof-but-colliding-key + // transition for free. + // + // Every ordinary shielded spend (Unshield / ShieldedTransfer / ShieldedWithdrawal) is + // data-only on success and error-only on rejection, so it NEVER carries data+errors + // here and always has `chargeable_failure == false`. If one ever did (a future misuse + // of `new_with_data_and_errors`), fail SAFE — do NOT commit a side-effectful spend or + // pay the proposer for a rejected transition — and surface the divergence in tests. + if !consensus_errors.is_empty() && !chargeable_failure { + debug_assert!( + false, + "a non-fallback PaidFromShieldedPool must not carry consensus errors" + ); + return Ok(UnpaidConsensusExecutionError(consensus_errors)); + } + let applied_fees = self .drive .apply_drive_operations( diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs index cb4750dd892..d0444676471 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs @@ -474,6 +474,7 @@ mod tests { ExecutionEvent::PaidFromShieldedPool { operations: vec![], fees_to_add_to_pool: 0, + chargeable_failure: false, }, ExecutionEvent::Free { operations: vec![] }, ExecutionEvent::PaidFromAssetLockWithoutIdentity { diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index dc9d62e9cbd..2605c2e8f40 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -92,6 +92,13 @@ pub(in crate::execution) enum ExecutionEvent<'a> { operations: Vec>, /// fees derived from value_balance to add to the fee pool fees_to_add_to_pool: Credits, + /// `true` ONLY for the `IdentityCreateFromShieldedPool` chargeable-failure fallback. It + /// authorizes the executor to apply `operations` even when consensus errors are attached + /// (the spend is finalized to the fallback address minus the penalty). For every ordinary + /// shielded spend (Unshield / ShieldedTransfer / ShieldedWithdrawal) this is `false`, so an + /// error-bearing event of those types is NEVER applied — the apply-despite-errors contract + /// is type-enforced here, not just by convention. + chargeable_failure: bool, }, /// A drive event that is paid from an asset lock PaidFromAssetLock { @@ -537,15 +544,20 @@ impl ExecutionEvent<'_> { Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + chargeable_failure: false, }) } StateTransitionAction::UnshieldAction(ref unshield_action) => { let fee_amount = unshield_action.fee_amount(); + // An ordinary Unshield is always `false`; only the IdentityCreateFromShieldedPool + // duplicate-key fallback (which also surfaces as an UnshieldAction) sets it `true`. + let chargeable_failure = unshield_action.chargeable_failure(); let operations = action.into_high_level_drive_operations(epoch, platform_version)?; Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + chargeable_failure, }) } StateTransitionAction::ShieldFromAssetLockAction(ref shield_from_asset_lock_action) => { @@ -581,6 +593,7 @@ impl ExecutionEvent<'_> { Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + chargeable_failure: false, }) } StateTransitionAction::IdentityCreateFromShieldedPoolAction(ref action_ref) => { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 93a394b77c5..55a8d944033 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -217,27 +217,29 @@ impl StateTransitionStateValidation for StateTransition { // the success action to `validate_state`, which branches success-vs-Unshield-fallback // on the identity-creation state checks. // Type 20 keeps `has_advanced_structure_validation_with_state() == false`, so the - // processor never pre-builds the action — it always arrives `None`. Assert that - // invariant so a future change that starts pre-building it fails loudly in tests - // rather than silently routing around `transform`'s pool/anchor/nullifier checks. - debug_assert!( - action.is_none(), - "IdentityCreateFromShieldedPool must not be pre-built by the processor \ - (advanced_structure_with_state is false)" - ); - let action = if let Some(action) = action { - action - } else { - let transform_result = st - .transform_into_action_for_identity_create_from_shielded_pool_transition( - platform, - execution_context, - tx, - )?; - if !transform_result.is_valid_with_data() { - return Ok(transform_result); + // processor never pre-builds the action — it always arrives `None`, and we build the + // optimistic success action here via `transform` (which runs the pool/anchor/ + // nullifier/balance checks). Fail CLOSED at runtime if that invariant is ever broken: + // using a pre-built action would silently route around those checks. + let action = match action { + Some(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "IdentityCreateFromShieldedPool must not be pre-built by the processor \ + (advanced_structure_with_state is false)", + ))); + } + None => { + let transform_result = st + .transform_into_action_for_identity_create_from_shielded_pool_transition( + platform, + execution_context, + tx, + )?; + if !transform_result.is_valid_with_data() { + return Ok(transform_result); + } + transform_result.into_data()? } - transform_result.into_data()? }; let StateTransitionAction::IdentityCreateFromShieldedPoolAction(action) = action else { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs index 5a003c59171..03e388c87d2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v0/mod.rs @@ -116,6 +116,10 @@ impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV0 anchor: *action.anchor(), fee_amount: penalty, current_total_balance: action.current_total_balance(), + // This is the chargeable failure of an identity create: the `PaidFromShieldedPool` + // execution event reads this flag to apply its ops despite the attached collision + // errors (so the apply-despite-errors path is type-enforced, not comment-enforced). + chargeable_failure: true, }); Ok(ConsensusValidationResult::new_with_data_and_errors( diff --git a/packages/rs-drive/src/state_transition_action/shielded/unshield/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/unshield/mod.rs index 8a56378f41b..3afa869af81 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/unshield/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/unshield/mod.rs @@ -47,6 +47,13 @@ impl UnshieldTransitionAction { UnshieldTransitionAction::V0(transition) => transition.fee_amount, } } + /// `true` only when this action is the chargeable failure of an + /// `IdentityCreateFromShieldedPool` (see `UnshieldTransitionActionV0::chargeable_failure`). + pub fn chargeable_failure(&self) -> bool { + match self { + UnshieldTransitionAction::V0(transition) => transition.chargeable_failure, + } + } } #[cfg(test)] @@ -69,6 +76,7 @@ mod tests { anchor: [0x55; 32], fee_amount: 250, current_total_balance: 100000, + chargeable_failure: false, }; UnshieldTransitionAction::from(v0) } @@ -121,6 +129,7 @@ mod tests { anchor: [0x00; 32], fee_amount: 0, current_total_balance: 0, + chargeable_failure: false, }; let action = UnshieldTransitionAction::from(v0); assert_eq!(action.amount(), 0); diff --git a/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs index ca30c4162ef..e6daca85329 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs @@ -16,10 +16,20 @@ pub struct UnshieldTransitionActionV0 { /// The anchor used for verification pub anchor: [u8; 32], /// Shielded fee paid to proposers, carved out of `amount` (the recipient - /// receives `amount - fee_amount`). Equals `compute_shielded_unshield_fee` - /// (the base shielded minimum fee plus the flat `AddBalanceToAddress` - /// output-write storage cost). + /// receives `amount - fee_amount`). For an ordinary `Unshield` this equals + /// `compute_shielded_unshield_fee` (the base shielded minimum fee plus the + /// flat `AddBalanceToAddress` output-write storage cost). When + /// `chargeable_failure` is set (the `IdentityCreateFromShieldedPool` + /// fallback) it is instead the failure penalty. pub fee_amount: Credits, /// Current total balance of the shielded pool pub current_total_balance: Credits, + /// `false` for an ordinary `Unshield`. `true` ONLY when this action is the + /// chargeable failure of an `IdentityCreateFromShieldedPool` (the spend is + /// finalized to `output_address` minus the penalty even though identity + /// creation failed). This flag is what authorizes the `PaidFromShieldedPool` + /// execution event to apply its ops despite the attached consensus errors — + /// so the apply-despite-errors invariant is type-enforced rather than only + /// comment-enforced. An ordinary `Unshield` must NEVER set it. + pub chargeable_failure: bool, } diff --git a/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/transformer.rs index 095e7b3e8b7..ca4e94d5c08 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/transformer.rs @@ -21,6 +21,8 @@ impl UnshieldTransitionActionV0 { anchor: value.anchor, fee_amount, current_total_balance, + // An ordinary Unshield is a SUCCESS action and must never apply ops on the error path. + chargeable_failure: false, }) } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index e21a0493229..5c520a38575 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -56,6 +56,9 @@ use crate::handle::*; use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; use crate::runtime::{block_on_worker, runtime}; +/// A serialized `PlatformAddress` is exactly 21 bytes (1-byte variant tag + 20-byte hash). +const PLATFORM_ADDRESS_LEN: usize = 21; + /// Parse an optional platform address supplied as raw `PlatformAddress` /// storage bytes (21 bytes: 1-byte variant tag + 20-byte hash — the /// encoding `PlatformAddress::to_bytes()` produces and @@ -77,7 +80,6 @@ unsafe fn parse_optional_platform_address( len: usize, field_name: &str, ) -> Result, PlatformWalletFFIResult> { - const PLATFORM_ADDRESS_LEN: usize = 21; if ptr.is_null() || len == 0 { return Ok(None); } @@ -102,6 +104,25 @@ unsafe fn parse_optional_platform_address( } } +/// Decode a REQUIRED `PlatformAddress` from a raw pointer with no companion length argument over the +/// C ABI — the caller's safety contract guarantees exactly [`PLATFORM_ADDRESS_LEN`] readable bytes. +/// A null pointer or a malformed address is a hard error. `field_name` names the parameter in errors. +/// +/// # Safety +/// `ptr` must point to at least [`PLATFORM_ADDRESS_LEN`] readable bytes for the duration of the call. +unsafe fn parse_required_platform_address( + ptr: *const u8, + field_name: &str, +) -> Result { + match parse_optional_platform_address(ptr, PLATFORM_ADDRESS_LEN, field_name)? { + Some(addr) => Ok(addr), + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("{field_name} is required ({PLATFORM_ADDRESS_LEN} PlatformAddress bytes)"), + )), + } +} + /// Kick off the Halo 2 proving-key build on a background tokio /// worker if it hasn't been built yet. Returns immediately — /// hosts can call this at app startup without blocking the UI @@ -365,21 +386,14 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p ); } - // Decode the REQUIRED fallback failure address (21 raw `PlatformAddress` bytes: 1-byte variant - // tag + 20-byte hash). Reuses the same strict decoder as `surplus_output`, but here a null / - // malformed address is a hard error (the fallback is mandatory for Type 20). - let send_to_address_on_creation_failure = match parse_optional_platform_address( + // Decode the REQUIRED fallback failure address (raw `PlatformAddress` bytes: 1-byte variant tag + + // 20-byte hash). The fallback is mandatory for Type 20, so a null / malformed address is a hard + // error. No companion length arg crosses the C ABI — the helper enforces the 21-byte contract. + let send_to_address_on_creation_failure = match parse_required_platform_address( send_to_address_on_creation_failure_bytes, - 21, "send_to_address_on_creation_failure_bytes", ) { - Ok(Some(addr)) => addr, - Ok(None) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "`send_to_address_on_creation_failure_bytes` is required (21 PlatformAddress bytes)", - ); - } + Ok(addr) => addr, Err(result) => return result, }; From 94f42aeec43f1edf7635128db869ac3d6b600118 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 05:37:59 +0200 Subject: [PATCH 24/28] fix(drive): add chargeable_failure to the 5 Unshield converter test literals (#3816) The new required `chargeable_failure: bool` on UnshieldTransitionActionV0 was not propagated to five test-only struct literals in the converter's test module (make_action + 4 fee/conservation regression tests), breaking `cargo check -p drive --tests` with E0063. All five are ordinary Unshield actions, so `chargeable_failure: false`. `cargo check -p drive --tests` green; 18 drive unshield/converter tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shielded/unshield_transition.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs index a1ce0d98006..30ca6ce1396 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs @@ -113,6 +113,7 @@ mod tests { anchor: [0xAA; 32], fee_amount: 500, current_total_balance: 10000, + chargeable_failure: false, }) } @@ -232,6 +233,7 @@ mod tests { anchor: [0x00; 32], fee_amount: 500, // fee > amount current_total_balance: 10000, + chargeable_failure: false, }); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); @@ -249,6 +251,7 @@ mod tests { anchor: [0x00; 32], fee_amount: 500, current_total_balance: 4000, // 4000 < 5000 (amount) + chargeable_failure: false, }); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); @@ -303,6 +306,7 @@ mod tests { anchor: [0xAA; 32], fee_amount, current_total_balance: amount + 1_000_000, + chargeable_failure: false, }); let ops = action @@ -356,6 +360,7 @@ mod tests { anchor: [0xAA; 32], fee_amount: 500, // net = amount - fee = 0 current_total_balance: 10000, + chargeable_failure: false, }); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); From 9a855a16d3e61a25b50d7d9ab60912b7b33161f2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 10:46:46 +0200 Subject: [PATCH 25/28] refactor(dpp): reuse consensus identity-create cost constants in shielded fee predictor (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shielded identity-create fee predictor had two bespoke calibrated storage-byte constants (SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES = 1200, SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES = 350) that duplicated — and diverged ~3-5x from — the codebase's existing identity-create cost calibration. Replace the storage-byte term with the SAME constants the non-shielded IdentityCreate / IdentityCreateFromAddresses transitions use in their StateTransitionEstimatedFeeValidation::calculate_min_required_fee: identity_create_fee = compute_minimum_shielded_fee_v0(num_actions) + identity_create_base_cost + num_keys * identity_key_in_creation_cost This gives one source of truth for the cost of creating an identity, so the shielded predictor cannot drift from the consensus minimum the create is actually subject to. The two byte constants are removed. Consensus-safe: the predicted value only feeds the cheap early `denomination >= fee` gate and the client-side note-selection predictor. The converter books `denomination` (not the predicted fee), the actual charged fee is metered GroveDB cost + compute_shielded_verification_fee, and the authoritative non-negative-balance check in validate_fees_of_event uses the real metered total_fee — all unchanged. Lowering the early gate only stops it from over-rejecting valid denominations; underfunded ones are still caught by the authoritative check via the same paid_from_identity_function path as PaidFromAssetLock. Also drop a clone() on the Copy PlatformAddress in a shield_from_asset_lock test (would fail the -D warnings clippy gate). Tests: dpp 166 shielded + 4 fee, platform-wallet 4 denomination, drive-abci 14 shielded_proof + 5 Type 20; full-workspace clippy --all-features --all-targets clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compute_minimum_shielded_fee/v0/mod.rs | 85 +++++++++---------- packages/rs-dpp/src/shielded/mod.rs | 17 ---- .../v0/mod.rs | 2 +- .../processor/traits/shielded_proof.rs | 20 +++-- 4 files changed, 54 insertions(+), 70 deletions(-) diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index 83dd2ff6f76..6dcc6a0d988 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -1,6 +1,5 @@ use crate::fee::Credits; use crate::shielded::{ - SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES, SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES, SHIELDED_STORAGE_BYTES_PER_ACTION, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, }; @@ -192,22 +191,27 @@ pub fn compute_shielded_unshield_fee_v0( /// v0 of the shielded **identity-create** fee formula: /// /// `identity_create_fee = compute_minimum_shielded_fee_v0(num_actions) -/// + (SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES -/// + num_keys × SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES) -/// × (disk + processing) credits/byte` +/// + identity_create_base_cost + num_keys × identity_key_in_creation_cost` /// /// This is [`compute_minimum_shielded_fee_v0`] (the per-action note/nullifier storage estimate + -/// the per-bundle ZK compute) PLUS one VARIABLE storage component for the `AddNewIdentity` write an -/// `IdentityCreateFromShieldedPool` performs (the identity record + balance + revision + N key -/// subtrees). Unlike the flat per-transition components of `Unshield`/`ShieldedWithdrawal`, this -/// component grows monotonically with the key count, which is why the flat pool-paid model does not -/// fit and the transition's execution meters its writes against the new identity's balance instead. +/// the per-bundle ZK compute) PLUS the consensus identity-create cost floor for the `AddNewIdentity` +/// write an `IdentityCreateFromShieldedPool` performs (the identity record + balance + revision + N +/// keys). Rather than a bespoke storage-byte estimate, this reuses the SAME +/// `identity_create_base_cost` + `identity_key_in_creation_cost` constants +/// (`platform_version.fee_version.state_transition_min_fees`) that the non-shielded +/// `IdentityCreate` / `IdentityCreateFromAddresses` transitions use in their +/// `StateTransitionEstimatedFeeValidation::calculate_min_required_fee` — one source of truth for +/// the cost of creating an identity, so the shielded predictor cannot drift from the consensus +/// minimum the create is actually subject to. Like those constants, it grows with the key count. /// -/// This function is NOT the authoritative consensus fee (execution meters the real GroveDB cost and -/// adds only the compute fee on top). It is the **client-side predictor** — so a client can size its -/// bundle and pick a denomination that comfortably covers the fee — and the **cheap floor** the -/// `denomination >= min_fee` gate uses to reject obviously-underfunded denominations before metering. -/// It is sized as a conservative upper bound on the real metered cost so it never under-predicts. +/// This function is NOT the authoritative consensus fee (execution meters the real GroveDB cost of +/// the identity write against the new identity's balance and adds only the compute fee on top). It +/// is the **client-side predictor** — so a client can size its bundle and pick a denomination that +/// covers the fee — and the **cheap floor** the `denomination >= min_fee` gate uses to reject +/// obviously-underfunded denominations before metering. Any residual on-chain under-funding (if the +/// metered write exceeds this floor) is absorbed by the transition's fallback-on-failure path, which +/// credits the fallback address minus a penalty — exactly the risk the non-shielded identity-create +/// predictor already accepts by using the same floor. /// /// All arithmetic is checked: an overflow (only reachable via pathological fee constants or key /// counts) surfaces as `ProtocolError::Overflow` instead of silently wrapping. @@ -216,34 +220,24 @@ pub fn compute_shielded_identity_create_fee_v0( num_keys: usize, platform_version: &PlatformVersion, ) -> Result { - let storage = &platform_version.fee_version.storage; + let min_fees = &platform_version.fee_version.state_transition_min_fees; let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; - let per_byte_rate = storage - .storage_disk_usage_credit_per_byte - .checked_add(storage.storage_processing_credit_per_byte) - .ok_or(ProtocolError::Overflow( - "shielded storage per-byte rate overflow", - ))?; - let per_key_bytes = (num_keys as u64) - .checked_mul(SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES) + let keys_fee = min_fees + .identity_key_in_creation_cost + .checked_mul(num_keys as u64) .ok_or(ProtocolError::Overflow( - "shielded identity create per-key bytes overflow", + "shielded identity create per-key fee overflow", ))?; - let identity_bytes = SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES - .checked_add(per_key_bytes) + let identity_create_floor = min_fees + .identity_create_base_cost + .checked_add(keys_fee) .ok_or(ProtocolError::Overflow( - "shielded identity create bytes overflow", + "shielded identity create floor overflow", ))?; - let identity_storage_fee = - identity_bytes - .checked_mul(per_byte_rate) - .ok_or(ProtocolError::Overflow( - "shielded identity create storage fee overflow", - ))?; base_fee - .checked_add(identity_storage_fee) + .checked_add(identity_create_floor) .ok_or(ProtocolError::Overflow( "shielded identity create fee overflow", )) @@ -329,16 +323,16 @@ mod tests { } } - /// The identity-create fee MUST equal the base shielded fee plus the variable identity-write - /// component `(BASE + num_keys × PER_KEY) × per_byte_rate`, and it MUST grow strictly with the - /// key count (a larger key set is a larger `AddNewIdentity` write). This pins the formula so the - /// `denomination >= min_fee` gate and the client predictor stay aligned with the metered write. + /// The identity-create fee MUST equal the base shielded fee plus the consensus identity-create + /// floor `identity_create_base_cost + num_keys × identity_key_in_creation_cost`, and it MUST grow + /// strictly with the key count (a larger key set is a larger `AddNewIdentity` write). This pins + /// the formula to the SAME constants the non-shielded `IdentityCreate` predictor uses, so the + /// `denomination >= min_fee` gate stays aligned with the consensus minimum and cannot drift into + /// a second, divergent calibration. #[test] fn compute_shielded_identity_create_fee_v0_scales_with_keys() { let platform_version = PlatformVersion::latest(); - let storage = &platform_version.fee_version.storage; - let per_byte_rate = - storage.storage_disk_usage_credit_per_byte + storage.storage_processing_credit_per_byte; + let min_fees = &platform_version.fee_version.state_transition_min_fees; for num_actions in [1usize, 2, 5] { let base = compute_minimum_shielded_fee_v0(num_actions, platform_version) @@ -351,12 +345,13 @@ mod tests { platform_version, ) .expect("identity create fee"); - let expected_identity_bytes = SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES - + num_keys as u64 * SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES; + let expected_floor = min_fees.identity_create_base_cost + + num_keys as u64 * min_fees.identity_key_in_creation_cost; assert_eq!( fee, - base + expected_identity_bytes * per_byte_rate, - "identity create fee must equal base + (BASE + num_keys×PER_KEY)×rate" + base + expected_floor, + "identity create fee must equal base + identity_create_base_cost + \ + num_keys×identity_key_in_creation_cost" ); if let Some(prev) = previous { assert!( diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index efa89030b13..1178532a1ac 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -97,23 +97,6 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; -/// Calibrated effective storage-byte cost of the flat (key-independent) portion of the -/// `AddNewIdentity` write an `IdentityCreateFromShieldedPool` performs: the identity record, -/// balance, revision, and the empty key-tree scaffolding. Sized as a conservative upper bound on -/// the metered storage so the client-side fee predictor never under-estimates the real cost (the -/// authoritative consensus fee is metered by GroveDB at execution; this constant only feeds the -/// offline predictor and the cheap `denomination >= min_fee` gate). The per-key component is added -/// separately via [`SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES`]. -pub const SHIELDED_IDENTITY_CREATE_BASE_STORAGE_BYTES: u64 = 1200; - -/// Calibrated effective storage-byte cost of EACH `IdentityPublicKey` the `AddNewIdentity` write -/// inserts (the key entry in the identity key subtree plus its key-hash → key-id index entry and -/// tree overhead). Because the metered identity write grows monotonically with the key count, the -/// predictor scales this per-key term by the number of keys. Priced at the SAME per-byte storage -/// rate as the per-action note storage, so it tracks the storage rate as it evolves. See -/// [`compute_minimum_shielded_fee::compute_shielded_identity_create_fee`]. -pub const SHIELDED_IDENTITY_CREATE_PER_KEY_STORAGE_BYTES: u64 = 350; - /// Domain separator for Platform sighash computation. const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash"; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs index 8dbf0f3c949..7a4e185b618 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs @@ -230,7 +230,7 @@ mod tests { none_variant.surplus_output = None; let mut some_a = make_v0(); - some_a.surplus_output = Some(addr_a.clone()); + some_a.surplus_output = Some(addr_a); let mut some_b = make_v0(); some_b.surplus_output = Some(addr_b); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 2f2b212fd70..d805a30a136 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -123,9 +123,11 @@ enum ShieldedMinFeeKind { Unshield, /// `compute_shielded_withdrawal_fee` — ShieldedWithdrawal (base + the flat withdrawal-document cost). Withdrawal, - /// `compute_shielded_identity_create_fee` — IdentityCreateFromShieldedPool (base + the VARIABLE - /// `AddNewIdentity` write whose cost grows with the key count). Carries `num_keys` because the - /// fee scales with it, unlike the other (fixed) per-transition components. + /// `compute_shielded_identity_create_fee` — IdentityCreateFromShieldedPool (base + the consensus + /// identity-create floor `identity_create_base_cost + num_keys × identity_key_in_creation_cost`, + /// the same constants the non-shielded `IdentityCreate` predictor uses, which grows with the key + /// count). Carries `num_keys` because the fee scales with it, unlike the other (fixed) + /// per-transition components. IdentityCreate { num_keys: usize }, } @@ -201,10 +203,14 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { } }, // IdentityCreateFromShieldedPool: `denomination` is the TOTAL leaving the pool - // (new-identity balance + fee). We check it against `min_fee` so the net - // (`denomination - compute_shielded_identity_create_fee`) the new identity keeps - // at execution is non-negative. It is NOT pure fee (`>=` model); the exact - // `value_balance == denomination` equality is enforced by the proof verifier + // (new-identity balance + fee). This is a CHEAP early floor — it rejects a + // denomination that cannot even cover the consensus identity-create minimum + // (`identity_create_base_cost + num_keys × identity_key_in_creation_cost`, the + // same constants the non-shielded `IdentityCreate` predictor uses) plus the + // shielded compute fee, before the expensive proof verification + metering. The + // AUTHORITATIVE non-negative-balance check (`denomination >= metered + compute`) + // runs later in `validate_fees_of_event`. It is NOT pure fee (`>=` model); the + // exact `value_balance == denomination` equality is enforced by the proof verifier // (which passes `value_balance = denomination`). The fee scales with the key // count, so the `IdentityCreate` flavor carries `num_keys`. StateTransition::IdentityCreateFromShieldedPool(st) => match st { From 04fa1b636b16fdcf5d49b55543ebbbb260684e85 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 11:21:43 +0200 Subject: [PATCH 26/28] fix(dpp): version shielded sighash-data helpers; drop consensus debug_assert; correct fallback docstring (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review comments: 1. (shumkov) The three `*_extra_sighash_data` builders in shielded/mod.rs were unversioned hand-rolled consensus preimages. Platform serialization (`signable_bytes()`) cannot be used here: it EXCLUDES `identity_id` (`exclude_from_sig_hash`) and does not bind the per-key `read_only` / `contract_bounds` that the proof-of-possession misses — exactly the fields the Orchard binding signature must commit. Per the suggestion, version the methods: add `dpp.methods.shielded_extra_sighash_data` and dispatch `X(.., platform_version) -> X_v0(..)` (mirroring the sibling `compute_minimum_shielded_fee_v0` pattern). v0 is byte-identical to the prior layout, so zero consensus change. Threaded `&PlatformVersion` through the 6 prod call sites (3 builders + 3 verifiers); byte-layout/construction tests call the `_v0` impls directly. 2. (shumkov) Removed the `debug_assert!(false, ..)` in execute_event's PaidFromShieldedPool fail-safe. A debug_assert panics in debug but no-ops in release — a non-deterministic divergence in consensus-execution code. Replaced with a `tracing::error!` plus the deterministic UnpaidConsensusExecutionError return in BOTH builds. 3. (thepastaclaw/codex) The compute_shielded_identity_create_fee_v0 docstring overstated fallback coverage. The fallback-address-minus-penalty path is emitted ONLY on the unique-public-key-hash collision branch; metered insufficiency (`denomination < total_fee` in validate_fees_of_event) is a plain unpaid free rejection (IdentityInsufficientBalanceError, no nullifier consumed). Scoped the wording accordingly. Tests: dpp 166 shielded, platform-wallet denomination, drive-abci shielded_proof 14 / unshield 18 / shielded_withdrawal 21 / Type 20: 5 — all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../identity_create_from_shielded_pool.rs | 3 +- .../shielded/builder/shielded_withdrawal.rs | 3 +- .../rs-dpp/src/shielded/builder/unshield.rs | 7 +- .../compute_minimum_shielded_fee/v0/mod.rs | 12 ++- packages/rs-dpp/src/shielded/mod.rs | 84 ++++++++++++++++++- .../execute_event/v0/mod.rs | 11 ++- .../processor/traits/shielded_proof.rs | 9 +- .../shielded_withdrawal/tests.rs | 10 +-- .../state_transitions/unshield/tests.rs | 8 +- .../dpp_versions/dpp_method_versions/mod.rs | 1 + .../dpp_versions/dpp_method_versions/v1.rs | 1 + .../dpp_versions/dpp_method_versions/v2.rs | 1 + 12 files changed, 125 insertions(+), 25 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs index ddae9a4de66..a3955ab3b4c 100644 --- a/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs +++ b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs @@ -188,7 +188,8 @@ where denomination, &send_to_address_on_creation_failure, &in_creation_keys, - ); + platform_version, + )?; let bundle = build_spend_bundle( spends, diff --git a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs index c4e4e633619..6064101cec8 100644 --- a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs +++ b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs @@ -95,7 +95,8 @@ pub fn build_shielded_withdrawal_transition( required, core_fee_per_byte, pooling, - ); + platform_version, + )?; let bundle = build_spend_bundle( spends, diff --git a/packages/rs-dpp/src/shielded/builder/unshield.rs b/packages/rs-dpp/src/shielded/builder/unshield.rs index 0433d2681de..e40f0aa262c 100644 --- a/packages/rs-dpp/src/shielded/builder/unshield.rs +++ b/packages/rs-dpp/src/shielded/builder/unshield.rs @@ -84,8 +84,11 @@ pub fn build_unshield_transition( // Bind the transparent fields (output_address, unshielding_amount == required) into the // Orchard sighash. Shared with the consensus verifier in shielded_proof.rs so the signed // and verified bytes cannot diverge. - let extra_sighash_data = - crate::shielded::unshield_extra_sighash_data(&output_address.to_bytes(), required); + let extra_sighash_data = crate::shielded::unshield_extra_sighash_data( + &output_address.to_bytes(), + required, + platform_version, + )?; let bundle = build_spend_bundle( spends, diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index 6dcc6a0d988..d59a4d3aaf0 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -208,10 +208,14 @@ pub fn compute_shielded_unshield_fee_v0( /// the identity write against the new identity's balance and adds only the compute fee on top). It /// is the **client-side predictor** — so a client can size its bundle and pick a denomination that /// covers the fee — and the **cheap floor** the `denomination >= min_fee` gate uses to reject -/// obviously-underfunded denominations before metering. Any residual on-chain under-funding (if the -/// metered write exceeds this floor) is absorbed by the transition's fallback-on-failure path, which -/// credits the fallback address minus a penalty — exactly the risk the non-shielded identity-create -/// predictor already accepts by using the same floor. +/// obviously-underfunded denominations before metering. If the later metered affordability check +/// inside `validate_fees_of_event` finds `denomination < total_fee`, execution returns +/// `IdentityInsufficientBalanceError` through the standard unpaid-rejection path (the spend is not +/// finalized and no nullifier is consumed). Only the unique-public-key-hash collision branch in +/// state validation uses the fallback-address-minus-penalty path — the same residual-risk window the +/// non-shielded identity-create predictor relies on by using this floor. (In practice the smallest +/// legal denomination, 10^10 credits, far exceeds the max-key floor, so neither rejection arises for +/// well-formed transitions.) /// /// All arithmetic is checked: an overflow (only reachable via pathological fee constants or key /// counts) surfaces as `ProtocolError::Overflow` instead of silently wrapping. diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 1178532a1ac..dc53e00b22c 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -22,6 +22,8 @@ use crate::address_funds::PlatformAddress; use crate::identity::identity_public_key::contract_bounds::ContractBounds; use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; /// Permanent storage bytes per shielded action: 312 bytes total. /// @@ -143,11 +145,40 @@ pub fn compute_platform_sighash(bundle_commitment: &[u8; 32], extra_data: &[u8]) /// `output_script` to a canonical, fixed-length P2PKH (25 bytes) or P2SH (23 bytes); the /// remaining fields are fixed-width, so the preimage is well-defined for every accepted /// transition. If that script-shape restriction is ever relaxed, add a length prefix here. +/// Dispatches on the platform-versioned `dpp.methods.shielded_extra_sighash_data` so the +/// consensus-critical byte layout can evolve across protocol versions without breaking older +/// transitions — the same versioning the sibling shielded fee methods use. The signing +/// (client/builder) and verifying (consensus) sides both call this single function with the same +/// `platform_version`, so they can never produce divergent preimages. pub fn shielded_withdrawal_extra_sighash_data( output_script: &[u8], unshielding_amount: u64, core_fee_per_byte: u32, pooling: Pooling, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(shielded_withdrawal_extra_sighash_data_v0( + output_script, + unshielding_amount, + core_fee_per_byte, + pooling, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "shielded_withdrawal_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`shielded_withdrawal_extra_sighash_data`] (see that function's doc comment for +/// the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version. +pub fn shielded_withdrawal_extra_sighash_data_v0( + output_script: &[u8], + unshielding_amount: u64, + core_fee_per_byte: u32, + pooling: Pooling, ) -> Vec { let mut data = Vec::with_capacity(output_script.len() + 8 + 4 + 1); data.extend_from_slice(output_script); @@ -164,7 +195,27 @@ pub fn shielded_withdrawal_extra_sighash_data( /// verifying (consensus) sides MUST produce identical bytes, so both call this single /// function. Unshield credits a transparent platform address (not a Core asset-unlock /// `TxOut`), so it carries no `core_fee_per_byte`/`pooling` to bind. -pub fn unshield_extra_sighash_data(output_address: &[u8], unshielding_amount: u64) -> Vec { +pub fn unshield_extra_sighash_data( + output_address: &[u8], + unshielding_amount: u64, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(unshield_extra_sighash_data_v0( + output_address, + unshielding_amount, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "unshield_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`unshield_extra_sighash_data`] (see that function's doc comment for the layout +/// and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version bump. +pub fn unshield_extra_sighash_data_v0(output_address: &[u8], unshielding_amount: u64) -> Vec { let mut data = Vec::with_capacity(output_address.len() + 8); data.extend_from_slice(output_address); data.extend_from_slice(&unshielding_amount.to_le_bytes()); @@ -201,6 +252,31 @@ pub fn identity_create_from_shielded_extra_sighash_data( denomination: u64, send_to_address_on_creation_failure: &PlatformAddress, public_keys: &[IdentityPublicKeyInCreation], + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(identity_create_from_shielded_extra_sighash_data_v0( + identity_id, + denomination, + send_to_address_on_creation_failure, + public_keys, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "identity_create_from_shielded_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`identity_create_from_shielded_extra_sighash_data`] (see that function's doc +/// comment for the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` +/// + version bump. +pub fn identity_create_from_shielded_extra_sighash_data_v0( + identity_id: &[u8; 32], + denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, + public_keys: &[IdentityPublicKeyInCreation], ) -> Vec { let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); data.extend_from_slice(identity_id); @@ -350,6 +426,10 @@ mod tests { use super::*; use crate::identity::core_script::CoreScript; use crate::withdrawal::Pooling; + // These tests pin the v0 preimage directly (they assert exact bytes), so resolve the bare helper + // names to the `_v0` impls rather than the version-dispatching public wrappers. + use crate::shielded::shielded_withdrawal_extra_sighash_data_v0 as shielded_withdrawal_extra_sighash_data; + use crate::shielded::unshield_extra_sighash_data_v0 as unshield_extra_sighash_data; #[test] fn withdrawal_sighash_data_binds_core_fee_per_byte() { @@ -400,7 +480,9 @@ mod tests { mod identity_create_sighash { use super::*; + // Pin the v0 preimage directly (see the note in the parent test module). use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data; use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_value::BinaryData; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index d628f6a4b36..7b9150c911d 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -563,11 +563,14 @@ where // data-only on success and error-only on rejection, so it NEVER carries data+errors // here and always has `chargeable_failure == false`. If one ever did (a future misuse // of `new_with_data_and_errors`), fail SAFE — do NOT commit a side-effectful spend or - // pay the proposer for a rejected transition — and surface the divergence in tests. + // pay the proposer for a rejected transition. This is consensus-execution code, so we + // must behave identically in debug and release (a `debug_assert!` would panic in debug + // but silently fall through in release — a non-deterministic divergence): log the + // unexpected state at `error` and return the unpaid rejection in BOTH builds. if !consensus_errors.is_empty() && !chargeable_failure { - debug_assert!( - false, - "a non-fallback PaidFromShieldedPool must not carry consensus errors" + tracing::error!( + "a non-fallback PaidFromShieldedPool carried consensus errors; rejecting \ + unpaid (no spend committed, proposer not paid)" ); return Ok(UnpaidConsensusExecutionError(consensus_errors)); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index d805a30a136..95428999beb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -446,7 +446,8 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data( &v0.output_address.to_bytes(), v0.unshielding_amount, - ); + platform_version, + )?; reconstruct_and_verify_bundle( &v0.actions, FLAGS_SPENDS_AND_OUTPUTS, @@ -466,7 +467,8 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { v0.unshielding_amount, v0.core_fee_per_byte, v0.pooling, - ); + platform_version, + )?; reconstruct_and_verify_bundle( &v0.actions, FLAGS_SPENDS_AND_OUTPUTS, @@ -494,7 +496,8 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { v0.denomination, &v0.send_to_address_on_creation_failure, &v0.public_keys, - ); + platform_version, + )?; // value_balance = denomination EXACTLY (the ShieldedTransfer exact-equality // model): the binding signature proves the value commitments sum to exactly // the denomination leaving the pool. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs index 7ecaff6c1da..ebf5654c693 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs @@ -438,7 +438,7 @@ mod tests { // Compute platform sighash binding transparent fields (output_script, unshielding_amount) let output_script = create_output_script(); let unshielding_amount = 499_995_000u64; // value_balance as u64 - let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data( + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( output_script.as_bytes(), unshielding_amount, 1, @@ -589,7 +589,7 @@ mod tests { let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); // Bind transparent fields (output_script, unshielding_amount) to the sighash - let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data( + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( output_script.as_bytes(), unshielding_amount, 1, @@ -966,7 +966,7 @@ mod tests { // Compute platform sighash binding transparent fields (output_script, unshielding_amount) let output_script = create_output_script(); let unshielding_amount = 499_995_000u64; // value_balance as u64 - let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data( + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( output_script.as_bytes(), unshielding_amount, 1, @@ -1205,7 +1205,7 @@ mod tests { let output_script = create_output_script(); let unshielding_amount = 499_995_000u64; - let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data( + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( output_script.as_bytes(), unshielding_amount, 1, @@ -1476,7 +1476,7 @@ mod tests { let output_script = create_output_script(); let unshielding_amount = 499_995_000u64; - let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data( + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( output_script.as_bytes(), unshielding_amount, 1, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs index cf86527d95f..0c532a92f7c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs @@ -475,7 +475,7 @@ mod tests { // Compute platform sighash binding transparent fields (output_address, unshielding_amount) let output_address = create_output_address(); let unshielding_amount = 499_995_000u64; // value_balance as u64 - let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data( + let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data_v0( &output_address.to_bytes(), unshielding_amount, ); @@ -614,7 +614,7 @@ mod tests { let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); // Bind transparent fields (output_address, unshielding_amount) to the sighash - let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data( + let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data_v0( &output_address.to_bytes(), unshielding_amount, ); @@ -841,7 +841,7 @@ mod tests { // Compute platform sighash binding transparent fields (output_address, unshielding_amount) let output_address = create_output_address(); let unshielding_amount = 499_995_000u64; // value_balance as u64 - let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data( + let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data_v0( &output_address.to_bytes(), unshielding_amount, ); @@ -1042,7 +1042,7 @@ mod tests { let output_address = create_output_address(); let unshielding_amount = 499_995_000u64; - let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data( + let extra_sighash_data = dpp::shielded::unshield_extra_sighash_data_v0( &output_address.to_bytes(), unshielding_amount, ); diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs index c3b2001da98..14f4dc66c00 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/mod.rs @@ -9,4 +9,5 @@ pub struct DPPMethodVersions { pub daily_withdrawal_limit: FeatureVersion, pub deduct_fee_from_outputs_or_remaining_balance_of_inputs: FeatureVersion, pub compute_minimum_shielded_fee: FeatureVersion, + pub shielded_extra_sighash_data: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs index cb8f72ba088..e73b30c47f8 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v1.rs @@ -4,4 +4,5 @@ pub const DPP_METHOD_VERSIONS_V1: DPPMethodVersions = DPPMethodVersions { daily_withdrawal_limit: 0, deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, + shielded_extra_sighash_data: 0, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs index 6df00ae6498..12b88028ab5 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v2.rs @@ -4,4 +4,5 @@ pub const DPP_METHOD_VERSIONS_V2: DPPMethodVersions = DPPMethodVersions { daily_withdrawal_limit: 1, deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, + shielded_extra_sighash_data: 0, }; From 73b58d7ccb023f2732b7d6a43981dd0524f85925 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 9 Jun 2026 11:38:59 +0200 Subject: [PATCH 27/28] refactor(dpp): move shielded sighash preimage builders into their own file (#3816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, split the consensus-critical platform-sighash preimage code out of the catch-all `shielded/mod.rs` into a dedicated `shielded/sighash.rs`: SIGHASH_DOMAIN, compute_platform_sighash, the three `*_extra_sighash_data` version dispatchers + their `_v0` impls, and their byte-layout tests. The public paths are unchanged (re-exported from `shielded/mod.rs`), so no caller changes. `mod.rs` keeps the storage-byte fee constants and the OrchardBundleParams / SerializedAction wire structs. Pure code move — no logic change; the 7 sighash tests pass unchanged as `shielded::sighash::tests::*` (166 shielded tests green). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-dpp/src/shielded/mod.rs | 490 +----------------------- packages/rs-dpp/src/shielded/sighash.rs | 487 +++++++++++++++++++++++ 2 files changed, 497 insertions(+), 480 deletions(-) create mode 100644 packages/rs-dpp/src/shielded/sighash.rs diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index dc53e00b22c..0603d71374a 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -2,13 +2,11 @@ pub mod builder; mod compute_minimum_shielded_fee; +mod sighash; use bincode::{Decode, Encode}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::withdrawal::Pooling; // Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the // module and the function share a name but live in different namespaces). @@ -18,12 +16,15 @@ pub use compute_minimum_shielded_fee::{ compute_shielded_withdrawal_fee, }; -use crate::address_funds::PlatformAddress; -use crate::identity::identity_public_key::contract_bounds::ContractBounds; -use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; -use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; -use crate::ProtocolError; -use platform_version::version::PlatformVersion; +// Re-exported so the public paths stay `dpp::shielded::` after moving the sighash preimage +// builders into their own file. Both the version-dispatching wrappers and their `_v0` impls are +// re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). +pub use sighash::{ + compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, + unshield_extra_sighash_data_v0, +}; /// Permanent storage bytes per shielded action: 312 bytes total. /// @@ -99,236 +100,6 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; -/// Domain separator for Platform sighash computation. -const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash"; - -/// Computes the platform sighash from an Orchard bundle commitment and optional -/// transparent field data. -/// -/// The sighash is computed as: -/// `SHA-256(SIGHASH_DOMAIN || bundle_commitment || extra_data)` -/// -/// This binds transparent state transition fields (like `output_address` in unshield -/// or `output_script` in shielded withdrawal) to the Orchard signatures, preventing -/// replay attacks where an attacker substitutes transparent fields while reusing a -/// valid Orchard bundle. -/// -/// The same computation must be used on both the signing (client) and verification -/// (platform) sides. For transitions without transparent fields (shield and -/// shielded_transfer), `extra_data` is empty. -pub fn compute_platform_sighash(bundle_commitment: &[u8; 32], extra_data: &[u8]) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(SIGHASH_DOMAIN); - hasher.update(bundle_commitment); - hasher.update(extra_data); - hasher.finalize().into() -} - -/// Builds the transparent `extra_data` bound into a ShieldedWithdrawal's platform -/// sighash, with the byte layout -/// `output_script || unshielding_amount (u64 LE) || core_fee_per_byte (u32 LE) || pooling (u8)`. -/// -/// Every field here is written verbatim by the transformer into the queued withdrawal -/// document that constructs the Core asset-unlock TxOut. Binding all of them into the -/// Orchard sighash means the binding signature authorizes them: since ShieldedWithdrawal -/// has no identity-key signature and no address-witness check, the Orchard signature is -/// the only authorization boundary, so a relay or block proposer cannot malleate -/// `core_fee_per_byte` (or `pooling`, were it ever unpinned from `Never`) — e.g. flip a -/// user's `core_fee_per_byte = 1` to a much larger Fibonacci value to redirect the -/// withdrawn amount into L1 miner fees — without invalidating the proof. -/// -/// The signing (client/builder) and verifying (consensus) sides MUST produce identical -/// bytes, so both call this single function. -/// -/// The layout places the variable-length `output_script` first with no length prefix. This -/// is unambiguous only because `validate_structure` runs before proof verification and pins -/// `output_script` to a canonical, fixed-length P2PKH (25 bytes) or P2SH (23 bytes); the -/// remaining fields are fixed-width, so the preimage is well-defined for every accepted -/// transition. If that script-shape restriction is ever relaxed, add a length prefix here. -/// Dispatches on the platform-versioned `dpp.methods.shielded_extra_sighash_data` so the -/// consensus-critical byte layout can evolve across protocol versions without breaking older -/// transitions — the same versioning the sibling shielded fee methods use. The signing -/// (client/builder) and verifying (consensus) sides both call this single function with the same -/// `platform_version`, so they can never produce divergent preimages. -pub fn shielded_withdrawal_extra_sighash_data( - output_script: &[u8], - unshielding_amount: u64, - core_fee_per_byte: u32, - pooling: Pooling, - platform_version: &PlatformVersion, -) -> Result, ProtocolError> { - match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(shielded_withdrawal_extra_sighash_data_v0( - output_script, - unshielding_amount, - core_fee_per_byte, - pooling, - )), - version => Err(ProtocolError::UnknownVersionMismatch { - method: "shielded_withdrawal_extra_sighash_data".to_string(), - known_versions: vec![0], - received: version, - }), - } -} - -/// v0 byte layout of [`shielded_withdrawal_extra_sighash_data`] (see that function's doc comment for -/// the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version. -pub fn shielded_withdrawal_extra_sighash_data_v0( - output_script: &[u8], - unshielding_amount: u64, - core_fee_per_byte: u32, - pooling: Pooling, -) -> Vec { - let mut data = Vec::with_capacity(output_script.len() + 8 + 4 + 1); - data.extend_from_slice(output_script); - data.extend_from_slice(&unshielding_amount.to_le_bytes()); - data.extend_from_slice(&core_fee_per_byte.to_le_bytes()); - data.push(pooling as u8); - data -} - -/// Builds the transparent `extra_data` bound into an Unshield's platform sighash, with the -/// byte layout `output_address || unshielding_amount (u64 LE)`. -/// -/// As with [`shielded_withdrawal_extra_sighash_data`], the signing (client/builder) and -/// verifying (consensus) sides MUST produce identical bytes, so both call this single -/// function. Unshield credits a transparent platform address (not a Core asset-unlock -/// `TxOut`), so it carries no `core_fee_per_byte`/`pooling` to bind. -pub fn unshield_extra_sighash_data( - output_address: &[u8], - unshielding_amount: u64, - platform_version: &PlatformVersion, -) -> Result, ProtocolError> { - match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(unshield_extra_sighash_data_v0( - output_address, - unshielding_amount, - )), - version => Err(ProtocolError::UnknownVersionMismatch { - method: "unshield_extra_sighash_data".to_string(), - known_versions: vec![0], - received: version, - }), - } -} - -/// v0 byte layout of [`unshield_extra_sighash_data`] (see that function's doc comment for the layout -/// and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version bump. -pub fn unshield_extra_sighash_data_v0(output_address: &[u8], unshielding_amount: u64) -> Vec { - let mut data = Vec::with_capacity(output_address.len() + 8); - data.extend_from_slice(output_address); - data.extend_from_slice(&unshielding_amount.to_le_bytes()); - data -} - -/// Builds the transparent `extra_data` bound into an `IdentityCreateFromShieldedPool`'s platform -/// sighash, with the byte layout -/// `identity_id (32) || denomination (u64 LE) -/// || send_to_address_on_creation_failure (tag u8: 0=P2pkh, 1=P2sh || hash 20) -/// || num_keys (u16 LE) -/// || for each key in supplied order: key_id (u32 LE) || purpose (u8) || security_level (u8) -/// || key_type (u8) || key_data_len (u16 LE) || key_data || read_only (u8) -/// || contract_bounds (tag u8: 0=None, 1=SingleContract id(32), 2=SingleContractDocumentType -/// id(32) name_len(u16 LE) name)`. -/// -/// `IdentityCreateFromShieldedPool` carries NO platform identity signature: authorization is 100% -/// the Orchard proof + per-action spend-auth signatures + binding signature over this sighash. The -/// transparent, state-determining fields — the new identity id, the exit denomination, and the -/// FULL public-key set — must therefore be committed into the Orchard sighash, exactly as the -/// `surplus_output` field is committed into `ShieldFromAssetLock`'s ECDSA signature. Without this -/// binding a relay or block proposer could take a valid bundle exiting a denomination and re-point -/// it at a DIFFERENT identity id, or swap in DIFFERENT keys they control, stealing the credited -/// balance (the per-key proofs-of-possession alone do NOT prevent this — a relayer keeps valid PoP -/// sigs for their own keys while swapping the bundle). Binding `(this spend → these exact keys → -/// this id → this denomination)` here makes the redirection atomic-or-invalid. -/// -/// The signing (client/builder) and verifying (consensus) sides MUST produce identical bytes, so -/// both call this single function. Unlike the fixed-length withdrawal/unshield helpers, the -/// variable-length key list is fully length-prefixed (both the key count and each key's data) so -/// the preimage is unambiguous for any key set. -pub fn identity_create_from_shielded_extra_sighash_data( - identity_id: &[u8; 32], - denomination: u64, - send_to_address_on_creation_failure: &PlatformAddress, - public_keys: &[IdentityPublicKeyInCreation], - platform_version: &PlatformVersion, -) -> Result, ProtocolError> { - match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(identity_create_from_shielded_extra_sighash_data_v0( - identity_id, - denomination, - send_to_address_on_creation_failure, - public_keys, - )), - version => Err(ProtocolError::UnknownVersionMismatch { - method: "identity_create_from_shielded_extra_sighash_data".to_string(), - known_versions: vec![0], - received: version, - }), - } -} - -/// v0 byte layout of [`identity_create_from_shielded_extra_sighash_data`] (see that function's doc -/// comment for the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` -/// + version bump. -pub fn identity_create_from_shielded_extra_sighash_data_v0( - identity_id: &[u8; 32], - denomination: u64, - send_to_address_on_creation_failure: &PlatformAddress, - public_keys: &[IdentityPublicKeyInCreation], -) -> Vec { - let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); - data.extend_from_slice(identity_id); - data.extend_from_slice(&denomination.to_le_bytes()); - // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the - // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address. - match send_to_address_on_creation_failure { - PlatformAddress::P2pkh(hash) => { - data.push(0u8); - data.extend_from_slice(hash); - } - PlatformAddress::P2sh(hash) => { - data.push(1u8); - data.extend_from_slice(hash); - } - } - data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); - for key in public_keys { - data.extend_from_slice(&key.id().to_le_bytes()); - data.push(key.purpose() as u8); - data.push(key.security_level() as u8); - data.push(key.key_type() as u8); - let key_data = key.data().as_slice(); - data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); - data.extend_from_slice(key_data); - // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that - // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind - // them for hash-based key types (which accept an empty signature). Committing them into the - // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer - // cannot flip `read_only` or alter `contract_bounds` on an observed transition. - data.push(key.read_only() as u8); - match key.contract_bounds() { - None => data.push(0u8), - Some(ContractBounds::SingleContract { id }) => { - data.push(1u8); - data.extend_from_slice(id.as_bytes()); - } - Some(ContractBounds::SingleContractDocumentType { - id, - document_type_name, - }) => { - data.push(2u8); - data.extend_from_slice(id.as_bytes()); - let name = document_type_name.as_bytes(); - data.extend_from_slice(&(name.len() as u16).to_le_bytes()); - data.extend_from_slice(name); - } - } - } - data -} - /// Common Orchard bundle parameters shared across all shielded transition types. /// /// Groups the fields that every shielded transition carries identically: @@ -420,244 +191,3 @@ pub struct SerializedAction { /// signature from one transition cannot be reused in another. pub spend_auth_sig: [u8; 64], } - -#[cfg(test)] -mod tests { - use super::*; - use crate::identity::core_script::CoreScript; - use crate::withdrawal::Pooling; - // These tests pin the v0 preimage directly (they assert exact bytes), so resolve the bare helper - // names to the `_v0` impls rather than the version-dispatching public wrappers. - use crate::shielded::shielded_withdrawal_extra_sighash_data_v0 as shielded_withdrawal_extra_sighash_data; - use crate::shielded::unshield_extra_sighash_data_v0 as unshield_extra_sighash_data; - - #[test] - fn withdrawal_sighash_data_binds_core_fee_per_byte() { - let script = CoreScript::new_p2pkh([1u8; 20]); - let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never); - let b = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 2, Pooling::Never); - assert_ne!( - a, b, - "changing core_fee_per_byte must change the sighash preimage" - ); - } - - #[test] - fn withdrawal_sighash_data_binds_pooling() { - // `pooling` is pinned to `Never` by `validate_structure`, so this binding is currently - // dead defense-in-depth; assert it is nonetheless mixed into the preimage so a future - // unpinning would still be authorized by the Orchard binding signature. - let script = CoreScript::new_p2pkh([1u8; 20]); - let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never); - let b = shielded_withdrawal_extra_sighash_data( - script.as_bytes(), - 1000, - 1, - Pooling::IfAvailable, - ); - assert_ne!(a, b, "changing pooling must change the sighash preimage"); - } - - #[test] - fn withdrawal_sighash_data_layout() { - // output_script(2) || unshielding_amount(8) || core_fee_per_byte(4) || pooling(1) - let d = shielded_withdrawal_extra_sighash_data(&[0xAA, 0xBB], 1, 2, Pooling::Never); - assert_eq!(d.len(), 2 + 8 + 4 + 1); - assert_eq!(&d[0..2], &[0xAA, 0xBB]); - assert_eq!(&d[2..10], &1u64.to_le_bytes()); - assert_eq!(&d[10..14], &2u32.to_le_bytes()); - assert_eq!(d[14], Pooling::Never as u8); - } - - #[test] - fn unshield_sighash_data_layout() { - // output_address || unshielding_amount(8) - let d = unshield_extra_sighash_data(&[0xAA, 0xBB, 0xCC], 5); - assert_eq!(d.len(), 3 + 8); - assert_eq!(&d[0..3], &[0xAA, 0xBB, 0xCC]); - assert_eq!(&d[3..11], &5u64.to_le_bytes()); - } - - mod identity_create_sighash { - use super::*; - // Pin the v0 preimage directly (see the note in the parent test module). - use crate::identity::{KeyType, Purpose, SecurityLevel}; - use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data; - use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; - use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; - use platform_value::BinaryData; - - fn mk_key(id: u32, data_byte: u8) -> IdentityPublicKeyInCreation { - IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { - id, - key_type: KeyType::ECDSA_SECP256K1, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::MASTER, - contract_bounds: None, - read_only: false, - data: BinaryData::new(vec![data_byte; 33]), - signature: BinaryData::new(vec![]), - }) - } - - #[test] - fn layout_is_length_prefixed() { - // identity_id(32) || denomination(8) - // || send_to_address_on_creation_failure (tag(1) || hash(20)) - // || num_keys(2) - // || [key_id(4)|purpose|sec|type|len(2)|data|read_only(1)|contract_bounds_tag(1)] - let id = [0x11u8; 32]; - let keys = vec![mk_key(7, 0xAB)]; - let fallback = PlatformAddress::P2pkh([0x5Cu8; 20]); - let d = identity_create_from_shielded_extra_sighash_data( - &id, - 10_000_000_000, - &fallback, - &keys, - ); - assert_eq!(&d[0..32], &id); - assert_eq!(&d[32..40], &10_000_000_000u64.to_le_bytes()); - // Fallback address: tag(0=P2pkh) at offset 40, 20-byte hash at 41..61. - assert_eq!(d[40], 0u8, "fallback address P2pkh tag"); - assert_eq!(&d[41..61], &[0x5Cu8; 20], "fallback address hash"); - assert_eq!(&d[61..63], &1u16.to_le_bytes()); - assert_eq!(&d[63..67], &7u32.to_le_bytes()); - assert_eq!(d[67], Purpose::AUTHENTICATION as u8); - assert_eq!(d[68], SecurityLevel::MASTER as u8); - assert_eq!(d[69], KeyType::ECDSA_SECP256K1 as u8); - assert_eq!(&d[70..72], &33u16.to_le_bytes()); - assert_eq!(&d[72..105], &[0xAB; 33]); - assert_eq!(d[105], 0u8, "read_only=false"); - assert_eq!(d[106], 0u8, "contract_bounds=None tag"); - assert_eq!(d.len(), 32 + 8 + 21 + 2 + (4 + 1 + 1 + 1 + 2 + 33 + 1 + 1)); - } - - #[test] - fn binds_identity_id_denomination_and_keys() { - let id_a = [0x11u8; 32]; - let id_b = [0x22u8; 32]; - let keys = vec![mk_key(0, 0xAA)]; - let fallback = PlatformAddress::P2pkh([0x01u8; 20]); - let base = identity_create_from_shielded_extra_sighash_data( - &id_a, - 10_000_000_000, - &fallback, - &keys, - ); - - // Changing the identity id changes the preimage (anti-redirection to a different id). - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id_b, - 10_000_000_000, - &fallback, - &keys - ), - "identity id must be bound" - ); - // Changing the denomination changes the preimage. - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id_a, - 30_000_000_000, - &fallback, - &keys - ), - "denomination must be bound" - ); - // Changing the fallback failure address changes the preimage (anti-redirection of the - // failure credit: a relayer cannot point the penalty-charged spend at a different - // address than the one each key's proof-of-possession signed). - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id_a, - 10_000_000_000, - &PlatformAddress::P2pkh([0x02u8; 20]), - &keys - ), - "fallback failure address hash must be bound" - ); - // Changing only the fallback address TYPE (P2pkh -> P2sh, same hash) changes the - // preimage too (the type tag is bound, not just the hash). - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id_a, - 10_000_000_000, - &PlatformAddress::P2sh([0x01u8; 20]), - &keys - ), - "fallback failure address type tag must be bound" - ); - // Swapping in a different key changes the preimage (anti-key-swap). - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id_a, - 10_000_000_000, - &fallback, - &[mk_key(0, 0xBB)] - ), - "key data must be bound" - ); - // Adding a key changes the preimage (the full set is bound, not just the count). - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id_a, - 10_000_000_000, - &fallback, - &[mk_key(0, 0xAA), mk_key(1, 0xCC)] - ), - "the full key set must be bound" - ); - } - - #[test] - fn binds_read_only_and_contract_bounds() { - use crate::identity::identity_public_key::contract_bounds::ContractBounds; - use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; - let id = [0x11u8; 32]; - let fallback = PlatformAddress::P2pkh([0x01u8; 20]); - let base = identity_create_from_shielded_extra_sighash_data( - &id, - 10_000_000_000, - &fallback, - &[mk_key(0, 0xAA)], - ); - - // Flipping read_only changes the preimage (un-malleable for every key type). - let mut ro_key = mk_key(0, 0xAA); - ro_key.set_read_only(true); - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id, - 10_000_000_000, - &fallback, - &[ro_key] - ), - "read_only must be bound" - ); - - // Attaching contract_bounds changes the preimage. - let mut cb_key = mk_key(0, 0xAA); - cb_key.set_contract_bounds(Some(ContractBounds::SingleContract { - id: platform_value::Identifier::new([0x33; 32]), - })); - assert_ne!( - base, - identity_create_from_shielded_extra_sighash_data( - &id, - 10_000_000_000, - &fallback, - &[cb_key] - ), - "contract_bounds must be bound" - ); - } - } -} diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs new file mode 100644 index 00000000000..33a2b50d7d9 --- /dev/null +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -0,0 +1,487 @@ +//! Platform sighash preimage construction for shielded transitions. +//! +//! Shielded transitions carry NO platform identity signature — authorization is the Orchard proof + +//! per-action spend-auth signatures + the RedPallas binding signature over the platform sighash. +//! These helpers build the transparent `extra_data` each transition binds into that sighash so the +//! signing (client/builder) and verifying (consensus) sides commit to identical bytes. The byte +//! layouts are consensus-critical and versioned via `dpp.methods.shielded_extra_sighash_data`. + +use crate::address_funds::PlatformAddress; +use crate::identity::identity_public_key::contract_bounds::ContractBounds; +use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use crate::withdrawal::Pooling; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; +use sha2::{Digest, Sha256}; + +/// Domain separator for Platform sighash computation. +const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash"; + +/// Computes the platform sighash from an Orchard bundle commitment and optional +/// transparent field data. +/// +/// The sighash is computed as: +/// `SHA-256(SIGHASH_DOMAIN || bundle_commitment || extra_data)` +/// +/// This binds transparent state transition fields (like `output_address` in unshield +/// or `output_script` in shielded withdrawal) to the Orchard signatures, preventing +/// replay attacks where an attacker substitutes transparent fields while reusing a +/// valid Orchard bundle. +/// +/// The same computation must be used on both the signing (client) and verification +/// (platform) sides. For transitions without transparent fields (shield and +/// shielded_transfer), `extra_data` is empty. +pub fn compute_platform_sighash(bundle_commitment: &[u8; 32], extra_data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(SIGHASH_DOMAIN); + hasher.update(bundle_commitment); + hasher.update(extra_data); + hasher.finalize().into() +} + +/// Builds the transparent `extra_data` bound into a ShieldedWithdrawal's platform +/// sighash, with the byte layout +/// `output_script || unshielding_amount (u64 LE) || core_fee_per_byte (u32 LE) || pooling (u8)`. +/// +/// Every field here is written verbatim by the transformer into the queued withdrawal +/// document that constructs the Core asset-unlock TxOut. Binding all of them into the +/// Orchard sighash means the binding signature authorizes them: since ShieldedWithdrawal +/// has no identity-key signature and no address-witness check, the Orchard signature is +/// the only authorization boundary, so a relay or block proposer cannot malleate +/// `core_fee_per_byte` (or `pooling`, were it ever unpinned from `Never`) — e.g. flip a +/// user's `core_fee_per_byte = 1` to a much larger Fibonacci value to redirect the +/// withdrawn amount into L1 miner fees — without invalidating the proof. +/// +/// The signing (client/builder) and verifying (consensus) sides MUST produce identical +/// bytes, so both call this single function. +/// +/// The layout places the variable-length `output_script` first with no length prefix. This +/// is unambiguous only because `validate_structure` runs before proof verification and pins +/// `output_script` to a canonical, fixed-length P2PKH (25 bytes) or P2SH (23 bytes); the +/// remaining fields are fixed-width, so the preimage is well-defined for every accepted +/// transition. If that script-shape restriction is ever relaxed, add a length prefix here. +/// Dispatches on the platform-versioned `dpp.methods.shielded_extra_sighash_data` so the +/// consensus-critical byte layout can evolve across protocol versions without breaking older +/// transitions — the same versioning the sibling shielded fee methods use. The signing +/// (client/builder) and verifying (consensus) sides both call this single function with the same +/// `platform_version`, so they can never produce divergent preimages. +pub fn shielded_withdrawal_extra_sighash_data( + output_script: &[u8], + unshielding_amount: u64, + core_fee_per_byte: u32, + pooling: Pooling, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(shielded_withdrawal_extra_sighash_data_v0( + output_script, + unshielding_amount, + core_fee_per_byte, + pooling, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "shielded_withdrawal_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`shielded_withdrawal_extra_sighash_data`] (see that function's doc comment for +/// the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version. +pub fn shielded_withdrawal_extra_sighash_data_v0( + output_script: &[u8], + unshielding_amount: u64, + core_fee_per_byte: u32, + pooling: Pooling, +) -> Vec { + let mut data = Vec::with_capacity(output_script.len() + 8 + 4 + 1); + data.extend_from_slice(output_script); + data.extend_from_slice(&unshielding_amount.to_le_bytes()); + data.extend_from_slice(&core_fee_per_byte.to_le_bytes()); + data.push(pooling as u8); + data +} + +/// Builds the transparent `extra_data` bound into an Unshield's platform sighash, with the +/// byte layout `output_address || unshielding_amount (u64 LE)`. +/// +/// As with [`shielded_withdrawal_extra_sighash_data`], the signing (client/builder) and +/// verifying (consensus) sides MUST produce identical bytes, so both call this single +/// function. Unshield credits a transparent platform address (not a Core asset-unlock +/// `TxOut`), so it carries no `core_fee_per_byte`/`pooling` to bind. +pub fn unshield_extra_sighash_data( + output_address: &[u8], + unshielding_amount: u64, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(unshield_extra_sighash_data_v0( + output_address, + unshielding_amount, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "unshield_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`unshield_extra_sighash_data`] (see that function's doc comment for the layout +/// and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version bump. +pub fn unshield_extra_sighash_data_v0(output_address: &[u8], unshielding_amount: u64) -> Vec { + let mut data = Vec::with_capacity(output_address.len() + 8); + data.extend_from_slice(output_address); + data.extend_from_slice(&unshielding_amount.to_le_bytes()); + data +} + +/// Builds the transparent `extra_data` bound into an `IdentityCreateFromShieldedPool`'s platform +/// sighash, with the byte layout +/// `identity_id (32) || denomination (u64 LE) +/// || send_to_address_on_creation_failure (tag u8: 0=P2pkh, 1=P2sh || hash 20) +/// || num_keys (u16 LE) +/// || for each key in supplied order: key_id (u32 LE) || purpose (u8) || security_level (u8) +/// || key_type (u8) || key_data_len (u16 LE) || key_data || read_only (u8) +/// || contract_bounds (tag u8: 0=None, 1=SingleContract id(32), 2=SingleContractDocumentType +/// id(32) name_len(u16 LE) name)`. +/// +/// `IdentityCreateFromShieldedPool` carries NO platform identity signature: authorization is 100% +/// the Orchard proof + per-action spend-auth signatures + binding signature over this sighash. The +/// transparent, state-determining fields — the new identity id, the exit denomination, and the +/// FULL public-key set — must therefore be committed into the Orchard sighash, exactly as the +/// `surplus_output` field is committed into `ShieldFromAssetLock`'s ECDSA signature. Without this +/// binding a relay or block proposer could take a valid bundle exiting a denomination and re-point +/// it at a DIFFERENT identity id, or swap in DIFFERENT keys they control, stealing the credited +/// balance (the per-key proofs-of-possession alone do NOT prevent this — a relayer keeps valid PoP +/// sigs for their own keys while swapping the bundle). Binding `(this spend → these exact keys → +/// this id → this denomination)` here makes the redirection atomic-or-invalid. +/// +/// The signing (client/builder) and verifying (consensus) sides MUST produce identical bytes, so +/// both call this single function. Unlike the fixed-length withdrawal/unshield helpers, the +/// variable-length key list is fully length-prefixed (both the key count and each key's data) so +/// the preimage is unambiguous for any key set. +pub fn identity_create_from_shielded_extra_sighash_data( + identity_id: &[u8; 32], + denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, + public_keys: &[IdentityPublicKeyInCreation], + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(identity_create_from_shielded_extra_sighash_data_v0( + identity_id, + denomination, + send_to_address_on_creation_failure, + public_keys, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "identity_create_from_shielded_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`identity_create_from_shielded_extra_sighash_data`] (see that function's doc +/// comment for the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` +/// + version bump. +pub fn identity_create_from_shielded_extra_sighash_data_v0( + identity_id: &[u8; 32], + denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, + public_keys: &[IdentityPublicKeyInCreation], +) -> Vec { + let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); + data.extend_from_slice(identity_id); + data.extend_from_slice(&denomination.to_le_bytes()); + // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the + // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address. + match send_to_address_on_creation_failure { + PlatformAddress::P2pkh(hash) => { + data.push(0u8); + data.extend_from_slice(hash); + } + PlatformAddress::P2sh(hash) => { + data.push(1u8); + data.extend_from_slice(hash); + } + } + data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); + for key in public_keys { + data.extend_from_slice(&key.id().to_le_bytes()); + data.push(key.purpose() as u8); + data.push(key.security_level() as u8); + data.push(key.key_type() as u8); + let key_data = key.data().as_slice(); + data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); + data.extend_from_slice(key_data); + // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that + // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind + // them for hash-based key types (which accept an empty signature). Committing them into the + // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer + // cannot flip `read_only` or alter `contract_bounds` on an observed transition. + data.push(key.read_only() as u8); + match key.contract_bounds() { + None => data.push(0u8), + Some(ContractBounds::SingleContract { id }) => { + data.push(1u8); + data.extend_from_slice(id.as_bytes()); + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + data.push(2u8); + data.extend_from_slice(id.as_bytes()); + let name = document_type_name.as_bytes(); + data.extend_from_slice(&(name.len() as u16).to_le_bytes()); + data.extend_from_slice(name); + } + } + } + data +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::core_script::CoreScript; + use crate::withdrawal::Pooling; + // These tests pin the v0 preimage directly (they assert exact bytes), so resolve the bare helper + // names to the `_v0` impls rather than the version-dispatching public wrappers. + use crate::shielded::shielded_withdrawal_extra_sighash_data_v0 as shielded_withdrawal_extra_sighash_data; + use crate::shielded::unshield_extra_sighash_data_v0 as unshield_extra_sighash_data; + + #[test] + fn withdrawal_sighash_data_binds_core_fee_per_byte() { + let script = CoreScript::new_p2pkh([1u8; 20]); + let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never); + let b = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 2, Pooling::Never); + assert_ne!( + a, b, + "changing core_fee_per_byte must change the sighash preimage" + ); + } + + #[test] + fn withdrawal_sighash_data_binds_pooling() { + // `pooling` is pinned to `Never` by `validate_structure`, so this binding is currently + // dead defense-in-depth; assert it is nonetheless mixed into the preimage so a future + // unpinning would still be authorized by the Orchard binding signature. + let script = CoreScript::new_p2pkh([1u8; 20]); + let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never); + let b = shielded_withdrawal_extra_sighash_data( + script.as_bytes(), + 1000, + 1, + Pooling::IfAvailable, + ); + assert_ne!(a, b, "changing pooling must change the sighash preimage"); + } + + #[test] + fn withdrawal_sighash_data_layout() { + // output_script(2) || unshielding_amount(8) || core_fee_per_byte(4) || pooling(1) + let d = shielded_withdrawal_extra_sighash_data(&[0xAA, 0xBB], 1, 2, Pooling::Never); + assert_eq!(d.len(), 2 + 8 + 4 + 1); + assert_eq!(&d[0..2], &[0xAA, 0xBB]); + assert_eq!(&d[2..10], &1u64.to_le_bytes()); + assert_eq!(&d[10..14], &2u32.to_le_bytes()); + assert_eq!(d[14], Pooling::Never as u8); + } + + #[test] + fn unshield_sighash_data_layout() { + // output_address || unshielding_amount(8) + let d = unshield_extra_sighash_data(&[0xAA, 0xBB, 0xCC], 5); + assert_eq!(d.len(), 3 + 8); + assert_eq!(&d[0..3], &[0xAA, 0xBB, 0xCC]); + assert_eq!(&d[3..11], &5u64.to_le_bytes()); + } + + mod identity_create_sighash { + use super::*; + // Pin the v0 preimage directly (see the note in the parent test module). + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use platform_value::BinaryData; + + fn mk_key(id: u32, data_byte: u8) -> IdentityPublicKeyInCreation { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![data_byte; 33]), + signature: BinaryData::new(vec![]), + }) + } + + #[test] + fn layout_is_length_prefixed() { + // identity_id(32) || denomination(8) + // || send_to_address_on_creation_failure (tag(1) || hash(20)) + // || num_keys(2) + // || [key_id(4)|purpose|sec|type|len(2)|data|read_only(1)|contract_bounds_tag(1)] + let id = [0x11u8; 32]; + let keys = vec![mk_key(7, 0xAB)]; + let fallback = PlatformAddress::P2pkh([0x5Cu8; 20]); + let d = identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &keys, + ); + assert_eq!(&d[0..32], &id); + assert_eq!(&d[32..40], &10_000_000_000u64.to_le_bytes()); + // Fallback address: tag(0=P2pkh) at offset 40, 20-byte hash at 41..61. + assert_eq!(d[40], 0u8, "fallback address P2pkh tag"); + assert_eq!(&d[41..61], &[0x5Cu8; 20], "fallback address hash"); + assert_eq!(&d[61..63], &1u16.to_le_bytes()); + assert_eq!(&d[63..67], &7u32.to_le_bytes()); + assert_eq!(d[67], Purpose::AUTHENTICATION as u8); + assert_eq!(d[68], SecurityLevel::MASTER as u8); + assert_eq!(d[69], KeyType::ECDSA_SECP256K1 as u8); + assert_eq!(&d[70..72], &33u16.to_le_bytes()); + assert_eq!(&d[72..105], &[0xAB; 33]); + assert_eq!(d[105], 0u8, "read_only=false"); + assert_eq!(d[106], 0u8, "contract_bounds=None tag"); + assert_eq!(d.len(), 32 + 8 + 21 + 2 + (4 + 1 + 1 + 1 + 2 + 33 + 1 + 1)); + } + + #[test] + fn binds_identity_id_denomination_and_keys() { + let id_a = [0x11u8; 32]; + let id_b = [0x22u8; 32]; + let keys = vec![mk_key(0, 0xAA)]; + let fallback = PlatformAddress::P2pkh([0x01u8; 20]); + let base = identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &fallback, + &keys, + ); + + // Changing the identity id changes the preimage (anti-redirection to a different id). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_b, + 10_000_000_000, + &fallback, + &keys + ), + "identity id must be bound" + ); + // Changing the denomination changes the preimage. + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 30_000_000_000, + &fallback, + &keys + ), + "denomination must be bound" + ); + // Changing the fallback failure address changes the preimage (anti-redirection of the + // failure credit: a relayer cannot point the penalty-charged spend at a different + // address than the one each key's proof-of-possession signed). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &PlatformAddress::P2pkh([0x02u8; 20]), + &keys + ), + "fallback failure address hash must be bound" + ); + // Changing only the fallback address TYPE (P2pkh -> P2sh, same hash) changes the + // preimage too (the type tag is bound, not just the hash). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &PlatformAddress::P2sh([0x01u8; 20]), + &keys + ), + "fallback failure address type tag must be bound" + ); + // Swapping in a different key changes the preimage (anti-key-swap). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &fallback, + &[mk_key(0, 0xBB)] + ), + "key data must be bound" + ); + // Adding a key changes the preimage (the full set is bound, not just the count). + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id_a, + 10_000_000_000, + &fallback, + &[mk_key(0, 0xAA), mk_key(1, 0xCC)] + ), + "the full key set must be bound" + ); + } + + #[test] + fn binds_read_only_and_contract_bounds() { + use crate::identity::identity_public_key::contract_bounds::ContractBounds; + use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; + let id = [0x11u8; 32]; + let fallback = PlatformAddress::P2pkh([0x01u8; 20]); + let base = identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &[mk_key(0, 0xAA)], + ); + + // Flipping read_only changes the preimage (un-malleable for every key type). + let mut ro_key = mk_key(0, 0xAA); + ro_key.set_read_only(true); + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &[ro_key] + ), + "read_only must be bound" + ); + + // Attaching contract_bounds changes the preimage. + let mut cb_key = mk_key(0, 0xAA); + cb_key.set_contract_bounds(Some(ContractBounds::SingleContract { + id: platform_value::Identifier::new([0x33; 32]), + })); + assert_ne!( + base, + identity_create_from_shielded_extra_sighash_data( + &id, + 10_000_000_000, + &fallback, + &[cb_key] + ), + "contract_bounds must be bound" + ); + } + } +} From c4194400fdf14de130a2676700b9d2dd571c660a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 9 Jun 2026 17:44:08 +0700 Subject: [PATCH 28/28] Update packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs Co-authored-by: Lil Claw --- .../execute_event/v0/mod.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 7b9150c911d..12e556bfd37 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -662,13 +662,14 @@ where .. } => { // Reuse the create-then-deduct machinery: `paid_from_identity_function` applies the - // ops (which create the identity holding the full `denomination`, credit the system - // total, and debit the pool), then deducts the metered fee + the - // `additional_fixed_fee_cost` (the shielded compute fee) from the new identity's - // balance and books it to the fee pools — so the identity ends with - // `denomination - total_fee`. Conservation holds by the standard machinery, exactly - // as for `PaidFromAssetLock`. Shielded transitions have no fee bidding, so - // `user_fee_increase` is 0. + // ops (which create the identity holding the full `denomination` and debit the + // shielded pool — the credits move within the RHS balance trees, so no + // `AddToSystemCredits`/`RemoveFromSystemCredits` is emitted), then deducts the + // metered fee + the `additional_fixed_fee_cost` (the shielded compute fee) from the + // new identity's balance and books it to the fee pools — so the identity ends with + // `denomination - total_fee`. Conservation holds because the pool-to-identity move + // stays within the right-hand-side balance trees. Shielded transitions have no fee + // bidding, so `user_fee_increase` is 0. let fee_validation_result = maybe_fee_validation_result.unwrap(); self.paid_from_identity_function( fee_validation_result,