diff --git a/.gitignore b/.gitignore index 80f977f5be6..9b6ea3c2502 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,8 @@ book/book/ # gRPC coverage report grpc-coverage-report.txt +# Git worktrees +/worktrees/ __pycache__/ .claude/settings.local.json .claude/worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cdab82e51..fbaf3334329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,30 @@ * **sdk:** fix type inconsistencies across wasm-sdk and js-evo-sdk (#3047) * **sdk:** getSignableBytes is not compatible with sign and verify (#3048) * **platform:** update PlatformAddress encoding and HRP constants (#3059) +* **dpp:** `ProtocolError` is now `#[non_exhaustive]` and can carry plural + consensus failures via `ProtocolError::ConsensusErrors`; downstream crates + should keep a wildcard arm when matching protocol errors. * **platform:** 3.0 audit report fixes (#3053) +* **swift-sdk:** `SDKError.protocolError(String)` associated values are + clean human-readable messages again (no embedded payload). FFI errors + with structured consensus details still map to scalar `SDKError` in + public Swift throwing wrappers for source compatibility. Structured + consensus details are not retained on `SDKError`; callers that want + them must use the pointer-based FFI helpers + (`SDKError.consensusErrors(fromDashSDKError:)` and + `SDKError.fromDashSDKErrorWithConsensusErrors(_:)`) remain available + before `dash_sdk_error_free`, or explicitly wrap both values in + `SDKDetailedError`. +* **rs-sdk-ffi:** `DashSDKError.message` is now owned by the outer + `DashSDKError`/`dash_sdk_error_free` lifecycle. External C and Swift + consumers must not manually reclaim `error.message` with + `CString::from_raw` or equivalent; free the outer error with + `dash_sdk_error_free` instead. +* **dpp:** `DocumentPurchaseTransition::from_document` now requires a + `new_owner_id` argument so construction can reject self-purchase attempts + earlier in the version-independent dispatcher with + `InvalidDocumentTransitionActionError`. The V0 constructor path no longer + carries an unused version-specific owner argument. * **sdk:** comprehensive Evo SDK refactoring (#2999) * upgrade bincode to 2.0.1 (#2991) @@ -29,6 +52,7 @@ * **dapi-grpc:** files generated outside sandbox * **dashmate:** differentiate service ports between networks to avoid conflicts ([#3085](https://github.com/dashpay/platform/issues/3085)) * **platform:** 3.0 audit report fixes ([#3053](https://github.com/dashpay/platform/issues/3053)) +* **swift-sdk:** preserve structured consensus details in Swift state-transition helper errors * **sdk:** deserialization error due to outdated contract cache ([#3052](https://github.com/dashpay/platform/issues/3052)) * **sdk:** getSignableBytes is not compatible with sign and verify ([#3048](https://github.com/dashpay/platform/issues/3048)) * **sdk:** inconsistent document query operator ([#3039](https://github.com/dashpay/platform/issues/3039)) diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index ccb59612bb8..b5d461bb0a5 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -181,10 +181,17 @@ abci = [ ] cbor = ["ciborium"] validation = [ + "batch-base-structure-validation", "json-schema-validation", "value-conversion", "ed25519-dalek", ] +# Narrow feature that compiles only `BatchTransition::validate_base_structure` +# without pulling in json-schema-validation, value-conversion, or ed25519-dalek. +# Useful for clients that need pre-broadcast base-structure checks without +# the full validation toolchain. `state-transition-signing` enables this +# transitively so constructor pre-sign validation is always present there. +batch-base-structure-validation = [] # TODO: Tring to remove regexp create-contested-document = [] json-conversion = ["value-conversion", "platform-value/json", "json-object"] @@ -243,6 +250,7 @@ state-transition-signing = [ "state-transitions", "message-signing", "state-transition-validation", + "batch-base-structure-validation", ] vote-serialization = [] vote-serde-conversion = ["serde-conversion"] 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 f969346ebf3..d93c910c915 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -115,7 +115,15 @@ use crate::data_contract::errors::DataContractError; #[allow(clippy::large_enum_variant)] #[derive( - Error, Debug, PlatformSerialize, PlatformDeserialize, Encode, Decode, PartialEq, Clone, + Error, + Debug, + PlatformSerialize, + PlatformDeserialize, + Encode, + Decode, + PartialEq, + Clone, + strum::IntoStaticStr, )] pub enum BasicError { /* diff --git a/packages/rs-dpp/src/errors/consensus/fee/fee_error.rs b/packages/rs-dpp/src/errors/consensus/fee/fee_error.rs index cc19ca60f2f..3f5cf51484f 100644 --- a/packages/rs-dpp/src/errors/consensus/fee/fee_error.rs +++ b/packages/rs-dpp/src/errors/consensus/fee/fee_error.rs @@ -7,7 +7,15 @@ use crate::errors::ProtocolError; use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; #[derive( - Error, Debug, PartialEq, Encode, Decode, PlatformSerialize, PlatformDeserialize, Clone, + Error, + Debug, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserialize, + Clone, + strum::IntoStaticStr, )] pub enum FeeError { /* diff --git a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs index 72b0e7afb1a..816ad0f1efe 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs @@ -14,7 +14,15 @@ use crate::errors::ProtocolError; use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; #[derive( - Error, Debug, PartialEq, Encode, Decode, PlatformSerialize, PlatformDeserialize, Clone, + Error, + Debug, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserialize, + Clone, + strum::IntoStaticStr, )] pub enum SignatureError { /* diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 672c709198a..9f619a2d44c 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -62,7 +62,15 @@ use crate::consensus::state::voting::vote_poll_not_found_error::VotePollNotFound use super::document::document_timestamps_are_equal_error::DocumentTimestampsAreEqualError; #[derive( - Error, Debug, PartialEq, Encode, Decode, PlatformSerialize, PlatformDeserialize, Clone, + Error, + Debug, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserialize, + Clone, + strum::IntoStaticStr, )] pub enum StateError { /* diff --git a/packages/rs-dpp/src/errors/protocol_error.rs b/packages/rs-dpp/src/errors/protocol_error.rs index e0497c685b1..3ee77df98dc 100644 --- a/packages/rs-dpp/src/errors/protocol_error.rs +++ b/packages/rs-dpp/src/errors/protocol_error.rs @@ -44,6 +44,7 @@ use platform_value::{Error as ValueError, Value}; use platform_version::error::PlatformVersionError; #[allow(clippy::large_enum_variant)] +#[non_exhaustive] #[derive(Error, Debug)] pub enum ProtocolError { #[error("Identifier Error: {0}")] @@ -306,6 +307,9 @@ pub enum ProtocolError { using: u16, msg: &'static str, }, + + #[error("Multiple consensus errors: {}", join_consensus_errors(.0))] + ConsensusErrors(Vec), } impl From<&str> for ProtocolError { @@ -355,3 +359,14 @@ impl From for ProtocolError { Self::InvalidVectorSizeError(err) } } + +/// Join the `Display` representation of every inner [`ConsensusError`] with +/// `"; "`. Used by [`ProtocolError::ConsensusErrors`]'s `Display` impl so the +/// rendered message is human-readable instead of a `Debug`-formatted `Vec`. +fn join_consensus_errors(errors: &[ConsensusError]) -> String { + errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; ") +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs index 070db325a44..0986fcdc7c7 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs @@ -2,6 +2,7 @@ mod convertible; pub mod from_document; pub mod v0; mod v0_methods; +pub(crate) mod validate_structure; use crate::block::block_info::BlockInfo; use crate::data_contract::document_type::DocumentTypeRef; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..445414f589e --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/mod.rs @@ -0,0 +1,44 @@ +use crate::state_transition::batch_transition::document_create_transition::validate_structure::v0::DocumentCreateTransitionStructureValidationV0; +use crate::state_transition::batch_transition::DocumentCreateTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_value::Identifier; +use platform_version::version::PlatformVersion; + +mod v0; + +pub(crate) trait DocumentCreateTransitionStructureValidation { + fn validate_structure( + &self, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentCreateTransitionStructureValidation for DocumentCreateTransition { + fn validate_structure( + &self, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result { + // Dispatch via the DPP-owned version field. The drive-abci action + // validator has a separate field under + // `drive_abci.validation_and_processing.state_transitions.batch_state_transition` + // and is intentionally decoupled from this DPP/SDK pre-sign helper. + match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_create_transition_structure_validation + { + 0 => self.validate_structure_v0(owner_id), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentCreateTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..88f5fd0444b --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/v0/mod.rs @@ -0,0 +1,47 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionIdError; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::document::Document; +use crate::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; +use crate::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; +use crate::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use crate::state_transition::batch_transition::DocumentCreateTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_value::Identifier; + +pub(super) trait DocumentCreateTransitionStructureValidationV0 { + fn validate_structure_v0( + &self, + owner_id: Identifier, + ) -> Result; +} + +impl DocumentCreateTransitionStructureValidationV0 for DocumentCreateTransition { + fn validate_structure_v0( + &self, + owner_id: Identifier, + ) -> Result { + let (expected_id, invalid_id) = match self { + DocumentCreateTransition::V0(transition) => ( + Document::generate_document_id_v0( + &transition.base().data_contract_id(), + &owner_id, + transition.base().document_type_name(), + &transition.entropy(), + ), + transition.base().id(), + ), + }; + + if invalid_id != expected_id { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::InvalidDocumentTransitionIdError( + InvalidDocumentTransitionIdError::new(expected_id, invalid_id), + )), + )); + } + + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/from_document.rs index 4af388b6b76..7920aa2923d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/from_document.rs @@ -4,6 +4,7 @@ use crate::prelude::IdentityNonce; use crate::ProtocolError; use platform_version::version::{FeatureVersion, PlatformVersion}; +use crate::state_transition::batch_transition::batched_transition::document_delete_transition::validate_structure::DocumentDeleteTransitionStructureValidation; use crate::state_transition::batch_transition::batched_transition::document_delete_transition::DocumentDeleteTransitionV0; use crate::state_transition::batch_transition::batched_transition::DocumentDeleteTransition; use crate::tokens::token_payment_info::TokenPaymentInfo; @@ -27,15 +28,25 @@ impl DocumentDeleteTransition { .bounds .default_current_version, ) { - 0 => Ok(DocumentDeleteTransitionV0::from_document( - document, - document_type, - token_payment_info, - identity_contract_nonce, - platform_version, - base_feature_version, - )? - .into()), + 0 => { + let transition: DocumentDeleteTransition = + DocumentDeleteTransitionV0::from_document( + document, + document_type, + token_payment_info, + identity_contract_nonce, + platform_version, + base_feature_version, + )? + .into(); + if let Some(error) = transition + .validate_structure(document_type, platform_version)? + .errors_to_consensus_protocol_error() + { + return Err(error); + } + Ok(transition) + } version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentDeleteTransition::from_document".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs index 024177a223a..18dde4e1ebd 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs @@ -1,6 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; +pub(crate) mod validate_structure; use bincode::{Decode, Encode}; use derive_more::{Display, From}; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..3f23fe888d1 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/mod.rs @@ -0,0 +1,40 @@ +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::document_delete_transition::validate_structure::v0::DocumentDeleteTransitionStructureValidationV0; +use crate::state_transition::batch_transition::batched_transition::DocumentDeleteTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub(crate) trait DocumentDeleteTransitionStructureValidation { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentDeleteTransitionStructureValidation for DocumentDeleteTransition { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_delete_transition_structure_validation + { + 0 => self.validate_structure_v0(document_type), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentDeleteTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..bd068d6d5fc --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/v0/mod.rs @@ -0,0 +1,34 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionActionError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::DocumentDeleteTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait DocumentDeleteTransitionStructureValidationV0 { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result; +} + +impl DocumentDeleteTransitionStructureValidationV0 for DocumentDeleteTransition { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result { + // Mirrors the drive-abci action validator deletability check; + // contract-local and safe pre-sign. + if !document_type.documents_can_be_deleted() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "documents of type {} can not be deleted", + document_type.name() + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs index 5c92946a2fb..ea4ae80b03f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/from_document.rs @@ -1,10 +1,14 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionActionError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::DocumentTypeRef; -use crate::document::Document; +use crate::document::{Document, DocumentV0Getters}; use crate::fee::Credits; +use crate::prelude::Identifier; use crate::prelude::IdentityNonce; use crate::ProtocolError; use platform_version::version::{FeatureVersion, PlatformVersion}; +use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::validate_structure::DocumentPurchaseTransitionStructureValidation; use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::DocumentPurchaseTransitionV0; use crate::state_transition::batch_transition::batched_transition::DocumentPurchaseTransition; use crate::tokens::token_payment_info::TokenPaymentInfo; @@ -14,6 +18,7 @@ impl DocumentPurchaseTransition { pub fn from_document( document: Document, document_type: DocumentTypeRef, + new_owner_id: Identifier, price: Credits, token_payment_info: Option, identity_contract_nonce: IdentityNonce, @@ -21,6 +26,18 @@ impl DocumentPurchaseTransition { feature_version: Option, base_feature_version: Option, ) -> Result { + // Self-purchase is intentionally version-independent: every current + // and future purchase transition version must reject transferring a + // document to its existing owner before constructing the transition. + if document.owner_id() == new_owner_id { + return Err(ProtocolError::ConsensusError(Box::new( + InvalidDocumentTransitionActionError::new(format!( + "on document type: {} identity trying to purchase a document that is already owned by the purchaser", + document_type.name() + )) + .into(), + ))); + } match feature_version.unwrap_or( platform_version .dpp @@ -29,16 +46,26 @@ impl DocumentPurchaseTransition { .bounds .default_current_version, ) { - 0 => Ok(DocumentPurchaseTransitionV0::from_document( - document, - document_type, - price, - token_payment_info, - identity_contract_nonce, - platform_version, - base_feature_version, - )? - .into()), + 0 => { + let transition: DocumentPurchaseTransition = + DocumentPurchaseTransitionV0::from_document( + document, + document_type, + price, + token_payment_info, + identity_contract_nonce, + platform_version, + base_feature_version, + )? + .into(); + if let Some(error) = transition + .validate_structure(document_type, platform_version)? + .errors_to_consensus_protocol_error() + { + return Err(error); + } + Ok(transition) + } version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentPurchaseTransition::from_document".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs index 32563fe5876..a786b98baf1 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs @@ -1,6 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; +pub(crate) mod validate_structure; use bincode::{Decode, Encode}; use derive_more::{Display, From}; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs index d25745fa30b..d42a027a631 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/v0/from_document.rs @@ -10,6 +10,7 @@ use crate::ProtocolError; use platform_version::version::{FeatureVersion, PlatformVersion}; impl DocumentPurchaseTransitionV0 { + #[allow(clippy::too_many_arguments)] pub(crate) fn from_document( document: Document, document_type: DocumentTypeRef, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..1c531d45143 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/mod.rs @@ -0,0 +1,40 @@ +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::validate_structure::v0::DocumentPurchaseTransitionStructureValidationV0; +use crate::state_transition::batch_transition::batched_transition::DocumentPurchaseTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub(crate) trait DocumentPurchaseTransitionStructureValidation { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentPurchaseTransitionStructureValidation for DocumentPurchaseTransition { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_purchase_transition_structure_validation + { + 0 => self.validate_structure_v0(document_type), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentPurchaseTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..34810faa5d9 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/v0/mod.rs @@ -0,0 +1,38 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionActionError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::nft::TradeMode; +use crate::state_transition::batch_transition::batched_transition::DocumentPurchaseTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait DocumentPurchaseTransitionStructureValidationV0 { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result; +} + +impl DocumentPurchaseTransitionStructureValidationV0 for DocumentPurchaseTransition { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result { + // Mirrors the drive-abci action validator trade-mode check; safe + // pre-sign because it depends only on the document type definition. + // The self-purchase check now lives in + // `DocumentPurchaseTransition::from_document`, which has both the + // seller (`document.owner_id()`) and the buyer (`new_owner_id`). + if document_type.trade_mode() != TradeMode::DirectPurchase { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "{} trade mode is not direct purchase but we are trying to purchase directly", + document_type.name() + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/from_document.rs index 373ccf4aa00..3bf5f7f2cda 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/from_document.rs @@ -1,6 +1,7 @@ use crate::data_contract::document_type::DocumentTypeRef; use crate::document::Document; use crate::prelude::IdentityNonce; +use crate::state_transition::batch_transition::batched_transition::document_replace_transition::validate_structure::DocumentReplaceTransitionStructureValidation; use crate::state_transition::batch_transition::batched_transition::document_replace_transition::DocumentReplaceTransitionV0; use crate::state_transition::batch_transition::batched_transition::DocumentReplaceTransition; use crate::tokens::token_payment_info::TokenPaymentInfo; @@ -26,15 +27,25 @@ impl DocumentReplaceTransition { .bounds .default_current_version, ) { - 0 => Ok(DocumentReplaceTransitionV0::from_document( - document, - document_type, - token_payment_info, - identity_contract_nonce, - platform_version, - base_feature_version, - )? - .into()), + 0 => { + let transition: DocumentReplaceTransition = + DocumentReplaceTransitionV0::from_document( + document, + document_type, + token_payment_info, + identity_contract_nonce, + platform_version, + base_feature_version, + )? + .into(); + if let Some(error) = transition + .validate_structure(document_type, platform_version)? + .errors_to_consensus_protocol_error() + { + return Err(error); + } + Ok(transition) + } version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentReplaceTransition::from_document".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs index 7edb19e02e9..6ca15d9277b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs @@ -1,6 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; +pub(crate) mod validate_structure; use crate::block::block_info::BlockInfo; use crate::data_contract::document_type::DocumentTypeRef; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..f70c2ce17f6 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/mod.rs @@ -0,0 +1,44 @@ +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::document_replace_transition::validate_structure::v0::DocumentReplaceTransitionStructureValidationV0; +use crate::state_transition::batch_transition::batched_transition::DocumentReplaceTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub(crate) trait DocumentReplaceTransitionStructureValidation { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentReplaceTransitionStructureValidation for DocumentReplaceTransition { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + // Constructor-only pre-sign helper. The drive-abci action validator + // has its own version field under + // `drive_abci.validation_and_processing.state_transitions.batch_state_transition` + // and is intentionally decoupled from this DPP/SDK pre-sign helper. + match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_replace_transition_structure_validation + { + 0 => self.validate_structure_v0(document_type), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentReplaceTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..71fe7d0985d --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/v0/mod.rs @@ -0,0 +1,35 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionActionError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::DocumentReplaceTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait DocumentReplaceTransitionStructureValidationV0 { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result; +} + +impl DocumentReplaceTransitionStructureValidationV0 for DocumentReplaceTransition { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result { + // The document type was resolved by the caller, so its existence on + // the contract is implicit. Mirrors the drive-abci action validator + // mutability check, which is contract-local and safe pre-sign. + if !document_type.documents_mutable() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "{} is not mutable and can not be replaced", + document_type.name() + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/from_document.rs index 5b16c302d6c..04817ad4c4f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/from_document.rs @@ -1,6 +1,7 @@ use crate::data_contract::document_type::DocumentTypeRef; use crate::document::Document; use crate::prelude::IdentityNonce; +use crate::state_transition::batch_transition::batched_transition::document_transfer_transition::validate_structure::DocumentTransferTransitionStructureValidation; use crate::state_transition::batch_transition::batched_transition::document_transfer_transition::{ DocumentTransferTransition, DocumentTransferTransitionV0, }; @@ -29,16 +30,26 @@ impl DocumentTransferTransition { .bounds .default_current_version, ) { - 0 => Ok(DocumentTransferTransitionV0::from_document( - document, - document_type, - token_payment_info, - identity_contract_nonce, - recipient_owner_id, - platform_version, - base_feature_version, - )? - .into()), + 0 => { + let transition: DocumentTransferTransition = + DocumentTransferTransitionV0::from_document( + document, + document_type, + token_payment_info, + identity_contract_nonce, + recipient_owner_id, + platform_version, + base_feature_version, + )? + .into(); + if let Some(error) = transition + .validate_structure(document_type, platform_version)? + .errors_to_consensus_protocol_error() + { + return Err(error); + } + Ok(transition) + } version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentTransferTransition::from_document".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs index e66ec5fa714..9d2586b2e98 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs @@ -1,6 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; +pub(crate) mod validate_structure; use bincode::{Decode, Encode}; use derive_more::{Display, From}; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..8f485635e9f --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/mod.rs @@ -0,0 +1,40 @@ +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::document_transfer_transition::validate_structure::v0::DocumentTransferTransitionStructureValidationV0; +use crate::state_transition::batch_transition::batched_transition::DocumentTransferTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub(crate) trait DocumentTransferTransitionStructureValidation { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentTransferTransitionStructureValidation for DocumentTransferTransition { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_transfer_transition_structure_validation + { + 0 => self.validate_structure_v0(document_type), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentTransferTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..d0abccb148a --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/v0/mod.rs @@ -0,0 +1,34 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionActionError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::DocumentTransferTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait DocumentTransferTransitionStructureValidationV0 { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result; +} + +impl DocumentTransferTransitionStructureValidationV0 for DocumentTransferTransition { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result { + // Mirrors the drive-abci action validator transferability check; + // contract-local and safe pre-sign. + if !document_type.documents_transferable().is_transferable() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "{} is not a transferable document type", + document_type.name() + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/from_document.rs index 331f2c677cb..49bbeac949a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/from_document.rs @@ -6,6 +6,7 @@ use crate::prelude::IdentityNonce; use crate::ProtocolError; use crate::state_transition::batch_transition::batched_transition::DocumentUpdatePriceTransition; use crate::state_transition::batch_transition::batched_transition::document_update_price_transition::DocumentUpdatePriceTransitionV0; +use crate::state_transition::batch_transition::batched_transition::document_update_price_transition::validate_structure::DocumentUpdatePriceTransitionStructureValidation; use crate::tokens::token_payment_info::TokenPaymentInfo; impl DocumentUpdatePriceTransition { @@ -28,16 +29,26 @@ impl DocumentUpdatePriceTransition { .bounds .default_current_version, ) { - 0 => Ok(DocumentUpdatePriceTransitionV0::from_document( - document, - document_type, - price, - token_payment_info, - identity_contract_nonce, - platform_version, - base_feature_version, - )? - .into()), + 0 => { + let transition: DocumentUpdatePriceTransition = + DocumentUpdatePriceTransitionV0::from_document( + document, + document_type, + price, + token_payment_info, + identity_contract_nonce, + platform_version, + base_feature_version, + )? + .into(); + if let Some(error) = transition + .validate_structure(document_type, platform_version)? + .errors_to_consensus_protocol_error() + { + return Err(error); + } + Ok(transition) + } version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentUpdatePriceTransition::from_document".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs index f9e99c6c584..b5475c143b4 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs @@ -1,6 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; +pub(crate) mod validate_structure; use bincode::{Decode, Encode}; use derive_more::{Display, From}; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/mod.rs new file mode 100644 index 00000000000..82b01ed10bd --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/mod.rs @@ -0,0 +1,40 @@ +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::document_update_price_transition::validate_structure::v0::DocumentUpdatePriceTransitionStructureValidationV0; +use crate::state_transition::batch_transition::batched_transition::DocumentUpdatePriceTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +pub(crate) trait DocumentUpdatePriceTransitionStructureValidation { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentUpdatePriceTransitionStructureValidation for DocumentUpdatePriceTransition { + fn validate_structure( + &self, + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_update_price_transition_structure_validation + { + 0 => self.validate_structure_v0(document_type), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentUpdatePriceTransition::validate_structure".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/v0/mod.rs new file mode 100644 index 00000000000..58426c59911 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/v0/mod.rs @@ -0,0 +1,35 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionActionError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::state_transition::batch_transition::batched_transition::DocumentUpdatePriceTransition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; + +pub(super) trait DocumentUpdatePriceTransitionStructureValidationV0 { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result; +} + +impl DocumentUpdatePriceTransitionStructureValidationV0 for DocumentUpdatePriceTransition { + fn validate_structure_v0( + &self, + document_type: DocumentTypeRef, + ) -> Result { + // Mirrors the drive-abci action validator trade-mode check; safe + // pre-sign because it depends only on the document type definition. + if !document_type.trade_mode().seller_sets_price() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "{} is in trade mode {} that does not support the seller setting the price", + document_type.name(), + document_type.trade_mode(), + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::default()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs index 13123abfbda..a824600b595 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs @@ -47,7 +47,7 @@ mod state_transition_estimated_fee_validation; mod state_transition_like; mod v0; mod v1; -#[cfg(feature = "validation")] +#[cfg(any(feature = "validation", feature = "batch-base-structure-validation"))] mod validation; #[cfg(feature = "value-conversion")] mod value_conversion; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs index c122859f31f..084735ee62b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs @@ -122,17 +122,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -166,17 +164,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -212,17 +208,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -256,17 +250,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -302,17 +294,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -333,6 +323,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { let purchase_transition = DocumentPurchaseTransition::from_document( document, document_type, + new_owner_id, price, token_payment_info, identity_contract_nonce, @@ -348,17 +339,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } fn set_transitions(&mut self, transitions: Vec) { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs index ddf692e75d8..501822dba90 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs @@ -131,17 +131,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -175,17 +173,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -219,17 +215,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -265,17 +259,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -311,17 +303,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } #[cfg(feature = "state-transition-signing")] @@ -342,6 +332,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { let purchase_transition = DocumentPurchaseTransition::from_document( document, document_type, + new_owner_id, price, token_payment_info, identity_contract_nonce, @@ -357,17 +348,15 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - let required_security_level = document_type.security_level_requirement(); - state_transition - .sign_external_with_options( + documents_batch_transition + .validate_and_sign( identity_public_key, signer, - Some(|_, _| Ok(required_security_level)), - resolved_options.signing_options, + Some(document_type.security_level_requirement()), + platform_version, + Some(resolved_options), ) - .await?; - Ok(state_transition) + .await } fn set_transitions(&mut self, transitions: Vec) { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs index 1b5d848c9d6..162dd49533b 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v1_methods.rs @@ -69,8 +69,6 @@ use crate::state_transition::batch_transition::token_transfer_transition::TokenT #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::token_unfreeze_transition::TokenUnfreezeTransitionV0; #[cfg(feature = "state-transition-signing")] -use crate::state_transition::GetDataContractSecurityLevelRequirementFn; -#[cfg(feature = "state-transition-signing")] use crate::tokens::emergency_action::TokenEmergencyAction; #[cfg(feature = "state-transition-signing")] use crate::tokens::{PrivateEncryptedNote, SharedEncryptedNote}; @@ -137,27 +135,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - - Ok(state_transition) + documents_batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -219,28 +199,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); - // Create the state transition - let mut state_transition: StateTransition = documents_batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - - Ok(state_transition) + documents_batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] async fn new_token_transfer_transition>( @@ -257,7 +218,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, signer: &S, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, options: Option, ) -> Result { // Create the transfer transition for batch version 1 @@ -286,28 +247,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); - // Create the state transition - let mut state_transition: StateTransition = documents_batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - - Ok(state_transition) + documents_batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -370,26 +312,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + documents_batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -452,26 +377,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); - let mut state_transition: StateTransition = documents_batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + documents_batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -536,26 +444,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -618,26 +509,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -700,26 +574,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -734,7 +591,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, signer: &S, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, options: Option, ) -> Result { let claim_transition = TokenClaimTransition::V0(TokenClaimTransitionV0 { @@ -757,26 +614,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -843,26 +683,9 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } #[cfg(feature = "state-transition-signing")] @@ -877,7 +700,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { identity_contract_nonce: IdentityNonce, user_fee_increase: UserFeeIncrease, signer: &S, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, options: Option, ) -> Result { let direct_purchase_transition = @@ -901,25 +724,8 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { signature: Default::default(), } .into(); - let mut state_transition: StateTransition = batch_transition.into(); - if let Some(options) = options { - state_transition - .sign_external_with_options( - identity_public_key, - signer, - None::, - options.signing_options, - ) - .await?; - } else { - state_transition - .sign_external( - identity_public_key, - signer, - None::, - ) - .await?; - } - Ok(state_transition) + batch_transition + .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .await } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs index b2f89c158d7..f4836ca12c0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/mod.rs @@ -1,11 +1,33 @@ +use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use crate::state_transition::batch_transition::document_create_transition::validate_structure::DocumentCreateTransitionStructureValidation; use crate::state_transition::batch_transition::BatchTransition; +use crate::state_transition::StateTransitionOwned; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; +#[cfg(feature = "state-transition-signing")] +use crate::identity::signer::Signer; +#[cfg(feature = "state-transition-signing")] +use crate::identity::{IdentityPublicKey, SecurityLevel}; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::batch_transition::methods::StateTransitionCreationOptions; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::{GetDataContractSecurityLevelRequirementFn, StateTransition}; + mod v0; impl BatchTransition { + /// Validates the base structure of a batch transition before broadcast. + /// + /// This always performs batch-level checks such as emptiness, max count, + /// duplicate document IDs, and document/token nonce bounds. + /// + /// The document branch is intentionally batch-level only in this + /// server-reachable validator. Document transition-local checks either + /// depend on contract/state context or are reserved for constructor-only + /// pre-sign validation. Token transition variants still receive their + /// client-side typed structure checks here. pub fn validate_base_structure( &self, platform_version: &PlatformVersion, @@ -26,4 +48,124 @@ impl BatchTransition { }), } } + + /// Runs constructor-side batch base-structure validation, adds the + /// constructor-only create document ID checks, and maps consensus + /// validation failures into `ProtocolError`. + /// + /// Used by `BatchTransition::new_*` constructors to fail fast before + /// signing when the freshly-constructed transition is structurally invalid. + /// When `state-transition-signing` is enabled this helper is compiled in + /// together with `batch-base-structure-validation`, so the constructor + /// pre-sign hook is not cfg-elided. The create-transition ID check is + /// constructor defense-in-depth; SDK create builders normalize document IDs + /// before calling this hook, so that error is not user-reachable there. + /// + /// Non-create document transition-local checks are expected to have + /// already run in the relevant `from_document` constructor, so this hook + /// relies on those constructors instead of re-dispatching that work here. + #[cfg(any(test, feature = "state-transition-signing"))] + pub(crate) fn validate_base_structure_pre_sign( + &self, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> { + let mut result = match platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .validate_base_structure_pre_sign + { + 0 => self.validate_base_structure_pre_sign_v0(platform_version)?, + version => { + return Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentsBatchTransition::validate_base_structure_pre_sign" + .to_string(), + known_versions: vec![0], + received: version, + }) + } + }; + + for batch_transition in self.transitions_iter() { + let crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef::Document( + document_transition, + ) = batch_transition else { + continue; + }; + + let transition_result = match document_transition { + crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Create( + create_transition, + ) => Some(create_transition.validate_structure( + self.owner_id(), + platform_version, + )?), + crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Delete(_) + | crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Replace(_) + | crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Transfer(_) + | crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Purchase(_) + | crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::UpdatePrice(_) => None, + }; + + if let Some(transition_result) = transition_result { + if !transition_result.is_valid() { + result.merge(transition_result); + } + } + } + + result + .errors_to_consensus_protocol_error() + .map(Err) + .unwrap_or(Ok(())) + } + + /// Runs the constructor pre-sign validation, converts the batch into a + /// `StateTransition`, and signs it. + /// + /// This consolidates the duplicated validate-and-sign sequence used by all + /// `BatchTransition::new_*` constructors (document and token alike). + /// + /// `required_security_level` lets document constructors pin the + /// signing key's security level to the document type's requirement; token + /// constructors pass `None` so the default per-state-transition logic + /// applies. + #[cfg(feature = "state-transition-signing")] + pub(crate) async fn validate_and_sign>( + self, + identity_public_key: &IdentityPublicKey, + signer: &S, + required_security_level: Option, + platform_version: &PlatformVersion, + options: Option, + ) -> Result { + self.validate_base_structure_pre_sign(platform_version)?; + let resolved_options = options.unwrap_or_default(); + let mut state_transition: StateTransition = self.into(); + match required_security_level { + Some(level) => { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + Some(move |_, _| Ok(level)), + resolved_options.signing_options, + ) + .await?; + } + None => { + state_transition + .sign_external_with_options( + identity_public_key, + signer, + None::, + resolved_options.signing_options, + ) + .await?; + } + } + Ok(state_transition) + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs index 126e61d71f5..8b7f71010df 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/validation/validate_basic_structure/v0/mod.rs @@ -35,10 +35,22 @@ use crate::state_transition::state_transitions::document::batch_transition::batc use crate::state_transition::state_transitions::document::batch_transition::batched_transition::token_burn_transition::validate_structure::TokenBurnTransitionStructureValidation; use crate::state_transition::StateTransitionOwned; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TokenStructureErrorMode { + /// Server-reachable consensus validation preserves the pre-existing + /// short-circuit behavior for token transition-local structure failures. + PreserveConsensusEarlyReturn, + /// Constructor pre-sign validation accumulates token transition-local + /// structure failures with batch-level errors so SDK callers can see every + /// locally-detectable issue before signing. + AccumulateForPreSign, +} + impl BatchTransition { #[inline(always)] - pub(super) fn validate_base_structure_v0( + fn validate_base_structure_v0_internal( &self, + token_structure_error_mode: TokenStructureErrorMode, platform_version: &PlatformVersion, ) -> Result { if self.transitions_are_empty() { @@ -94,6 +106,10 @@ impl BatchTransition { let mut result = SimpleConsensusValidationResult::default(); for transitions in document_transitions_by_contracts.values() { + // Keep the document branch batch-level only here. Transition-local + // document checks either require contract/state context or run in + // constructor-only pre-sign builders while `DocumentTypeRef` is + // still available. for transition in transitions { // We need to make sure that the identity contract nonce is within the allowed bounds // This means that it is stored on 40 bits @@ -183,7 +199,14 @@ impl BatchTransition { }; if !consensus_result.is_valid() { - return Ok(consensus_result); + match token_structure_error_mode { + TokenStructureErrorMode::AccumulateForPreSign => { + result.merge(consensus_result); + } + TokenStructureErrorMode::PreserveConsensusEarlyReturn => { + return Ok(consensus_result); + } + } } // We need to verify that the action id given matches the expected action id @@ -221,57 +244,159 @@ impl BatchTransition { Ok(result) } + + #[inline(always)] + pub(super) fn validate_base_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result { + self.validate_base_structure_v0_internal( + TokenStructureErrorMode::PreserveConsensusEarlyReturn, + platform_version, + ) + } + + #[cfg(any(test, feature = "state-transition-signing"))] + #[inline(always)] + pub(super) fn validate_base_structure_pre_sign_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result { + self.validate_base_structure_v0_internal( + TokenStructureErrorMode::AccumulateForPreSign, + platform_version, + ) + } } #[cfg(test)] mod tests { use super::*; use crate::consensus::ConsensusError; + use crate::data_contract::associated_token::token_perpetual_distribution::distribution_function::MAX_DISTRIBUTION_PARAM; + use crate::document::Document; + use crate::state_transition::batch_transition::batched_transition::token_burn_transition::v0::TokenBurnTransitionV0; + use crate::state_transition::batch_transition::batched_transition::token_burn_transition::TokenBurnTransition; + use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; use crate::state_transition::batch_transition::batched_transition::document_create_transition::v0::DocumentCreateTransitionV0; use crate::state_transition::batch_transition::batched_transition::document_create_transition::DocumentCreateTransition; use crate::state_transition::batch_transition::batched_transition::document_delete_transition::v0::DocumentDeleteTransitionV0; use crate::state_transition::batch_transition::batched_transition::document_delete_transition::DocumentDeleteTransition; + use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::DocumentPurchaseTransitionV0; + use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::DocumentPurchaseTransition; + use crate::state_transition::batch_transition::batched_transition::document_replace_transition::v0::DocumentReplaceTransitionV0; + use crate::state_transition::batch_transition::batched_transition::document_replace_transition::DocumentReplaceTransition; + use crate::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use crate::state_transition::batch_transition::batched_transition::document_transfer_transition::DocumentTransferTransition; + use crate::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::DocumentUpdatePriceTransitionV0; + use crate::state_transition::batch_transition::batched_transition::document_update_price_transition::DocumentUpdatePriceTransition; use crate::state_transition::batch_transition::batched_transition::BatchedTransition; use crate::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use crate::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use crate::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; + use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; use crate::state_transition::batch_transition::{ BatchTransition, BatchTransitionV0, BatchTransitionV1, }; use platform_value::BinaryData; use std::collections::BTreeMap; - fn make_base(nonce: u64, type_name: &str) -> DocumentBaseTransition { + fn owner_id_v0() -> Identifier { + Identifier::new([0x01; 32]) + } + + fn owner_id_v1() -> Identifier { + Identifier::new([0x02; 32]) + } + + fn make_base(id: Identifier, nonce: u64, type_name: &str) -> DocumentBaseTransition { DocumentBaseTransition::V0(DocumentBaseTransitionV0 { - id: Identifier::new([1u8; 32]), + id, identity_contract_nonce: nonce, document_type_name: type_name.to_string(), data_contract_id: Identifier::new([0xAA; 32]), }) } - fn make_create(nonce: u64) -> DocumentTransition { + fn make_create(owner_id: Identifier, nonce: u64) -> DocumentTransition { + let data_contract_id = Identifier::new([0xAA; 32]); + let document_type_name = "test_doc".to_string(); + let entropy = [0u8; 32]; + let id = Document::generate_document_id_v0( + &data_contract_id, + &owner_id, + &document_type_name, + &entropy, + ); + DocumentTransition::Create(DocumentCreateTransition::V0(DocumentCreateTransitionV0 { - base: make_base(nonce, "test_doc"), - entropy: [0u8; 32], + base: DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id, + identity_contract_nonce: nonce, + document_type_name, + data_contract_id, + }), + entropy, data: BTreeMap::new(), prefunded_voting_balance: None, })) } + fn make_invalid_create(owner_id: Identifier, nonce: u64) -> DocumentTransition { + let mut transition = make_create(owner_id, nonce); + transition.base_mut().set_id(Identifier::new([9u8; 32])); + transition + } + fn make_delete(nonce: u64, id_byte: u8) -> DocumentTransition { DocumentTransition::Delete(DocumentDeleteTransition::V0(DocumentDeleteTransitionV0 { - base: DocumentBaseTransition::V0(DocumentBaseTransitionV0 { - id: Identifier::new([id_byte; 32]), - identity_contract_nonce: nonce, - document_type_name: "test_doc".to_string(), - data_contract_id: Identifier::new([0xAA; 32]), - }), + base: make_base(Identifier::new([id_byte; 32]), nonce, "test_doc"), + })) + } + + fn make_replace(nonce: u64, id_byte: u8) -> DocumentTransition { + DocumentTransition::Replace(DocumentReplaceTransition::V0(DocumentReplaceTransitionV0 { + base: make_base(Identifier::new([id_byte; 32]), nonce, "test_doc"), + revision: 1, + data: BTreeMap::new(), })) } + fn make_transfer(nonce: u64, id_byte: u8) -> DocumentTransition { + DocumentTransition::Transfer(DocumentTransferTransition::V0( + DocumentTransferTransitionV0 { + base: make_base(Identifier::new([id_byte; 32]), nonce, "test_doc"), + revision: 1, + recipient_owner_id: Identifier::new([0xBB; 32]), + }, + )) + } + + fn make_purchase(nonce: u64, id_byte: u8) -> DocumentTransition { + DocumentTransition::Purchase(DocumentPurchaseTransition::V0( + DocumentPurchaseTransitionV0 { + base: make_base(Identifier::new([id_byte; 32]), nonce, "test_doc"), + revision: 1, + price: 100, + }, + )) + } + + fn make_update_price(nonce: u64, id_byte: u8) -> DocumentTransition { + DocumentTransition::UpdatePrice(DocumentUpdatePriceTransition::V0( + DocumentUpdatePriceTransitionV0 { + base: make_base(Identifier::new([id_byte; 32]), nonce, "test_doc"), + revision: 1, + price: 200, + }, + )) + } + fn make_batch_v0(transitions: Vec) -> BatchTransition { BatchTransition::V0(BatchTransitionV0 { - owner_id: Identifier::new([0x01; 32]), + owner_id: owner_id_v0(), transitions, user_fee_increase: 0, signature_public_key_id: 0, @@ -281,7 +406,7 @@ mod tests { fn make_batch_v1_empty() -> BatchTransition { BatchTransition::V1(BatchTransitionV1 { - owner_id: Identifier::new([0x02; 32]), + owner_id: owner_id_v1(), transitions: vec![], user_fee_increase: 0, signature_public_key_id: 0, @@ -289,6 +414,48 @@ mod tests { }) } + fn make_token_base(nonce: u64) -> TokenBaseTransition { + let data_contract_id = Identifier::new([0xBB; 32]); + let token_contract_position = 0; + let token_id = TokenBaseTransitionV0 { + identity_contract_nonce: nonce, + token_contract_position, + data_contract_id, + token_id: Identifier::default(), + using_group_info: None, + } + .calculate_token_id(); + + TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: nonce, + token_contract_position, + data_contract_id, + token_id, + using_group_info: None, + }) + } + + fn make_invalid_token_burn(nonce: u64) -> TokenTransition { + TokenTransition::Burn(TokenBurnTransition::V0(TokenBurnTransitionV0 { + base: make_token_base(nonce), + burn_amount: MAX_DISTRIBUTION_PARAM + 1, + public_note: None, + })) + } + + fn make_token_batch_v1(transitions: Vec) -> BatchTransition { + BatchTransition::V1(BatchTransitionV1 { + owner_id: owner_id_v1(), + transitions: transitions + .into_iter() + .map(BatchedTransition::Token) + .collect(), + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::default(), + }) + } + // ----------------------------------------------------------------------- // empty batch — DocumentTransitionsAreAbsentError // ----------------------------------------------------------------------- @@ -336,13 +503,105 @@ mod tests { #[test] fn validate_base_structure_v0_passes_with_single_valid_transition() { let pv = PlatformVersion::latest(); - let batch = make_batch_v0(vec![make_create(1)]); + let batch = make_batch_v0(vec![make_create(owner_id_v0(), 1)]); let result = batch .validate_base_structure_v0(pv) .expect("no protocol err"); assert!(result.is_valid(), "expected valid, got {:?}", result.errors); } + #[test] + fn validate_base_structure_v0_skips_invalid_create_document_id() { + let pv = PlatformVersion::latest(); + let batch = make_batch_v0(vec![make_invalid_create(owner_id_v0(), 1)]); + let result = batch + .validate_base_structure_v0(pv) + .expect("no protocol err"); + assert!( + result.is_valid(), + "server-side base structure should skip create-id validation, got {:?}", + result.errors + ); + } + + #[test] + fn validate_base_structure_v0_reports_batch_level_errors_only_for_invalid_create() { + let pv = PlatformVersion::latest(); + let bad_nonce: u64 = u64::MAX; + let batch = make_batch_v0(vec![make_invalid_create(owner_id_v0(), bad_nonce)]); + let result = batch + .validate_base_structure_v0(pv) + .expect("no protocol err"); + + assert!(!result.is_valid()); + assert_eq!(result.errors.len(), 1, "expected batch-level errors only"); + assert!(result.errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError(_)) + ))); + } + + #[test] + fn validate_base_structure_pre_sign_returns_all_accumulated_consensus_errors() { + let pv = PlatformVersion::latest(); + let bad_nonce: u64 = u64::MAX; + let batch = make_batch_v0(vec![make_invalid_create(owner_id_v0(), bad_nonce)]); + + match batch.validate_base_structure_pre_sign(pv) { + Err(ProtocolError::ConsensusErrors(errors)) => { + assert_eq!(errors.len(), 2, "expected all accumulated errors"); + assert!(errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::InvalidDocumentTransitionIdError(_)) + ))); + assert!(errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError(_)) + ))); + } + other => panic!("expected ProtocolError::ConsensusErrors, got {:?}", other), + } + } + + #[test] + fn validate_base_structure_pre_sign_accumulates_token_structure_and_nonce_errors() { + let pv = PlatformVersion::latest(); + let bad_nonce: u64 = u64::MAX; + let batch = make_token_batch_v1(vec![make_invalid_token_burn(bad_nonce)]); + + match batch.validate_base_structure_pre_sign(pv) { + Err(ProtocolError::ConsensusErrors(errors)) => { + assert_eq!(errors.len(), 2, "expected all accumulated errors"); + assert!(errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError(_)) + ))); + assert!(errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_)) + ))); + } + other => panic!("expected ProtocolError::ConsensusErrors, got {:?}", other), + } + } + + #[test] + fn validate_base_structure_v0_keeps_token_structure_early_return_behavior() { + let pv = PlatformVersion::latest(); + let bad_nonce: u64 = u64::MAX; + let batch = make_token_batch_v1(vec![make_invalid_token_burn(bad_nonce)]); + + let result = batch + .validate_base_structure_v0(pv) + .expect("no protocol err"); + + assert_eq!(result.errors.len(), 1, "expected only nested token error"); + assert!(matches!( + &result.errors[0], + ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_)) + )); + } + // ----------------------------------------------------------------------- // nonce out of bounds — high bits set above 40-bit cap // ----------------------------------------------------------------------- @@ -414,8 +673,8 @@ mod tests { fn validate_base_structure_v0_passes_for_v1_with_documents_only() { let pv = PlatformVersion::latest(); let batch = BatchTransition::V1(BatchTransitionV1 { - owner_id: Identifier::new([0x02; 32]), - transitions: vec![BatchedTransition::Document(make_create(1))], + owner_id: owner_id_v1(), + transitions: vec![BatchedTransition::Document(make_create(owner_id_v1(), 1))], user_fee_increase: 0, signature_public_key_id: 0, signature: BinaryData::default(), @@ -425,4 +684,22 @@ mod tests { .expect("no protocol err"); assert!(result.is_valid(), "expected valid, got {:?}", result.errors); } + + #[test] + fn validate_base_structure_v0_passes_for_non_create_document_variants() { + let pv = PlatformVersion::latest(); + for transition in [ + make_delete(1, 1), + make_replace(2, 2), + make_transfer(3, 3), + make_purchase(4, 4), + make_update_price(5, 5), + ] { + let batch = make_batch_v0(vec![transition]); + let result = batch + .validate_base_structure_v0(pv) + .expect("no protocol err"); + assert!(result.is_valid(), "expected valid, got {:?}", result.errors); + } + } } diff --git a/packages/rs-dpp/src/validation/validation_result/mod.rs b/packages/rs-dpp/src/validation/validation_result/mod.rs index 73714d808cf..fbd3ef4802c 100644 --- a/packages/rs-dpp/src/validation/validation_result/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/mod.rs @@ -126,6 +126,28 @@ impl SimpleValidationResult { } } +impl ConsensusValidationResult { + /// Convert any accumulated consensus errors into a single + /// [`ProtocolError`], preserving multiplicity: + /// + /// * `0` errors → `None` + /// * `1` error → `Some(ProtocolError::ConsensusError(_))` + /// * `>1` errors → `Some(ProtocolError::ConsensusErrors(_))` + /// + /// Used by callers that translate a structural validation result into a + /// short-circuit `Err(ProtocolError)` without dropping additional + /// accumulated errors. + pub fn errors_to_consensus_protocol_error(mut self) -> Option { + match self.errors.len() { + 0 => None, + 1 => Some(ProtocolError::ConsensusError(Box::new( + self.errors.pop().expect("len == 1"), + ))), + _ => Some(ProtocolError::ConsensusErrors(self.errors)), + } + } +} + impl ValidationResult { pub fn new() -> Self { Self { @@ -747,4 +769,54 @@ mod tests { let result: ValidationResult = ValidationResult::new(); assert!(result.data_as_borrowed().is_err()); } + + // -- errors_to_consensus_protocol_error() -- + + mod errors_to_consensus_protocol_error { + use crate::consensus::basic::document::NonceOutOfBoundsError; + use crate::consensus::basic::token::InvalidTokenAmountError; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::validation::SimpleConsensusValidationResult; + use crate::ProtocolError; + + fn nonce_err() -> ConsensusError { + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )) + } + + fn token_err() -> ConsensusError { + ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(7, 0), + )) + } + + #[test] + fn no_errors_returns_none() { + let result = SimpleConsensusValidationResult::new(); + assert!(result.errors_to_consensus_protocol_error().is_none()); + } + + #[test] + fn single_error_returns_consensus_error_variant() { + let result = SimpleConsensusValidationResult::new_with_error(nonce_err()); + match result.errors_to_consensus_protocol_error() { + Some(ProtocolError::ConsensusError(_)) => {} + other => panic!("expected ConsensusError variant, got {:?}", other), + } + } + + #[test] + fn multiple_errors_preserved_as_consensus_errors_variant() { + let result = + SimpleConsensusValidationResult::new_with_errors(vec![nonce_err(), token_err()]); + match result.errors_to_consensus_protocol_error() { + Some(ProtocolError::ConsensusErrors(errors)) => { + assert_eq!(errors.len(), 2, "all accumulated errors must be preserved"); + } + other => panic!("expected ConsensusErrors variant, got {:?}", other), + } + } + } } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/mod.rs index 1bde330b4db..7e3410089bd 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/mod.rs @@ -71,4 +71,34 @@ pub struct DocumentsBatchTransitionVersions { pub struct DocumentsBatchTransitionValidationVersions { pub find_duplicates_by_id: FeatureVersion, pub validate_base_structure: FeatureVersion, + /// DPP-side dispatch for the SDK/DPP constructor-only pre-sign batch + /// structure validator. This intentionally evolves independently from + /// the server-reachable consensus `validate_base_structure` dispatcher. + pub validate_base_structure_pre_sign: FeatureVersion, + /// DPP-side dispatch for the constructor-only document create transition + /// structure validator used by `validate_base_structure_pre_sign`. This is + /// independent of the drive-abci action validator (which has its own + /// version field under `drive_abci.validation_and_processing`). + pub document_create_transition_structure_validation: FeatureVersion, + /// DPP-side dispatch for the constructor-only document replace transition + /// structure validator used by `from_document` to surface unsafe local + /// structure (e.g. immutable document type) before signing. + pub document_replace_transition_structure_validation: FeatureVersion, + /// DPP-side dispatch for the constructor-only document transfer transition + /// structure validator used by `from_document` to surface unsafe local + /// structure (e.g. non-transferable document type) before signing. + pub document_transfer_transition_structure_validation: FeatureVersion, + /// DPP-side dispatch for the constructor-only document purchase transition + /// structure validator used by `from_document` to surface unsafe local + /// structure (e.g. wrong trade mode) before signing. + pub document_purchase_transition_structure_validation: FeatureVersion, + /// DPP-side dispatch for the constructor-only document update-price + /// transition structure validator used by `from_document` to surface + /// unsafe local structure (e.g. trade mode does not let the seller set + /// price) before signing. + pub document_update_price_transition_structure_validation: FeatureVersion, + /// DPP-side dispatch for the constructor-only document delete transition + /// structure validator used by `from_document` to surface unsafe local + /// structure (e.g. non-deletable document type) before signing. + pub document_delete_transition_structure_validation: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v1.rs index 07637973453..4e105492c6a 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v1.rs @@ -11,6 +11,13 @@ pub const STATE_TRANSITION_VERSIONS_V1: DPPStateTransitionVersions = DPPStateTra validation: DocumentsBatchTransitionValidationVersions { find_duplicates_by_id: 0, validate_base_structure: 0, + validate_base_structure_pre_sign: 0, + document_create_transition_structure_validation: 0, + document_replace_transition_structure_validation: 0, + document_transfer_transition_structure_validation: 0, + document_purchase_transition_structure_validation: 0, + document_update_price_transition_structure_validation: 0, + document_delete_transition_structure_validation: 0, }, }, }, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v2.rs index 8f63e97b8c6..b77fbb83ecb 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v2.rs @@ -11,6 +11,13 @@ pub const STATE_TRANSITION_VERSIONS_V2: DPPStateTransitionVersions = DPPStateTra validation: DocumentsBatchTransitionValidationVersions { find_duplicates_by_id: 0, validate_base_structure: 0, + validate_base_structure_pre_sign: 0, + document_create_transition_structure_validation: 0, + document_replace_transition_structure_validation: 0, + document_transfer_transition_structure_validation: 0, + document_purchase_transition_structure_validation: 0, + document_update_price_transition_structure_validation: 0, + document_delete_transition_structure_validation: 0, }, }, }, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v3.rs index cc43aa2771d..7ef7661b165 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_versions/v3.rs @@ -11,6 +11,13 @@ pub const STATE_TRANSITION_VERSIONS_V3: DPPStateTransitionVersions = DPPStateTra validation: DocumentsBatchTransitionValidationVersions { find_duplicates_by_id: 0, validate_base_structure: 0, + validate_base_structure_pre_sign: 0, + document_create_transition_structure_validation: 0, + document_replace_transition_structure_validation: 0, + document_transfer_transition_structure_validation: 0, + document_purchase_transition_structure_validation: 0, + document_update_price_transition_structure_validation: 0, + document_delete_transition_structure_validation: 0, }, }, }, diff --git a/packages/rs-sdk-ffi/README.md b/packages/rs-sdk-ffi/README.md index 16eb502a99b..b5525e991bc 100644 --- a/packages/rs-sdk-ffi/README.md +++ b/packages/rs-sdk-ffi/README.md @@ -87,7 +87,8 @@ DashSDKConfig config = { // Create SDK instance DashSDKResult result = dash_sdk_create(&config); if (result.error) { - // Handle error + // Handle error. Do not free result.error->message separately; the outer + // DashSDKError owns it and must be released with dash_sdk_error_free. dash_sdk_error_free(result.error); return; } @@ -117,7 +118,8 @@ var config = DashSDKConfig( // Create SDK instance let result = dash_sdk_create(&config) if let error = result.error { - // Handle error + // Handle error. Do not reclaim error.pointee.message separately; the outer + // DashSDKError owns it and must be released with dash_sdk_error_free. dash_sdk_error_free(error) return } diff --git a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs index 414ae29e395..5a9604d0fe0 100644 --- a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs +++ b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs @@ -53,7 +53,8 @@ impl DashSDKDataContractFetchResult { json_string: std::ptr::null_mut(), serialized_data: std::ptr::null_mut(), serialized_data_len: 0, - error: Box::into_raw(Box::new(error)), + // Sidecar-aware boxer; see `crate::error::box_dashsdk_error`. + error: crate::error::box_dashsdk_error(error), } } } @@ -205,7 +206,7 @@ pub unsafe extern "C" fn dash_sdk_data_contract_fetch_result_free( } if !result.error.is_null() { - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); result.error = std::ptr::null_mut(); } } diff --git a/packages/rs-sdk-ffi/src/document/util.rs b/packages/rs-sdk-ffi/src/document/util.rs index 33b6cc69bf3..4e22c8b1b22 100644 --- a/packages/rs-sdk-ffi/src/document/util.rs +++ b/packages/rs-sdk-ffi/src/document/util.rs @@ -39,7 +39,7 @@ pub unsafe extern "C" fn dash_sdk_document_destroy( match result { Ok(_) => std::ptr::null_mut(), - Err(e) => Box::into_raw(Box::new(e.into())), + Err(e) => crate::error::box_dashsdk_error(e.into()), } } diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index d4788dff542..a8a19d3aefc 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -1,9 +1,87 @@ //! Error handling for FFI layer +//! +//! # ABI stability +//! +//! The public C ABI struct [`DashSDKError`] is intentionally frozen: it always +//! consists of a [`DashSDKErrorCode`] discriminant plus an owned, NUL-terminated +//! `message` pointer. Consumers built against older headers continue to work as +//! before — the readable scalar message remains the primary surface for protocol +//! consensus errors (singular: the error's own `Display`; plural: `;`-joined). +//! +//! Structured details about consensus errors are exposed through a *sidecar* +//! lookup keyed on the heap [`DashSDKError`] pointer returned to the FFI +//! caller. Callers query +//! [`dash_sdk_error_consensus_error_count`] and +//! [`dash_sdk_error_consensus_error_at`] *before* freeing the error with +//! [`dash_sdk_error_free`]; freeing also releases the sidecar entry. If an FFI +//! caller leaks a returned `DashSDKError` and never calls the matching free +//! function, the active sidecar entry is leaked for the same process lifetime +//! as the leaked error allocation. Long-running embedders must therefore treat +//! `dash_sdk_error_free` / `dash_sdk_result_free` as mandatory ownership +//! cleanup, not just message-string cleanup. +//! +//! # Sidecar contract (pointer-identity) +//! +//! The sidecar is keyed on the heap `*mut DashSDKError` pointer that the SDK +//! returns to the caller (the value of `DashSDKResult.error` or the raw error +//! pointer returned by an FFI call). Callers must observe the following rules: +//! +//! - Always pass the original `*mut DashSDKError` / `*const DashSDKError` +//! pointer to [`dash_sdk_error_consensus_error_count`] / +//! [`dash_sdk_error_consensus_error_at`]. Querying through a copy of the +//! `DashSDKError` value (a separate stack/heap allocation that happens to +//! share the same `message` pointer) returns no structured details. +//! - Structured consensus details must be queried synchronously before the +//! error is freed with [`dash_sdk_error_free`]. Once freed, the sidecar +//! entry is gone and the pointer value may be reused by future allocations. +//! - During construction (between `DashSDKError::from(FFIError::SDKError(..))` +//! and the final `Box::into_raw`), pending sidecar entries are temporarily +//! indexed by the message pointer; this is an implementation detail and is +//! not exposed to FFI callers. Sidecar-capable errors — in particular those +//! produced by the `From` impl from +//! `FFIError::SDKError(dash_sdk::Error::Protocol(_))` — must be returned via +//! [`box_dashsdk_error`] (directly or via [`DashSDKResult::error`] / +//! [`ffi_result!`]) so the pending entry is promoted to a stable pointer +//! key. Hand-crafted [`DashSDKError::new`] values with no pending sidecar +//! entry (e.g. local validation errors built without a `From` +//! conversion) are outside this contract and may be boxed directly; they +//! simply have no structured details to expose. +//! +//! # Compatibility notes +//! +//! [`DashSDKError::message`] is always owned by the `DashSDKError` allocation +//! itself. Ownership is released only when [`dash_sdk_error_free`] reclaims the +//! outer error (or when an unboxed value is dropped in Rust). External callers +//! and in-crate tests must treat `message` as borrowed memory and must not +//! reclaim it manually with `CString::from_raw`. +//! +//! For in-crate construction, any `DashSDKError` produced by +//! `From` while a consensus sidecar is still pending must not be +//! `mem::forget`-ed or otherwise have `Drop` bypassed before boxing, because +//! doing so would leak both the owned message and the pending sidecar entry. +//! All sidecar-capable return paths should route through [`box_dashsdk_error`] +//! (directly or via [`DashSDKResult::error`] / [`ffi_result!`]). +use dash_sdk::dapi_client::DapiClientError; +use dash_sdk::dpp::consensus::{codes::ErrorWithCode, ConsensusError}; +use dash_sdk::dpp::ProtocolError; +use once_cell::sync::Lazy; +use std::collections::HashMap; use std::ffi::{CString, NulError}; use std::os::raw::c_char; +use std::sync::{Mutex, MutexGuard}; use thiserror::Error; +/// Lock a sidecar mutex tolerating poisoning. A panic on another thread that +/// poisoned the mutex must not permanently disable sidecar lookup or cleanup +/// — silently dropping details for every subsequent error would be a worse +/// failure mode than continuing with a recovered guard. +fn lock_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// Error codes returned by FFI functions #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,7 +110,21 @@ pub enum DashSDKErrorCode { InternalError = 99, } -/// Error structure returned by FFI functions +/// Error structure returned by FFI functions. +/// +/// # ABI +/// +/// This struct is frozen for backwards compatibility — do not add or reorder +/// fields. To inspect structured protocol consensus errors associated with this +/// error, use [`dash_sdk_error_consensus_error_count`] and +/// [`dash_sdk_error_consensus_error_at`] before calling +/// [`dash_sdk_error_free`]. +/// +/// # Compatibility notes +/// +/// `message` is Drop-owned / [`dash_sdk_error_free`]-owned memory. Consumers, +/// including in-crate tests, may read it through [`std::ffi::CStr`] while the +/// error is live, but must not take ownership with `CString::from_raw`. #[repr(C)] pub struct DashSDKError { /// Error code @@ -42,6 +134,22 @@ pub struct DashSDKError { pub message: *mut c_char, } +/// Structured detail for a single protocol consensus error. +/// +/// Returned by [`dash_sdk_error_consensus_error_at`]. Free each instance with +/// [`dash_sdk_consensus_error_free`]. +#[repr(C)] +pub struct DashSDKConsensusError { + /// Numeric consensus error code from DPP's `ErrorWithCode`. + pub code: u32, + /// High-level kind, e.g. `BasicError`, `StateError` (owned C string). + pub kind: *mut c_char, + /// Specific consensus error variant name (owned C string). + pub name: *mut c_char, + /// Human-readable message (owned C string). + pub message: *mut c_char, +} + /// Internal error type for FFI operations #[derive(Debug, Error)] pub enum FFIError { @@ -76,6 +184,131 @@ pub enum FFIError { NulError(#[from] NulError), } +#[derive(Debug, Clone)] +struct ConsensusErrorEntry { + code: u32, + kind: String, + name: String, + message: String, +} + +/// Pending sidecar map keyed by the `DashSDKError.message` raw pointer +/// (as `usize`). Populated transiently during `From` conversion +/// while the resulting value-type `DashSDKError` is still being constructed; +/// drained by [`box_dashsdk_error`] when the error is boxed for FFI return, +/// or by [`DashSDKError::drop`] if the value is dropped before being boxed. +static PENDING_CONSENSUS_ERRORS: Lazy>>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +/// Active sidecar entry keyed in [`ACTIVE_CONSENSUS_ERRORS`]. The +/// `message_ptr` is captured at boxing time and re-checked on lookup against +/// the current `DashSDKError.message`. This catches the common stale-pointer +/// reuse case where the heap allocation of a freed error is recycled for a +/// brand-new `DashSDKError`: the new value's `message` pointer will not match +/// the captured one, so we return no details rather than the previous error's +/// stale entries. +/// +/// Limit: a true post-free read of a *dangling* pointer (where the underlying +/// memory has not yet been recycled and still happens to contain the original +/// `message` field) is undefined behavior at the FFI layer and is +/// indistinguishable from a valid live pointer at this layer; the +/// move-only sidecar contract documented at the module level forbids this +/// usage pattern but cannot be enforced from inside the SDK. +#[derive(Debug, Clone)] +struct ActiveSidecarEntry { + /// `DashSDKError.message` value at the time of boxing. + message_ptr: usize, + entries: Vec, +} + +/// Active sidecar map keyed by the heap `*mut DashSDKError` pointer that is +/// handed back across the FFI boundary. Populated by [`box_dashsdk_error`]; +/// freed by [`dash_sdk_error_free`]. Keying by the boxed `DashSDKError` +/// pointer means a copied-by-value `DashSDKError` (which has a different +/// pointer identity) cannot accidentally resolve another error's sidecar +/// entry — even if its `message` raw pointer happens to coincide due to +/// allocator reuse. Each entry also carries the original `message` pointer +/// so post-free pointer reuse for a different error is detected on lookup. +static ACTIVE_CONSENSUS_ERRORS: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +fn register_pending_consensus_errors(message_ptr: *mut c_char, errors: Vec) { + if message_ptr.is_null() || errors.is_empty() { + return; + } + let mut map = lock_recover(&PENDING_CONSENSUS_ERRORS); + map.insert(message_ptr as usize, errors); +} + +fn take_pending_consensus_errors(message_ptr: *mut c_char) -> Option> { + if message_ptr.is_null() { + return None; + } + let mut map = lock_recover(&PENDING_CONSENSUS_ERRORS); + map.remove(&(message_ptr as usize)) +} + +fn take_active_consensus_errors(error_ptr: *mut DashSDKError) { + if error_ptr.is_null() { + return; + } + let mut map = lock_recover(&ACTIVE_CONSENSUS_ERRORS); + map.remove(&(error_ptr as usize)); +} + +fn with_active_consensus_errors( + error_ptr: *const DashSDKError, + f: impl FnOnce(&[ConsensusErrorEntry]) -> R, +) -> Option { + if error_ptr.is_null() { + return None; + } + let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS); + let entry = guard.get(&(error_ptr as usize))?; + // Only dereference caller memory after confirming the boxed error pointer + // is still active in the sidecar map. This avoids touching arbitrary + // non-null pointers that are not one of our live boxed errors, while still + // checking that the current `message` field matches the value captured at + // boxing time. + let current_message = unsafe { (*error_ptr).message } as usize; + if entry.message_ptr != current_message { + // Pointer key matched but the message field doesn't match the value + // we recorded at boxing time — almost certainly a recycled heap + // allocation now occupied by a different error. Treat as no sidecar. + return None; + } + Some(f(entry.entries.as_slice())) +} + +/// Box a [`DashSDKError`] for return across the FFI boundary, promoting any +/// pending consensus-error sidecar entries (keyed by the error's `message` +/// pointer) to the active sidecar (keyed by the heap error pointer). +/// +/// Sidecar-capable errors — those built via the `From` impl from +/// `FFIError::SDKError(dash_sdk::Error::Protocol(_))` — MUST go through this +/// helper (directly or via [`DashSDKResult::error`] / the [`ffi_result!`] +/// macro) so the pending sidecar is reachable through the pointer the caller +/// actually receives. Hand-crafted [`DashSDKError::new`] errors that have no +/// pending sidecar entry are outside this contract; boxing them with bare +/// `Box::into_raw` is sound (it just produces an error with no structured +/// details), though routing them through this helper is also fine and is the +/// recommended default to keep return paths uniform. +pub fn box_dashsdk_error(error: DashSDKError) -> *mut DashSDKError { + let message_ptr = error.message; + let raw = Box::into_raw(Box::new(error)); + if let Some(entries) = take_pending_consensus_errors(message_ptr) { + let mut map = lock_recover(&ACTIVE_CONSENSUS_ERRORS); + map.insert( + raw as usize, + ActiveSidecarEntry { + message_ptr: message_ptr as usize, + entries, + }, + ); + } + raw +} + impl DashSDKError { /// Create a new error pub fn new(code: DashSDKErrorCode, message: String) -> Self { @@ -97,50 +330,58 @@ impl DashSDKError { } } +/// Reclaim the owned `message` `CString` and drop any pending sidecar entry +/// keyed on the message pointer. This makes it safe to `drop` a +/// `DashSDKError` value built via `From` without ever boxing it +/// (e.g. test helpers, error-conversion paths that fail before reaching +/// [`box_dashsdk_error`]) — both the message allocation and the pending +/// sidecar entry are reclaimed instead of leaking. +/// +/// `box_dashsdk_error` moves the error into a `Box` and uses `Box::into_raw`, +/// which suppresses Drop until [`dash_sdk_error_free`] runs `Box::from_raw`, +/// so successful boxing → free paths still drop exactly once. The active +/// sidecar (keyed on the heap pointer) is removed by `dash_sdk_error_free` +/// before the Drop runs. +/// +/// Compatibility note: `Drop` owns `message`. External callers and tests must +/// not reclaim `message` separately with `CString::from_raw(error.message)`; free +/// the outer error through [`dash_sdk_error_free`] instead. +impl Drop for DashSDKError { + fn drop(&mut self) { + if !self.message.is_null() { + // Drain any still-pending sidecar entry keyed on this message + // pointer. After a successful `box_dashsdk_error` promotion this + // is a no-op (the entry has already been moved to the active + // map). When the value is dropped without boxing, this prevents + // a leak that would later mis-attribute details to a recycled + // message allocation. + let _ = take_pending_consensus_errors(self.message); + // SAFETY: `message` was allocated via `CString::into_raw` in + // `DashSDKError::new`; reclaim the allocation exactly once. + unsafe { + let _ = CString::from_raw(self.message); + } + self.message = std::ptr::null_mut(); + } + } +} + impl From for DashSDKError { fn from(err: FFIError) -> Self { let (code, message) = match &err { FFIError::InvalidParameter(_) => (DashSDKErrorCode::InvalidParameter, err.to_string()), FFIError::SDKError(sdk_err) => { - // Extract more detailed error information - let error_str = sdk_err.to_string(); - - // Try to determine error type from the message - let (code, detailed_msg) = if error_str.contains("timeout") - || error_str.contains("Timeout") - { - (DashSDKErrorCode::Timeout, error_str) - } else if error_str.contains("I/O error") || error_str.contains("connection") { - ( - DashSDKErrorCode::NetworkError, - format!("Network connection failed: {}", error_str), - ) - } else if error_str.contains("DAPI") || error_str.contains("dapi") { - // Check for specific DAPI issues - if error_str.contains("No available addresses") - || error_str.contains("empty address list") + if let dash_sdk::Error::Protocol(protocol_error) = sdk_err { + if let Some((message, entries)) = + format_protocol_consensus_error(protocol_error) { - (DashSDKErrorCode::NetworkError, - "Cannot connect to network: No DAPI addresses configured. The SDK needs masternode quorum information to connect to the network.".to_string()) - } else { - ( - DashSDKErrorCode::NetworkError, - format!("DAPI error: {}", error_str), - ) + let error = DashSDKError::new(DashSDKErrorCode::ProtocolError, message); + register_pending_consensus_errors(error.message, entries); + return error; } - } else if error_str.contains("protocol") || error_str.contains("Protocol") { - (DashSDKErrorCode::ProtocolError, error_str) - } else if error_str.contains("not found") || error_str.contains("Not found") { - (DashSDKErrorCode::NotFound, error_str) - } else { - // Default to network error with the original message - ( - DashSDKErrorCode::NetworkError, - format!("Failed to fetch balances: {}", error_str), - ) - }; - - (code, detailed_msg) + } + + classify_sdk_error(sdk_err) } FFIError::SerializationError(_) => { (DashSDKErrorCode::SerializationError, err.to_string()) @@ -161,18 +402,252 @@ impl From for DashSDKError { } } -/// Free an error message +/// Map a non-`Protocol` `dash_sdk::Error` to an FFI `(code, message)` pair by +/// matching on the variant rather than scanning the formatted message. +/// +/// Protocol consensus errors are handled separately by the caller (they carry +/// a structured sidecar). Variants we cannot meaningfully classify fall back +/// to `InternalError` with a neutral `"SDK error: ..."` prefix so we never +/// misattribute them to a specific operation. +fn classify_sdk_error(sdk_err: &dash_sdk::Error) -> (DashSDKErrorCode, String) { + match sdk_err { + // Non-consensus protocol errors still surface as ProtocolError; their + // consensus sibling is handled by the caller before this point. + dash_sdk::Error::Protocol(_) => (DashSDKErrorCode::ProtocolError, sdk_err.to_string()), + dash_sdk::Error::TimeoutReached(_, _) => (DashSDKErrorCode::Timeout, sdk_err.to_string()), + dash_sdk::Error::Cancelled(message) => ( + DashSDKErrorCode::Timeout, + format!("Operation cancelled: {message}"), + ), + dash_sdk::Error::StaleNode(_) => ( + DashSDKErrorCode::NetworkError, + format!("Stale node response: {sdk_err}. Retry the operation or try another server."), + ), + // No-address / exhausted-addresses paths get the explicit operator + // hint message. + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + | dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_)) + | dash_sdk::Error::NoAvailableAddressesToRetry(_) => ( + DashSDKErrorCode::NetworkError, + "Cannot connect to network: No DAPI addresses configured. The SDK needs masternode quorum information to connect to the network.".to_string(), + ), + dash_sdk::Error::DapiClientError(_) => ( + DashSDKErrorCode::NetworkError, + format!("DAPI error: {}", sdk_err), + ), + dash_sdk::Error::ContextProviderError(_) => ( + DashSDKErrorCode::NetworkError, + format!("Context provider error: {}", sdk_err), + ), + dash_sdk::Error::CoreClientError(_) => ( + DashSDKErrorCode::NetworkError, + format!("Core client error: {}", sdk_err), + ), + dash_sdk::Error::MissingDependency(_, _) + | dash_sdk::Error::TotalCreditsNotFound + | dash_sdk::Error::EpochNotFound + | dash_sdk::Error::IdentityNonceNotFound(_) => { + (DashSDKErrorCode::NotFound, sdk_err.to_string()) + } + dash_sdk::Error::Config(_) + | dash_sdk::Error::Drive(_) + | dash_sdk::Error::DriveProofError(_, _, _) + | dash_sdk::Error::Proof(_) + | dash_sdk::Error::InvalidProvedResponse(_) + | dash_sdk::Error::CoreError(_) + | dash_sdk::Error::MerkleBlockError(_) + | dash_sdk::Error::AlreadyExists(_) + | dash_sdk::Error::InvalidCreditTransfer(_) + | dash_sdk::Error::NonceOverflow(_) + | dash_sdk::Error::Generic(_) + | dash_sdk::Error::StateTransitionBroadcastError(_) + | dash_sdk::Error::DapiMocksError(_) => ( + DashSDKErrorCode::InternalError, + format!("SDK error: {}", sdk_err), + ), + } +} + +fn consensus_error_kind_name(error: &ConsensusError) -> &'static str { + match error { + ConsensusError::DefaultError => "DefaultError", + ConsensusError::BasicError(_) => "BasicError", + ConsensusError::StateError(_) => "StateError", + ConsensusError::SignatureError(_) => "SignatureError", + ConsensusError::FeeError(_) => "FeeError", + } +} + +/// Resolve the specific variant identifier of a `ConsensusError`. +/// +/// The inner consensus enums (`BasicError`, `StateError`, `SignatureError`, +/// `FeeError`) derive `strum::IntoStaticStr`, which generates a compile-time +/// `impl From<&Enum> for &'static str` from the enum's structure. Adding a +/// future variant to one of those enums therefore extends this mapping +/// automatically with the correct variant identifier; there is no +/// `Debug`-format parsing or `_` wildcard that could silently drift if a +/// variant is added or renamed. +fn consensus_error_variant_name(error: &ConsensusError) -> &'static str { + match error { + ConsensusError::DefaultError => "DefaultError", + ConsensusError::BasicError(inner) => inner.into(), + ConsensusError::StateError(inner) => inner.into(), + ConsensusError::SignatureError(inner) => inner.into(), + ConsensusError::FeeError(inner) => inner.into(), + } +} + +fn consensus_error_entry(error: &ConsensusError) -> ConsensusErrorEntry { + ConsensusErrorEntry { + code: error.code(), + kind: consensus_error_kind_name(error).to_string(), + name: consensus_error_variant_name(error).to_string(), + message: error.to_string(), + } +} + +fn format_protocol_consensus_error( + error: &ProtocolError, +) -> Option<(String, Vec)> { + match error { + ProtocolError::ConsensusError(consensus_error) => { + let message = consensus_error.to_string(); + let entries = vec![consensus_error_entry(consensus_error)]; + Some((message, entries)) + } + ProtocolError::ConsensusErrors(consensus_errors) => { + let message = consensus_errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "); + let entries = consensus_errors.iter().map(consensus_error_entry).collect(); + Some((message, entries)) + } + _ => None, + } +} + +/// Free an error message. +/// +/// Also releases any structured consensus-error sidecar associated with the +/// error's message pointer, if one was attached. /// /// # Safety /// - `error` must be a pointer previously returned by this SDK or null (no-op). /// - After this call, `error` becomes invalid and must not be used again. +/// - Per the move-only sidecar contract documented at the module level, no +/// alias of `error` (including any copy of its `message` pointer) may be +/// used to query consensus details after this call. #[no_mangle] pub unsafe extern "C" fn dash_sdk_error_free(error: *mut DashSDKError) { if error.is_null() { return; } + // Drop any active sidecar entry keyed on the heap pointer the caller + // received. This must happen *before* `Box::from_raw` so the lookup uses + // the same `*mut DashSDKError` value the caller saw. + take_active_consensus_errors(error); + + // Reclaiming the box runs `DashSDKError::drop`, which frees the message + // `CString` and clears any (no-op for boxed paths) pending sidecar entry. + let _ = Box::from_raw(error); +} + +/// Returns the number of structured protocol consensus errors associated with +/// `error`, or `0` if `error` is null, is not a `ProtocolError`, or carries no +/// structured details. +/// +/// # Safety +/// - `error` must either be null or a pointer previously returned by this SDK +/// that has not yet been freed. +/// - Must be called synchronously, before [`dash_sdk_error_free`], on the +/// same `DashSDKError` value the SDK returned (not a copy/alias). See the +/// module-level move-only sidecar contract. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_error_consensus_error_count(error: *const DashSDKError) -> usize { + // Do not read fields from `error` before sidecar lookup: callers may pass + // a stale pointer, and the sidecar miss path must avoid dereferencing it. + // Active sidecar entries are only registered for ProtocolError consensus + // details, so the previous code check is redundant once lookup succeeds. + with_active_consensus_errors(error, |entries| entries.len()).unwrap_or(0) +} + +/// Returns a newly-allocated [`DashSDKConsensusError`] for the consensus error +/// at `index`, or null if `error` is null, is not a `ProtocolError`, has no +/// structured details, `index` is out of range, or memory allocation fails. +/// +/// The returned pointer is owned by the caller and must be freed with +/// [`dash_sdk_consensus_error_free`]. +/// +/// # Safety +/// - `error` must either be null or a pointer previously returned by this SDK +/// that has not yet been freed. +/// - Must be called synchronously, before [`dash_sdk_error_free`], on the +/// same `DashSDKError` value the SDK returned (not a copy/alias). See the +/// module-level move-only sidecar contract. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_error_consensus_error_at( + error: *const DashSDKError, + index: usize, +) -> *mut DashSDKConsensusError { + // Sidecar lookup comes before any field read from `error`; a miss returns + // null without dereferencing the caller-provided pointer. Active entries + // are only registered for ProtocolError consensus details. + let entry = + with_active_consensus_errors(error, |entries| entries.get(index).cloned()).flatten(); + let Some(entry) = entry else { + return std::ptr::null_mut(); + }; + + let kind = match CString::new(entry.kind) { + Ok(s) => s.into_raw(), + Err(_) => return std::ptr::null_mut(), + }; + let name = match CString::new(entry.name) { + Ok(s) => s.into_raw(), + Err(_) => { + let _ = CString::from_raw(kind); + return std::ptr::null_mut(); + } + }; + let message = match CString::new(entry.message) { + Ok(s) => s.into_raw(), + Err(_) => { + let _ = CString::from_raw(kind); + let _ = CString::from_raw(name); + return std::ptr::null_mut(); + } + }; + + Box::into_raw(Box::new(DashSDKConsensusError { + code: entry.code, + kind, + name, + message, + })) +} + +/// Free a [`DashSDKConsensusError`] returned by +/// [`dash_sdk_error_consensus_error_at`]. +/// +/// # Safety +/// - `error` must be a pointer previously returned by +/// `dash_sdk_error_consensus_error_at`, or null (no-op). +/// - After this call, `error` becomes invalid and must not be used again. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_consensus_error_free(error: *mut DashSDKConsensusError) { + if error.is_null() { + return; + } let error = Box::from_raw(error); + if !error.kind.is_null() { + let _ = CString::from_raw(error.kind); + } + if !error.name.is_null() { + let _ = CString::from_raw(error.name); + } if !error.message.is_null() { let _ = CString::from_raw(error.message); } @@ -186,8 +661,494 @@ macro_rules! ffi_result { Ok(val) => val, Err(e) => { let error: $crate::DashSDKError = e.into(); - return Box::into_raw(Box::new(error)); + return $crate::box_dashsdk_error(error); } } }; } + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::consensus::basic::document::NonceOutOfBoundsError; + use dash_sdk::dpp::consensus::basic::token::InvalidTokenAmountError; + use dash_sdk::dpp::consensus::fee::balance_is_not_enough_error::BalanceIsNotEnoughError; + use dash_sdk::dpp::consensus::fee::fee_error::FeeError; + use dash_sdk::dpp::consensus::signature::{ + IdentityNotFoundError, SignatureError as DppSignatureError, + }; + use dash_sdk::dpp::consensus::state::identity::IdentityAlreadyExistsError; + use dash_sdk::dpp::consensus::state::state_error::StateError; + use dash_sdk::dpp::consensus::{basic::BasicError, ConsensusError}; + use std::ffi::CStr; + + fn error_message_ptr(error: *const DashSDKError) -> String { + unsafe { CStr::from_ptr((*error).message) } + .to_str() + .expect("ffi error message should be valid utf-8") + .to_owned() + } + + fn cstr(ptr: *mut c_char) -> String { + unsafe { CStr::from_ptr(ptr) } + .to_str() + .expect("c string should be valid utf-8") + .to_owned() + } + + /// Box the error via the same helper used by real FFI return paths so the + /// pending sidecar entries are promoted to the active map keyed by the + /// heap `*mut DashSDKError` pointer. + fn boxed(error: DashSDKError) -> *mut DashSDKError { + box_dashsdk_error(error) + } + + #[test] + fn sdk_protocol_consensus_error_maps_to_protocol_error_code() { + let consensus_error = ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )); + let expected_code = consensus_error.code(); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + let message = error_message_ptr(ffi_error); + + assert_eq!( + unsafe { (*ffi_error).code }, + DashSDKErrorCode::ProtocolError + ); + assert!(message.contains("Nonce is out of bounds")); + assert!(!message.contains("Failed to fetch balances")); + + // Structured sidecar exposes the singular consensus error. + let count = unsafe { dash_sdk_error_consensus_error_count(ffi_error) }; + assert_eq!(count, 1); + + let detail_ptr = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; + assert!(!detail_ptr.is_null()); + let detail = unsafe { &*detail_ptr }; + assert_eq!(detail.code, expected_code); + assert_eq!(cstr(detail.kind), "BasicError"); + assert_eq!(cstr(detail.name), "NonceOutOfBoundsError"); + assert!(cstr(detail.message).contains("Nonce is out of bounds")); + unsafe { dash_sdk_consensus_error_free(detail_ptr) }; + + // Out-of-range index returns null. + let oob = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 1) }; + assert!(oob.is_null()); + + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn sdk_protocol_consensus_errors_join_messages_readably() { + let nonce_err = ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )); + let token_err = ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(100, 0), + )); + let expected_first_code = nonce_err.code(); + let expected_second_code = token_err.code(); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusErrors(vec![nonce_err, token_err])); + + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + let message = error_message_ptr(ffi_error); + + assert_eq!( + unsafe { (*ffi_error).code }, + DashSDKErrorCode::ProtocolError + ); + assert!(message.contains("Nonce is out of bounds")); + assert!(message.contains("Invalid token amount 0")); + assert!(message.contains("; ")); + assert!(!message.contains("Multiple consensus errors: [")); + + let count = unsafe { dash_sdk_error_consensus_error_count(ffi_error) }; + assert_eq!(count, 2); + + let first_ptr = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; + let second_ptr = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 1) }; + assert!(!first_ptr.is_null() && !second_ptr.is_null()); + let first = unsafe { &*first_ptr }; + let second = unsafe { &*second_ptr }; + + assert_eq!(cstr(first.kind), "BasicError"); + assert_eq!(cstr(first.name), "NonceOutOfBoundsError"); + assert!(cstr(first.message).contains("Nonce is out of bounds")); + assert_eq!(first.code, expected_first_code); + + assert_eq!(cstr(second.kind), "BasicError"); + assert_eq!(cstr(second.name), "InvalidTokenAmountError"); + assert!(cstr(second.message).contains("Invalid token amount 0")); + assert_eq!(second.code, expected_second_code); + + unsafe { dash_sdk_consensus_error_free(first_ptr) }; + unsafe { dash_sdk_consensus_error_free(second_ptr) }; + + unsafe { dash_sdk_error_free(ffi_error) }; + } + + /// Pointer-identity contract: a copied `DashSDKError` value that happens + /// to share the same `message` pointer as a boxed error MUST NOT expose + /// the boxed error's structured sidecar entries. Only the original boxed + /// `*mut DashSDKError` resolves the active sidecar. + #[test] + fn copied_error_value_does_not_resolve_sidecar() { + let consensus_error = ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + + let original = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + + // Original boxed pointer exposes the sidecar. + assert_eq!( + unsafe { dash_sdk_error_consensus_error_count(original) }, + 1, + "original boxed pointer must expose the sidecar" + ); + + // Construct a value-copy of the error that shares the same `message` + // pointer. Querying through this copy must NOT resolve the sidecar + // (different pointer identity). + let copy = DashSDKError { + code: unsafe { (*original).code }, + message: unsafe { (*original).message }, + }; + assert_eq!( + unsafe { dash_sdk_error_consensus_error_count(©) }, + 0, + "copy must not resolve sidecar via shared message pointer" + ); + let null = unsafe { dash_sdk_error_consensus_error_at(©, 0) }; + assert!(null.is_null(), "copy must not return any structured detail"); + // The copy aliases the original's owned `message` — `forget` it so + // its `Drop` does not double-free; `dash_sdk_error_free(original)` + // below releases the allocation. + std::mem::forget(copy); + + unsafe { dash_sdk_error_free(original) }; + } + + /// Dropping a `DashSDKError` produced by `From` without ever + /// boxing it must clear the pending sidecar entry keyed on its message + /// pointer. Otherwise a later allocation that recycles the same address + /// could pick up stale consensus-error details. + #[test] + fn dropping_unboxed_protocol_error_clears_pending_sidecar() { + let consensus_error = ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + + // Build the unboxed error and capture its message pointer key. While + // the value is alive, the pending sidecar map should hold an entry + // for this key. + let error = DashSDKError::from(FFIError::SDKError(sdk_error)); + let message_key = error.message as usize; + assert!( + lock_recover(&PENDING_CONSENSUS_ERRORS).contains_key(&message_key), + "From must register a pending sidecar entry" + ); + + // Dropping without boxing must reclaim the entry (and the CString), + // not leak it. + drop(error); + assert!( + !lock_recover(&PENDING_CONSENSUS_ERRORS).contains_key(&message_key), + "Drop must remove pending sidecar entry" + ); + + // A subsequent boxed protocol error must show only its own details + // even if the prior allocation is reused by the allocator. + let next_consensus = ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(7, 0), + )); + let next_sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(next_consensus))); + let next_ffi = boxed(DashSDKError::from(FFIError::SDKError(next_sdk_error))); + let count = unsafe { dash_sdk_error_consensus_error_count(next_ffi) }; + assert_eq!(count, 1, "fresh error must report exactly its own details"); + let detail_ptr = unsafe { dash_sdk_error_consensus_error_at(next_ffi, 0) }; + assert!(!detail_ptr.is_null()); + let detail = unsafe { &*detail_ptr }; + assert_eq!(cstr(detail.name), "InvalidTokenAmountError"); + unsafe { dash_sdk_consensus_error_free(detail_ptr) }; + unsafe { dash_sdk_error_free(next_ffi) }; + } + + /// If a recycled `*mut DashSDKError` allocation is subsequently occupied + /// by a different error, the active sidecar lookup must reject the stale + /// entry rather than mis-attributing details. The mitigation re-checks + /// the `message` pointer against the value captured at boxing time. + #[test] + fn active_sidecar_rejects_message_pointer_mismatch() { + let consensus_error = ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + let original = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + let original_key = original as usize; + + // Simulate post-free pointer reuse: forge an active-sidecar entry + // under `original`'s key whose recorded message pointer does NOT + // match `original`'s current message pointer. Lookup must reject it. + { + let mut map = lock_recover(&ACTIVE_CONSENSUS_ERRORS); + map.insert( + original_key, + ActiveSidecarEntry { + message_ptr: 0xdead_beef_usize, + entries: vec![ConsensusErrorEntry { + code: 9999, + kind: "BasicError".to_string(), + name: "BogusError".to_string(), + message: "stale entry from a freed predecessor".to_string(), + }], + }, + ); + } + + // The forged entry has the wrong message pointer, so the count must + // come back as 0 even though a key match exists. + let count = unsafe { dash_sdk_error_consensus_error_count(original) }; + assert_eq!( + count, 0, + "stale entry with mismatched message pointer must be rejected" + ); + let null = unsafe { dash_sdk_error_consensus_error_at(original, 0) }; + assert!(null.is_null(), "lookup must return no structured details"); + + unsafe { dash_sdk_error_free(original) }; + } + + #[test] + fn non_consensus_error_reports_zero_consensus_errors() { + let ffi_error = boxed(DashSDKError::from(FFIError::NotFound("nope".to_string()))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::NotFound); + + let count = unsafe { dash_sdk_error_consensus_error_count(ffi_error) }; + assert_eq!(count, 0); + let null = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; + assert!(null.is_null()); + + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn unclassified_sdk_error_uses_neutral_internal_fallback() { + // `Generic` is a non-protocol SDK error variant whose Display string + // does not match any of the timeout/network/DAPI/protocol/not-found + // heuristics, so it exercises the neutral fallback branch. + let sdk_error = dash_sdk::Error::Generic("widget exploded".to_string()); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + let message = error_message_ptr(ffi_error); + + assert_eq!( + unsafe { (*ffi_error).code }, + DashSDKErrorCode::InternalError + ); + assert!( + !message.contains("Failed to fetch balances"), + "neutral fallback must not reference fetch-balances; got: {}", + message + ); + assert!( + message.starts_with("SDK error:"), + "neutral fallback should be prefixed with 'SDK error:'; got: {}", + message + ); + assert!(message.contains("widget exploded")); + + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn timeout_reached_maps_to_timeout_code_structurally() { + let sdk_error = dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(5), + "fetching identity".to_string(), + ); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::Timeout); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn cancelled_maps_to_timeout_with_clear_message() { + let sdk_error = dash_sdk::Error::Cancelled("request aborted by caller".to_string()); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::Timeout); + let message = error_message_ptr(ffi_error); + assert!( + message.contains("Operation cancelled"), + "expected cancellation prefix, got: {message}" + ); + assert!( + message.contains("request aborted by caller"), + "expected original cancellation reason, got: {message}" + ); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn stale_node_maps_to_network_with_retry_hint() { + let sdk_error = dash_sdk::Error::StaleNode(dash_sdk::error::StaleNodeError::Height { + expected_height: 100, + received_height: 95, + tolerance_blocks: 2, + }); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::NetworkError); + let message = error_message_ptr(ffi_error); + assert!( + message.contains("Stale node response"), + "expected stale-node prefix, got: {message}" + ); + assert!( + message.contains("try another server") || message.contains("Retry the operation"), + "expected retry guidance, got: {message}" + ); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn dapi_no_available_addresses_maps_to_network_with_hint() { + let sdk_error = dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::NetworkError); + let message = error_message_ptr(ffi_error); + assert!( + message.contains("No DAPI addresses configured"), + "expected operator hint, got: {message}" + ); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn context_provider_error_maps_to_network_with_clear_prefix() { + let sdk_error = dash_sdk::Error::ContextProviderError( + dash_sdk::error::ContextProviderError::Generic("quorum lookup failed".to_string()), + ); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::NetworkError); + let message = error_message_ptr(ffi_error); + assert!(message.starts_with("Context provider error:")); + assert!(message.contains("quorum lookup failed")); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn missing_dependency_maps_to_not_found_structurally() { + let sdk_error = + dash_sdk::Error::MissingDependency("data contract".to_string(), "abc123".to_string()); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::NotFound); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn epoch_not_found_maps_to_not_found_structurally() { + let sdk_error = dash_sdk::Error::EpochNotFound; + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + assert_eq!(unsafe { (*ffi_error).code }, DashSDKErrorCode::NotFound); + unsafe { dash_sdk_error_free(ffi_error) }; + } + + #[test] + fn null_error_is_safe() { + let count = unsafe { dash_sdk_error_consensus_error_count(std::ptr::null()) }; + assert_eq!(count, 0); + let null = unsafe { dash_sdk_error_consensus_error_at(std::ptr::null(), 0) }; + assert!(null.is_null()); + } + + /// Representative variant-name extraction for `StateError`. Uses + /// `IdentityAlreadyExistsError`, a constructible state-error variant. + #[test] + fn state_error_extracts_specific_variant_name() { + let consensus_error = ConsensusError::StateError(StateError::IdentityAlreadyExistsError( + IdentityAlreadyExistsError::new(Default::default()), + )); + let expected_code = consensus_error.code(); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + + assert_eq!( + unsafe { dash_sdk_error_consensus_error_count(ffi_error) }, + 1 + ); + let detail_ptr = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; + assert!(!detail_ptr.is_null()); + let detail = unsafe { &*detail_ptr }; + assert_eq!(detail.code, expected_code); + assert_eq!(cstr(detail.kind), "StateError"); + assert_eq!(cstr(detail.name), "IdentityAlreadyExistsError"); + unsafe { dash_sdk_consensus_error_free(detail_ptr) }; + unsafe { dash_sdk_error_free(ffi_error) }; + } + + /// Representative variant-name extraction for `SignatureError`. Uses + /// `IdentityNotFoundError`, a constructible signature-error variant. + #[test] + fn signature_error_extracts_specific_variant_name() { + let consensus_error = + ConsensusError::SignatureError(DppSignatureError::IdentityNotFoundError( + IdentityNotFoundError::new(Default::default()), + )); + let expected_code = consensus_error.code(); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + + assert_eq!( + unsafe { dash_sdk_error_consensus_error_count(ffi_error) }, + 1 + ); + let detail_ptr = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; + assert!(!detail_ptr.is_null()); + let detail = unsafe { &*detail_ptr }; + assert_eq!(detail.code, expected_code); + assert_eq!(cstr(detail.kind), "SignatureError"); + assert_eq!(cstr(detail.name), "IdentityNotFoundError"); + unsafe { dash_sdk_consensus_error_free(detail_ptr) }; + unsafe { dash_sdk_error_free(ffi_error) }; + } + + /// Representative variant-name extraction for `FeeError`. Uses + /// `BalanceIsNotEnoughError`, a constructible fee-error variant. + #[test] + fn fee_error_extracts_specific_variant_name() { + let consensus_error = ConsensusError::FeeError(FeeError::BalanceIsNotEnoughError( + BalanceIsNotEnoughError::new(0, 1), + )); + let expected_code = consensus_error.code(); + let sdk_error = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); + + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + + assert_eq!( + unsafe { dash_sdk_error_consensus_error_count(ffi_error) }, + 1 + ); + let detail_ptr = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; + assert!(!detail_ptr.is_null()); + let detail = unsafe { &*detail_ptr }; + assert_eq!(detail.code, expected_code); + assert_eq!(cstr(detail.kind), "FeeError"); + assert_eq!(cstr(detail.name), "BalanceIsNotEnoughError"); + unsafe { dash_sdk_consensus_error_free(detail_ptr) }; + unsafe { dash_sdk_error_free(ffi_error) }; + } +} diff --git a/packages/rs-sdk-ffi/src/identity/transfer.rs b/packages/rs-sdk-ffi/src/identity/transfer.rs index 59a1e0d192a..7fb001ff218 100644 --- a/packages/rs-sdk-ffi/src/identity/transfer.rs +++ b/packages/rs-sdk-ffi/src/identity/transfer.rs @@ -247,7 +247,7 @@ pub unsafe extern "C" fn dash_sdk_identity_transfer_credits( let (sender_balance, receiver_balance) = transfer_result .map_err(|e| { eprintln!("❌ dash_sdk_identity_transfer_credits: transfer_credits failed: {}", e); - FFIError::InternalError(format!("Failed to transfer credits: {}", e)) + FFIError::SDKError(e) })?; eprintln!("🔵 dash_sdk_identity_transfer_credits: Transfer successful!"); @@ -289,7 +289,7 @@ mod tests { use crate::test_utils::test_utils::{ create_c_string, create_mock_sdk_handle, create_mock_signer, destroy_mock_sdk_handle, }; - use std::ffi::CString; + use std::ffi::{CStr, CString}; /// Verify that passing a null identity handle returns an error instead of crashing. /// @@ -326,20 +326,18 @@ mod tests { let error = unsafe { &*result.error }; assert_eq!(error.code, DashSDKErrorCode::InvalidParameter); - // Clean up error message - if !error.message.is_null() { - let msg = unsafe { CString::from_raw(error.message as *mut _) }; - let msg_str = msg.to_str().expect("valid utf-8"); - assert!( - msg_str.contains("null"), - "Error message should mention null, got: {}", - msg_str - ); - } + let msg_str = unsafe { CStr::from_ptr(error.message) } + .to_str() + .expect("valid utf-8"); + assert!( + msg_str.contains("null"), + "Error message should mention null, got: {}", + msg_str + ); // Clean up unsafe { - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); let _ = Box::from_raw(signer_ptr as *mut crate::signer::VTableSigner); let _ = CString::from_raw(to_id as *mut _); destroy_mock_sdk_handle(sdk_handle); @@ -374,10 +372,7 @@ mod tests { // Clean up unsafe { - if !error.message.is_null() { - let _ = CString::from_raw(error.message as *mut _); - } - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); let _ = Box::from_raw(signer_ptr as *mut crate::signer::VTableSigner); let _ = CString::from_raw(to_id as *mut _); } @@ -410,10 +405,7 @@ mod tests { // Clean up unsafe { - if !error.message.is_null() { - let _ = CString::from_raw(error.message as *mut _); - } - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); let _ = CString::from_raw(to_id as *mut _); destroy_mock_sdk_handle(sdk_handle); } @@ -447,10 +439,7 @@ mod tests { // Clean up unsafe { - if !error.message.is_null() { - let _ = CString::from_raw(error.message as *mut _); - } - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); let _ = Box::from_raw(signer_ptr as *mut crate::signer::VTableSigner); destroy_mock_sdk_handle(sdk_handle); } diff --git a/packages/rs-sdk-ffi/src/identity/withdraw.rs b/packages/rs-sdk-ffi/src/identity/withdraw.rs index 40efa4c20a4..3325650c1c0 100644 --- a/packages/rs-sdk-ffi/src/identity/withdraw.rs +++ b/packages/rs-sdk-ffi/src/identity/withdraw.rs @@ -223,7 +223,7 @@ pub unsafe extern "C" fn dash_sdk_identity_withdraw( .await .map_err(|e| { error!(error = %e, "dash_sdk_identity_withdraw: withdraw failed"); - FFIError::InternalError(format!("Failed to withdraw credits: {}", e)) + FFIError::SDKError(e) })?; info!(new_balance, "dash_sdk_identity_withdraw: withdrawal successful"); @@ -286,10 +286,7 @@ mod tests { // Clean up unsafe { - if !error.message.is_null() { - let _ = CString::from_raw(error.message as *mut _); - } - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); let _ = Box::from_raw(signer_ptr as *mut crate::signer::VTableSigner); let _ = CString::from_raw(address as *mut _); destroy_mock_sdk_handle(sdk_handle); @@ -325,10 +322,7 @@ mod tests { // Clean up unsafe { - if !error.message.is_null() { - let _ = CString::from_raw(error.message as *mut _); - } - let _ = Box::from_raw(result.error); + crate::error::dash_sdk_error_free(result.error); let _ = Box::from_raw(signer_ptr as *mut crate::signer::VTableSigner); let _ = CString::from_raw(address as *mut _); } diff --git a/packages/rs-sdk-ffi/src/token/burn.rs b/packages/rs-sdk-ffi/src/token/burn.rs index 3e5c344ded0..1c70e1bb44a 100644 --- a/packages/rs-sdk-ffi/src/token/burn.rs +++ b/packages/rs-sdk-ffi/src/token/burn.rs @@ -166,9 +166,7 @@ pub unsafe extern "C" fn dash_sdk_token_burn( .sdk .token_burn(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to burn token and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/claim.rs b/packages/rs-sdk-ffi/src/token/claim.rs index a6c6ab80eb2..22753409f81 100644 --- a/packages/rs-sdk-ffi/src/token/claim.rs +++ b/packages/rs-sdk-ffi/src/token/claim.rs @@ -168,9 +168,7 @@ pub unsafe extern "C" fn dash_sdk_token_claim( .sdk .token_claim(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to claim token and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/config_update.rs b/packages/rs-sdk-ffi/src/token/config_update.rs index 7c1fca2a734..d004fe800ad 100644 --- a/packages/rs-sdk-ffi/src/token/config_update.rs +++ b/packages/rs-sdk-ffi/src/token/config_update.rs @@ -231,9 +231,7 @@ pub unsafe extern "C" fn dash_sdk_token_update_contract_token_configuration( .sdk .token_update_contract_token_configuration(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to update token config and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs index 24e42ddf5a2..bebcb21aa7d 100644 --- a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs +++ b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs @@ -178,9 +178,7 @@ pub unsafe extern "C" fn dash_sdk_token_destroy_frozen_funds( .sdk .token_destroy_frozen_funds(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to destroy frozen funds and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/emergency_action.rs b/packages/rs-sdk-ffi/src/token/emergency_action.rs index 391bc090f45..8521b7a5e2f 100644 --- a/packages/rs-sdk-ffi/src/token/emergency_action.rs +++ b/packages/rs-sdk-ffi/src/token/emergency_action.rs @@ -179,9 +179,7 @@ pub unsafe extern "C" fn dash_sdk_token_emergency_action( .sdk .token_emergency_action(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to perform emergency action and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/freeze.rs b/packages/rs-sdk-ffi/src/token/freeze.rs index b7a1fcd5cd3..52f86449ba5 100644 --- a/packages/rs-sdk-ffi/src/token/freeze.rs +++ b/packages/rs-sdk-ffi/src/token/freeze.rs @@ -181,9 +181,7 @@ pub unsafe extern "C" fn dash_sdk_token_freeze( .sdk .token_freeze(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to freeze token and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/mint.rs b/packages/rs-sdk-ffi/src/token/mint.rs index 0eaaf989639..2c728071315 100644 --- a/packages/rs-sdk-ffi/src/token/mint.rs +++ b/packages/rs-sdk-ffi/src/token/mint.rs @@ -274,7 +274,7 @@ pub unsafe extern "C" fn dash_sdk_token_mint( .await .map_err(|e| { tracing::error!(error = %e, "FFI TOKEN MINT: failed to mint token"); - FFIError::InternalError(format!("Failed to mint token and wait: {}", e)) + FFIError::SDKError(e) })?; tracing::info!("FFI TOKEN MINT: token mint succeeded"); Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/purchase.rs b/packages/rs-sdk-ffi/src/token/purchase.rs index 7227fdb2b56..570c8444047 100644 --- a/packages/rs-sdk-ffi/src/token/purchase.rs +++ b/packages/rs-sdk-ffi/src/token/purchase.rs @@ -170,9 +170,7 @@ pub unsafe extern "C" fn dash_sdk_token_purchase( .sdk .token_purchase(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to purchase token and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/set_price.rs b/packages/rs-sdk-ffi/src/token/set_price.rs index 4f469973d0a..dbb7f08181a 100644 --- a/packages/rs-sdk-ffi/src/token/set_price.rs +++ b/packages/rs-sdk-ffi/src/token/set_price.rs @@ -218,9 +218,7 @@ pub unsafe extern "C" fn dash_sdk_token_set_price( .sdk .token_set_price_for_direct_purchase(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to set token price and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/transfer.rs b/packages/rs-sdk-ffi/src/token/transfer.rs index da1b40b94f5..d312bf7501f 100644 --- a/packages/rs-sdk-ffi/src/token/transfer.rs +++ b/packages/rs-sdk-ffi/src/token/transfer.rs @@ -180,9 +180,7 @@ pub unsafe extern "C" fn dash_sdk_token_transfer( .sdk .token_transfer(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to transfer token and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/token/unfreeze.rs b/packages/rs-sdk-ffi/src/token/unfreeze.rs index f20dba4b560..c5b5b1be017 100644 --- a/packages/rs-sdk-ffi/src/token/unfreeze.rs +++ b/packages/rs-sdk-ffi/src/token/unfreeze.rs @@ -178,9 +178,7 @@ pub unsafe extern "C" fn dash_sdk_token_unfreeze( .sdk .token_unfreeze_identity(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to unfreeze token and wait: {}", e)) - })?; + .map_err(FFIError::SDKError)?; Ok(result) }); diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index 0f57d4bda63..6e2722e0741 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -508,7 +508,10 @@ impl DashSDKResult { DashSDKResult { data_type: DashSDKResultDataType::NoData, data: std::ptr::null_mut(), - error: Box::into_raw(Box::new(error)), + // Use the sidecar-aware boxer so any pending consensus-error + // entries are promoted to the active map keyed by this heap + // pointer. See `crate::error::box_dashsdk_error`. + error: crate::error::box_dashsdk_error(error), } } } @@ -1255,11 +1258,10 @@ pub unsafe extern "C" fn dash_sdk_result_free(result: *mut DashSDKResult) { // ── Free the error field ────────────────────────────────────────── if !res.error.is_null() { - let error = Box::from_raw(res.error); - if !error.message.is_null() { - let _ = std::ffi::CString::from_raw(error.message); - } - // Box is dropped here, freeing the DashSDKError struct + // Delegate to the canonical error free path so any structured + // consensus-error sidecar associated with this heap pointer is + // released along with the message and struct. + super::error::dash_sdk_error_free(res.error); res.error = std::ptr::null_mut(); } diff --git a/packages/rs-sdk-ffi/tests/integration_tests/ffi_utils.rs b/packages/rs-sdk-ffi/tests/integration_tests/ffi_utils.rs index 1137f8cc78f..2a2fec7db41 100644 --- a/packages/rs-sdk-ffi/tests/integration_tests/ffi_utils.rs +++ b/packages/rs-sdk-ffi/tests/integration_tests/ffi_utils.rs @@ -145,12 +145,10 @@ pub fn base58_from_hex32(hex_str: &str) -> String { /// Parse a DashSDKResult and extract the string data pub unsafe fn parse_string_result(result: DashSDKResult) -> Result, String> { if !result.error.is_null() { - let error = Box::from_raw(result.error); - return Err(format!( - "Error code {}: {}", - error.code as i32, - from_c_string(error.message).unwrap_or_default() - )); + let code = (*result.error).code as i32; + let message = from_c_string((*result.error).message).unwrap_or_default(); + dash_sdk_error_free(result.error); + return Err(format!("Error code {}: {}", code, message)); } match result.data_type { diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index bf1561e6224..ca22cd1cc51 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -9,6 +9,7 @@ arc-swap = { version = "1.7.1" } chrono = { version = "0.4.38" } dpp = { path = "../rs-dpp", default-features = false, features = [ "dash-sdk-features", + "batch-base-structure-validation", ] } dapi-grpc = { path = "../dapi-grpc", default-features = false } rs-dapi-client = { path = "../rs-dapi-client", default-features = false } diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index 36d42444f4c..381625058da 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -20,6 +20,8 @@ pub mod identities_contract_keys_query; pub mod query; #[cfg(feature = "shielded")] pub mod shielded; +#[cfg(test)] +pub(crate) mod test_helpers; pub mod tokens; pub mod transition; pub mod trunk_branch_sync; diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index bfcb7bc43be..b69ddff8d9f 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -1,11 +1,14 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; +use dpp::consensus::basic::document::InvalidDocumentTransitionIdError; +use dpp::consensus::ConsensusError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; -use dpp::document::{Document, DocumentV0Getters}; +use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; +use dpp::prelude::Identifier; use dpp::prelude::UserFeeIncrease; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; @@ -22,6 +25,9 @@ use tracing::trace; pub struct DocumentCreateTransitionBuilder { pub data_contract: Arc, pub document_type_name: String, + /// The document to create. Callers may set `document.id()` to + /// `Identifier::default()` to have the SDK derive the deterministic id, or + /// to that exact derived id. Any other non-default id is rejected. pub document: Document, pub document_state_transition_entropy: [u8; 32], pub token_payment_info: Option, @@ -120,6 +126,37 @@ impl DocumentCreateTransitionBuilder { self } + fn generated_document_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { + // Keep deterministic create-id derivation aligned with the DPP pre-sign + // create-transition structure validator, not the transition serializer. + let feature_version = platform_version + .dpp + .state_transitions + .documents + .documents_batch_transition + .validation + .document_create_transition_structure_validation; + + match feature_version { + 0 => Ok(Document::generate_document_id_v0( + self.data_contract.id_ref(), + &self.document.owner_id(), + &self.document_type_name, + self.document_state_transition_entropy.as_slice(), + )), + version => Err(Error::Protocol( + dpp::ProtocolError::UnknownVersionMismatch { + method: "DocumentCreateTransitionBuilder::generated_document_id".to_string(), + known_versions: vec![0], + received: version, + }, + )), + } + } + /// Signs the document create transition /// /// # Arguments @@ -132,6 +169,10 @@ impl DocumentCreateTransitionBuilder { /// # Returns /// /// * `Result` - The signed state transition or an error + /// + /// The builder accepts either `Identifier::default()` on `self.document` + /// to request deterministic id derivation, or the exact derived id. Any + /// other non-default id is rejected before nonce fetching. pub async fn sign( &self, sdk: &Sdk, @@ -139,6 +180,26 @@ impl DocumentCreateTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { + let document_type = self + .data_contract + .document_type_for_name(&self.document_type_name) + .map_err(|e| Error::Protocol(e.into()))?; + + let generated_id = self.generated_document_id(platform_version)?; + let mut document = self.document.clone(); + let current_id = document.id(); + + if current_id == Identifier::default() { + document.set_id(generated_id); + } else if current_id != generated_id { + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(ConsensusError::from(InvalidDocumentTransitionIdError::new( + generated_id, + current_id, + ))), + ))); + } + let identity_contract_nonce = sdk .get_identity_contract_nonce( self.document.owner_id(), @@ -148,13 +209,8 @@ impl DocumentCreateTransitionBuilder { ) .await?; - let document_type = self - .data_contract - .document_type_for_name(&self.document_type_name) - .map_err(|e| Error::Protocol(e.into()))?; - let state_transition = BatchTransition::new_document_creation_transition_from_document( - self.document.clone(), + document, document_type, self.document_state_transition_entropy, identity_public_key, diff --git a/packages/rs-sdk/src/platform/documents/transitions/mod.rs b/packages/rs-sdk/src/platform/documents/transitions/mod.rs index 200a2164267..a93fda02beb 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/mod.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/mod.rs @@ -3,6 +3,8 @@ pub mod delete; pub mod purchase; pub mod replace; pub mod set_price; +#[cfg(test)] +mod tests; pub mod transfer; pub use create::{DocumentCreateResult, DocumentCreateTransitionBuilder}; diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs new file mode 100644 index 00000000000..e7a7d81cb93 --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -0,0 +1,674 @@ +use super::create::DocumentCreateTransitionBuilder; +use super::delete::DocumentDeleteTransitionBuilder; +use super::purchase::DocumentPurchaseTransitionBuilder; +use super::replace::DocumentReplaceTransitionBuilder; +use super::set_price::DocumentSetPriceTransitionBuilder; +use super::transfer::DocumentTransferTransitionBuilder; +use crate::platform::test_helpers::{ + new_mock_sdk_with_contract_nonce, test_data_contract, test_data_contract_with_options, + test_identity_public_key, TestDocumentTypeOptions, TestSigner, TEST_DOCUMENT_TYPE_NAME, +}; +use crate::Error; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dpp::prelude::Identifier; +use dpp::state_transition::StateTransition; +use dpp::state_transition::StateTransitionLike; +use dpp::ProtocolError; +use std::sync::Arc; + +/// Asserts the error chain returned by an SDK builder matches a single +/// `InvalidDocumentTransitionActionError` carrying the expected message +/// fragment, surfaced by the new DPP `from_document` constructor validators. +#[track_caller] +fn assert_invalid_document_transition_action( + result: Result, + expected_message_fragment: &str, +) { + match result { + Err(Error::Protocol(ProtocolError::ConsensusError(boxed))) => match *boxed { + ConsensusError::BasicError(BasicError::InvalidDocumentTransitionActionError( + ref err, + )) => { + assert!( + err.action().contains(expected_message_fragment), + "expected action message containing {:?}, got {:?}", + expected_message_fragment, + err.action() + ); + } + other => panic!( + "expected InvalidDocumentTransitionActionError, got {:?}", + other + ), + }, + other => panic!( + "expected ProtocolError::ConsensusError(InvalidDocumentTransitionActionError), got {:?}", + other + ), + } +} + +#[track_caller] +fn assert_invalid_document_transition_id( + result: Result, + expected_id: Identifier, + invalid_id: Identifier, +) { + match result { + Err(Error::Protocol(ProtocolError::ConsensusError(boxed))) => match *boxed { + ConsensusError::BasicError(BasicError::InvalidDocumentTransitionIdError(ref err)) => { + assert_eq!(err.expected_id(), expected_id); + assert_eq!(err.invalid_id(), invalid_id); + } + other => panic!("expected InvalidDocumentTransitionIdError, got {:?}", other), + }, + other => panic!( + "expected ProtocolError::ConsensusError(InvalidDocumentTransitionIdError), got {:?}", + other + ), + } +} + +fn test_document(owner_id: Identifier) -> Document { + Document::V0(DocumentV0 { + id: Identifier::random(), + owner_id, + properties: Default::default(), + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }) +} + +#[tokio::test] +async fn document_builder_sign_masks_nonce_so_out_of_bounds_is_unreachable() { + // Document builders obtain nonce through `Sdk::get_identity_contract_nonce`, + // which masks out-of-bounds bits. This makes `validate_base_structure` + // nonce-out-of-bounds errors unreachable through the builder API. + // One test suffices since all document builders use the same SDK nonce path. + let document_type_name = TEST_DOCUMENT_TYPE_NAME; + let data_contract = test_data_contract(document_type_name); + let owner_id = Identifier::random(); + + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 1_u64 << 50).await; + + let builder = DocumentDeleteTransitionBuilder::new( + Arc::clone(&data_contract), + document_type_name.to_string(), + Identifier::random(), + owner_id, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "SDK should mask nonce internally; got error: {:?}", + result.err() + ); +} + +#[tokio::test] +async fn document_delete_builder_sign_succeeds_for_valid_input() { + let document_type_name = TEST_DOCUMENT_TYPE_NAME; + let data_contract = test_data_contract(document_type_name); + let owner_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentDeleteTransitionBuilder::new( + Arc::clone(&data_contract), + document_type_name.to_string(), + Identifier::random(), + owner_id, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn document_create_builder_sign_succeeds_for_valid_input() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let mut document = test_document(owner_id); + document.set_id(Identifier::default()); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentCreateTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + [7; 32], + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn document_create_builder_sign_rejects_incorrect_document_id() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let mut document = test_document(owner_id); + let entropy = [7; 32]; + let expected_id = Document::generate_document_id_v0( + data_contract.id_ref(), + &owner_id, + TEST_DOCUMENT_TYPE_NAME, + entropy.as_slice(), + ); + + document.set_id(Identifier::random()); + let invalid_id = document.id(); + assert_ne!(invalid_id, expected_id); + + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentCreateTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + entropy, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_id(result, expected_id, invalid_id); +} + +#[tokio::test] +async fn document_create_builder_sign_normalizes_default_document_id() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let mut document = test_document(owner_id); + let entropy = [7; 32]; + let expected_id = Document::generate_document_id_v0( + data_contract.id_ref(), + &owner_id, + TEST_DOCUMENT_TYPE_NAME, + entropy.as_slice(), + ); + + document.set_id(Identifier::default()); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentCreateTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + entropy, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "builder should normalize default document id before signing; got error: {:?}", + result.err() + ); + + let state_transition = result.unwrap(); + let StateTransition::Batch(batch_transition) = state_transition else { + panic!("document create builder should return a batch transition"); + }; + + assert_eq!(batch_transition.modified_data_ids(), vec![expected_id]); +} + +#[tokio::test] +async fn document_create_builder_generated_id_ignores_unknown_method_feature_version() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let mut document = test_document(owner_id); + document.set_id(Identifier::default()); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentCreateTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + [7; 32], + ) + .with_state_transition_creation_options( + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions { + method_feature_version: Some(999), + ..Default::default() + }, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + match result { + Err(Error::Protocol(ProtocolError::UnknownVersionMismatch { + method, + known_versions, + received, + })) => { + assert_eq!(method, "DocumentCreateTransition::from_document"); + assert_eq!(known_versions, vec![0]); + assert_eq!(received, 999); + } + other => panic!( + "expected unknown method feature version to be rejected by DocumentCreateTransition::from_document, got {:?}", + other + ), + } +} + +#[tokio::test] +async fn document_replace_builder_sign_succeeds_for_valid_input() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentReplaceTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn document_purchase_builder_sign_succeeds_for_valid_input() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let purchaser_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(purchaser_id, data_contract.id(), 0).await; + + let builder = DocumentPurchaseTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + purchaser_id, + 100, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn document_set_price_builder_sign_succeeds_for_valid_input() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentSetPriceTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + 200, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn document_transfer_builder_sign_succeeds_for_valid_input() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let recipient_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentTransferTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + recipient_id, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +// ---------------------------------------------------------------------------- +// Constructor-only structure validators surfaced through the SDK builders. +// Each of these tests proves that the new DPP `from_document` (or higher +// constructor) hook fails before signing when the document type rejects the +// requested operation. They exist so future changes to the dispatch wiring +// keep the public SDK builder reachable. +// ---------------------------------------------------------------------------- + +#[tokio::test] +async fn document_replace_builder_sign_fails_when_type_is_immutable() { + let data_contract = test_data_contract_with_options( + TEST_DOCUMENT_TYPE_NAME, + TestDocumentTypeOptions { + mutable: false, + ..Default::default() + }, + ); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentReplaceTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_action(result, "is not mutable and can not be replaced"); +} + +#[tokio::test] +async fn document_delete_builder_sign_fails_when_type_is_undeletable() { + let data_contract = test_data_contract_with_options( + TEST_DOCUMENT_TYPE_NAME, + TestDocumentTypeOptions { + can_be_deleted: false, + ..Default::default() + }, + ); + let owner_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentDeleteTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + Identifier::random(), + owner_id, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_action(result, "can not be deleted"); +} + +#[tokio::test] +async fn document_transfer_builder_sign_fails_when_type_is_not_transferable() { + let data_contract = test_data_contract_with_options( + TEST_DOCUMENT_TYPE_NAME, + TestDocumentTypeOptions { + transferable: false, + ..Default::default() + }, + ); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let recipient_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentTransferTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + recipient_id, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_action(result, "is not a transferable document type"); +} + +#[tokio::test] +async fn document_purchase_builder_sign_fails_when_type_is_not_direct_purchase() { + let data_contract = test_data_contract_with_options( + TEST_DOCUMENT_TYPE_NAME, + TestDocumentTypeOptions { + direct_purchase: false, + ..Default::default() + }, + ); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let purchaser_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(purchaser_id, data_contract.id(), 0).await; + + let builder = DocumentPurchaseTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + purchaser_id, + 100, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_action( + result, + "trade mode is not direct purchase but we are trying to purchase directly", + ); +} + +#[tokio::test] +async fn document_purchase_builder_sign_fails_when_purchaser_already_owns_document() { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + // Same identity is the document owner and the purchaser → self-purchase. + let document = test_document(owner_id); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentPurchaseTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + owner_id, + 100, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_action(result, "is already owned by the purchaser"); +} + +#[tokio::test] +async fn document_set_price_builder_sign_fails_when_seller_cannot_set_price() { + let data_contract = test_data_contract_with_options( + TEST_DOCUMENT_TYPE_NAME, + TestDocumentTypeOptions { + direct_purchase: false, + ..Default::default() + }, + ); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let builder = DocumentSetPriceTransitionBuilder::new( + Arc::clone(&data_contract), + TEST_DOCUMENT_TYPE_NAME.to_string(), + document, + 200, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert_invalid_document_transition_action( + result, + "does not support the seller setting the price", + ); +} diff --git a/packages/rs-sdk/src/platform/test_helpers.rs b/packages/rs-sdk/src/platform/test_helpers.rs new file mode 100644 index 00000000000..041eefd5d63 --- /dev/null +++ b/packages/rs-sdk/src/platform/test_helpers.rs @@ -0,0 +1,211 @@ +//! Shared test infrastructure for document and token transition builder tests. + +use crate::{Error, Sdk, SdkBuilder}; +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::config::DataContractConfig; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentType; +use dpp::data_contract::DataContractFactory; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::{platform_value, BinaryData, Value}; +use dpp::prelude::Identifier; +use dpp::state_transition::StateTransition; +use dpp::ProtocolError; +use drive_proof_verifier::types::IdentityContractNonceFetcher; +use std::collections::BTreeMap; +use std::sync::Arc; + +pub(crate) const TEST_DOCUMENT_TYPE_NAME: &str = "testDoc"; +/// Exceeds the 40-bit nonce mask (MISSING_IDENTITY_REVISIONS_FILTER), triggering +/// NonceOutOfBoundsError in validate_base_structure. +pub(crate) const INVALID_NONCE: u64 = 1_u64 << 50; + +#[derive(Debug)] +pub(crate) struct TestSigner; + +#[async_trait] +impl Signer for TestSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Ok(BinaryData::from(vec![1; 65])) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::CorruptedCodeExecution( + "sign_create_witness is not used in these tests".to_string(), + )) + } + + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } +} + +pub(crate) fn test_identity_public_key() -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::from(vec![2; 33]), + disabled_at: None, + }) +} + +/// Optional document-type capability flags for tests that need to surface +/// the new constructor-only structure validators (mutability, transferability, +/// trade-mode, deletability). Defaults enable every operation so happy-path +/// tests using the basic `test_data_contract` helper continue to pass. +#[derive(Clone, Copy, Debug)] +pub(crate) struct TestDocumentTypeOptions { + pub mutable: bool, + pub can_be_deleted: bool, + pub transferable: bool, + pub direct_purchase: bool, +} + +impl Default for TestDocumentTypeOptions { + fn default() -> Self { + Self { + mutable: true, + can_be_deleted: true, + transferable: true, + direct_purchase: true, + } + } +} + +pub(crate) fn test_data_contract( + document_type_name: &str, +) -> Arc { + test_data_contract_with_options(document_type_name, TestDocumentTypeOptions::default()) +} + +pub(crate) fn test_data_contract_with_options( + document_type_name: &str, + options: TestDocumentTypeOptions, +) -> Arc { + let platform_version = dpp::version::PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("create contract config"); + + // `transferable: 1` and `tradeMode: 1` (DirectPurchase) match the values + // expected by the per-transition constructor validators in DPP. + let schema = platform_value!({ + "type": "object", + "properties": { + "a": { + "type": "string", + "maxLength": 10, + "position": 0 + } + }, + "additionalProperties": false, + "documentsMutable": options.mutable, + "canBeDeleted": options.can_be_deleted, + "transferable": if options.transferable { 1u64 } else { 0u64 }, + "tradeMode": if options.direct_purchase { 1u64 } else { 0u64 }, + }); + + let document_type = DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect("create test document type"); + + let mut document_types: BTreeMap = BTreeMap::new(); + document_types.insert( + document_type.name().to_string(), + document_type.schema().clone(), + ); + + let contract = DataContractFactory::new(platform_version.protocol_version) + .expect("create data contract factory") + .create( + Identifier::random(), + 0, + platform_value!(document_types), + None, + None, + ) + .expect("create test data contract") + .data_contract_owned(); + + Arc::new(contract) +} + +/// Asserts that a freshly-constructed transition was rejected by DPP's +/// pre-sign base-structure validation with `NonceOutOfBoundsError`. +/// +/// `BatchTransition::new_*` constructors run pre-sign base-structure validation before +/// signing (see `batch-base-structure-validation` feature), so an +/// out-of-bounds identity contract nonce surfaces as +/// `ProtocolError::ConsensusError(BasicError::NonceOutOfBoundsError)` or inside +/// `ProtocolError::ConsensusErrors(...)` from the constructor itself. +#[track_caller] +pub(crate) fn assert_nonce_out_of_bounds_construction_error( + construction_result: Result, +) { + let result = construction_result.map_err(Error::Protocol); + let has_nonce_error = match &result { + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) => matches!( + **consensus_error, + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError(_)), + ), + Err(Error::Protocol(ProtocolError::ConsensusErrors(consensus_errors))) => { + consensus_errors.iter().any(|consensus_error| { + matches!( + consensus_error, + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError(_)), + ) + }) + } + _ => false, + }; + + assert!( + has_nonce_error, + "expected NonceOutOfBoundsError, got {result:?}", + ); +} + +pub(crate) async fn new_mock_sdk_with_contract_nonce( + identity_id: Identifier, + contract_id: Identifier, + fetched_nonce: u64, +) -> Sdk { + let mut sdk = SdkBuilder::new_mock().build().expect("build mock sdk"); + + sdk.mock() + .expect_fetch::( + (identity_id, contract_id), + Some(IdentityContractNonceFetcher(fetched_nonce)), + ) + .await + .expect("set nonce fetch expectation"); + + sdk +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index eeb92c51877..d5075c83ac3 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -183,3 +183,11 @@ impl TokenBurnTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_burn_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/claim.rs b/packages/rs-sdk/src/platform/tokens/builders/claim.rs index e4269ae5089..00688e9b80d 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -169,3 +169,11 @@ impl TokenClaimTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_claim_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs index ce10f74c7bd..c7ca792d0ce 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -190,3 +190,11 @@ impl TokenConfigUpdateTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_config_update_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs index 5c614374223..2343042a3a8 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -189,3 +189,11 @@ impl TokenDestroyFrozenFundsTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_destroy_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs index 21cbeb32275..4d5df31e8c2 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -217,3 +217,11 @@ impl TokenEmergencyActionTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_emergency_action_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs index eef7ecd01a1..91a7a280785 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -189,3 +189,11 @@ impl TokenFreezeTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_freeze_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index 350032f4119..0506bce8d6c 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -210,3 +210,11 @@ impl TokenMintTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_mint_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/mod.rs b/packages/rs-sdk/src/platform/tokens/builders/mod.rs index eec8c140062..bc675dcdfc9 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mod.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mod.rs @@ -9,5 +9,7 @@ pub mod freeze; pub mod mint; pub mod purchase; pub mod set_price; +#[cfg(test)] +mod tests; pub mod transfer; pub mod unfreeze; diff --git a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index e7037326fef..ad4bfda4e27 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -158,3 +158,11 @@ impl TokenDirectPurchaseTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_purchase_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs index de761c5fa57..cba3af7da69 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -234,3 +234,11 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_set_price_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/tests.rs b/packages/rs-sdk/src/platform/tokens/builders/tests.rs new file mode 100644 index 00000000000..477325d3121 --- /dev/null +++ b/packages/rs-sdk/src/platform/tokens/builders/tests.rs @@ -0,0 +1,1134 @@ +use super::burn::TokenBurnTransitionBuilder; +use super::claim::TokenClaimTransitionBuilder; +use super::config_update::TokenConfigUpdateTransitionBuilder; +use super::destroy::TokenDestroyFrozenFundsTransitionBuilder; +use super::emergency_action::TokenEmergencyActionTransitionBuilder; +use super::freeze::TokenFreezeTransitionBuilder; +use super::mint::TokenMintTransitionBuilder; +use super::purchase::TokenDirectPurchaseTransitionBuilder; +use super::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; +use super::transfer::TokenTransferTransitionBuilder; +use super::unfreeze::TokenUnfreezeTransitionBuilder; +use crate::platform::test_helpers::{ + assert_nonce_out_of_bounds_construction_error, new_mock_sdk_with_contract_nonce, + test_data_contract, test_identity_public_key, TestSigner, INVALID_NONCE, + TEST_DOCUMENT_TYPE_NAME, +}; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; +use dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; +use dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; +use dpp::prelude::Identifier; +use dpp::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::tokens::calculate_token_id; +use dpp::tokens::emergency_action::TokenEmergencyAction; +use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; +use dpp::ProtocolError; +use std::sync::Arc; + +const TEST_TOKEN_POSITION: u16 = 0; + +fn token_setup() -> ( + Arc, + Identifier, + Identifier, +) { + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let token_id = Identifier::from(calculate_token_id( + data_contract.id().as_bytes(), + TEST_TOKEN_POSITION, + )); + (data_contract, owner_id, token_id) +} + +pub(super) async fn assert_token_burn_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_burn_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + 1, + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_claim_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_claim_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + TokenDistributionType::PreProgrammed, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_config_update_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + // Use a structurally-valid config change so the nonce check is the first + // base-structure error reported (TokenConfigurationNoChange would trip + // InvalidTokenConfigUpdateNoChangeError before nonce is reported). + let result = BatchTransition::new_token_config_update_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + TokenConfigurationChangeItem::MintingAllowChoosingDestination(true), + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_destroy_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_destroy_frozen_funds_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + Identifier::random(), + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_emergency_action_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_emergency_action_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + TokenEmergencyAction::Pause, + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_freeze_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_freeze_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + Identifier::random(), + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_mint_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_mint_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + 1, + Some(Identifier::random()), + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_purchase_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_direct_purchase_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + 1, + 10, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_set_price_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_change_direct_purchase_price_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + Some(TokenPricingSchedule::SinglePrice(5)), + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_transfer_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_transfer_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + 1, + Identifier::random(), + None, + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +pub(super) async fn assert_token_unfreeze_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let (data_contract, owner_id, token_id) = token_setup(); + let result = BatchTransition::new_token_unfreeze_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + Identifier::random(), + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .await; + + assert_nonce_out_of_bounds_construction_error(result); +} + +#[tokio::test] +async fn token_mint_sign_returns_invalid_token_amount_error_when_amount_is_zero() { + let issuer_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(issuer_id, data_contract.id(), 0).await; + + let result = TokenMintTransitionBuilder::new(Arc::clone(&data_contract), 0, issuer_id, 0) + .issued_to_identity_id(Identifier::random()) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_mint_sign_returns_invalid_action_id_error_for_mismatched_group_action_id() { + let issuer_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(issuer_id, data_contract.id(), 0).await; + + let invalid_group_info = GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id: Identifier::from_bytes(&[0; 32]).expect("create static action id"), + action_is_proposer: true, + }, + ); + + let result = TokenMintTransitionBuilder::new(Arc::clone(&data_contract), 0, issuer_id, 1) + .issued_to_identity_id(Identifier::random()) + .with_using_group_info(invalid_group_info) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidActionIdError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_burn_sign_returns_invalid_token_amount_error_when_amount_is_zero() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenBurnTransitionBuilder::new(Arc::clone(&data_contract), 0, owner_id, 0) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_burn_sign_returns_note_too_big_error() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenBurnTransitionBuilder::new(Arc::clone(&data_contract), 0, owner_id, 1) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_transfer_sign_returns_invalid_token_amount_error_when_amount_is_zero() { + let sender_id = Identifier::random(); + let recipient_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(sender_id, data_contract.id(), 0).await; + + let result = TokenTransferTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + sender_id, + recipient_id, + 0, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_transfer_sign_returns_transfer_to_ourself_error() { + let sender_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(sender_id, data_contract.id(), 0).await; + + let result = + TokenTransferTransitionBuilder::new(Arc::clone(&data_contract), 0, sender_id, sender_id, 1) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::TokenTransferToOurselfError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_transfer_sign_returns_note_too_big_error_for_public_note() { + let sender_id = Identifier::random(); + let recipient_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(sender_id, data_contract.id(), 0).await; + + let result = TokenTransferTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + sender_id, + recipient_id, + 1, + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_freeze_sign_returns_note_too_big_error() { + let actor_id = Identifier::random(); + let freeze_identity_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = TokenFreezeTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + actor_id, + freeze_identity_id, + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_unfreeze_sign_returns_note_too_big_error() { + let actor_id = Identifier::random(); + let unfreeze_identity_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = TokenUnfreezeTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + actor_id, + unfreeze_identity_id, + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_destroy_sign_returns_note_too_big_error() { + let actor_id = Identifier::random(); + let frozen_identity_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = TokenDestroyFrozenFundsTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + actor_id, + frozen_identity_id, + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_emergency_action_sign_returns_note_too_big_error() { + let actor_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = + TokenEmergencyActionTransitionBuilder::pause(Arc::clone(&data_contract), 0, actor_id) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_config_update_sign_returns_no_change_error() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenConfigUpdateTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + owner_id, + TokenConfigurationChangeItem::TokenConfigurationNoChange, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenConfigUpdateNoChangeError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_config_update_sign_returns_note_too_big_error() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenConfigUpdateTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + owner_id, + TokenConfigurationChangeItem::MintingAllowChoosingDestination(true), + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_claim_sign_returns_note_too_big_error() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenClaimTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + owner_id, + TokenDistributionType::PreProgrammed, + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_purchase_sign_returns_invalid_token_amount_error_when_amount_is_zero() { + let actor_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = + TokenDirectPurchaseTransitionBuilder::new(Arc::clone(&data_contract), 0, actor_id, 0, 1000) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +#[tokio::test] +async fn token_set_price_sign_returns_note_too_big_error() { + let issuer_id = Identifier::random(); + let data_contract = test_data_contract("testDoc"); + let sdk = new_mock_sdk_with_contract_nonce(issuer_id, data_contract.id(), 0).await; + + let result = TokenChangeDirectPurchasePriceTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + issuer_id, + ) + .with_public_note("x".repeat(2049)) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + matches!( + result, + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} + +// ── Happy-path sign() tests ──────────────────────────────────────────── + +#[tokio::test] +async fn token_mint_sign_succeeds_for_valid_input() { + let issuer_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(issuer_id, data_contract.id(), 0).await; + + let result = TokenMintTransitionBuilder::new(Arc::clone(&data_contract), 0, issuer_id, 1) + .issued_to_identity_id(Identifier::random()) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_burn_sign_succeeds_for_valid_input() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenBurnTransitionBuilder::new(Arc::clone(&data_contract), 0, owner_id, 1) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_transfer_sign_succeeds_for_valid_input() { + let sender_id = Identifier::random(); + let recipient_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(sender_id, data_contract.id(), 0).await; + + let result = TokenTransferTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + sender_id, + recipient_id, + 1, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_freeze_sign_succeeds_for_valid_input() { + let actor_id = Identifier::random(); + let freeze_identity_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = TokenFreezeTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + actor_id, + freeze_identity_id, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_unfreeze_sign_succeeds_for_valid_input() { + let actor_id = Identifier::random(); + let unfreeze_identity_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = TokenUnfreezeTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + actor_id, + unfreeze_identity_id, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_destroy_sign_succeeds_for_valid_input() { + let actor_id = Identifier::random(); + let frozen_identity_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = TokenDestroyFrozenFundsTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + actor_id, + frozen_identity_id, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_emergency_action_sign_succeeds_for_valid_input() { + let actor_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = + TokenEmergencyActionTransitionBuilder::pause(Arc::clone(&data_contract), 0, actor_id) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_config_update_sign_succeeds_for_valid_input() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenConfigUpdateTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + owner_id, + TokenConfigurationChangeItem::MintingAllowChoosingDestination(true), + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_claim_sign_succeeds_for_valid_input() { + let owner_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 0).await; + + let result = TokenClaimTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + owner_id, + TokenDistributionType::PreProgrammed, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_purchase_sign_succeeds_for_valid_input() { + let actor_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(actor_id, data_contract.id(), 0).await; + + let result = + TokenDirectPurchaseTransitionBuilder::new(Arc::clone(&data_contract), 0, actor_id, 1, 1000) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} + +#[tokio::test] +async fn token_set_price_sign_succeeds_for_valid_input() { + let issuer_id = Identifier::random(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let sdk = new_mock_sdk_with_contract_nonce(issuer_id, data_contract.id(), 0).await; + + let result = TokenChangeDirectPurchasePriceTransitionBuilder::new( + Arc::clone(&data_contract), + 0, + issuer_id, + ) + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "valid input should pass validation; got error: {:?}", + result.err() + ); + let st = result.unwrap(); + assert!( + st.signature().is_some_and(|sig| !sig.is_empty()), + "transition should have a non-empty signature" + ); +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index a8e93f54be6..7d356786187 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -215,3 +215,11 @@ impl TokenTransferTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_transfer_validate_base_structure_error().await; + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs index 83a1dd2f8b6..09e537544c4 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -189,3 +189,11 @@ impl TokenUnfreezeTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_unfreeze_validate_base_structure_error().await; + } +} diff --git a/packages/swift-sdk/README.md b/packages/swift-sdk/README.md index 1e7ceffd153..73b5082b088 100644 --- a/packages/swift-sdk/README.md +++ b/packages/swift-sdk/README.md @@ -38,6 +38,24 @@ cargo build --release import SwiftDashSDK ``` +### Structured Consensus Errors + +Public Swift errors keep `SDKError` associated `String` payloads clean and +human-readable — `case .protocolError(let message)` produces the original FFI +message. Public throwing wrappers still throw `SDKError` for source +compatibility, but scalar `SDKError` values do not retain structured +`DashSDKConsensusError` entries. A scalar enum value has no stable identity, so +attaching process-global details by `(code, message)` can misattribute +same-signature concurrent failures. + +If you are working directly with the FFI `DashSDKError` pointer, inspect +`SDKError.consensusErrors(fromDashSDKError:)` or +`SDKError.fromDashSDKErrorWithConsensusErrors(_:)` before +`dash_sdk_error_free` to retrieve race-free structured consensus details. Code +that wants to pass both values around together can explicitly wrap them in +`SDKDetailedError`. Public Swift throwing wrappers do not throw +`SDKDetailedError`. + ## API Reference ### Identity Operations diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift index c804d24fec4..7993face113 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift @@ -32,9 +32,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -156,9 +154,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -282,9 +278,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -398,9 +392,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -485,9 +477,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -566,9 +556,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -780,9 +768,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -954,9 +940,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -1167,9 +1151,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -1283,10 +1265,8 @@ public class Addresses: @unchecked Sendable { dash_sdk_identity_parse_json(cString) } - guard parseResult.error == nil else { - let error = parseResult.error!.pointee - defer { dash_sdk_error_free(parseResult.error) } - throw SDKError.fromDashSDKError(error) + if let errorPtr = parseResult.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard let identityHandlePtr = parseResult.data else { @@ -1341,9 +1321,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -1423,10 +1401,8 @@ public class Addresses: @unchecked Sendable { dash_sdk_identity_parse_json(cString) } - guard parseResult.error == nil else { - let error = parseResult.error!.pointee - defer { dash_sdk_error_free(parseResult.error) } - throw SDKError.fromDashSDKError(error) + if let errorPtr = parseResult.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard let identityHandlePtr = parseResult.data else { @@ -1448,9 +1424,7 @@ public class Addresses: @unchecked Sendable { guard signerResult.error == nil, let signer = signerResult.data else { if let error = signerResult.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } throw SDKError.internalError("Failed to create signer") } @@ -1494,9 +1468,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -1577,10 +1549,8 @@ public class Addresses: @unchecked Sendable { dash_sdk_identity_parse_json(cString) } - guard parseResult.error == nil else { - let error = parseResult.error!.pointee - defer { dash_sdk_error_free(parseResult.error) } - throw SDKError.fromDashSDKError(error) + if let errorPtr = parseResult.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard let identityHandlePtr = parseResult.data else { @@ -1603,9 +1573,7 @@ public class Addresses: @unchecked Sendable { let signer = signerResult.data else { dash_sdk_identity_destroy(identityHandle) // Clean up identity handle if let error = signerResult.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } throw SDKError.internalError("Failed to create signer") } @@ -1683,9 +1651,7 @@ public class Addresses: @unchecked Sendable { // Check for errors if let error = result.error { - let sdkError = SDKError.fromDashSDKError(error.pointee) - dash_sdk_error_free(error) - throw sdkError + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 874ef15e29e..82f5a319b9f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -17,10 +17,9 @@ extension SDK { print("🔵 processJSONResult: Processing result...") if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - print("❌ processJSONResult: FFI returned error: \(errorMessage)") - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + let mapped = SDKError.consumeDashSDKError(error) + print("❌ processJSONResult: FFI returned error: \(mapped.localizedDescription)") + throw mapped } guard let dataPtr = result.data else { @@ -51,9 +50,7 @@ extension SDK { /// Process DashSDKResult and extract JSON array private func processJSONArrayResult(_ result: DashSDKResult) throws -> [[String: Any]] { if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -74,9 +71,7 @@ extension SDK { /// Process DashSDKResult and extract string private func processStringResult(_ result: DashSDKResult) throws -> String { if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -276,9 +271,7 @@ extension SDK { // Check for error if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError("Failed to fetch data contract: \(errorMessage)") + throw SDKError.consumeDashSDKError(error) } // Get the JSON string @@ -357,9 +350,7 @@ extension SDK { // First fetch the data contract let contractResult = dash_sdk_data_contract_fetch(handle, dataContractId) if let error = contractResult.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError("Failed to fetch data contract: \(errorMessage)") + throw SDKError.consumeDashSDKError(error) } guard let contractHandle = contractResult.data else { @@ -448,9 +439,7 @@ extension SDK { // First fetch the data contract let contractResult = dash_sdk_data_contract_fetch(handle, dataContractId) if let error = contractResult.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError("Failed to fetch data contract: \(errorMessage)") + throw SDKError.consumeDashSDKError(error) } guard let contractHandle = contractResult.data else { @@ -467,9 +456,7 @@ extension SDK { let documentResult = dash_sdk_document_fetch(handle, contractHandle.assumingMemoryBound(to: DataContractHandle.self), documentType, documentId) if let error = documentResult.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError("Failed to fetch document: \(errorMessage)") + throw SDKError.consumeDashSDKError(error) } guard let documentHandle = documentResult.data else { @@ -694,9 +681,7 @@ extension SDK { // Check for error if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } // Parse the JSON result @@ -788,9 +773,7 @@ extension SDK { let result = dash_sdk_identity_resolve_name(handle, name) if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -986,9 +969,7 @@ extension SDK { // Special handling for protocol version upgrade state which returns an array if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } // If no data, return empty result @@ -1215,11 +1196,7 @@ extension SDK { } if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { @@ -1241,9 +1218,7 @@ extension SDK { // Special handling for this query - null means no claim found if let error = result.error { - let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw SDKError.consumeDashSDKError(error) } guard let dataPtr = result.data else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index be0232a1c34..e2049811c53 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -15,6 +15,16 @@ import DashSDKFFI // MARK: - Sendable wrappers private final class SendableOpaque: @unchecked Sendable { let p: OpaquePointer; init(_ p: OpaquePointer) { self.p = p } } +private func consumeDashSDKErrorOrInternal( + _ error: UnsafeMutablePointer?, + fallbackMessage: String +) -> Error { + guard let error else { + return SDKError.internalError(fallbackMessage) + } + return SDKError.consumeDashSDKError(error) +} + // MARK: - Key Selection Helpers /// Helper to select the appropriate key for signing operations @@ -51,7 +61,7 @@ private func selectSigningKey(from identity: DPPIdentity, operation: String) -> } /// Helper to create a public key handle from an IdentityPublicKey -private func createPublicKeyHandle(from key: IdentityPublicKey, operation: String) -> UnsafeMutablePointer? { +private func createPublicKeyHandle(from key: IdentityPublicKey, operation: String) throws -> UnsafeMutablePointer { let keyData = key.data let keyType = key.keyType.ffiValue let purpose = key.purpose.ffiValue @@ -70,17 +80,15 @@ private func createPublicKeyHandle(from key: IdentityPublicKey, operation: Strin ) } - guard keyResult.error == nil else { - let errorString = keyResult.error?.pointee.message != nil ? - String(cString: keyResult.error!.pointee.message) : "Failed to create public key handle" - print("❌ [\(operation)] Key handle creation failed: \(errorString)") - dash_sdk_error_free(keyResult.error) - return nil + if let error = keyResult.error { + let mapped = SDKError.consumeDashSDKError(error) + print("❌ [\(operation)] Key handle creation failed: \(mapped.localizedDescription)") + throw mapped } guard let keyHandle = keyResult.data else { print("❌ [\(operation)] Invalid public key handle") - return nil + throw SDKError.internalError("Failed to create public key handle") } print("✅ [\(operation)] Public key handle created from local data") @@ -198,9 +206,10 @@ extension SDK { } if let error = result.error { - let errorString = String(cString: error.pointee.message) - dash_sdk_error_free(error) - throw SDKError.internalError(errorString) + throw consumeDashSDKErrorOrInternal( + error, + fallbackMessage: "Failed to create identity handle" + ) } guard let handle = result.data else { @@ -262,7 +271,12 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume( + throwing: consumeDashSDKErrorOrInternal( + result.error, + fallbackMessage: errorString + ) + ) } } } @@ -336,7 +350,12 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume( + throwing: consumeDashSDKErrorOrInternal( + result.error, + fallbackMessage: errorString + ) + ) } } } @@ -388,7 +407,12 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume( + throwing: consumeDashSDKErrorOrInternal( + result.error, + fallbackMessage: errorString + ) + ) } } } @@ -445,7 +469,12 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume( + throwing: consumeDashSDKErrorOrInternal( + result.error, + fallbackMessage: errorString + ) + ) } } } @@ -516,8 +545,7 @@ extension SDK { String(cString: createResult.error!.pointee.message) : "Failed to create document" print("❌ [DOCUMENT CREATE] Document creation failed: \(errorString)") print("⏱️ [DOCUMENT CREATE] Total time before failure: \(Date().timeIntervalSince(startTime)) seconds") - dash_sdk_error_free(createResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(createResult.error, fallbackMessage: errorString)) return } @@ -554,9 +582,12 @@ extension SDK { } // Create public key handle - guard let keyHandle = createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT CREATE") else { + let keyHandle: UnsafeMutablePointer + do { + keyHandle = try createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT CREATE") + } catch { print("⏱️ [DOCUMENT CREATE] Total time before failure: \(Date().timeIntervalSince(startTime)) seconds") - continuation.resume(throwing: SDKError.internalError("Failed to create public key handle")) + continuation.resume(throwing: error) return } @@ -604,8 +635,7 @@ extension SDK { String(cString: error.pointee.message) : "Failed to put document to platform" print("❌ [DOCUMENT CREATE] Platform submission failed: \(errorString)") print("⏱️ [DOCUMENT CREATE] Total operation time: \(Date().timeIntervalSince(startTime)) seconds") - dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) } else if putResult.data_type == DashSDKFFI.String, let jsonData = putResult.data { // Parse the returned JSON @@ -674,7 +704,7 @@ extension SDK { if let error = contractResult.error { let errorMsg = String(cString: error.pointee.message) print("❌ [DOCUMENT REPLACE] Failed to fetch contract: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) } else { continuation.resume(throwing: SDKError.notFound("Contract not found")) } @@ -704,9 +734,8 @@ extension SDK { guard fetchResult.error == nil else { let errorString = fetchResult.error?.pointee.message != nil ? String(cString: fetchResult.error!.pointee.message) : "Failed to fetch document" - dash_sdk_error_free(fetchResult.error) print("❌ [DOCUMENT REPLACE] Failed to fetch document: \(errorString)") - continuation.resume(throwing: SDKError.internalError("Failed to fetch document: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(fetchResult.error, fallbackMessage: errorString)) return } @@ -739,8 +768,11 @@ extension SDK { } // Create public key handle - guard let keyHandle = createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT REPLACE") else { - continuation.resume(throwing: SDKError.internalError("Failed to create public key handle")) + let keyHandle: UnsafeMutablePointer + do { + keyHandle = try createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT REPLACE") + } catch { + continuation.resume(throwing: error) return } @@ -772,10 +804,9 @@ extension SDK { print("⏱️ [DOCUMENT REPLACE] Platform submission took \(replaceTime) seconds") if let error = replaceResult.error { - print("❌ [DOCUMENT REPLACE] Replace failed after \(replaceTime) seconds") let errorString = String(cString: error.pointee.message) - dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError(errorString)) + print("❌ [DOCUMENT REPLACE] Replace failed after \(replaceTime) seconds: \(errorString)") + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) } else if replaceResult.data_type == DashSDKFFI.ResultDocumentHandle, let resultHandle = replaceResult.data { // Document was successfully replaced @@ -830,9 +861,7 @@ extension SDK { } // Create public key handle - guard let keyHandle = createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT DELETE") else { - throw SDKError.protocolError("Failed to create public key handle") - } + let keyHandle = try createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT DELETE") defer { dash_sdk_identity_public_key_destroy(keyHandle) @@ -862,8 +891,8 @@ extension SDK { if let error = result.error { let errorMessage = String(cString: error.pointee.message) - dash_sdk_error_free(error) - throw SDKError.protocolError(errorMessage) + print("❌ [DOCUMENT DELETE] Failed: \(errorMessage)") + throw SDKError.consumeDashSDKError(error) } let totalTime = Date().timeIntervalSince(startTime) @@ -917,8 +946,11 @@ extension SDK { } // Create public key handle - guard let keyHandle = createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT TRANSFER") else { - continuation.resume(throwing: SDKError.internalError("Failed to create key handle")) + let keyHandle: UnsafeMutablePointer + do { + keyHandle = try createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT TRANSFER") + } catch { + continuation.resume(throwing: error) return } @@ -937,7 +969,7 @@ extension SDK { if let error = contractResult.error { let errorMsg = String(cString: error.pointee.message) print("❌ [DOCUMENT TRANSFER] Failed to fetch contract: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) } else { continuation.resume(throwing: SDKError.notFound("Contract not found")) } @@ -968,10 +1000,13 @@ extension SDK { guard fetchResult.error == nil, let documentHandle = fetchResult.data else { - let error = fetchResult.error.pointee - let errorMsg = String(cString: error.message) - print("❌ [DOCUMENT TRANSFER] Failed to fetch document: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + if let error = fetchResult.error { + let errorMsg = String(cString: error.pointee.message) + print("❌ [DOCUMENT TRANSFER] Failed to fetch document: \(errorMsg)") + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) + } else { + continuation.resume(throwing: SDKError.notFound("Document not found")) + } return } @@ -999,11 +1034,10 @@ extension SDK { nil // state_transition_creation_options ) - guard transitionResult.error == nil else { - let error = transitionResult.error.pointee - let errorMsg = String(cString: error.message) + if let error = transitionResult.error { + let errorMsg = String(cString: error.pointee.message) print("❌ [DOCUMENT TRANSFER] Failed to create transition: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1026,13 +1060,13 @@ extension SDK { let transferTime = Date().timeIntervalSince(transferStartTime) print("🔄 [DOCUMENT TRANSFER] Transfer operation took \(transferTime) seconds") - if result.error != nil { - let error = result.error.pointee - let errorMsg = String(cString: error.message) + if let error = result.error { + let errorMsg = String(cString: error.pointee.message) // Check if it's the "already in chain" error if errorMsg.contains("already in chain") || errorMsg.contains("AlreadyExists") { print("⚠️ [DOCUMENT TRANSFER] State transition already in chain - treating as success") + dash_sdk_error_free(error) let totalTime = Date().timeIntervalSince(startTime) print("✅ [DOCUMENT TRANSFER] Successfully transferred in \(totalTime) seconds") @@ -1046,7 +1080,7 @@ extension SDK { } print("❌ [DOCUMENT TRANSFER] Broadcast failed: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1093,11 +1127,10 @@ extension SDK { dash_sdk_data_contract_fetch(handle, contractIdCStr) } - guard contractResult.error == nil else { - let error = contractResult.error.pointee - let errorMsg = String(cString: error.message) + if let error = contractResult.error { + let errorMsg = String(cString: error.pointee.message) print("❌ [DOCUMENT UPDATE PRICE] Failed to fetch contract: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1125,11 +1158,10 @@ extension SDK { } } - guard fetchResult.error == nil else { - let error = fetchResult.error.pointee - let errorMsg = String(cString: error.message) + if let error = fetchResult.error { + let errorMsg = String(cString: error.pointee.message) print("❌ [DOCUMENT UPDATE PRICE] Failed to fetch document: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1152,8 +1184,11 @@ extension SDK { return } - guard let keyHandle = createPublicKeyHandle(from: keyToUse, operation: "UPDATE_PRICE") else { - continuation.resume(throwing: SDKError.serializationError("Failed to create key handle")) + let keyHandle: UnsafeMutablePointer + do { + keyHandle = try createPublicKeyHandle(from: keyToUse, operation: "UPDATE_PRICE") + } catch { + continuation.resume(throwing: error) return } @@ -1180,11 +1215,10 @@ extension SDK { } } - if updateResult.error != nil { - let error = updateResult.error.pointee - let errorMsg = String(cString: error.message) + if let error = updateResult.error { + let errorMsg = String(cString: error.pointee.message) print("❌ [DOCUMENT UPDATE PRICE] Failed: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1237,8 +1271,11 @@ extension SDK { } // Create public key handle - guard let keyHandle = createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT PURCHASE") else { - continuation.resume(throwing: SDKError.internalError("Failed to create key handle")) + let keyHandle: UnsafeMutablePointer + do { + keyHandle = try createPublicKeyHandle(from: keyToUse, operation: "DOCUMENT PURCHASE") + } catch { + continuation.resume(throwing: error) return } @@ -1254,8 +1291,8 @@ extension SDK { if let error = contractResult.error { let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError("Failed to fetch contract: \(errorMessage)")) + print("❌ [DOCUMENT PURCHASE] Failed to fetch contract: \(errorMessage)") + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1278,8 +1315,8 @@ extension SDK { if let error = documentResult.error { let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError("Failed to fetch document: \(errorMessage)")) + print("❌ [DOCUMENT PURCHASE] Failed to fetch document: \(errorMessage)") + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1318,13 +1355,12 @@ extension SDK { if let error = result.error { let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" - dash_sdk_error_free(error) print("❌ [DOCUMENT PURCHASE] Failed: \(errorMessage)") let totalTime = Date().timeIntervalSince(startTime) print("❌ [DOCUMENT PURCHASE] Total time: \(totalTime) seconds") - continuation.resume(throwing: SDKError.internalError("Document purchase failed: \(errorMessage)")) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1478,8 +1514,7 @@ extension SDK { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" print("❌ TOKEN MINT: Failed to get public key handle: \(errorString)") - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -1566,8 +1601,7 @@ extension SDK { String(cString: result.error!.pointee.message) : "Unknown error" let errorCode = result.error?.pointee.code.rawValue ?? 0 print("❌ TOKEN MINT: Failed with error code \(errorCode): \(errorString)") - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token mint failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -1634,8 +1668,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -1698,8 +1731,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token freeze failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -1766,8 +1798,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -1830,8 +1861,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token unfreeze failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -1888,8 +1918,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -1950,8 +1979,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token burn failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -2018,8 +2046,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -2082,8 +2109,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token destroy frozen funds failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -2133,8 +2159,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -2207,8 +2232,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token claim failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -2269,8 +2293,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -2336,8 +2359,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token transfer failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } @@ -2388,8 +2410,7 @@ extension SDK { let keyHandleData = keyHandleResult.data else { let errorString = keyHandleResult.error?.pointee.message != nil ? String(cString: keyHandleResult.error!.pointee.message) : "Failed to get public key" - dash_sdk_error_free(keyHandleResult.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(keyHandleResult.error, fallbackMessage: errorString)) return } @@ -2476,8 +2497,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" - dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token set price failed: \(errorString)")) + continuation.resume(throwing: consumeDashSDKErrorOrInternal(result.error, fallbackMessage: errorString)) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 39147c56ed1..5c630070183 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -128,14 +128,8 @@ public final class SDK: @unchecked Sendable { } // Check for errors - if result.error != nil { - let error = result.error!.pointee - let errorMessage = error.message != nil ? String(cString: error.message!) : "Unknown error" - defer { - dash_sdk_error_free(result.error) - } - - throw SDKError.internalError("Failed to create SDK: \(errorMessage)") + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard result.data != nil else { @@ -186,14 +180,8 @@ public final class SDK: @unchecked Sendable { } // Check for errors - if result.error != nil { - let error = result.error!.pointee - let errorMessage = error.message != nil ? String(cString: error.message!) : "Unknown error" - defer { - dash_sdk_error_free(result.error) - } - - throw SDKError.internalError("Failed to add known contracts: \(errorMessage)") + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } print("✅ Successfully loaded \(contracts.count) known contracts into SDK") @@ -214,13 +202,8 @@ public final class SDK: @unchecked Sendable { let result = dash_sdk_get_status(handle) // Check for error - if result.error != nil { - let error = result.error!.pointee - let errorMessage = error.message != nil ? String(cString: error.message!) : "Unknown error" - defer { - dash_sdk_error_free(result.error) - } - throw SDKError.internalError("Failed to get SDK status: \(errorMessage)") + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } // Parse the JSON result @@ -272,7 +255,71 @@ public struct SDKStatus: Codable { public let quorumCount: Int } +/// Structured detail for a single protocol consensus error, mirroring +/// `DashSDKConsensusError` from the Rust FFI layer. +public struct SDKConsensusError: Equatable, Sendable { + /// Numeric consensus error code from DPP's `ErrorWithCode`. + public let code: UInt32 + /// High-level kind, e.g. `BasicError`, `StateError`, `SignatureError`, + /// `FeeError`, `DefaultError`. + public let kind: String + /// Specific consensus error variant name (e.g. `NonceOutOfBoundsError`, + /// `IdentityAlreadyExistsError`). This is distinct from `kind` and is + /// derived on the Rust side from the inner consensus-error variant. + public let name: String + /// Human-readable message. + public let message: String + + public init(code: UInt32, kind: String, name: String, message: String) { + self.code = code + self.kind = kind + self.name = name + self.message = message + } +} + +/// Wrapper error for callers that explicitly want to bundle an `SDKError` with +/// structured protocol consensus details. +/// +/// `SDKError`'s associated `String` values are kept clean — they never embed +/// private payload markers, so `case .protocolError(let message)` matches +/// produce the original human-readable message. +/// +/// Public Swift wrappers throw `SDKError` for scalar errors and upgrade to this +/// wrapper when a consumed FFI error still carries structured consensus +/// details. +public struct SDKDetailedError: Error, LocalizedError { + public let sdkError: SDKError + public let consensusErrors: [SDKConsensusError] + + public init(sdkError: SDKError, consensusErrors: [SDKConsensusError]) { + self.sdkError = sdkError + self.consensusErrors = consensusErrors + } + + public var errorDescription: String? { + let summary = sdkError.localizedDescription + guard !consensusErrors.isEmpty else { + return summary + } + + let details = consensusErrors.enumerated().map { index, detail in + let kind = detail.kind.isEmpty ? "Consensus" : detail.kind + let name = detail.name.isEmpty ? "Unknown" : detail.name + return "\(index + 1). [\(kind)] \(name) (\(detail.code)): \(detail.message)" + }.joined(separator: "\n") + + return "\(summary)\nConsensus details:\n\(details)" + } +} + /// SDK Error handling +/// +/// Associated `String` values are clean human-readable messages with no +/// private embedded payload — `case .protocolError(let message)` matches +/// produce exactly the message returned by the FFI. Structured protocol +/// consensus details are only race-free on the original FFI error pointer and +/// are not retained on the public scalar `SDKError` value. public enum SDKError: Error { case invalidParameter(String) case invalidState(String) @@ -286,10 +333,39 @@ public enum SDKError: Error { case internalError(String) case unknown(String) + /// Map a Rust FFI `DashSDKError` into a Swift `SDKError`. + /// + /// This pointer-typed overload only produces the scalar mapping. To also + /// retrieve structured consensus details before the pointer is freed, use + /// `fromDashSDKErrorWithConsensusErrors(_:)` or + /// `consensusErrors(fromDashSDKError:)`. + public static func fromDashSDKError(_ error: UnsafePointer) -> SDKError { + let raw = error.pointee + let message = raw.message != nil ? String(cString: raw.message!) : "Unknown error" + return mapScalar(code: raw.code, message: message) + } + + /// Source-compatibility overload that maps a value-typed `DashSDKError` + /// into an `SDKError`. This overload cannot resolve the structured + /// consensus details owned by the original heap pointer returned by the FFI. + /// Callers that need those details must use the pointer-typed + /// `fromDashSDKError(_:)` overload combined with + /// `consensusErrors(fromDashSDKError:)`. + @available( + *, deprecated, + message: + "Use the pointer-typed fromDashSDKError(_:) overload before freeing the FFI error so structured consensus details remain accessible." + ) public static func fromDashSDKError(_ error: DashSDKError) -> SDKError { let message = error.message != nil ? String(cString: error.message!) : "Unknown error" + return mapScalar(code: error.code, message: message) + } - switch error.code { + private static func mapScalar( + code: DashSDKErrorCode, + message: String + ) -> SDKError { + switch code { case DashSDKErrorCode(rawValue: 1): // Invalid parameter return .invalidParameter(message) case DashSDKErrorCode(rawValue: 2): // Invalid state @@ -314,38 +390,161 @@ public enum SDKError: Error { return .unknown(message) } } + + /// Structured consensus details are not retained on `SDKError`. + /// + /// A scalar `SDKError` value does not have stable identity, so attaching + /// process-global structured details to matching `(code, message)` pairs can + /// misattribute concurrent same-signature failures, lose delayed catches, and + /// produce one-shot reads. Callers that need race-free structured consensus + /// details must read them from the original FFI pointer via + /// `fromDashSDKErrorWithConsensusErrors(_:)` or + /// `consensusErrors(fromDashSDKError:)` before `dash_sdk_error_free` runs. + @available( + *, deprecated, + message: + "Scalar SDKError does not retain structured consensus details; read them from the FFI pointer before free or wrap them explicitly in SDKDetailedError." + ) + public var consensusErrors: [SDKConsensusError]? { + nil + } + + /// Returns the structured consensus errors carried by `error`, if any. + /// + /// Takes the **original** heap pointer that the FFI returned. Sidecar + /// lookup is keyed on the pointer's identity, so a copied `DashSDKError` + /// value will not resolve structured details. Must be called before + /// `dash_sdk_error_free` is invoked on this pointer. + public static func consensusErrors(fromDashSDKError error: UnsafePointer) + -> [SDKConsensusError]? + { + let count = Int(dash_sdk_error_consensus_error_count(error)) + guard count > 0 else { return nil } + + var result: [SDKConsensusError] = [] + result.reserveCapacity(count) + for index in 0..) + -> (SDKError, [SDKConsensusError]?) + { + let mapped = fromDashSDKError(error) + let details = consensusErrors(fromDashSDKError: error) + return (mapped, details) + } + + // Shared finalization path so tests can verify wrapper behavior without + // depending on FFI-owned pointers. + static func finalizeConsumedDashSDKError(_ error: SDKError) -> SDKError { + return error + } + + /// Frees the owned FFI error pointer after mapping it to a Swift error. + /// + /// Public throwing wrappers intentionally collapse the FFI error to scalar + /// `SDKError` for source compatibility. Callers that need structured + /// consensus details must inspect the pointer with + /// `consensusErrors(fromDashSDKError:)` or + /// `fromDashSDKErrorWithConsensusErrors(_:)` before this free runs. + static func consumeDashSDKError(_ error: UnsafeMutablePointer) -> SDKError { + let pointer = UnsafePointer(error) + let mapped = fromDashSDKError(pointer) + let finalized = finalizeConsumedDashSDKError(mapped) + dash_sdk_error_free(error) + return finalized + } + + /// Human-readable message carried by this error, regardless of case. + public var message: String { + switch self { + case .invalidParameter(let message), + .invalidState(let message), + .networkError(let message), + .serializationError(let message), + .protocolError(let message), + .cryptoError(let message), + .notFound(let message), + .timeout(let message), + .notImplemented(let message), + .internalError(let message), + .unknown(let message): + return message + } + } + + var code: UInt32 { + switch self { + case .invalidParameter: + return 1 + case .invalidState: + return 2 + case .networkError: + return 3 + case .serializationError: + return 4 + case .protocolError: + return 5 + case .cryptoError: + return 6 + case .notFound: + return 7 + case .timeout: + return 8 + case .notImplemented: + return 9 + case .internalError: + return 99 + case .unknown: + return 0 + } + } } extension SDKError: LocalizedError { public var errorDescription: String? { + let description: String switch self { case .invalidParameter(let message): - return "Invalid Parameter: \(message)" + description = "Invalid Parameter: \(message)" case .invalidState(let message): - return "Invalid State: \(message)" + description = "Invalid State: \(message)" case .networkError(let message): - return "Network Error: \(message)" + description = "Network Error: \(message)" case .serializationError(let message): - return "Serialization Error: \(message)" + description = "Serialization Error: \(message)" case .protocolError(let message): - return "Protocol Error: \(message)" + description = "Protocol Error: \(message)" case .cryptoError(let message): - return "Cryptographic Error: \(message)" + description = "Cryptographic Error: \(message)" case .notFound(let message): - return "Not Found: \(message)" + description = "Not Found: \(message)" case .timeout(let message): - return "Operation Timed Out: \(message)" + description = "Operation Timed Out: \(message)" case .notImplemented(let message): - return "Feature Not Implemented: \(message)" + description = "Feature Not Implemented: \(message)" case .internalError(let message): - return "Internal Error: \(message)" + description = "Internal Error: \(message)" case .unknown(let message): - return "Unknown Error: \(message)" + description = "Unknown Error: \(message)" } + return description } } - /// Identities operations public class Identities { private weak var sdk: SDK? @@ -373,12 +572,8 @@ public class Identities { } // Check for errors - if result.error != nil { - let error = result.error!.pointee - defer { - dash_sdk_error_free(result.error) - } - throw SDKError.fromDashSDKError(error) + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard result.data != nil else { @@ -436,12 +631,8 @@ public class Identities { } // Check for errors - if result.error != nil { - let error = result.error!.pointee - defer { - dash_sdk_error_free(result.error) - } - throw SDKError.fromDashSDKError(error) + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard result.data != nil else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift index c3d98ec70c9..e57c707a591 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift @@ -596,34 +596,49 @@ struct DiagnosticsView: View { private func formatError(_ error: Error) -> String { if let sdkError = error as? SDKError { - switch sdkError { - case .invalidParameter(let msg): - return "Invalid Parameter: \(msg)" - case .invalidState(let msg): - return "Invalid State: \(msg)" - case .networkError(let msg): - return "Network Error: \(msg)" - case .serializationError(let msg): - return "Serialization Error: \(msg)" - case .protocolError(let msg): - return "Protocol Error: \(msg)" - case .cryptoError(let msg): - return "Crypto Error: \(msg)" - case .notFound(let msg): - return "Not Found: \(msg)" - case .timeout(let msg): - return "Timeout: \(msg)" - case .notImplemented(let msg): - return "Not Implemented: \(msg)" - case .internalError(let msg): - return "Internal Error: \(msg)" - case .unknown(let msg): - return "Unknown Error: \(msg)" - } + return formatSDKError(sdkError, consensusErrors: nil) } return error.localizedDescription } + private func formatSDKError( + _ sdkError: SDKError, + consensusErrors: [SDKConsensusError]? + ) -> String { + let primary: String + switch sdkError { + case .invalidParameter(let msg): + primary = "Invalid Parameter: \(msg)" + case .invalidState(let msg): + primary = "Invalid State: \(msg)" + case .networkError(let msg): + primary = "Network Error: \(msg)" + case .serializationError(let msg): + primary = "Serialization Error: \(msg)" + case .protocolError(let msg): + primary = "Protocol Error: \(msg)" + case .cryptoError(let msg): + primary = "Crypto Error: \(msg)" + case .notFound(let msg): + primary = "Not Found: \(msg)" + case .timeout(let msg): + primary = "Timeout: \(msg)" + case .notImplemented(let msg): + primary = "Not Implemented: \(msg)" + case .internalError(let msg): + primary = "Internal Error: \(msg)" + case .unknown(let msg): + primary = "Unknown Error: \(msg)" + } + guard let consensusErrors, !consensusErrors.isEmpty else { return primary } + let details = consensusErrors.enumerated().map { index, detail in + let kind = detail.kind.isEmpty ? "Consensus" : detail.kind + let name = detail.name.isEmpty ? "Unknown" : detail.name + return "\(index + 1). [\(kind)] \(name) (\(detail.code)): \(detail.message)" + }.joined(separator: "\n") + return "\(primary)\nConsensus details:\n\(details)" + } + private func formatTestResult(_ result: Any) -> String { if let dict = result as? [String: Any] { return formatDictionary(dict) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift index 7cb6c838254..6e456f28b7f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift @@ -179,31 +179,10 @@ struct QueryDetailView: View { } catch let sdkError as SDKError { print("❌ QueryDetailView: SDK error occurred: \(sdkError)") await MainActor.run { - // Handle SDK errors with more detail - switch sdkError { - case .invalidParameter(let message): - self.error = "Invalid Parameter: \(message)" - case .invalidState(let message): - self.error = "Invalid State: \(message)" - case .networkError(let message): - self.error = "Network Error: \(message)" - case .serializationError(let message): - self.error = "Serialization Error: \(message)" - case .protocolError(let message): - self.error = "Protocol Error: \(message)" - case .cryptoError(let message): - self.error = "Crypto Error: \(message)" - case .notFound(let message): - self.error = "Not Found: \(message)" - case .timeout(let message): - self.error = "Timeout: \(message)" - case .notImplemented(let message): - self.error = "Not Implemented: \(message)" - case .internalError(let message): - self.error = "Internal Error: \(message)" - case .unknown(let message): - self.error = "Unknown Error: \(message)" - } + self.error = QueryDetailView.formatSDKError( + sdkError, + consensusErrors: nil + ) isLoading = false print("❌ QueryDetailView: Error set to: \(self.error)") } @@ -1143,6 +1122,44 @@ struct QueryDetailView: View { return [] } } + + static func formatSDKError( + _ sdkError: SDKError, + consensusErrors: [SDKConsensusError]? + ) -> String { + let primary: String + switch sdkError { + case .invalidParameter(let message): + primary = "Invalid Parameter: \(message)" + case .invalidState(let message): + primary = "Invalid State: \(message)" + case .networkError(let message): + primary = "Network Error: \(message)" + case .serializationError(let message): + primary = "Serialization Error: \(message)" + case .protocolError(let message): + primary = "Protocol Error: \(message)" + case .cryptoError(let message): + primary = "Crypto Error: \(message)" + case .notFound(let message): + primary = "Not Found: \(message)" + case .timeout(let message): + primary = "Timeout: \(message)" + case .notImplemented(let message): + primary = "Not Implemented: \(message)" + case .internalError(let message): + primary = "Internal Error: \(message)" + case .unknown(let message): + primary = "Unknown Error: \(message)" + } + guard let consensusErrors, !consensusErrors.isEmpty else { return primary } + let details = consensusErrors.enumerated().map { index, detail in + let kind = detail.kind.isEmpty ? "Consensus" : detail.kind + let name = detail.name.isEmpty ? "Unknown" : detail.name + return "\(index + 1). [\(kind)] \(name) (\(detail.code)): \(detail.message)" + }.joined(separator: "\n") + return "\(primary)\nConsensus details:\n\(details)" + } } struct QueryInput { diff --git a/packages/swift-sdk/SwiftTests/Package.swift b/packages/swift-sdk/SwiftTests/Package.swift index 2500f1b1d86..672049eb672 100644 --- a/packages/swift-sdk/SwiftTests/Package.swift +++ b/packages/swift-sdk/SwiftTests/Package.swift @@ -2,30 +2,32 @@ import PackageDescription let package = Package( - name: "SwiftDashSDKTests", - platforms: [ - .macOS(.v10_15), - .iOS(.v13) - ], - products: [ - .library( - name: "SwiftDashSDKTests", - targets: ["SwiftDashSDKTests"]), - ], - dependencies: [], - targets: [ - .target( - name: "SwiftDashSDKMock", - dependencies: [], - path: "Sources/SwiftDashSDKMock", - publicHeadersPath: "." - ), - .testTarget( - name: "SwiftDashSDKTests", - dependencies: ["SwiftDashSDKMock"], - path: "Tests/SwiftDashSDKTests", - exclude: ["*.o", "*.d", "*.swiftdeps"] - ), - ], - swiftLanguageVersions: [.v6] + name: "SwiftDashSDKTests", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + ], + products: [ + .library( + name: "SwiftDashSDKTests", + targets: ["SwiftDashSDKTests"]) + ], + dependencies: [ + .package(path: "..") + ], + targets: [ + .target( + name: "SwiftDashSDKMock", + dependencies: [], + path: "Sources/SwiftDashSDKMock", + publicHeadersPath: "." + ), + .testTarget( + name: "SwiftDashSDKTests", + dependencies: ["SwiftDashSDKMock", .product(name: "SwiftDashSDK", package: "swift-sdk")], + path: "Tests/SwiftDashSDKTests", + exclude: ["*.o", "*.d", "*.swiftdeps"] + ), + ], + swiftLanguageVersions: [.v6] ) diff --git a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift new file mode 100644 index 00000000000..752e577e572 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift @@ -0,0 +1,114 @@ +import XCTest + +@testable import SwiftDashSDK + +final class SDKErrorTests: XCTestCase { + func testProtocolErrorAssociatedMessageIsCleanForPatternMatchingCallers() { + let message = "Unicode preface: こんにちは 🚀" + + let error = SDKError.protocolError(message) + + if case .protocolError(let raw) = error { + XCTAssertEqual(raw, message, "Pattern-matched associated value must be the original message") + } else { + XCTFail("Expected SDKError.protocolError") + } + + XCTAssertEqual(error.message, message) + XCTAssertEqual(error.localizedDescription, "Protocol Error: \(message)") + } + + func testProtocolErrorPreservesLiteralMarkerLookalikeWithoutDecodingIt() { + // The historical embedded-payload marker is no longer recognized; if the + // upstream message text happens to contain it, the SDK must surface it + // verbatim instead of silently stripping it. + let message = "Literal marker stays visible: \n[[dashsdk-consensus:v1:not-a-real-payload]]" + + let error = SDKError.protocolError(message) + + if case .protocolError(let raw) = error { + XCTAssertEqual(raw, message) + } else { + XCTFail("Expected SDKError.protocolError") + } + XCTAssertEqual(error.message, message) + } + + func testSDKDetailedErrorWrapsSDKErrorAndPreservesConsensusErrors() { + let consensusErrors = [ + SDKConsensusError( + code: 101, + kind: "Consensus", + name: "FirstError", + message: "Primary failure" + ), + SDKConsensusError( + code: 202, + kind: "DataContract", + name: "EmojiError", + message: "Snowman ☃️ and café" + ), + ] + let inner = SDKError.protocolError("Outer protocol error") + + let detailed = SDKDetailedError(sdkError: inner, consensusErrors: consensusErrors) + + XCTAssertEqual(detailed.consensusErrors, consensusErrors) + if case .protocolError(let raw) = detailed.sdkError { + XCTAssertEqual(raw, "Outer protocol error") + } else { + XCTFail("Expected wrapped SDKError.protocolError") + } + + let description = detailed.errorDescription ?? "" + XCTAssertTrue(description.contains("Protocol Error: Outer protocol error")) + XCTAssertTrue(description.contains("[Consensus] FirstError (101): Primary failure")) + XCTAssertTrue(description.contains("[DataContract] EmojiError (202): Snowman ☃️ and café")) + } + + func testSDKDetailedErrorWithoutConsensusDetailsFallsBackToInnerDescription() { + let detailed = SDKDetailedError( + sdkError: .internalError("boom"), + consensusErrors: [] + ) + + XCTAssertEqual(detailed.errorDescription, "Internal Error: boom") + } + + func testConsumeDashSDKErrorReturnsSDKErrorForExistingCatchLogic() { + let finalized = SDKError.finalizeConsumedDashSDKError( + .protocolError("Protocol mismatch"), + consensusErrors: nil + ) + + let sdkError = finalized + if case .protocolError(let message) = sdkError { + XCTAssertEqual(message, "Protocol mismatch") + } else { + XCTFail("Expected SDKError.protocolError") + } + XCTAssertNil(sdkError.consensusErrors) + } + + func testSDKErrorConsensusErrorsDoesNotExposeStructuredDetailsFromScalarValue() { + let sdkError = SDKError.protocolError("Protocol mismatch") + + XCTAssertNil(sdkError.consensusErrors) + } + + func testFinalizeConsumedDashSDKErrorDropsStructuredDetailsForPublicWrappers() { + let finalized = SDKError.finalizeConsumedDashSDKError( + .protocolError("Protocol mismatch"), + consensusErrors: [ + SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") + ] + ) + + if case .protocolError(let message) = finalized { + XCTAssertEqual(message, "Protocol mismatch") + } else { + XCTFail("Expected SDKError.protocolError") + } + XCTAssertNil(finalized.consensusErrors) + } +} diff --git a/packages/wasm-dpp/src/errors/from.rs b/packages/wasm-dpp/src/errors/from.rs index ca7d7416f23..5d7aeb22854 100644 --- a/packages/wasm-dpp/src/errors/from.rs +++ b/packages/wasm-dpp/src/errors/from.rs @@ -10,11 +10,13 @@ use crate::errors::value_error::PlatformValueErrorWasm; use super::data_contract_not_present_error::DataContractNotPresentNotConsensusErrorWasm; use crate::errors::consensus::consensus_error::from_consensus_error; +use crate::errors::protocol_error::from_consensus_errors; pub fn from_dpp_err(pe: ProtocolError) -> JsValue { match pe { // TODO(versioning): restore this ProtocolError::ConsensusError(consensus_error) => from_consensus_error(*consensus_error), + ProtocolError::ConsensusErrors(consensus_errors) => from_consensus_errors(consensus_errors), ProtocolError::DataContractError(e) => from_data_contract_to_js_error(e), ProtocolError::Document(e) => from_document_to_js_error(*e), diff --git a/packages/wasm-dpp/src/errors/protocol_error.rs b/packages/wasm-dpp/src/errors/protocol_error.rs index a91e8820a55..a750a45c05a 100644 --- a/packages/wasm-dpp/src/errors/protocol_error.rs +++ b/packages/wasm-dpp/src/errors/protocol_error.rs @@ -1,12 +1,52 @@ +use dpp::consensus::ConsensusError; +use js_sys::{Array, Reflect}; use wasm_bindgen::JsValue; use crate::errors::consensus::consensus_error::from_consensus_error; +pub(crate) fn from_consensus_errors(consensus_errors: Vec) -> JsValue { + let consensus_errors_array = + Array::from_iter(consensus_errors.into_iter().map(from_consensus_error)); + let consensus_errors_property = + consensus_errors_array.slice(0, consensus_errors_array.length()); + let message = match consensus_errors_array.length() { + 0 => "ProtocolError contained no consensus errors".to_string(), + 1 => "ProtocolError contained 1 consensus error".to_string(), + count => format!("ProtocolError contained {count} consensus errors"), + }; + + // Preserve the pre-existing wasm-dpp contract: plural consensus errors are + // thrown as an Array so JS callers can keep using Array.isArray(e), e[0], + // e.length, and iteration. Attach the richer properties to the same array + // rather than switching the thrown value to Error-shaped. + let error_value = JsValue::from(consensus_errors_array.clone()); + let _ = Reflect::set( + &error_value, + &JsValue::from_str("name"), + &JsValue::from_str("ConsensusErrors"), + ); + let _ = Reflect::set( + &error_value, + &JsValue::from_str("message"), + &JsValue::from_str(&message), + ); + let _ = Reflect::set( + &error_value, + &JsValue::from_str("consensusErrors"), + &consensus_errors_property.into(), + ); + + error_value +} + pub fn from_protocol_error(protocol_error: dpp::ProtocolError) -> JsValue { match protocol_error { dpp::ProtocolError::ConsensusError(consensus_error) => { from_consensus_error(*consensus_error) } + dpp::ProtocolError::ConsensusErrors(consensus_errors) => { + from_consensus_errors(consensus_errors) + } dpp::ProtocolError::Error(anyhow_error) => { format!("Non-protocol error: {}", anyhow_error).into() } diff --git a/packages/wasm-dpp2/src/error.rs b/packages/wasm-dpp2/src/error.rs index 2a38ccc9f8d..5e35c4ad9b6 100644 --- a/packages/wasm-dpp2/src/error.rs +++ b/packages/wasm-dpp2/src/error.rs @@ -1,5 +1,6 @@ use anyhow::Error as AnyhowError; use dpp::ProtocolError; +use dpp::consensus::{ConsensusError, codes::ErrorWithCode}; use wasm_bindgen::prelude::wasm_bindgen; /// Structured error returned by wasm-dpp2 APIs. @@ -27,14 +28,26 @@ pub struct WasmDppError { message: String, /// Optional numeric error code. `-1` indicates absence. code: i32, + /// Structured consensus errors when this wraps a protocol consensus error. + consensus_errors: Vec, } impl WasmDppError { fn new(kind: WasmDppErrorKind, message: impl Into, code: Option) -> Self { + Self::new_with_consensus_errors(kind, message, code, Vec::new()) + } + + fn new_with_consensus_errors( + kind: WasmDppErrorKind, + message: impl Into, + code: Option, + consensus_errors: Vec, + ) -> Self { Self { kind, message: message.into(), code: code.unwrap_or(-1), + consensus_errors, } } @@ -57,11 +70,41 @@ impl WasmDppError { pub fn generic(message: impl Into) -> Self { Self::new(WasmDppErrorKind::Generic, message, None) } + + pub fn consensus_errors(&self) -> &[ConsensusError] { + &self.consensus_errors + } } impl From for WasmDppError { fn from(error: ProtocolError) -> Self { - Self::protocol(error.to_string()) + match error { + ProtocolError::ConsensusError(consensus_error) => { + let consensus_error = *consensus_error; + let message = consensus_error.to_string(); + let code = consensus_error.code() as i32; + Self::new_with_consensus_errors( + WasmDppErrorKind::Protocol, + message, + Some(code), + vec![consensus_error], + ) + } + ProtocolError::ConsensusErrors(consensus_errors) => { + let message = consensus_errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "); + Self::new_with_consensus_errors( + WasmDppErrorKind::Protocol, + message, + None, + consensus_errors, + ) + } + error => Self::protocol(error.to_string()), + } } } diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index 6f41a1d76ea..8b0c126ce2e 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -1,7 +1,10 @@ +use dash_sdk::dpp::consensus::{codes::ErrorWithCode, ConsensusError}; use dash_sdk::dpp::ProtocolError; use dash_sdk::{error::StateTransitionBroadcastError, Error as SdkError}; +use js_sys::{Array, Object, Reflect}; use rs_dapi_client::CanRetry; use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; use wasm_dpp2::error::WasmDppError; /// Structured error surfaced to JS consumers @@ -51,10 +54,20 @@ pub struct WasmSdkError { code: i32, /// Indicates if the operation can be retried safely. is_retriable: bool, + /// Structured carrier for protocol consensus errors. + consensus_errors: Vec, } // wasm-bindgen getters defined below in the second impl block +#[derive(Debug, Clone, Eq, PartialEq)] +struct WasmConsensusError { + kind: String, + name: String, + message: String, + code: u32, +} + impl WasmSdkError { fn new>( kind: WasmSdkErrorKind, @@ -67,6 +80,41 @@ impl WasmSdkError { message: message.into(), code: code.unwrap_or(-1), is_retriable, + consensus_errors: Vec::new(), + } + } + + fn from_protocol_error(err: ProtocolError, is_retriable: bool) -> Self { + let (message, code, consensus_errors) = match &err { + ProtocolError::ConsensusError(error) => { + let consensus = error.as_ref(); + ( + consensus.to_string(), + consensus.code() as i32, + vec![WasmConsensusError::from_consensus_error(consensus)], + ) + } + ProtocolError::ConsensusErrors(errors) => { + let message = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "); + let consensus_errors = errors + .iter() + .map(WasmConsensusError::from_consensus_error) + .collect(); + (message, -1, consensus_errors) + } + _ => (err.to_string(), -1, Vec::new()), + }; + + Self { + kind: WasmSdkErrorKind::Protocol, + message, + code, + is_retriable, + consensus_errors, } } @@ -101,7 +149,7 @@ impl From for WasmSdkError { None, retriable, ), - Protocol(e) => Self::new(WasmSdkErrorKind::Protocol, e.to_string(), None, retriable), + Protocol(e) => Self::from_protocol_error(e, retriable), Proof(e) => Self::new(WasmSdkErrorKind::Proof, e.to_string(), None, retriable), InvalidProvedResponse(msg) => Self::new( WasmSdkErrorKind::InvalidProvedResponse, @@ -202,7 +250,7 @@ impl From for WasmSdkError { } impl From for WasmSdkError { fn from(err: ProtocolError) -> Self { - Self::new(WasmSdkErrorKind::Protocol, err.to_string(), None, false) + Self::from_protocol_error(err, false) } } @@ -220,7 +268,9 @@ impl From for WasmSdkError { impl From for WasmSdkError { fn from(err: WasmDppError) -> Self { use wasm_dpp2::error::WasmDppErrorKind; - // Map WasmDppError kind to appropriate WasmSdkError kind + // Map WasmDppError kind to appropriate WasmSdkError kind. If wasm-dpp2 + // preserved protocol consensus errors, thread them through to the same + // structured JS `consensusErrors` surface as direct ProtocolError paths. let kind = match err.kind() { WasmDppErrorKind::Protocol => WasmSdkErrorKind::Protocol, WasmDppErrorKind::InvalidArgument => WasmSdkErrorKind::InvalidArgument, @@ -228,7 +278,88 @@ impl From for WasmSdkError { WasmDppErrorKind::Conversion => WasmSdkErrorKind::SerializationError, WasmDppErrorKind::Generic => WasmSdkErrorKind::Generic, }; - Self::new(kind, err.to_string(), None, false) + let consensus_errors = err + .consensus_errors() + .iter() + .map(WasmConsensusError::from_consensus_error) + .collect::>(); + if consensus_errors.is_empty() { + Self::new(kind, err.to_string(), None, false) + } else { + Self { + kind, + message: err.to_string(), + code: err.code(), + is_retriable: false, + consensus_errors, + } + } + } +} + +fn consensus_error_kind_name(err: &ConsensusError) -> &'static str { + match err { + ConsensusError::DefaultError => "DefaultError", + ConsensusError::BasicError(_) => "BasicError", + ConsensusError::StateError(_) => "StateError", + ConsensusError::SignatureError(_) => "SignatureError", + ConsensusError::FeeError(_) => "FeeError", + } +} + +/// Resolve the specific variant identifier of a `ConsensusError`. +/// +/// The inner consensus enums (`BasicError`, `StateError`, `SignatureError`, +/// `FeeError`) derive `strum::IntoStaticStr`, which generates a compile-time +/// `impl From<&Enum> for &'static str` from the enum's structure. Adding a +/// future variant to one of those enums therefore extends this mapping +/// automatically with the correct variant identifier; there is no +/// `Debug`-format parsing or `_` wildcard that could silently drift if a +/// variant is added or renamed. Mirrors `consensus_error_variant_name` in +/// `rs-sdk-ffi`. +fn consensus_error_variant_name(err: &ConsensusError) -> &'static str { + match err { + ConsensusError::DefaultError => "DefaultError", + ConsensusError::BasicError(inner) => inner.into(), + ConsensusError::StateError(inner) => inner.into(), + ConsensusError::SignatureError(inner) => inner.into(), + ConsensusError::FeeError(inner) => inner.into(), + } +} + +impl WasmConsensusError { + fn from_consensus_error(err: &ConsensusError) -> Self { + Self { + kind: consensus_error_kind_name(err).to_string(), + name: consensus_error_variant_name(err).to_string(), + message: err.to_string(), + code: err.code(), + } + } + + fn to_js_value(&self) -> JsValue { + let object = Object::new(); + let _ = Reflect::set( + &object, + &JsValue::from_str("kind"), + &JsValue::from_str(&self.kind), + ); + let _ = Reflect::set( + &object, + &JsValue::from_str("name"), + &JsValue::from_str(&self.name), + ); + let _ = Reflect::set( + &object, + &JsValue::from_str("message"), + &JsValue::from_str(&self.message), + ); + let _ = Reflect::set( + &object, + &JsValue::from_str("code"), + &JsValue::from_f64(self.code as f64), + ); + object.into() } } @@ -293,4 +424,111 @@ impl WasmSdkError { pub fn is_retriable(&self) -> bool { self.is_retriable } + + /// Structured protocol consensus errors when the originating protocol error + /// was `ProtocolError::ConsensusError` or `ProtocolError::ConsensusErrors`. + #[wasm_bindgen( + getter = "consensusErrors", + unchecked_return_type = "Array<{ kind: string; name: string; message: string; code: number }> | undefined" + )] + pub fn consensus_errors(&self) -> JsValue { + if self.consensus_errors.is_empty() { + return JsValue::UNDEFINED; + } + + Array::from_iter( + self.consensus_errors + .iter() + .map(WasmConsensusError::to_js_value), + ) + .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::consensus::basic::document::DocumentTransitionsAreAbsentError; + + #[test] + fn protocol_consensus_errors_plural_are_preserved_structurally() { + let absent_err: ConsensusError = DocumentTransitionsAreAbsentError::new().into(); + let absent_message = absent_err.to_string(); + let error = WasmSdkError::from(ProtocolError::ConsensusErrors(vec![ + absent_err, + ConsensusError::DefaultError, + ])); + + assert_eq!(error.kind, WasmSdkErrorKind::Protocol); + assert!(!error.is_retriable); + assert_eq!(error.consensus_errors.len(), 2); + assert_eq!(error.consensus_errors[0].kind, "BasicError"); + assert_eq!( + error.consensus_errors[0].name, + "DocumentTransitionsAreAbsentError" + ); + assert_eq!(error.consensus_errors[0].message, absent_message); + assert_eq!(error.consensus_errors[1].kind, "DefaultError"); + assert_eq!(error.consensus_errors[1].name, "DefaultError"); + assert_eq!(error.consensus_errors[1].code, 1); + assert_eq!(error.code, -1); + + // Plural messages are joined with "; " for readability, + // mirroring the rs-sdk-ffi behavior. + let expected_message = format!("{}; {}", absent_message, ConsensusError::DefaultError); + assert_eq!(error.message, expected_message); + assert!(!error.message.contains("Multiple consensus errors: [")); + } + + #[test] + fn protocol_consensus_errors_singular_is_preserved_structurally() { + let consensus_error: ConsensusError = DocumentTransitionsAreAbsentError::new().into(); + let expected_message = consensus_error.to_string(); + let expected_code = consensus_error.code(); + let error = WasmSdkError::from(ProtocolError::ConsensusError(Box::new(consensus_error))); + + assert_eq!(error.kind, WasmSdkErrorKind::Protocol); + assert!(!error.is_retriable); + assert_eq!(error.consensus_errors.len(), 1); + assert_eq!(error.consensus_errors[0].kind, "BasicError"); + assert_eq!( + error.consensus_errors[0].name, + "DocumentTransitionsAreAbsentError" + ); + assert_eq!(error.consensus_errors[0].message, expected_message); + assert_eq!(error.consensus_errors[0].code, expected_code); + assert_eq!(error.code, expected_code as i32); + // Singular keeps the inner consensus error's Display unchanged. + assert_eq!(error.message, expected_message); + } + + #[test] + fn sdk_protocol_errors_use_protocol_mapping() { + let sdk_error = SdkError::Protocol(ProtocolError::ConsensusErrors(vec![ + ConsensusError::DefaultError, + ConsensusError::DefaultError, + ])); + let retriable = sdk_error.can_retry(); + let error = WasmSdkError::from(sdk_error); + + assert_eq!(error.kind, WasmSdkErrorKind::Protocol); + assert_eq!(error.is_retriable, retriable); + assert_eq!(error.consensus_errors.len(), 2); + assert!(error.message.contains("; ")); + } + + #[test] + fn wasm_dpp_error_consensus_errors_are_preserved_structurally() { + let dpp_error = WasmDppError::from(ProtocolError::ConsensusErrors(vec![ + ConsensusError::DefaultError, + ConsensusError::DefaultError, + ])); + let error = WasmSdkError::from(dpp_error); + + assert_eq!(error.kind, WasmSdkErrorKind::Protocol); + assert_eq!(error.code, -1); + assert_eq!(error.consensus_errors.len(), 2); + assert_eq!(error.consensus_errors[0].name, "DefaultError"); + assert!(error.message.contains("; ")); + } } diff --git a/packages/wasm-sdk/src/state_transitions/addresses.rs b/packages/wasm-sdk/src/state_transitions/addresses.rs index be9c2e9aa71..040a14dd8c6 100644 --- a/packages/wasm-sdk/src/state_transitions/addresses.rs +++ b/packages/wasm-sdk/src/state_transitions/addresses.rs @@ -175,7 +175,7 @@ impl WasmSdk { .inner_sdk() .transfer_address_funds(inputs_map, outputs_map, fee_strategy, &signer, settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to transfer funds: {}", e)))?; + .map_err(WasmSdkError::from)?; address_infos_to_js_map(address_infos, "transfer") } @@ -298,7 +298,7 @@ impl WasmSdk { let (address_infos, new_balance) = identity .top_up_from_addresses(self.inner_sdk(), inputs_map, &signer, settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to top up identity: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(IdentityTopUpFromAddressesResultWasm { address_infos: address_infos_to_js_map(address_infos, "top up")?, @@ -464,7 +464,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to withdraw funds: {}", e)))?; + .map_err(WasmSdkError::from)?; address_infos_to_js_map(address_infos, "withdrawal") } @@ -516,9 +516,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to transfer credits to addresses: {}", e)) - })?; + .map_err(WasmSdkError::from)?; Ok(IdentityTransferToAddressesResultWasm { address_infos: address_infos_to_js_map(address_infos, "transfer")?, @@ -756,7 +754,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to fund addresses: {}", e)))?; + .map_err(WasmSdkError::from)?; address_infos_to_js_map(address_infos, "funding") } @@ -925,9 +923,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to create identity from addresses: {}", e)) - })?; + .map_err(WasmSdkError::from)?; Ok(IdentityCreateFromAddressesResultWasm { identity: created_identity.into(), @@ -952,12 +948,7 @@ async fn fetch_nonces_into_address_map( // fetch nonces let fetched_addresses = dash_sdk::query_types::AddressInfo::fetch_many(sdk, input_addresses) .await - .map_err(|e| { - WasmSdkError::generic(format!( - "Failed to fetch address infos for identity creation: {}", - e - )) - })? + .map_err(WasmSdkError::from)? .into_iter() .filter_map(|(k, v)| v.map(|info| (k, info))) .collect::>(); diff --git a/packages/wasm-sdk/src/state_transitions/broadcast.rs b/packages/wasm-sdk/src/state_transitions/broadcast.rs index 6e4a6a279f2..4839d659bcb 100644 --- a/packages/wasm-sdk/src/state_transitions/broadcast.rs +++ b/packages/wasm-sdk/src/state_transitions/broadcast.rs @@ -36,7 +36,7 @@ impl WasmSdk { st.broadcast(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(()) } @@ -65,9 +65,7 @@ impl WasmSdk { let result = st .wait_for_response::(self.as_ref(), put_settings) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to wait for state transition result: {}", e)) - })?; + .map_err(WasmSdkError::from)?; convert_proof_result(result).map_err(WasmSdkError::from) } @@ -94,7 +92,7 @@ impl WasmSdk { let result = st .broadcast_and_wait::(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(WasmSdkError::from)?; convert_proof_result(result).map_err(WasmSdkError::from) } diff --git a/packages/wasm-sdk/src/state_transitions/contract.rs b/packages/wasm-sdk/src/state_transitions/contract.rs index a6da24b0d14..c29c39537c7 100644 --- a/packages/wasm-sdk/src/state_transitions/contract.rs +++ b/packages/wasm-sdk/src/state_transitions/contract.rs @@ -222,7 +222,7 @@ impl WasmSdk { None, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to create update transition: {}", e)))?; + .map_err(WasmSdkError::from)?; // Broadcast the transition use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index edf90726745..009262d9991 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -122,7 +122,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to create identity: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(()) } @@ -211,7 +211,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to top up identity: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(BigInt::from(new_balance)) } @@ -494,7 +494,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Withdrawal failed: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(BigInt::from(remaining_balance)) } @@ -666,7 +666,7 @@ impl WasmSdk { .inner_sdk() .get_identity_nonce(identity.id(), true, settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to get identity nonce: {}", e)))?; + .map_err(WasmSdkError::from)?; // Create the identity update transition use crate::settings::get_user_fee_increase; @@ -685,14 +685,14 @@ impl WasmSdk { None, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to create update transition: {}", e)))?; + .map_err(WasmSdkError::from)?; // Broadcast the transition use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; state_transition .broadcast_and_wait::(self.inner_sdk(), settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast update: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(()) } diff --git a/packages/wasm-sdk/src/state_transitions/token.rs b/packages/wasm-sdk/src/state_transitions/token.rs index 59480bb863d..f92ecf12d7f 100644 --- a/packages/wasm-sdk/src/state_transitions/token.rs +++ b/packages/wasm-sdk/src/state_transitions/token.rs @@ -398,7 +398,7 @@ impl WasmSdk { .inner_sdk() .token_mint(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to mint tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenMintResultWasm::from_result(result, contract_id)) } @@ -626,7 +626,7 @@ impl WasmSdk { .inner_sdk() .token_burn(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to burn tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenBurnResultWasm::from_result(result, contract_id)) } @@ -865,7 +865,7 @@ impl WasmSdk { .inner_sdk() .token_transfer(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to transfer tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenTransferResultWasm::from_result( result, @@ -1083,7 +1083,7 @@ impl WasmSdk { .inner_sdk() .token_freeze(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to freeze tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenFreezeResultWasm::from_result(result, contract_id)) } @@ -1296,7 +1296,7 @@ impl WasmSdk { .inner_sdk() .token_unfreeze_identity(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to unfreeze tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenUnfreezeResultWasm::from_result(result, contract_id)) } @@ -1495,9 +1495,7 @@ impl WasmSdk { .inner_sdk() .token_destroy_frozen_funds(builder, &identity_key, &signer) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to destroy frozen tokens: {}", e)) - })?; + .map_err(WasmSdkError::from)?; Ok(TokenDestroyFrozenResultWasm::from_result( result, @@ -1713,9 +1711,7 @@ impl WasmSdk { .inner_sdk() .token_emergency_action(builder, &identity_key, &signer) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to perform emergency action: {}", e)) - })?; + .map_err(WasmSdkError::from)?; Ok(TokenEmergencyActionResultWasm::from_result( result, @@ -1909,7 +1905,7 @@ impl WasmSdk { .inner_sdk() .token_claim(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to claim tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenClaimResultWasm::from_result(result, contract_id)) } @@ -2148,7 +2144,7 @@ impl WasmSdk { .inner_sdk() .token_set_price_for_direct_purchase(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to set token price: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenSetPriceResultWasm::from_result(result, contract_id)) } @@ -2360,7 +2356,7 @@ impl WasmSdk { .inner_sdk() .token_purchase(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to purchase tokens: {}", e)))?; + .map_err(WasmSdkError::from)?; Ok(TokenDirectPurchaseResultWasm::from_result( result, @@ -2563,9 +2559,7 @@ impl WasmSdk { .inner_sdk() .token_update_contract_token_configuration(builder, &identity_key, &signer) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to update token configuration: {}", e)) - })?; + .map_err(WasmSdkError::from)?; Ok(TokenConfigUpdateResultWasm::from_result( result, diff --git a/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts b/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts index bbbf2915719..411c0c1fdf0 100644 --- a/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts +++ b/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts @@ -52,6 +52,7 @@ const contract = { rarity: { type: 'string', description: 'Rarity level of the card', + maxLength: 9, enum: [ 'common', 'uncommon',