diff --git a/Cargo.lock b/Cargo.lock index a6315e45f7c..68f4d972466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5081,7 +5081,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "4.0.0-beta.2" +version = "4.0.0-beta.3" dependencies = [ "apple-native-keyring-store", "argon2", 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/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 diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 53c895232a6..e8aec3c3183 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`, 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/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/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..a3955ab3b4c --- /dev/null +++ b/packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs @@ -0,0 +1,271 @@ +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; +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, + send_to_address_on_creation_failure: PlatformAddress, + 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(), + )); + } + + // 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() + .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 {}", + 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. + // 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 + // 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 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 + // 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, + &send_to_address_on_creation_failure, + &in_creation_keys, + platform_version, + )?; + + 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. + 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. + 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(), + 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-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/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..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 @@ -188,6 +188,65 @@ 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) +/// + 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 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 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. 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. +pub fn compute_shielded_identity_create_fee_v0( + num_actions: usize, + num_keys: usize, + platform_version: &PlatformVersion, +) -> Result { + let min_fees = &platform_version.fee_version.state_transition_min_fees; + + let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; + + 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 fee overflow", + ))?; + let identity_create_floor = min_fees + .identity_create_base_cost + .checked_add(keys_fee) + .ok_or(ProtocolError::Overflow( + "shielded identity create floor overflow", + ))?; + base_fee + .checked_add(identity_create_floor) + .ok_or(ProtocolError::Overflow( + "shielded identity create fee overflow", + )) +} + #[cfg(test)] mod tests { use super::*; @@ -268,6 +327,47 @@ mod tests { } } + /// 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 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) + .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_floor = min_fees.identity_create_base_cost + + num_keys as u64 * min_fees.identity_key_in_creation_cost; + assert_eq!( + fee, + 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!( + 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 cb9d32ac9e7..0603d71374a 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -2,21 +2,30 @@ 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). 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, }; +// 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. /// /// - 280 bytes in the BulkAppendTree: 32 (`cmx`, the note commitment) + 32 @@ -91,80 +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. -pub fn shielded_withdrawal_extra_sighash_data( - 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) -> 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 -} - /// Common Orchard bundle parameters shared across all shielded transition types. /// /// Groups the fields that every shielded transition carries identically: @@ -256,57 +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; - - #[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()); - } -} 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" + ); + } + } +} 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/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-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..68715016c64 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/accessors/mod.rs @@ -0,0 +1,45 @@ +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; +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 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 new file mode 100644 index 00000000000..bf0c2c0b62f --- /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,30 @@ +use crate::address_funds::PlatformAddress; +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 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; + + /// 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..683528ccb4c --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/methods/mod.rs @@ -0,0 +1,57 @@ +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; +#[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, + send_to_address_on_creation_failure: PlatformAddress, + 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, + send_to_address_on_creation_failure, + 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..1d434311923 --- /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,34 @@ +#[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; +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, + send_to_address_on_creation_failure: PlatformAddress, + 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..1e503a85ca3 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs @@ -0,0 +1,178 @@ +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], + send_to_address_on_creation_failure: crate::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + 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..7de79fd1073 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/v0/mod.rs @@ -0,0 +1,195 @@ +mod state_transition_like; +mod state_transition_validation; +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; +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], + /// 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 + /// 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], + send_to_address_on_creation_failure: crate::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + 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" + ); + + 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(); + 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..f0a11d4b27c --- /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,283 @@ +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, + 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 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 + // 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], + send_to_address_on_creation_failure: crate::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + 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_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(); + 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..8b083f01dcf --- /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,45 @@ +#[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; +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, + send_to_address_on_creation_failure: PlatformAddress, + 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, + send_to_address_on_creation_failure, + 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-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/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..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 @@ -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 { .. } @@ -545,37 +548,66 @@ where ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool, - .. + chargeable_failure, } => { - 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)?; + // 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. 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 { + tracing::error!( + "a non-fallback PaidFromShieldedPool carried consensus errors; rejecting \ + unpaid (no spend committed, proposer not paid)" + ); + return Ok(UnpaidConsensusExecutionError(consensus_errors)); + } - // 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 applied_fees = self + .drive + .apply_drive_operations( + operations, + true, + block_info, + Some(transaction), + platform_version, + Some(previous_fee_versions), + ) + .map_err(Error::Drive)?; - Ok(SuccessfulPaidExecution( + // 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); + + 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 { @@ -622,6 +654,37 @@ 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` 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, + 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..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 @@ -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 { .. } @@ -419,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 b64aede39e7..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 { @@ -122,6 +129,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 { @@ -515,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) => { @@ -559,6 +593,38 @@ impl ExecutionEvent<'_> { Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + chargeable_failure: false, + }) + } + 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), }) } _ => { 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..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 @@ -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()) + } } } @@ -216,6 +219,28 @@ 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], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), + identity_id: Default::default(), + }, + ), + ), + ) + }, ]; for (name, st) in transitions { assert!( 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..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 @@ -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(_) @@ -387,6 +392,28 @@ 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], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), + identity_id: Default::default(), + }, + ), + ), + ) + }, ] } @@ -580,6 +607,28 @@ 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], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), + identity_id: Default::default(), + }, + ), + ), + ) + }, ]; for (name, st) in transitions_without_sig_validation { assert!( 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..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 @@ -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 { @@ -232,6 +234,27 @@ 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], + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + identity_id: Default::default(), + }, + ), + ) + } + /// Returns all state transitions grouped by expected `has_is_allowed_validation` result. fn transitions_requiring_allowed_validation() -> Vec { vec![ @@ -265,6 +288,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 c31966ffa96..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 @@ -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; @@ -52,6 +56,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) ) } @@ -63,6 +68,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) ) } } @@ -117,6 +123,12 @@ enum ShieldedMinFeeKind { Unshield, /// `compute_shielded_withdrawal_fee` — ShieldedWithdrawal (base + the flat withdrawal-document cost). Withdrawal, + /// `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 }, } impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { @@ -190,6 +202,29 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { ) } }, + // IdentityCreateFromShieldedPool: `denomination` is the TOTAL leaving the pool + // (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 { + 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 +278,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 { @@ -339,6 +381,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) => { @@ -371,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, @@ -391,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, @@ -403,6 +480,38 @@ 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.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. + 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()), @@ -516,6 +625,26 @@ 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], + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + identity_id: Default::default(), + }, + ), + ) + } mod has_shielded_proof_validation { use super::*; @@ -527,6 +656,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!( @@ -572,6 +705,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!( @@ -751,6 +888,140 @@ 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], + send_to_address_on_creation_failure: + dpp::address_funds::PlatformAddress::P2pkh([0u8; 20]), + 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-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..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 @@ -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,12 +208,59 @@ impl StateTransitionStateValidation for StateTransition { "shielded withdrawal 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. + // Type 20 keeps `has_advanced_structure_validation_with_state() == false`, so the + // 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()? + } + }; + 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, + ) + } } } fn has_state_validation(&self) -> bool { match self { StateTransition::IdentityCreateFromAddresses(_) + | StateTransition::IdentityCreateFromShieldedPool(_) | StateTransition::DataContractCreate(_) | StateTransition::IdentityCreate(_) | StateTransition::DataContractUpdate(_) @@ -337,6 +386,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/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..c295362bbdc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -0,0 +1,117 @@ +mod state; +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::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; +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 *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>; +} + +impl StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer + for IdentityCreateFromShieldedPoolTransition +{ + 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()?; + + 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, + 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, + })), + } + } +} + +/// 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..03e388c87d2 --- /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,131 @@ +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 (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", + ))? + .min(denomination); + + let failure_action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { + output_address: *self.send_to_address_on_creation_failure(), + amount: denomination, + notes: action.notes().to_vec(), + 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( + 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 new file mode 100644 index 00000000000..c019d04e650 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -0,0 +1,733 @@ +//! 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. +//! +//! Covered here: +//! - 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; +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 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 { + 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], + 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 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 `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 + .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 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"); + + // 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( + StateError::IdentityAlreadyExistsError(_) + )], + "got: {:?}", + result.errors + ); +} + +#[test] +fn validate_state_returns_unshield_fallback_on_duplicate_key_hash() { + 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 fails — triggering the chargeable fallback rather than a free rejection. + 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 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 + .validate_state_v0( + &platform_ref, + action, + &mut execution_context, + None, + platform_version, + ) + .expect("validate_state should not error"); + + // 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!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DuplicatedIdentityPublicKeyIdStateError(_) + )], + "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 + ); +} + +/// 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 +/// 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}" + ); +} + +/// 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/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..d252b11baed --- /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,143 @@ +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::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; +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, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0 + for IdentityCreateFromShieldedPoolTransition +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + 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(); + + // 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, 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![]; + 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); + } + + // 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 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- + // 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( + 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/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-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..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 @@ -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,16 @@ 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 *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, + execution_context, + tx, + ) + } } } } 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/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..401a739da17 --- /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,281 @@ +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; +use crate::util::batch::IdentityOperationType; +use dpp::block::epoch::Epoch; +use dpp::identity::accessors::IdentityGettersV0; +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(); + + // 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); + + // 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. 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); + + // 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) + .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 crate::util::batch::DriveOperation::SystemOperation; + use crate::util::batch::SystemOperationType; + 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 + InsertNote(1) + UpdateTotalBalance + assert_eq!(ops.len(), 4); + } + + #[test] + 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 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] + 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 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_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); + 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 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 }) => { + 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 }, + ) => pool_delta = *new_total_balance as i128 - pool as i128, + _ => {} + } + } + assert_eq!( + lhs_delta, 0, + "a pool -> identity move must NOT change the system-credits scalar (LHS)" + ); + assert_eq!( + identity_balance_delta + pool_delta, + 0, + "the new identity balance (RHS) must be exactly funded by the shielded-pool decrement (RHS)" + ); + } + + #[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/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(); 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..92152189618 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/mod.rs @@ -0,0 +1,28 @@ +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 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`. + 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..4bbb8756eec --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/v0/transformer.rs @@ -0,0 +1,52 @@ +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(); + + // 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(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/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-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 72af4fbd4d1..fd11590abcb 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 @@ -1593,6 +1593,190 @@ 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::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; + use dpp::state_transition::proof_result::StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers; + 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 + // 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", + ))) + } + }; + + // 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, + balance, + revision, + } + .into(); + + Ok(( + root_hash, + VerifiedIdentityWithShieldedNullifiers(identity, statuses), + )) + } } } @@ -3026,6 +3210,65 @@ 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], + send_to_address_on_creation_failure: PlatformAddress::P2pkh([0u8; 20]), + 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() { 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, }; 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..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 @@ -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)] @@ -91,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 32ca6a7a44b..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, @@ -268,5 +277,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..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, @@ -268,5 +277,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..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, @@ -268,5 +277,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..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, @@ -271,5 +280,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..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, @@ -272,5 +281,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..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, @@ -275,5 +284,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..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, @@ -269,5 +278,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..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, @@ -326,5 +335,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, + ], }, }; 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, }, }; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 398b5e57e10..5c520a38575 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,14 +53,20 @@ 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 -/// `PlatformAddress` storage bytes (21 bytes: 1-byte variant tag + -/// 20-byte hash — the encoding `PlatformAddress::to_bytes()` produces -/// and `PlatformAddressWasm`/the Swift wrapper expose). +/// 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 +/// `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 @@ -59,11 +75,11 @@ 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 { return Ok(None); } @@ -75,7 +91,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); @@ -83,7 +99,26 @@ 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}"), + )), + } +} + +/// 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)"), )), } } @@ -289,6 +324,147 @@ 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. +/// +/// `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. +/// - `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, + 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 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "`identity_pubkeys_count` must be >= 1", + ); + } + + // 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, + "send_to_address_on_creation_failure_bytes", + ) { + Ok(addr) => addr, + Err(result) => return result, + }; + + 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, + send_to_address_on_creation_failure, + 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. /// @@ -444,8 +620,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, }; @@ -580,8 +759,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, }; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index fe4b93d58c1..f99aa8e4bac 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -701,6 +701,61 @@ 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, + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress, + 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, + send_to_address_on_creation_failure, + 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..99c11e46ec4 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) + } } } } @@ -78,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, @@ -94,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; } @@ -165,6 +198,62 @@ 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> { + // 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(); + 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::*; @@ -193,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)]; @@ -364,4 +481,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..bc29b43cd5d 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,141 @@ 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, + send_to_address_on_creation_failure: PlatformAddress, + 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, + send_to_address_on_creation_failure, + 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, + send_to_address_on_creation_failure, + 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 +944,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/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index a1b58e7135d..7d14e6c6b57 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -8,6 +8,8 @@ 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; 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..7586526b088 --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs @@ -0,0 +1,78 @@ +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; +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`. + /// + /// 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, + send_to_address_on_creation_failure: PlatformAddress, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result; +} + +#[async_trait::async_trait] +impl IdentityCreateFromShieldedPool for Sdk { + async fn identity_create_from_shielded_pool( + &self, + public_keys: Vec, + denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result { + let OrchardBundleParams { + actions, + anchor, + proof, + binding_signature, + } = bundle; + + let state_transition = IdentityCreateFromShieldedPoolTransition::try_from_bundle( + public_keys, + denomination, + send_to_address_on_creation_failure, + actions, + anchor, + proof, + binding_signature, + self.version(), + )?; + ensure_valid_state_transition_structure(&state_transition, self.version())?; + + // 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) + } +} 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..b578a24263a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -610,6 +610,130 @@ 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)`). + /// + /// `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( + walletId: Data, + account: UInt32 = 0, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + denomination: UInt64, + sendToAddressOnCreationFailure: Data, + 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" + ) + } + 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: ( + 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 } + // 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") + } + // 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 + ) + } + } + } + } + + 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( 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..26a439121de 100644 --- a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs +++ b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs @@ -83,9 +83,10 @@ impl StateTransitionFactoryWasm { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented in state_transition_factory") - } + | StateTransition::ShieldedWithdrawal(_) + | 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 { 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..62779cb5d3d 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs @@ -4,6 +4,7 @@ //! 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; @@ -417,3 +418,93 @@ 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) -> 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 { + 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)?; + // `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, + }) + } + + #[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)?; + // `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, + }) + } +} + +impl VerifiedIdentityWithShieldedNullifiersWasm { + pub fn new(identity: IdentityWasm, nullifiers: Map) -> Self { + Self { + identity, + nullifiers, + } + } +} + +impl_wasm_type_info!( + VerifiedIdentityWithShieldedNullifiersWasm, + VerifiedIdentityWithShieldedNullifiers +);