From 53eab0bab9b851537aa95782a6215f761254ee3d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 20:14:10 -0600 Subject: [PATCH 1/6] feat(dpp): add client-side validation to IdentityCreditTransferTransition construction --- .../v0/v0_methods.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs index a35fb291e53..f294c2d0cb6 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs @@ -1,5 +1,8 @@ #[cfg(feature = "state-transition-signing")] use crate::{ + consensus::basic::identity::{ + IdentityCreditTransferToSelfError, InvalidIdentityCreditTransferAmountError, + }, identity::{ accessors::IdentityGettersV0, identity_public_key::accessors::v0::IdentityPublicKeyGettersV0, signer::Signer, Identity, @@ -29,9 +32,21 @@ impl IdentityCreditTransferTransitionMethodsV0 for IdentityCreditTransferTransit signer: S, signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, nonce: IdentityNonce, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, _version: Option, ) -> Result { + let _ = platform_version; + + if identity.id() == to_identity_with_identifier { + let error = IdentityCreditTransferToSelfError::default(); + return Err(ProtocolError::ConsensusError(Box::new(error.into()))); + } + + if amount < 100000 { + let error = InvalidIdentityCreditTransferAmountError::new(amount, 100000); + return Err(ProtocolError::ConsensusError(Box::new(error.into()))); + } + let mut transition: StateTransition = IdentityCreditTransferTransitionV0 { identity_id: identity.id(), recipient_id: to_identity_with_identifier, From 8f105a6a656df136baf33079c1fd33043b637048 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 20:14:13 -0600 Subject: [PATCH 2/6] feat(dpp): add client-side validation to IdentityTopUpTransition construction --- .../identity_topup_transition/v0/v0_methods.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs index da2b8f680b3..70e33192b97 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs @@ -31,9 +31,20 @@ impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransitionV0 { asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], user_fee_increase: UserFeeIncrease, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, _version: Option, ) -> Result { + #[cfg(feature = "validation")] + { + let validation_result = asset_lock_proof.validate_structure(platform_version)?; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(ProtocolError::ConsensusError(Box::new(first_error))); + } + } + + let _ = platform_version; + let identity_top_up_transition = IdentityTopUpTransitionV0 { asset_lock_proof, identity_id: identity.id(), From a4a2d674d0ab5e18f618949e9a5652e74e8743e3 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 00:00:53 -0600 Subject: [PATCH 3/6] refactor(dpp): use if-let instead of unwrap for validation error extraction Replace is_valid() check + unwrap() with if-let pattern to eliminate fragile coupling to ValidationResult internals. Addresses CodeRabbit nitpick on PR #3138. --- .../identity/identity_topup_transition/v0/v0_methods.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs index 70e33192b97..71584d25151 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs @@ -37,8 +37,7 @@ impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransitionV0 { #[cfg(feature = "validation")] { let validation_result = asset_lock_proof.validate_structure(platform_version)?; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(ProtocolError::ConsensusError(Box::new(first_error))); } } From e23a47e7dfb86156e99e91c230ddee450cc07793 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 13:48:15 -0600 Subject: [PATCH 4/6] refactor(dpp): use platform version config for min transfer amount instead of magic number --- .../identity_credit_transfer_transition/v0/v0_methods.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs index f294c2d0cb6..54eb358d61c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs @@ -35,15 +35,18 @@ impl IdentityCreditTransferTransitionMethodsV0 for IdentityCreditTransferTransit platform_version: &PlatformVersion, _version: Option, ) -> Result { - let _ = platform_version; + let min_transfer_amount = platform_version + .fee_version + .state_transition_min_fees + .credit_transfer; if identity.id() == to_identity_with_identifier { let error = IdentityCreditTransferToSelfError::default(); return Err(ProtocolError::ConsensusError(Box::new(error.into()))); } - if amount < 100000 { - let error = InvalidIdentityCreditTransferAmountError::new(amount, 100000); + if amount < min_transfer_amount { + let error = InvalidIdentityCreditTransferAmountError::new(amount, min_transfer_amount); return Err(ProtocolError::ConsensusError(Box::new(error.into()))); } From 3397bd7b1ee63029348a8f4031061f3a2f57097f Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 13:48:39 -0600 Subject: [PATCH 5/6] refactor(dpp): clean up platform_version unused variable handling in topup --- .../identity/identity_topup_transition/v0/v0_methods.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs index 71584d25151..c52b97a065b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs @@ -42,6 +42,7 @@ impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransitionV0 { } } + #[cfg(not(feature = "validation"))] let _ = platform_version; let identity_top_up_transition = IdentityTopUpTransitionV0 { From f6d73d1f66874e0a0b50bdc5d0b65e44a868dd33 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 13:49:32 -0600 Subject: [PATCH 6/6] test(dpp): add tests for credit transfer and topup validation error paths --- .../v0/v0_methods.rs | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs index 54eb358d61c..f1aa2594f40 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_credit_transfer_transition/v0/v0_methods.rs @@ -110,3 +110,168 @@ impl IdentityCreditTransferTransitionMethodsV0 for IdentityCreditTransferTransit Ok(transition) } } + +#[cfg(all(test, feature = "state-transition-signing"))] +mod tests { + use crate::address_funds::AddressWitness; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + use crate::identity::signer::Signer; + use crate::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use crate::state_transition::identity_credit_transfer_transition::methods::IdentityCreditTransferTransitionMethodsV0; + use crate::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; + use crate::ProtocolError; + use platform_value::BinaryData; + use platform_value::Identifier; + use platform_version::version::LATEST_PLATFORM_VERSION; + + #[derive(Debug)] + struct TestIdentitySigner { + allowed_key_id: u32, + } + + impl Signer for TestIdentitySigner { + fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Ok(vec![0; 65].into()) + } + + fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::NotSupported( + "sign_create_witness is not used in these tests".to_string(), + )) + } + + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + key.id() == self.allowed_key_id + } + } + + fn transfer_key(key_id: u32) -> IdentityPublicKey { + IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::TRANSFER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: vec![2; 33].into(), + disabled_at: None, + } + .into() + } + + fn identity_with_transfer_key(identity_id: Identifier, key_id: u32) -> Identity { + let mut identity = + Identity::default_versioned(LATEST_PLATFORM_VERSION).expect("expected identity"); + identity.set_id(identity_id); + identity.add_public_key(transfer_key(key_id)); + identity + } + + #[test] + fn should_return_identity_credit_transfer_to_self_error_when_recipient_is_sender() { + let identity_id = Identifier::random(); + let identity = identity_with_transfer_key(identity_id, 1); + let min_transfer_amount = LATEST_PLATFORM_VERSION + .fee_version + .state_transition_min_fees + .credit_transfer; + + let result = IdentityCreditTransferTransitionV0::try_from_identity( + &identity, + identity_id, + min_transfer_amount, + 0, + TestIdentitySigner { allowed_key_id: 1 }, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ); + + match result { + Err(ProtocolError::ConsensusError(consensus_error)) => { + assert!(matches!( + consensus_error.as_ref(), + ConsensusError::BasicError(BasicError::IdentityCreditTransferToSelfError(_)) + )); + } + other => panic!("expected to-self consensus error, got {other:?}"), + } + } + + #[test] + fn should_return_invalid_identity_credit_transfer_amount_error_when_below_minimum() { + let identity_id = Identifier::random(); + let identity = identity_with_transfer_key(identity_id, 1); + let min_transfer_amount = LATEST_PLATFORM_VERSION + .fee_version + .state_transition_min_fees + .credit_transfer; + assert!( + min_transfer_amount > 0, + "minimum transfer amount must be positive" + ); + let below_min_amount = min_transfer_amount - 1; + + let result = IdentityCreditTransferTransitionV0::try_from_identity( + &identity, + Identifier::random(), + below_min_amount, + 0, + TestIdentitySigner { allowed_key_id: 1 }, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ); + + match result { + Err(ProtocolError::ConsensusError(consensus_error)) => { + assert!(matches!( + consensus_error.as_ref(), + ConsensusError::BasicError( + BasicError::InvalidIdentityCreditTransferAmountError(error) + ) if error.amount() == below_min_amount + && error.min_amount() == min_transfer_amount + )); + } + other => panic!("expected invalid amount consensus error, got {other:?}"), + } + } + + #[test] + fn should_succeed_when_transfer_amount_equals_minimum() { + let identity_id = Identifier::random(); + let identity = identity_with_transfer_key(identity_id, 1); + let min_transfer_amount = LATEST_PLATFORM_VERSION + .fee_version + .state_transition_min_fees + .credit_transfer; + + let result = IdentityCreditTransferTransitionV0::try_from_identity( + &identity, + Identifier::random(), + min_transfer_amount, + 0, + TestIdentitySigner { allowed_key_id: 1 }, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ); + + assert!(result.is_ok(), "expected boundary amount to be valid"); + } +}