From 2446db0439c6822d1f1ba7cd53fbc90c826f4f78 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 16:06:26 -0600 Subject: [PATCH 01/25] feat(sdk): add client-side validate_base_structure for document and token transitions Add structural validation to all document and token SDK transition builders, matching the pattern from PR #3096 (identity/address transitions). Calls validate_base_structure() on BatchTransition after construction but before broadcast, catching invalid transitions early. Applied to: - Document transitions: create, delete, replace, purchase, set_price, transfer - Token builders: burn, claim, config_update, destroy, purchase, emergency_action, freeze, mint, set_price, transfer, unfreeze - Enabled dpp 'validation' feature for dash-sdk crate --- .gitignore | 5 ++++- packages/rs-sdk/Cargo.toml | 1 + .../src/platform/documents/transitions/create.rs | 16 ++++++++++++++++ .../src/platform/documents/transitions/delete.rs | 16 ++++++++++++++++ .../platform/documents/transitions/purchase.rs | 16 ++++++++++++++++ .../platform/documents/transitions/replace.rs | 16 ++++++++++++++++ .../platform/documents/transitions/set_price.rs | 16 ++++++++++++++++ .../platform/documents/transitions/transfer.rs | 16 ++++++++++++++++ .../rs-sdk/src/platform/tokens/builders/burn.rs | 16 ++++++++++++++++ .../rs-sdk/src/platform/tokens/builders/claim.rs | 16 ++++++++++++++++ .../platform/tokens/builders/config_update.rs | 16 ++++++++++++++++ .../src/platform/tokens/builders/destroy.rs | 16 ++++++++++++++++ .../platform/tokens/builders/emergency_action.rs | 16 ++++++++++++++++ .../src/platform/tokens/builders/freeze.rs | 16 ++++++++++++++++ .../rs-sdk/src/platform/tokens/builders/mint.rs | 16 ++++++++++++++++ .../src/platform/tokens/builders/purchase.rs | 16 ++++++++++++++++ .../src/platform/tokens/builders/set_price.rs | 16 ++++++++++++++++ .../src/platform/tokens/builders/transfer.rs | 16 ++++++++++++++++ .../src/platform/tokens/builders/unfreeze.rs | 16 ++++++++++++++++ 19 files changed, 277 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6803d4a3177..fb86e67b999 100644 --- a/.gitignore +++ b/.gitignore @@ -89,8 +89,11 @@ book/book/ # gRPC coverage report grpc-coverage-report.txt -__pycache__/ +# Git worktrees +/worktrees/ .claude/worktrees/ +__pycache__/ + # Security audit reports (local-only, not committed) audits/ diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 42d595eb1fd..8eedcc41831 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", + "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/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 450836ae167..65e68c234e4 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -166,6 +166,22 @@ impl DocumentCreateTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 2f1a8c005c3..f3158ac22fc 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -207,6 +207,22 @@ impl DocumentDeleteTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index b7832cb7782..4bc860585ba 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -221,6 +221,22 @@ impl DocumentPurchaseTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index aacfc2f9624..e2e979db293 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -160,6 +160,22 @@ impl DocumentReplaceTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index 61316f3b5c5..005b0f47eb2 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -208,6 +208,22 @@ impl DocumentSetPriceTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index ae1f2afbb04..4f7b9c1d438 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -207,6 +207,22 @@ impl DocumentTransferTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index e714a1d4a27..8a6f815c860 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -179,6 +179,22 @@ impl TokenBurnTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/claim.rs b/packages/rs-sdk/src/platform/tokens/builders/claim.rs index bde33938446..1d148027d4e 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -165,6 +165,22 @@ impl TokenClaimTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } 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 960ad87470c..8b1faf66f7a 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -186,6 +186,22 @@ impl TokenConfigUpdateTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs index b1686785a9b..fea8ac5dded 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -185,6 +185,22 @@ impl TokenDestroyFrozenFundsTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } 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 00b052b1145..b644e121691 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -213,6 +213,22 @@ impl TokenEmergencyActionTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs index 12c4c9ad229..973350ab2c2 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -185,6 +185,22 @@ impl TokenFreezeTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index 45614d06bd3..e5656075503 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -206,6 +206,22 @@ impl TokenMintTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index 67771fc1ae9..d7af4c6600d 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -154,6 +154,22 @@ impl TokenDirectPurchaseTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } 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 57c298d3b61..e081c486d8b 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -230,6 +230,22 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index a8cf0514e5c..0533877b96f 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -211,6 +211,22 @@ impl TokenTransferTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } diff --git a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs index 8cf605f36a9..1ae315ab242 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -185,6 +185,22 @@ impl TokenUnfreezeTransitionBuilder { self.state_transition_creation_options, )?; + // Validate the transition structure before returning + let validation_result = match &state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if !validation_result.is_valid() { + let first_error = validation_result.errors.into_iter().next().unwrap(); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + } + Ok(state_transition) } } From 472bcadeb19aa087fbcf7cb00dfffa86272857b3 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 17:31:45 -0600 Subject: [PATCH 02/25] fix: replace unwrap() with safe if-let in validation error handling CodeRabbit correctly flagged that validation_result.errors.into_iter() .next().unwrap() could panic if is_valid() returns false but the errors vec is somehow empty. Use if-let pattern instead for safe handling. --- packages/rs-sdk/src/platform/documents/transitions/create.rs | 3 +-- packages/rs-sdk/src/platform/documents/transitions/delete.rs | 3 +-- packages/rs-sdk/src/platform/documents/transitions/purchase.rs | 3 +-- packages/rs-sdk/src/platform/documents/transitions/replace.rs | 3 +-- .../rs-sdk/src/platform/documents/transitions/set_price.rs | 3 +-- packages/rs-sdk/src/platform/documents/transitions/transfer.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/burn.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/claim.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/config_update.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/destroy.rs | 3 +-- .../rs-sdk/src/platform/tokens/builders/emergency_action.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/freeze.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/mint.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/purchase.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/set_price.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/transfer.rs | 3 +-- packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs | 3 +-- 17 files changed, 17 insertions(+), 34 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 65e68c234e4..97088adfc3a 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -177,8 +177,7 @@ impl DocumentCreateTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index f3158ac22fc..dc254ce76a5 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -218,8 +218,7 @@ impl DocumentDeleteTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index 4bc860585ba..8fd93529d14 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -232,8 +232,7 @@ impl DocumentPurchaseTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index e2e979db293..e85fb551c6a 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -171,8 +171,7 @@ impl DocumentReplaceTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index 005b0f47eb2..435e9ebee0b 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -219,8 +219,7 @@ impl DocumentSetPriceTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index 4f7b9c1d438..dfc60854b36 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -218,8 +218,7 @@ impl DocumentTransferTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index 8a6f815c860..917fcdcd204 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -190,8 +190,7 @@ impl TokenBurnTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/claim.rs b/packages/rs-sdk/src/platform/tokens/builders/claim.rs index 1d148027d4e..378caa2baf1 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -176,8 +176,7 @@ impl TokenClaimTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } 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 8b1faf66f7a..1d230418616 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -197,8 +197,7 @@ impl TokenConfigUpdateTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs index fea8ac5dded..55465557e0a 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -196,8 +196,7 @@ impl TokenDestroyFrozenFundsTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } 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 b644e121691..aebb2880c44 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -224,8 +224,7 @@ impl TokenEmergencyActionTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs index 973350ab2c2..61d88cb312c 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -196,8 +196,7 @@ impl TokenFreezeTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index e5656075503..b0e602ff74c 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -217,8 +217,7 @@ impl TokenMintTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index d7af4c6600d..62db4bdd7b1 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -165,8 +165,7 @@ impl TokenDirectPurchaseTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } 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 e081c486d8b..bd7007f57b4 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -241,8 +241,7 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index 0533877b96f..336938e3c48 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -222,8 +222,7 @@ impl TokenTransferTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } diff --git a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs index 1ae315ab242..471c41a1a6c 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -196,8 +196,7 @@ impl TokenUnfreezeTransitionBuilder { ))); } }; - if !validation_result.is_valid() { - let first_error = validation_result.errors.into_iter().next().unwrap(); + if let Some(first_error) = validation_result.errors.into_iter().next() { return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); } From 956c192cae6a2e807d1b08451e22fc7c51f30958 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 19:27:59 -0600 Subject: [PATCH 03/25] style: apply cargo fmt to rs-sdk and rs-dapi --- .../src/platform/documents/transitions/create.rs | 12 ++++++++---- .../src/platform/documents/transitions/delete.rs | 12 ++++++++---- .../src/platform/documents/transitions/purchase.rs | 12 ++++++++---- .../src/platform/documents/transitions/replace.rs | 12 ++++++++---- .../src/platform/documents/transitions/set_price.rs | 12 ++++++++---- .../src/platform/documents/transitions/transfer.rs | 12 ++++++++---- packages/rs-sdk/src/platform/tokens/builders/burn.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/claim.rs | 12 ++++++++---- .../src/platform/tokens/builders/config_update.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/destroy.rs | 12 ++++++++---- .../src/platform/tokens/builders/emergency_action.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/freeze.rs | 12 ++++++++---- packages/rs-sdk/src/platform/tokens/builders/mint.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/purchase.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/set_price.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/transfer.rs | 12 ++++++++---- .../rs-sdk/src/platform/tokens/builders/unfreeze.rs | 12 ++++++++---- 17 files changed, 136 insertions(+), 68 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 97088adfc3a..fca6bda77f2 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -172,13 +172,17 @@ impl DocumentCreateTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index dc254ce76a5..9a1862b98af 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -213,13 +213,17 @@ impl DocumentDeleteTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index 8fd93529d14..6b33bc2bf3b 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -227,13 +227,17 @@ impl DocumentPurchaseTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index e85fb551c6a..27dede0bb3f 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -166,13 +166,17 @@ impl DocumentReplaceTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index 435e9ebee0b..537c09283db 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -214,13 +214,17 @@ impl DocumentSetPriceTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index dfc60854b36..53eef22c776 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -213,13 +213,17 @@ impl DocumentTransferTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index 917fcdcd204..83f7f3719b7 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -185,13 +185,17 @@ impl TokenBurnTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/claim.rs b/packages/rs-sdk/src/platform/tokens/builders/claim.rs index 378caa2baf1..d68923841f4 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -171,13 +171,17 @@ impl TokenClaimTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) 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 1d230418616..1d20a26d2b3 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -192,13 +192,17 @@ impl TokenConfigUpdateTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs index 55465557e0a..a89f2416b8c 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -191,13 +191,17 @@ impl TokenDestroyFrozenFundsTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) 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 aebb2880c44..f292d08d764 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -219,13 +219,17 @@ impl TokenEmergencyActionTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs index 61d88cb312c..bf6a0868096 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -191,13 +191,17 @@ impl TokenFreezeTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index b0e602ff74c..1595592033b 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -212,13 +212,17 @@ impl TokenMintTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index 62db4bdd7b1..069f19f6dcf 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -160,13 +160,17 @@ impl TokenDirectPurchaseTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) 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 bd7007f57b4..1933532689f 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -236,13 +236,17 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index 336938e3c48..0822f8d924d 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -217,13 +217,17 @@ impl TokenTransferTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) diff --git a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs index 471c41a1a6c..109a2e19e44 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -191,13 +191,17 @@ impl TokenUnfreezeTransitionBuilder { batch_transition.validate_base_structure(platform_version)? } _ => { - return Err(Error::Protocol(dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )); } }; if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(first_error)))); + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); } Ok(state_transition) From 3235ac7a88520bae91456142f29b7eb76ed328da Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 19:28:04 -0600 Subject: [PATCH 04/25] fix(wasm-sdk): add maxLength to indexed rarity field in test fixture The validation feature added to rs-sdk's dpp dependency now enforces that indexed string properties must have maxLength <= 63. The rarity field in the crypto card game test fixture was missing this constraint, causing all DataContract tests to fail. --- .../tests/unit/fixtures/data-contract-v0-crypto-card-game.ts | 1 + 1 file changed, 1 insertion(+) 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', From 5647f5163b94b5819168878fbb001ba278c4cf70 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 13:56:26 -0600 Subject: [PATCH 05/25] test(sdk): add validate_base_structure error path tests for document and token builders Tests cover: - InvalidTokenAmountError via TokenMintTransitionBuilder with amount=0 - InvalidActionIdError via TokenMintTransitionBuilder with mismatched group action ID - Document nonce masking (validates nonce out-of-bounds is unreachable via builder API) Documents that several error paths (empty transitions, max exceeded, duplicate transitions, invalid token ID) are unreachable through the single-transition builder API by design. --- .../src/platform/documents/transitions/mod.rs | 2 + .../platform/documents/transitions/tests.rs | 374 ++++++++++++++++++ .../src/platform/tokens/builders/mod.rs | 2 + .../src/platform/tokens/builders/tests.rs | 190 +++++++++ 4 files changed, 568 insertions(+) create mode 100644 packages/rs-sdk/src/platform/documents/transitions/tests.rs create mode 100644 packages/rs-sdk/src/platform/tokens/builders/tests.rs 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..87057371e21 --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -0,0 +1,374 @@ +use super::delete::DocumentDeleteTransitionBuilder; +use crate::{Error, Sdk, SdkBuilder}; +use dpp::address_funds::AddressWitness; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +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::document::{Document, DocumentV0}; +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::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::StateTransition; +use dpp::ProtocolError; +use drive_proof_verifier::types::IdentityContractNonceFetcher; +use std::collections::BTreeMap; +use std::sync::Arc; + +#[derive(Debug)] +struct TestSigner; + +impl Signer for TestSigner { + fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result { + Ok(BinaryData::from(vec![1; 65])) + } + + 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 + } +} + +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, + }) +} + +fn test_data_contract(document_type_name: &str) -> Arc { + let platform_version = dpp::version::PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("create contract config"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "a": { + "type": "string", + "maxLength": 10, + "position": 0 + } + }, + "additionalProperties": false, + }); + + 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) +} + +const TEST_DOCUMENT_TYPE_NAME: &str = "testDoc"; +const INVALID_NONCE: u64 = 1_u64 << 50; + +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, + }) +} + +fn validate_transition_like_builder(state_transition: &StateTransition) -> Result<(), Error> { + let platform_version = dpp::version::PlatformVersion::latest(); + let validation_result = match state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )) + } + }; + if let Some(first_error) = validation_result.errors.into_iter().next() { + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); + } + Ok(()) +} + +pub(super) fn assert_document_create_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let document_type = data_contract + .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) + .expect("expected test document type"); + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + [7; 32], + &test_identity_public_key(), + INVALID_NONCE, + 0, + None, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) fn assert_document_delete_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let document_type = data_contract + .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) + .expect("expected test document type"); + let transition = BatchTransition::new_document_deletion_transition_from_document( + document, + document_type, + &test_identity_public_key(), + INVALID_NONCE, + 0, + None, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) fn assert_document_purchase_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + 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 document_type = data_contract + .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) + .expect("expected test document type"); + let transition = BatchTransition::new_document_purchase_transition_from_document( + document, + document_type, + purchaser_id, + 100, + &test_identity_public_key(), + INVALID_NONCE, + 0, + None, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) fn assert_document_replace_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let document_type = data_contract + .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) + .expect("expected test document type"); + let transition = BatchTransition::new_document_replacement_transition_from_document( + document, + document_type, + &test_identity_public_key(), + INVALID_NONCE, + 0, + None, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) fn assert_document_set_price_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); + let owner_id = Identifier::random(); + let document = test_document(owner_id); + let document_type = data_contract + .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) + .expect("expected test document type"); + let transition = BatchTransition::new_document_update_price_transition_from_document( + document, + document_type, + 200, + &test_identity_public_key(), + INVALID_NONCE, + 0, + None, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) fn assert_document_transfer_validate_base_structure_error() { + let platform_version = dpp::version::PlatformVersion::latest(); + 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 document_type = data_contract + .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) + .expect("expected test document type"); + let transition = BatchTransition::new_document_transfer_transition_from_document( + document, + document_type, + recipient_id, + &test_identity_public_key(), + INVALID_NONCE, + 0, + None, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +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 +} + +#[tokio::test] +async fn document_delete_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { + // Document builders always create exactly one transition and 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 here. + let document_type_name = "testDoc"; + 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(), + "unexpected error while signing document delete transition: {:?}", + result.err() + ); + + assert!( + !matches!( + result, + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) + if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) + )) + ), + "nonce out-of-bounds should be unreachable via document builders" + ); +} 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/tests.rs b/packages/rs-sdk/src/platform/tokens/builders/tests.rs new file mode 100644 index 00000000000..4eccf101d8b --- /dev/null +++ b/packages/rs-sdk/src/platform/tokens/builders/tests.rs @@ -0,0 +1,190 @@ +use super::mint::TokenMintTransitionBuilder; +use crate::{Error, Sdk, SdkBuilder}; +use dpp::address_funds::AddressWitness; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +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::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; +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::ProtocolError; +use drive_proof_verifier::types::IdentityContractNonceFetcher; +use std::collections::BTreeMap; +use std::sync::Arc; + +#[derive(Debug)] +struct TestSigner; + +impl Signer for TestSigner { + fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result { + Ok(BinaryData::from(vec![1; 65])) + } + + 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 + } +} + +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, + }) +} + +fn test_data_contract(document_type_name: &str) -> Arc { + let platform_version = dpp::version::PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("create contract config"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "a": { + "type": "string", + "maxLength": 10, + "position": 0 + } + }, + "additionalProperties": false, + }); + + 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) +} + +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 +} + +#[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("testDoc"); + 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(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("testDoc"); + 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(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidActionIdError(_))) + ), + "unexpected result: {:?}", + result + ); +} From fde80314f578203b3a4772ecb03338b611032ba1 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 14:01:49 -0600 Subject: [PATCH 06/25] test(sdk): add validate_base_structure error path tests for all builders --- .../platform/documents/transitions/create.rs | 8 + .../platform/documents/transitions/delete.rs | 8 + .../documents/transitions/purchase.rs | 8 + .../platform/documents/transitions/replace.rs | 8 + .../documents/transitions/set_price.rs | 8 + .../platform/documents/transitions/tests.rs | 244 ++++++ .../documents/transitions/transfer.rs | 8 + .../src/platform/tokens/builders/burn.rs | 8 + .../src/platform/tokens/builders/claim.rs | 8 + .../platform/tokens/builders/config_update.rs | 8 + .../src/platform/tokens/builders/destroy.rs | 8 + .../tokens/builders/emergency_action.rs | 8 + .../src/platform/tokens/builders/freeze.rs | 8 + .../src/platform/tokens/builders/mint.rs | 8 + .../src/platform/tokens/builders/purchase.rs | 8 + .../src/platform/tokens/builders/set_price.rs | 8 + .../src/platform/tokens/builders/tests.rs | 754 +++++++++++++++++- .../src/platform/tokens/builders/transfer.rs | 8 + .../src/platform/tokens/builders/unfreeze.rs | 8 + 19 files changed, 1132 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index fca6bda77f2..1c5bc3ab99d 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -267,3 +267,11 @@ impl Sdk { } } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_document_create_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 9a1862b98af..50f491bcbd1 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -304,3 +304,11 @@ impl Sdk { } } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_document_delete_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index 6b33bc2bf3b..d34f70e9975 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -321,3 +321,11 @@ impl Sdk { } } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_document_purchase_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index 27dede0bb3f..77b510088e1 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -267,3 +267,11 @@ impl Sdk { } } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_document_replace_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index 537c09283db..cda735f7c5d 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -305,3 +305,11 @@ impl Sdk { } } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_document_set_price_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index 87057371e21..1f8002498a2 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -1,4 +1,9 @@ +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::{Error, Sdk, SdkBuilder}; use dpp::address_funds::AddressWitness; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -131,6 +136,35 @@ fn test_document(owner_id: Identifier) -> Document { }) } +fn test_document_for_create( + data_contract_id: &Identifier, + document_type_name: &str, + owner_id: Identifier, + entropy: [u8; 32], +) -> Document { + Document::V0(DocumentV0 { + id: Document::generate_document_id_v0( + data_contract_id, + &owner_id, + document_type_name, + &entropy, + ), + owner_id, + properties: Default::default(), + revision: None, + 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, + }) +} + fn validate_transition_like_builder(state_transition: &StateTransition) -> Result<(), Error> { let platform_version = dpp::version::PlatformVersion::latest(); let validation_result = match state_transition { @@ -372,3 +406,213 @@ async fn document_delete_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_v "nonce out-of-bounds should be unreachable via document builders" ); } + +#[tokio::test] +async fn document_create_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { + let document_type_name = "testDoc"; + let data_contract = test_data_contract(document_type_name); + let owner_id = Identifier::random(); + let entropy = [11; 32]; + let document = + test_document_for_create(&data_contract.id(), document_type_name, owner_id, entropy); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 1_u64 << 50).await; + + let builder = DocumentCreateTransitionBuilder::new( + Arc::clone(&data_contract), + 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(), + "unexpected error while signing document create transition: {:?}", + result.err() + ); + + assert!( + !matches!( + result, + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) + if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) + )) + ), + "nonce out-of-bounds should be unreachable via document builders" + ); +} + +#[tokio::test] +async fn document_replace_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { + let document_type_name = "testDoc"; + 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 = DocumentReplaceTransitionBuilder::new( + Arc::clone(&data_contract), + document_type_name.to_string(), + test_document(owner_id), + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "unexpected error while signing document replace transition: {:?}", + result.err() + ); + + assert!( + !matches!( + result, + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) + if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) + )) + ), + "nonce out-of-bounds should be unreachable via document builders" + ); +} + +#[tokio::test] +async fn document_purchase_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { + let document_type_name = "testDoc"; + let data_contract = test_data_contract(document_type_name); + let owner_id = Identifier::random(); + let purchaser_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(purchaser_id, data_contract.id(), 1_u64 << 50).await; + + let builder = DocumentPurchaseTransitionBuilder::new( + Arc::clone(&data_contract), + document_type_name.to_string(), + test_document(owner_id), + purchaser_id, + 100, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "unexpected error while signing document purchase transition: {:?}", + result.err() + ); + + assert!( + !matches!( + result, + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) + if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) + )) + ), + "nonce out-of-bounds should be unreachable via document builders" + ); +} + +#[tokio::test] +async fn document_set_price_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { + let document_type_name = "testDoc"; + 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 = DocumentSetPriceTransitionBuilder::new( + Arc::clone(&data_contract), + document_type_name.to_string(), + test_document(owner_id), + 123, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "unexpected error while signing document set_price transition: {:?}", + result.err() + ); + + assert!( + !matches!( + result, + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) + if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) + )) + ), + "nonce out-of-bounds should be unreachable via document builders" + ); +} + +#[tokio::test] +async fn document_transfer_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { + let document_type_name = "testDoc"; + let data_contract = test_data_contract(document_type_name); + let owner_id = Identifier::random(); + let recipient_id = Identifier::random(); + let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 1_u64 << 50).await; + + let builder = DocumentTransferTransitionBuilder::new( + Arc::clone(&data_contract), + document_type_name.to_string(), + test_document(owner_id), + recipient_id, + ); + + let result = builder + .sign( + &sdk, + &test_identity_public_key(), + &TestSigner, + dpp::version::PlatformVersion::latest(), + ) + .await; + + assert!( + result.is_ok(), + "unexpected error while signing document transfer transition: {:?}", + result.err() + ); + + assert!( + !matches!( + result, + Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) + if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) + )) + ), + "nonce out-of-bounds should be unreachable via document builders" + ); +} diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index 53eef22c776..452d5590265 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -305,3 +305,11 @@ impl Sdk { } } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_document_transfer_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index 83f7f3719b7..c69dac24bf5 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -201,3 +201,11 @@ impl TokenBurnTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_burn_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/claim.rs b/packages/rs-sdk/src/platform/tokens/builders/claim.rs index d68923841f4..22fc107cdbe 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -187,3 +187,11 @@ impl TokenClaimTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_claim_validate_base_structure_error(); + } +} 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 1d20a26d2b3..eb7a6fd9146 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -208,3 +208,11 @@ impl TokenConfigUpdateTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_config_update_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs index a89f2416b8c..6ee484126ea 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -207,3 +207,11 @@ impl TokenDestroyFrozenFundsTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_destroy_validate_base_structure_error(); + } +} 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 f292d08d764..3bb0bf70e01 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -235,3 +235,11 @@ impl TokenEmergencyActionTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_emergency_action_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs index bf6a0868096..e1f2a5edc15 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -207,3 +207,11 @@ impl TokenFreezeTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_freeze_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index 1595592033b..9bfb4af5adc 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -228,3 +228,11 @@ impl TokenMintTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_mint_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index 069f19f6dcf..a91f99d45a5 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -176,3 +176,11 @@ impl TokenDirectPurchaseTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_purchase_validate_base_structure_error(); + } +} 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 1933532689f..85ba354f0e4 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -252,3 +252,11 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_set_price_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/tests.rs b/packages/rs-sdk/src/platform/tokens/builders/tests.rs index 4eccf101d8b..51659b0ad14 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/tests.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/tests.rs @@ -1,9 +1,21 @@ +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::{Error, Sdk, SdkBuilder}; use dpp::address_funds::AddressWitness; 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::data_contract::config::DataContractConfig; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentType; @@ -14,6 +26,12 @@ 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::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::StateTransition; +use dpp::tokens::calculate_token_id; +use dpp::tokens::emergency_action::TokenEmergencyAction; +use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dpp::ProtocolError; use drive_proof_verifier::types::IdentityContractNonceFetcher; use std::collections::BTreeMap; @@ -108,6 +126,311 @@ fn test_data_contract(document_type_name: &str) -> Arc Result<(), Error> { + let platform_version = dpp::version::PlatformVersion::latest(); + let validation_result = match state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ), + )) + } + }; + if let Some(first_error) = validation_result.errors.into_iter().next() { + return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(first_error), + ))); + } + Ok(()) +} + +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) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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(); + let transition = BatchTransition::new_token_config_update_transition( + token_id, + owner_id, + data_contract.id(), + TEST_TOKEN_POSITION, + TokenConfigurationChangeItem::TokenConfigurationNoChange, + None, + None, + &test_identity_public_key(), + INVALID_NONCE, + 0, + &TestSigner, + platform_version, + None, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + +pub(super) 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 transition = 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, + ) + .expect("transition should build"); + + let result = validate_transition_like_builder(&transition); + assert!(result.is_err(), "expected validation error, got {result:?}"); +} + async fn new_mock_sdk_with_contract_nonce( identity_id: Identifier, contract_id: Identifier, @@ -129,7 +452,7 @@ async fn new_mock_sdk_with_contract_nonce( #[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("testDoc"); + 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) @@ -156,7 +479,7 @@ async fn token_mint_sign_returns_invalid_token_amount_error_when_amount_is_zero( #[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("testDoc"); + 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( @@ -188,3 +511,430 @@ async fn token_mint_sign_returns_invalid_action_id_error_for_mismatched_group_ac 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(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(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(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(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(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(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(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(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(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(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(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(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(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(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) + ), + "unexpected result: {:?}", + result + ); +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index 0822f8d924d..83127672925 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -233,3 +233,11 @@ impl TokenTransferTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_transfer_validate_base_structure_error(); + } +} diff --git a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs index 109a2e19e44..ae4aea50026 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -207,3 +207,11 @@ impl TokenUnfreezeTransitionBuilder { Ok(state_transition) } } + +#[cfg(test)] +mod validation_tests { + #[test] + fn validate_base_structure_error_case() { + super::super::tests::assert_token_unfreeze_validate_base_structure_error(); + } +} From 555940bfbefd5139309c6bde61c9749aaf87b1d4 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 21 Feb 2026 14:14:28 -0600 Subject: [PATCH 07/25] refactor(sdk): remove 5 redundant document nonce-masking tests All 6 document_*_sign_masks_nonce tests tested the same underlying behavior (Sdk::get_identity_contract_nonce masks out-of-bounds bits). Keep one representative test, remove the other 5. Also removes dead second assert that was always true (result was Ok) and unused test_document_for_create helper. --- .../platform/documents/transitions/tests.rs | 266 +----------------- 1 file changed, 6 insertions(+), 260 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index 1f8002498a2..dd88faaa80a 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -1,9 +1,4 @@ -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::{Error, Sdk, SdkBuilder}; use dpp::address_funds::AddressWitness; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -136,35 +131,6 @@ fn test_document(owner_id: Identifier) -> Document { }) } -fn test_document_for_create( - data_contract_id: &Identifier, - document_type_name: &str, - owner_id: Identifier, - entropy: [u8; 32], -) -> Document { - Document::V0(DocumentV0 { - id: Document::generate_document_id_v0( - data_contract_id, - &owner_id, - document_type_name, - &entropy, - ), - owner_id, - properties: Default::default(), - revision: None, - 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, - }) -} - fn validate_transition_like_builder(state_transition: &StateTransition) -> Result<(), Error> { let platform_version = dpp::version::PlatformVersion::latest(); let validation_result = match state_transition { @@ -363,10 +329,11 @@ async fn new_mock_sdk_with_contract_nonce( } #[tokio::test] -async fn document_delete_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { - // Document builders always create exactly one transition and 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 here. +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 = "testDoc"; let data_contract = test_data_contract(document_type_name); let owner_id = Identifier::random(); @@ -391,228 +358,7 @@ async fn document_delete_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_v assert!( result.is_ok(), - "unexpected error while signing document delete transition: {:?}", - result.err() - ); - - assert!( - !matches!( - result, - Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) - if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( - dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) - )) - ), - "nonce out-of-bounds should be unreachable via document builders" - ); -} - -#[tokio::test] -async fn document_create_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { - let document_type_name = "testDoc"; - let data_contract = test_data_contract(document_type_name); - let owner_id = Identifier::random(); - let entropy = [11; 32]; - let document = - test_document_for_create(&data_contract.id(), document_type_name, owner_id, entropy); - let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 1_u64 << 50).await; - - let builder = DocumentCreateTransitionBuilder::new( - Arc::clone(&data_contract), - 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(), - "unexpected error while signing document create transition: {:?}", - result.err() - ); - - assert!( - !matches!( - result, - Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) - if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( - dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) - )) - ), - "nonce out-of-bounds should be unreachable via document builders" - ); -} - -#[tokio::test] -async fn document_replace_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { - let document_type_name = "testDoc"; - 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 = DocumentReplaceTransitionBuilder::new( - Arc::clone(&data_contract), - document_type_name.to_string(), - test_document(owner_id), - ); - - let result = builder - .sign( - &sdk, - &test_identity_public_key(), - &TestSigner, - dpp::version::PlatformVersion::latest(), - ) - .await; - - assert!( - result.is_ok(), - "unexpected error while signing document replace transition: {:?}", - result.err() - ); - - assert!( - !matches!( - result, - Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) - if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( - dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) - )) - ), - "nonce out-of-bounds should be unreachable via document builders" - ); -} - -#[tokio::test] -async fn document_purchase_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { - let document_type_name = "testDoc"; - let data_contract = test_data_contract(document_type_name); - let owner_id = Identifier::random(); - let purchaser_id = Identifier::random(); - let sdk = new_mock_sdk_with_contract_nonce(purchaser_id, data_contract.id(), 1_u64 << 50).await; - - let builder = DocumentPurchaseTransitionBuilder::new( - Arc::clone(&data_contract), - document_type_name.to_string(), - test_document(owner_id), - purchaser_id, - 100, - ); - - let result = builder - .sign( - &sdk, - &test_identity_public_key(), - &TestSigner, - dpp::version::PlatformVersion::latest(), - ) - .await; - - assert!( - result.is_ok(), - "unexpected error while signing document purchase transition: {:?}", - result.err() - ); - - assert!( - !matches!( - result, - Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) - if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( - dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) - )) - ), - "nonce out-of-bounds should be unreachable via document builders" - ); -} - -#[tokio::test] -async fn document_set_price_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { - let document_type_name = "testDoc"; - 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 = DocumentSetPriceTransitionBuilder::new( - Arc::clone(&data_contract), - document_type_name.to_string(), - test_document(owner_id), - 123, - ); - - let result = builder - .sign( - &sdk, - &test_identity_public_key(), - &TestSigner, - dpp::version::PlatformVersion::latest(), - ) - .await; - - assert!( - result.is_ok(), - "unexpected error while signing document set_price transition: {:?}", + "SDK should mask nonce internally; got error: {:?}", result.err() ); - - assert!( - !matches!( - result, - Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) - if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( - dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) - )) - ), - "nonce out-of-bounds should be unreachable via document builders" - ); -} - -#[tokio::test] -async fn document_transfer_sign_masks_nonce_and_does_not_hit_nonce_out_of_bounds_validation() { - let document_type_name = "testDoc"; - let data_contract = test_data_contract(document_type_name); - let owner_id = Identifier::random(); - let recipient_id = Identifier::random(); - let sdk = new_mock_sdk_with_contract_nonce(owner_id, data_contract.id(), 1_u64 << 50).await; - - let builder = DocumentTransferTransitionBuilder::new( - Arc::clone(&data_contract), - document_type_name.to_string(), - test_document(owner_id), - recipient_id, - ); - - let result = builder - .sign( - &sdk, - &test_identity_public_key(), - &TestSigner, - dpp::version::PlatformVersion::latest(), - ) - .await; - - assert!( - result.is_ok(), - "unexpected error while signing document transfer transition: {:?}", - result.err() - ); - - assert!( - !matches!( - result, - Err(Error::Protocol(ProtocolError::ConsensusError(consensus_error))) - if matches!(*consensus_error, dpp::consensus::ConsensusError::BasicError( - dpp::consensus::basic::BasicError::NonceOutOfBoundsError(_) - )) - ), - "nonce out-of-bounds should be unreachable via document builders" - ); } From 89610b9134531c19bb78045918324397f0a5de19 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Mar 2026 14:31:58 -0500 Subject: [PATCH 08/25] refactor(sdk): extract validate_batch_base_structure helper to reduce duplication Co-Authored-By: Claude Opus 4.6 (1M context) --- .../platform/documents/transitions/create.rs | 19 ++----------- .../platform/documents/transitions/delete.rs | 19 ++----------- .../documents/transitions/purchase.rs | 19 ++----------- .../platform/documents/transitions/replace.rs | 19 ++----------- .../documents/transitions/set_price.rs | 19 ++----------- .../documents/transitions/transfer.rs | 19 ++----------- .../src/platform/tokens/builders/burn.rs | 19 ++----------- .../src/platform/tokens/builders/claim.rs | 19 ++----------- .../platform/tokens/builders/config_update.rs | 19 ++----------- .../src/platform/tokens/builders/destroy.rs | 19 ++----------- .../tokens/builders/emergency_action.rs | 19 ++----------- .../src/platform/tokens/builders/freeze.rs | 19 ++----------- .../src/platform/tokens/builders/mint.rs | 19 ++----------- .../src/platform/tokens/builders/purchase.rs | 19 ++----------- .../src/platform/tokens/builders/set_price.rs | 19 ++----------- .../src/platform/tokens/builders/transfer.rs | 19 ++----------- .../src/platform/tokens/builders/unfreeze.rs | 19 ++----------- .../src/platform/transition/validation.rs | 28 +++++++++++++++++++ 18 files changed, 62 insertions(+), 289 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 1c5bc3ab99d..e9f8f6168b5 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; @@ -167,23 +168,7 @@ impl DocumentCreateTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 50f491bcbd1..ba7425a512a 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -208,23 +209,7 @@ impl DocumentDeleteTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index d34f70e9975..0e6fc745a70 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -222,23 +223,7 @@ impl DocumentPurchaseTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index 77b510088e1..69d70073bdb 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; @@ -161,23 +162,7 @@ impl DocumentReplaceTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index cda735f7c5d..72a845299a3 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -209,23 +210,7 @@ impl DocumentSetPriceTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index 452d5590265..2571c85370e 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -208,23 +209,7 @@ impl DocumentTransferTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index c69dac24bf5..04d2e3fb364 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -180,23 +181,7 @@ impl TokenBurnTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/claim.rs b/packages/rs-sdk/src/platform/tokens/builders/claim.rs index 22fc107cdbe..0a14f68f7c0 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -166,23 +167,7 @@ impl TokenClaimTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } 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 eb7a6fd9146..814cb431148 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -187,23 +188,7 @@ impl TokenConfigUpdateTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs index 6ee484126ea..f8873b0082d 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -186,23 +187,7 @@ impl TokenDestroyFrozenFundsTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } 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 3bb0bf70e01..8bbad7c4f50 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -214,23 +215,7 @@ impl TokenEmergencyActionTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs index e1f2a5edc15..e2e618e2927 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -186,23 +187,7 @@ impl TokenFreezeTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/mint.rs b/packages/rs-sdk/src/platform/tokens/builders/mint.rs index 9bfb4af5adc..ba9b6d30926 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -207,23 +208,7 @@ impl TokenMintTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index a91f99d45a5..d7fff044334 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -155,23 +156,7 @@ impl TokenDirectPurchaseTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } 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 85ba354f0e4..50691fdffb7 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::Credits; @@ -231,23 +232,7 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index 83127672925..bd409de9eb4 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -212,23 +213,7 @@ impl TokenTransferTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs index ae4aea50026..1a5c4b9e312 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -1,4 +1,5 @@ use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -186,23 +187,7 @@ impl TokenUnfreezeTransitionBuilder { )?; // Validate the transition structure before returning - let validation_result = match &state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )); - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } + validate_batch_base_structure(&state_transition, platform_version)?; Ok(state_transition) } diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index 846d9ddae2d..303b2f078ef 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -3,8 +3,36 @@ use dpp::{ consensus::{basic::BasicError, ConsensusError}, state_transition::{StateTransition, StateTransitionStructureValidation}, version::PlatformVersion, + ProtocolError, }; +/// Validates the base structure of a Batch state transition. +/// +/// Used by document and token transition builders to validate the constructed +/// `BatchTransition` before returning it to the caller. Catches invalid +/// transitions early with clear errors instead of confusing network rejections. +pub(crate) fn validate_batch_base_structure( + state_transition: &StateTransition, + platform_version: &PlatformVersion, +) -> Result<(), Error> { + let validation_result = match state_transition { + StateTransition::Batch(batch_transition) => { + batch_transition.validate_base_structure(platform_version)? + } + _ => { + return Err(Error::Protocol(ProtocolError::InvalidStateTransitionType( + "expected Batch transition".to_string(), + ))); + } + }; + if let Some(first_error) = validation_result.errors.into_iter().next() { + return Err(Error::Protocol(ProtocolError::ConsensusError(Box::new( + first_error, + )))); + } + Ok(()) +} + /// Checks if an error is an UnsupportedFeatureError fn is_unsupported_feature_error(error: &ConsensusError) -> bool { matches!( From c773a818dad4a20ff0232e5ee19909c1ab53cd89 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Mar 2026 14:35:59 -0500 Subject: [PATCH 09/25] refactor(sdk): extract shared test helpers to reduce test duplication Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/platform.rs | 2 + .../platform/documents/transitions/tests.rs | 149 +-------------- packages/rs-sdk/src/platform/test_helpers.rs | 139 ++++++++++++++ .../src/platform/tokens/builders/tests.rs | 180 ++---------------- 4 files changed, 165 insertions(+), 305 deletions(-) create mode 100644 packages/rs-sdk/src/platform/test_helpers.rs 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/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index dd88faaa80a..b5ba3eaf477 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -1,117 +1,16 @@ use super::delete::DocumentDeleteTransitionBuilder; -use crate::{Error, Sdk, SdkBuilder}; -use dpp::address_funds::AddressWitness; +use crate::platform::test_helpers::{ + new_mock_sdk_with_contract_nonce, test_data_contract, test_identity_public_key, + validate_transition_like_builder, TestSigner, INVALID_NONCE, TEST_DOCUMENT_TYPE_NAME, +}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -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::document::{Document, DocumentV0}; -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::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::BatchTransition; -use dpp::state_transition::StateTransition; -use dpp::ProtocolError; -use drive_proof_verifier::types::IdentityContractNonceFetcher; -use std::collections::BTreeMap; use std::sync::Arc; -#[derive(Debug)] -struct TestSigner; - -impl Signer for TestSigner { - fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result { - Ok(BinaryData::from(vec![1; 65])) - } - - 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 - } -} - -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, - }) -} - -fn test_data_contract(document_type_name: &str) -> Arc { - let platform_version = dpp::version::PlatformVersion::latest(); - let config = - DataContractConfig::default_for_version(platform_version).expect("create contract config"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "a": { - "type": "string", - "maxLength": 10, - "position": 0 - } - }, - "additionalProperties": false, - }); - - 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) -} - -const TEST_DOCUMENT_TYPE_NAME: &str = "testDoc"; -const INVALID_NONCE: u64 = 1_u64 << 50; - fn test_document(owner_id: Identifier) -> Document { Document::V0(DocumentV0 { id: Identifier::random(), @@ -131,28 +30,6 @@ fn test_document(owner_id: Identifier) -> Document { }) } -fn validate_transition_like_builder(state_transition: &StateTransition) -> Result<(), Error> { - let platform_version = dpp::version::PlatformVersion::latest(); - let validation_result = match state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )) - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } - Ok(()) -} - pub(super) fn assert_document_create_validate_base_structure_error() { let platform_version = dpp::version::PlatformVersion::latest(); let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); @@ -310,24 +187,6 @@ pub(super) fn assert_document_transfer_validate_base_structure_error() { assert!(result.is_err(), "expected validation error, got {result:?}"); } -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 -} - #[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`, 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..eb759742e6f --- /dev/null +++ b/packages/rs-sdk/src/platform/test_helpers.rs @@ -0,0 +1,139 @@ +//! Shared test infrastructure for document and token transition builder tests. + +use crate::{Error, Sdk, SdkBuilder}; +use dpp::address_funds::AddressWitness; +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; + +use crate::platform::transition::validation::validate_batch_base_structure; + +pub(crate) const TEST_DOCUMENT_TYPE_NAME: &str = "testDoc"; +pub(crate) const INVALID_NONCE: u64 = 1_u64 << 50; + +#[derive(Debug)] +pub(crate) struct TestSigner; + +impl Signer for TestSigner { + fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result { + Ok(BinaryData::from(vec![1; 65])) + } + + 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, + }) +} + +pub(crate) fn test_data_contract( + document_type_name: &str, +) -> Arc { + let platform_version = dpp::version::PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("create contract config"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "a": { + "type": "string", + "maxLength": 10, + "position": 0 + } + }, + "additionalProperties": false, + }); + + 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) +} + +pub(crate) fn validate_transition_like_builder( + state_transition: &StateTransition, +) -> Result<(), Error> { + let platform_version = dpp::version::PlatformVersion::latest(); + validate_batch_base_structure(state_transition, platform_version) +} + +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/tests.rs b/packages/rs-sdk/src/platform/tokens/builders/tests.rs index 51659b0ad14..a30bab19423 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/tests.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/tests.rs @@ -9,148 +9,26 @@ use super::purchase::TokenDirectPurchaseTransitionBuilder; use super::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; use super::transfer::TokenTransferTransitionBuilder; use super::unfreeze::TokenUnfreezeTransitionBuilder; -use crate::{Error, Sdk, SdkBuilder}; -use dpp::address_funds::AddressWitness; +use crate::platform::test_helpers::{ + new_mock_sdk_with_contract_nonce, test_data_contract, test_identity_public_key, + validate_transition_like_builder, 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::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::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; -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::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; use dpp::state_transition::batch_transition::BatchTransition; -use dpp::state_transition::StateTransition; use dpp::tokens::calculate_token_id; use dpp::tokens::emergency_action::TokenEmergencyAction; use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dpp::ProtocolError; -use drive_proof_verifier::types::IdentityContractNonceFetcher; -use std::collections::BTreeMap; use std::sync::Arc; -#[derive(Debug)] -struct TestSigner; - -impl Signer for TestSigner { - fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result { - Ok(BinaryData::from(vec![1; 65])) - } - - 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 - } -} - -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, - }) -} - -fn test_data_contract(document_type_name: &str) -> Arc { - let platform_version = dpp::version::PlatformVersion::latest(); - let config = - DataContractConfig::default_for_version(platform_version).expect("create contract config"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "a": { - "type": "string", - "maxLength": 10, - "position": 0 - } - }, - "additionalProperties": false, - }); - - 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) -} - -const TEST_DOCUMENT_TYPE_NAME: &str = "testDoc"; const TEST_TOKEN_POSITION: u16 = 0; -const INVALID_NONCE: u64 = 1_u64 << 50; - -fn validate_transition_like_builder(state_transition: &StateTransition) -> Result<(), Error> { - let platform_version = dpp::version::PlatformVersion::latest(); - let validation_result = match state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ), - )) - } - }; - if let Some(first_error) = validation_result.errors.into_iter().next() { - return Err(Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(first_error), - ))); - } - Ok(()) -} fn token_setup() -> ( Arc, @@ -431,24 +309,6 @@ pub(super) fn assert_token_unfreeze_validate_base_structure_error() { assert!(result.is_err(), "expected validation error, got {result:?}"); } -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 -} - #[tokio::test] async fn token_mint_sign_returns_invalid_token_amount_error_when_amount_is_zero() { let issuer_id = Identifier::random(); @@ -468,7 +328,7 @@ async fn token_mint_sign_returns_invalid_token_amount_error_when_amount_is_zero( assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) ), "unexpected result: {:?}", @@ -504,7 +364,7 @@ async fn token_mint_sign_returns_invalid_action_id_error_for_mismatched_group_ac assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidActionIdError(_))) ), "unexpected result: {:?}", @@ -530,7 +390,7 @@ async fn token_burn_sign_returns_invalid_token_amount_error_when_amount_is_zero( assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) ), "unexpected result: {:?}", @@ -557,7 +417,7 @@ async fn token_burn_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -590,7 +450,7 @@ async fn token_transfer_sign_returns_invalid_token_amount_error_when_amount_is_z assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) ), "unexpected result: {:?}", @@ -617,7 +477,7 @@ async fn token_transfer_sign_returns_transfer_to_ourself_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::TokenTransferToOurselfError(_))) ), "unexpected result: {:?}", @@ -651,7 +511,7 @@ async fn token_transfer_sign_returns_note_too_big_error_for_public_note() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -684,7 +544,7 @@ async fn token_freeze_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -717,7 +577,7 @@ async fn token_unfreeze_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -750,7 +610,7 @@ async fn token_destroy_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -778,7 +638,7 @@ async fn token_emergency_action_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -809,7 +669,7 @@ async fn token_config_update_sign_returns_no_change_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenConfigUpdateNoChangeError(_))) ), "unexpected result: {:?}", @@ -841,7 +701,7 @@ async fn token_config_update_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -873,7 +733,7 @@ async fn token_claim_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", @@ -900,7 +760,7 @@ async fn token_purchase_sign_returns_invalid_token_amount_error_when_amount_is_z assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenAmountError(_))) ), "unexpected result: {:?}", @@ -931,7 +791,7 @@ async fn token_set_price_sign_returns_note_too_big_error() { assert!( matches!( result, - Err(Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) + Err(crate::Error::Protocol(ProtocolError::ConsensusError(ref consensus_error))) if matches!(**consensus_error, ConsensusError::BasicError(BasicError::InvalidTokenNoteTooBigError(_))) ), "unexpected result: {:?}", From b81e0652405170b0fb1ff41f4107486d5e0de9c4 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Mar 2026 14:37:03 -0500 Subject: [PATCH 10/25] test(sdk): add document builder sign() integration test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../platform/documents/transitions/tests.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index b5ba3eaf477..daa7fa05e71 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -221,3 +221,33 @@ async fn document_builder_sign_masks_nonce_so_out_of_bounds_is_unreachable() { result.err() ); } + +#[tokio::test] +async fn document_delete_builder_sign_succeeds_for_valid_input() { + let document_type_name = "testDoc"; + 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() + ); +} From 52a04ff63da4480c1d2cef6e153824c74d8e97f4 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Mar 2026 19:12:08 -0500 Subject: [PATCH 11/25] fix(sdk): log dropped validation errors in validate_batch_base_structure When multiple validation errors occur, only the first is returned as an error. Additional errors are now logged via tracing::warn! instead of being silently discarded. Also adds a doc comment to INVALID_NONCE explaining why it triggers NonceOutOfBoundsError. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/platform/test_helpers.rs | 2 ++ packages/rs-sdk/src/platform/transition/validation.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/test_helpers.rs b/packages/rs-sdk/src/platform/test_helpers.rs index eb759742e6f..bffa923c832 100644 --- a/packages/rs-sdk/src/platform/test_helpers.rs +++ b/packages/rs-sdk/src/platform/test_helpers.rs @@ -20,6 +20,8 @@ use std::sync::Arc; use crate::platform::transition::validation::validate_batch_base_structure; 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)] diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index 303b2f078ef..fa136744a29 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -25,7 +25,15 @@ pub(crate) fn validate_batch_base_structure( ))); } }; - if let Some(first_error) = validation_result.errors.into_iter().next() { + let mut errors = validation_result.errors.into_iter(); + if let Some(first_error) = errors.next() { + // Log any additional errors that won't be reported + for additional_error in errors { + tracing::warn!( + ?additional_error, + "additional validation error dropped (only first error is reported)" + ); + } return Err(Error::Protocol(ProtocolError::ConsensusError(Box::new( first_error, )))); From 8125b54405ca8216b6b0d47378911ded5c52032d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Mar 2026 19:12:29 -0500 Subject: [PATCH 12/25] test(sdk): add happy-path sign() tests for all document builders Add sign() integration tests for create, replace, purchase, set_price, and transfer builders, complementing the existing delete builder test. Each test verifies that valid input passes validation end-to-end through the builder's sign() method with a mock SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../platform/documents/transitions/tests.rs | 158 +++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index daa7fa05e71..575b9060422 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -1,10 +1,14 @@ +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_identity_public_key, validate_transition_like_builder, TestSigner, INVALID_NONCE, TEST_DOCUMENT_TYPE_NAME, }; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::document::{Document, DocumentV0}; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; @@ -251,3 +255,155 @@ async fn document_delete_builder_sign_succeeds_for_valid_input() { result.err() ); } + +#[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 document = test_document(owner_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, + [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() + ); +} + +#[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() + ); +} + +#[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() + ); +} + +#[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() + ); +} + +#[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() + ); +} From 10e173aabded1d0ab40fb106bbbd3c42a3e58085 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 17 Mar 2026 01:31:07 -0500 Subject: [PATCH 13/25] test(sdk): add happy-path sign() tests for all token builders Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/platform/tokens/builders/tests.rs | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) diff --git a/packages/rs-sdk/src/platform/tokens/builders/tests.rs b/packages/rs-sdk/src/platform/tokens/builders/tests.rs index a30bab19423..0697bc04a88 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/tests.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/tests.rs @@ -798,3 +798,344 @@ async fn token_set_price_sign_returns_note_too_big_error() { 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" + ); +} From 868e1a6734280d4143385000466e0cd60e2a746c Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 17 Mar 2026 01:34:16 -0500 Subject: [PATCH 14/25] test(sdk): assert non-empty signature in document builder happy-path tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../platform/documents/transitions/tests.rs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index 575b9060422..0d4f148b0e3 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -197,7 +197,7 @@ async fn document_builder_sign_masks_nonce_so_out_of_bounds_is_unreachable() { // 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 = "testDoc"; + let document_type_name = TEST_DOCUMENT_TYPE_NAME; let data_contract = test_data_contract(document_type_name); let owner_id = Identifier::random(); @@ -228,7 +228,7 @@ async fn document_builder_sign_masks_nonce_so_out_of_bounds_is_unreachable() { #[tokio::test] async fn document_delete_builder_sign_succeeds_for_valid_input() { - let document_type_name = "testDoc"; + 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; @@ -254,6 +254,11 @@ async fn document_delete_builder_sign_succeeds_for_valid_input() { "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] @@ -284,6 +289,11 @@ async fn document_create_builder_sign_succeeds_for_valid_input() { "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] @@ -313,6 +323,11 @@ async fn document_replace_builder_sign_succeeds_for_valid_input() { "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] @@ -345,6 +360,11 @@ async fn document_purchase_builder_sign_succeeds_for_valid_input() { "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] @@ -375,6 +395,11 @@ async fn document_set_price_builder_sign_succeeds_for_valid_input() { "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] @@ -406,4 +431,9 @@ async fn document_transfer_builder_sign_succeeds_for_valid_input() { "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" + ); } From 8742a0b5d66dfbe9f7f9ac581ebd8a7a3335d52d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 2 May 2026 16:28:46 -0500 Subject: [PATCH 15/25] fix(sdk): validate batch structure before signing --- packages/rs-dpp/Cargo.toml | 8 + packages/rs-dpp/src/errors/protocol_error.rs | 3 + .../document_create_transition/mod.rs | 1 + .../validate_structure/mod.rs | 44 +++ .../validate_structure/v0/mod.rs | 47 +++ .../document/batch_transition/mod.rs | 2 +- .../batch_transition/v0/v0_methods.rs | 84 +++-- .../batch_transition/v1/v0_methods.rs | 84 +++-- .../batch_transition/v1/v1_methods.rs | 266 +++------------- .../validate_basic_structure/mod.rs | 130 ++++++++ .../validate_basic_structure/v0/mod.rs | 292 ++++++++++++++++-- .../dpp_state_transition_versions/mod.rs | 5 + .../dpp_state_transition_versions/v1.rs | 1 + .../dpp_state_transition_versions/v2.rs | 1 + .../dpp_state_transition_versions/v3.rs | 1 + packages/rs-sdk-ffi/src/error.rs | 86 ++++++ packages/rs-sdk-ffi/src/token/burn.rs | 4 +- packages/rs-sdk-ffi/src/token/claim.rs | 4 +- .../rs-sdk-ffi/src/token/config_update.rs | 4 +- .../src/token/destroy_frozen_funds.rs | 4 +- .../rs-sdk-ffi/src/token/emergency_action.rs | 4 +- packages/rs-sdk-ffi/src/token/freeze.rs | 4 +- packages/rs-sdk-ffi/src/token/mint.rs | 2 +- packages/rs-sdk-ffi/src/token/purchase.rs | 4 +- packages/rs-sdk-ffi/src/token/set_price.rs | 4 +- packages/rs-sdk-ffi/src/token/transfer.rs | 4 +- packages/rs-sdk-ffi/src/token/unfreeze.rs | 4 +- packages/rs-sdk/Cargo.toml | 2 +- .../platform/documents/transitions/create.rs | 24 +- .../platform/documents/transitions/delete.rs | 12 - .../documents/transitions/purchase.rs | 12 - .../platform/documents/transitions/replace.rs | 12 - .../documents/transitions/set_price.rs | 12 - .../platform/documents/transitions/tests.rs | 215 ++++--------- .../documents/transitions/transfer.rs | 12 - packages/rs-sdk/src/platform/test_helpers.rs | 52 +++- .../src/platform/tokens/builders/burn.rs | 10 +- .../src/platform/tokens/builders/claim.rs | 10 +- .../platform/tokens/builders/config_update.rs | 10 +- .../src/platform/tokens/builders/destroy.rs | 10 +- .../tokens/builders/emergency_action.rs | 10 +- .../src/platform/tokens/builders/freeze.rs | 10 +- .../src/platform/tokens/builders/mint.rs | 10 +- .../src/platform/tokens/builders/purchase.rs | 10 +- .../src/platform/tokens/builders/set_price.rs | 10 +- .../src/platform/tokens/builders/tests.rs | 109 +++---- .../src/platform/tokens/builders/transfer.rs | 10 +- .../src/platform/tokens/builders/unfreeze.rs | 10 +- .../src/platform/transition/validation.rs | 36 --- packages/wasm-dpp/src/errors/from.rs | 4 + .../wasm-dpp/src/errors/protocol_error.rs | 4 + packages/wasm-sdk/src/error.rs | 158 +++++++++- .../wasm-sdk/src/state_transitions/token.rs | 28 +- 53 files changed, 1087 insertions(+), 812 deletions(-) create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/validate_structure/v0/mod.rs diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index a2683750d65..38fd47c6def 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/protocol_error.rs b/packages/rs-dpp/src/errors/protocol_error.rs index e0497c685b1..a4cab4027a5 100644 --- a/packages/rs-dpp/src/errors/protocol_error.rs +++ b/packages/rs-dpp/src/errors/protocol_error.rs @@ -131,6 +131,9 @@ pub enum ProtocolError { #[error(transparent)] ConsensusError(Box), + #[error("Multiple consensus errors: {0:?}")] + ConsensusErrors(Vec), + #[error(transparent)] Document(Box), 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..c7e4aaa2406 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 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..15d2da151e4 --- /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 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/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..72750227e94 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")] @@ -348,17 +338,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..e773d487bcf 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")] @@ -357,17 +347,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..bc4cf325bb6 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,112 @@ impl BatchTransition { }), } } + + /// Runs constructor-side batch base-structure validation, adds + /// 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. + #[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 + { + 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( + crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition::Create( + create_transition, + ), + ) = batch_transition + else { + continue; + }; + + let create_result = + create_transition.validate_structure(self.owner_id(), platform_version)?; + if !create_result.is_valid() { + result.merge(create_result); + } + } + + match result.errors.len() { + 0 => Ok(()), + 1 => Err(ProtocolError::ConsensusError(Box::new( + result.errors.pop().expect("validated single error count"), + ))), + _ => Err(ProtocolError::ConsensusErrors(result.errors)), + } + } + + /// 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..53a7d2d8438 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 @@ -37,8 +37,9 @@ use crate::state_transition::StateTransitionOwned; impl BatchTransition { #[inline(always)] - pub(super) fn validate_base_structure_v0( + fn validate_base_structure_v0_internal( &self, + accumulate_token_structure_errors: bool, platform_version: &PlatformVersion, ) -> Result { if self.transitions_are_empty() { @@ -94,6 +95,9 @@ 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 are + // reserved for constructor-only pre-sign validation. 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 +187,11 @@ impl BatchTransition { }; if !consensus_result.is_valid() { - return Ok(consensus_result); + if accumulate_token_structure_errors { + result.merge(consensus_result); + } else { + return Ok(consensus_result); + } } // We need to verify that the action id given matches the expected action id @@ -221,57 +229,153 @@ impl BatchTransition { Ok(result) } + + #[inline(always)] + pub(super) fn validate_base_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result { + self.validate_base_structure_v0_internal(false, 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(true, 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 +385,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 +393,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 +482,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 +652,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 +663,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-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..fd577fd2d65 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,9 @@ pub struct DocumentsBatchTransitionVersions { pub struct DocumentsBatchTransitionValidationVersions { pub find_duplicates_by_id: FeatureVersion, pub validate_base_structure: 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, } 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..92f10539f78 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,7 @@ pub const STATE_TRANSITION_VERSIONS_V1: DPPStateTransitionVersions = DPPStateTra validation: DocumentsBatchTransitionValidationVersions { find_duplicates_by_id: 0, validate_base_structure: 0, + document_create_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..de9a658eea3 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,7 @@ pub const STATE_TRANSITION_VERSIONS_V2: DPPStateTransitionVersions = DPPStateTra validation: DocumentsBatchTransitionValidationVersions { find_duplicates_by_id: 0, validate_base_structure: 0, + document_create_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..8a43b56ad1a 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,7 @@ pub const STATE_TRANSITION_VERSIONS_V3: DPPStateTransitionVersions = DPPStateTra validation: DocumentsBatchTransitionValidationVersions { find_duplicates_by_id: 0, validate_base_structure: 0, + document_create_transition_structure_validation: 0, }, }, }, diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index d4788dff542..9f8aa8d004e 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -1,5 +1,6 @@ //! Error handling for FFI layer +use dash_sdk::dpp::ProtocolError; use std::ffi::{CString, NulError}; use std::os::raw::c_char; use thiserror::Error; @@ -102,6 +103,12 @@ impl From for DashSDKError { let (code, message) = match &err { FFIError::InvalidParameter(_) => (DashSDKErrorCode::InvalidParameter, err.to_string()), FFIError::SDKError(sdk_err) => { + if let dash_sdk::Error::Protocol(protocol_error) = sdk_err { + if let Some(message) = format_protocol_consensus_error(protocol_error) { + return DashSDKError::new(DashSDKErrorCode::ProtocolError, message); + } + } + // Extract more detailed error information let error_str = sdk_err.to_string(); @@ -161,6 +168,20 @@ impl From for DashSDKError { } } +fn format_protocol_consensus_error(error: &ProtocolError) -> Option { + match error { + ProtocolError::ConsensusError(consensus_error) => Some(consensus_error.to_string()), + ProtocolError::ConsensusErrors(consensus_errors) => Some( + consensus_errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "), + ), + _ => None, + } +} + /// Free an error message /// /// # Safety @@ -191,3 +212,68 @@ macro_rules! ffi_result { } }; } + +#[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::{basic::BasicError, ConsensusError}; + use std::ffi::CStr; + + fn error_message(error: &DashSDKError) -> String { + unsafe { CStr::from_ptr(error.message) } + .to_str() + .expect("ffi error message should be valid utf-8") + .to_owned() + } + + fn free_error_message(error: DashSDKError) { + if !error.message.is_null() { + unsafe { + let _ = CString::from_raw(error.message); + } + } + } + + #[test] + fn sdk_protocol_consensus_error_maps_to_protocol_error_code() { + let sdk_error = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new( + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )), + ))); + + let ffi_error = DashSDKError::from(FFIError::SDKError(sdk_error)); + let message = error_message(&ffi_error); + + assert_eq!(ffi_error.code, DashSDKErrorCode::ProtocolError); + assert!(message.contains("Nonce is out of bounds")); + assert!(!message.contains("Failed to fetch balances")); + + free_error_message(ffi_error); + } + + #[test] + fn sdk_protocol_consensus_errors_join_messages_readably() { + let sdk_error = dash_sdk::Error::Protocol(ProtocolError::ConsensusErrors(vec![ + ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( + NonceOutOfBoundsError::new(u64::MAX), + )), + ConsensusError::BasicError(BasicError::InvalidTokenAmountError( + InvalidTokenAmountError::new(100, 0), + )), + ])); + + let ffi_error = DashSDKError::from(FFIError::SDKError(sdk_error)); + let message = error_message(&ffi_error); + + assert_eq!(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: [")); + + free_error_message(ffi_error); + } +} 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 627d339e05f..1849ef9dc67 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 52dd6e308b6..49fbf53e4bb 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 d13bccf771b..858a59fb8a3 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/Cargo.toml b/packages/rs-sdk/Cargo.toml index 678b44926c3..4741837a677 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -9,7 +9,7 @@ arc-swap = { version = "1.7.1" } chrono = { version = "0.4.38" } dpp = { path = "../rs-dpp", default-features = false, features = [ "dash-sdk-features", - "validation", + "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/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 7dbfa659e2c..702510a3e82 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -1,10 +1,9 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::{Error, Sdk}; 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::UserFeeIncrease; @@ -154,8 +153,16 @@ impl DocumentCreateTransitionBuilder { .document_type_for_name(&self.document_type_name) .map_err(|e| Error::Protocol(e.into()))?; + let mut document = self.document.clone(); + document.set_id(Document::generate_document_id_v0( + self.data_contract.id_ref(), + &document.owner_id(), + &self.document_type_name, + self.document_state_transition_entropy.as_slice(), + )); + let state_transition = BatchTransition::new_document_creation_transition_from_document( - self.document.clone(), + document, document_type, self.document_state_transition_entropy, identity_public_key, @@ -168,9 +175,6 @@ impl DocumentCreateTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } @@ -253,11 +257,3 @@ impl Sdk { } } } - -#[cfg(test)] -mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_document_create_validate_base_structure_error(); - } -} diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index bbeff041a3b..2d44ec735a5 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -1,6 +1,5 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -209,9 +208,6 @@ impl DocumentDeleteTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } @@ -290,11 +286,3 @@ impl Sdk { } } } - -#[cfg(test)] -mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_document_delete_validate_base_structure_error(); - } -} diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index e9c2b315962..fa6be76a464 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -1,6 +1,5 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -223,9 +222,6 @@ impl DocumentPurchaseTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } @@ -307,11 +303,3 @@ impl Sdk { } } } - -#[cfg(test)] -mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_document_purchase_validate_base_structure_error(); - } -} diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index f79e27b9853..b40d27c15ea 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -1,6 +1,5 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; @@ -162,9 +161,6 @@ impl DocumentReplaceTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } @@ -253,11 +249,3 @@ impl Sdk { } } } - -#[cfg(test)] -mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_document_replace_validate_base_structure_error(); - } -} diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index ac79767165d..6750a6700cc 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -1,6 +1,5 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -210,9 +209,6 @@ impl DocumentSetPriceTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } @@ -291,11 +287,3 @@ impl Sdk { } } } - -#[cfg(test)] -mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_document_set_price_validate_base_structure_error(); - } -} diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index 0d4f148b0e3..75f3460cb2e 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -5,14 +5,14 @@ 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_identity_public_key, - validate_transition_like_builder, TestSigner, INVALID_NONCE, TEST_DOCUMENT_TYPE_NAME, + new_mock_sdk_with_contract_nonce, test_data_contract, test_identity_public_key, TestSigner, + TEST_DOCUMENT_TYPE_NAME, }; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::document::{Document, DocumentV0}; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; use dpp::prelude::Identifier; -use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; -use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::StateTransition; +use dpp::state_transition::StateTransitionLike; use std::sync::Arc; fn test_document(owner_id: Identifier) -> Document { @@ -34,163 +34,6 @@ fn test_document(owner_id: Identifier) -> Document { }) } -pub(super) fn assert_document_create_validate_base_structure_error() { - let platform_version = dpp::version::PlatformVersion::latest(); - let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); - let owner_id = Identifier::random(); - let document = test_document(owner_id); - let document_type = data_contract - .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) - .expect("expected test document type"); - let transition = BatchTransition::new_document_creation_transition_from_document( - document, - document_type, - [7; 32], - &test_identity_public_key(), - INVALID_NONCE, - 0, - None, - &TestSigner, - platform_version, - None, - ) - .expect("transition should build"); - - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); -} - -pub(super) fn assert_document_delete_validate_base_structure_error() { - let platform_version = dpp::version::PlatformVersion::latest(); - let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); - let owner_id = Identifier::random(); - let document = test_document(owner_id); - let document_type = data_contract - .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) - .expect("expected test document type"); - let transition = BatchTransition::new_document_deletion_transition_from_document( - document, - document_type, - &test_identity_public_key(), - INVALID_NONCE, - 0, - None, - &TestSigner, - platform_version, - None, - ) - .expect("transition should build"); - - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); -} - -pub(super) fn assert_document_purchase_validate_base_structure_error() { - let platform_version = dpp::version::PlatformVersion::latest(); - 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 document_type = data_contract - .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) - .expect("expected test document type"); - let transition = BatchTransition::new_document_purchase_transition_from_document( - document, - document_type, - purchaser_id, - 100, - &test_identity_public_key(), - INVALID_NONCE, - 0, - None, - &TestSigner, - platform_version, - None, - ) - .expect("transition should build"); - - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); -} - -pub(super) fn assert_document_replace_validate_base_structure_error() { - let platform_version = dpp::version::PlatformVersion::latest(); - let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); - let owner_id = Identifier::random(); - let document = test_document(owner_id); - let document_type = data_contract - .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) - .expect("expected test document type"); - let transition = BatchTransition::new_document_replacement_transition_from_document( - document, - document_type, - &test_identity_public_key(), - INVALID_NONCE, - 0, - None, - &TestSigner, - platform_version, - None, - ) - .expect("transition should build"); - - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); -} - -pub(super) fn assert_document_set_price_validate_base_structure_error() { - let platform_version = dpp::version::PlatformVersion::latest(); - let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); - let owner_id = Identifier::random(); - let document = test_document(owner_id); - let document_type = data_contract - .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) - .expect("expected test document type"); - let transition = BatchTransition::new_document_update_price_transition_from_document( - document, - document_type, - 200, - &test_identity_public_key(), - INVALID_NONCE, - 0, - None, - &TestSigner, - platform_version, - None, - ) - .expect("transition should build"); - - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); -} - -pub(super) fn assert_document_transfer_validate_base_structure_error() { - let platform_version = dpp::version::PlatformVersion::latest(); - 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 document_type = data_contract - .document_type_for_name(TEST_DOCUMENT_TYPE_NAME) - .expect("expected test document type"); - let transition = BatchTransition::new_document_transfer_transition_from_document( - document, - document_type, - recipient_id, - &test_identity_public_key(), - INVALID_NONCE, - 0, - None, - &TestSigner, - platform_version, - None, - ) - .expect("transition should build"); - - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); -} - #[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`, @@ -296,6 +139,54 @@ async fn document_create_builder_sign_succeeds_for_valid_input() { ); } +#[tokio::test] +async fn document_create_builder_sign_replaces_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()); + assert_ne!(document.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!( + result.is_ok(), + "builder should normalize 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_replace_builder_sign_succeeds_for_valid_input() { let data_contract = test_data_contract(TEST_DOCUMENT_TYPE_NAME); diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index 746b39ab97b..490380cc3fa 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -1,6 +1,5 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -209,9 +208,6 @@ impl DocumentTransferTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } @@ -291,11 +287,3 @@ impl Sdk { } } } - -#[cfg(test)] -mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_document_transfer_validate_base_structure_error(); - } -} diff --git a/packages/rs-sdk/src/platform/test_helpers.rs b/packages/rs-sdk/src/platform/test_helpers.rs index bffa923c832..c6a50bd896e 100644 --- a/packages/rs-sdk/src/platform/test_helpers.rs +++ b/packages/rs-sdk/src/platform/test_helpers.rs @@ -1,7 +1,10 @@ //! 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; @@ -17,8 +20,6 @@ use drive_proof_verifier::types::IdentityContractNonceFetcher; use std::collections::BTreeMap; use std::sync::Arc; -use crate::platform::transition::validation::validate_batch_base_structure; - 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. @@ -27,12 +28,17 @@ pub(crate) const INVALID_NONCE: u64 = 1_u64 << 50; #[derive(Debug)] pub(crate) struct TestSigner; +#[async_trait] impl Signer for TestSigner { - fn sign(&self, _key: &IdentityPublicKey, _data: &[u8]) -> Result { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { Ok(BinaryData::from(vec![1; 65])) } - fn sign_create_witness( + async fn sign_create_witness( &self, _key: &IdentityPublicKey, _data: &[u8], @@ -115,11 +121,39 @@ pub(crate) fn test_data_contract( Arc::new(contract) } -pub(crate) fn validate_transition_like_builder( - state_transition: &StateTransition, -) -> Result<(), Error> { - let platform_version = dpp::version::PlatformVersion::latest(); - validate_batch_base_structure(state_transition, platform_version) +/// 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( diff --git a/packages/rs-sdk/src/platform/tokens/builders/burn.rs b/packages/rs-sdk/src/platform/tokens/builders/burn.rs index a07d48f6132..d5075c83ac3 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/burn.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/burn.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -181,17 +180,14 @@ impl TokenBurnTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_burn_validate_base_structure_error(); + #[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 a718db363e0..00688e9b80d 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/claim.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/claim.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -167,17 +166,14 @@ impl TokenClaimTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_claim_validate_base_structure_error(); + #[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 7ed6a3f5138..c7ca792d0ce 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/config_update.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/config_update.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -188,17 +187,14 @@ impl TokenConfigUpdateTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_config_update_validate_base_structure_error(); + #[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 395c3091a42..2343042a3a8 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/destroy.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/destroy.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -187,17 +186,14 @@ impl TokenDestroyFrozenFundsTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_destroy_validate_base_structure_error(); + #[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 92b592767ce..4d5df31e8c2 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/emergency_action.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -215,17 +214,14 @@ impl TokenEmergencyActionTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_emergency_action_validate_base_structure_error(); + #[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 7134cb6fe6a..91a7a280785 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/freeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/freeze.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -187,17 +186,14 @@ impl TokenFreezeTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_freeze_validate_base_structure_error(); + #[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 eab79ebd572..0506bce8d6c 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/mint.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/mint.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -208,17 +207,14 @@ impl TokenMintTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_mint_validate_base_structure_error(); + #[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/purchase.rs b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs index 29567b93ff6..ad4bfda4e27 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/purchase.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/purchase.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -156,17 +155,14 @@ impl TokenDirectPurchaseTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_purchase_validate_base_structure_error(); + #[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 aced5f05076..cba3af7da69 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/set_price.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/set_price.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::Credits; @@ -232,17 +231,14 @@ impl TokenChangeDirectPurchasePriceTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_set_price_validate_base_structure_error(); + #[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 index 0697bc04a88..477325d3121 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/tests.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/tests.rs @@ -10,8 +10,9 @@ use super::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; use super::transfer::TokenTransferTransitionBuilder; use super::unfreeze::TokenUnfreezeTransitionBuilder; use crate::platform::test_helpers::{ - new_mock_sdk_with_contract_nonce, test_data_contract, test_identity_public_key, - validate_transition_like_builder, TestSigner, INVALID_NONCE, TEST_DOCUMENT_TYPE_NAME, + 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; @@ -44,10 +45,10 @@ fn token_setup() -> ( (data_contract, owner_id, token_id) } -pub(super) fn assert_token_burn_validate_base_structure_error() { +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 transition = BatchTransition::new_token_burn_transition( + let result = BatchTransition::new_token_burn_transition( token_id, owner_id, data_contract.id(), @@ -62,16 +63,15 @@ pub(super) fn assert_token_burn_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_claim_validate_base_structure_error() { +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 transition = BatchTransition::new_token_claim_transition( + let result = BatchTransition::new_token_claim_transition( token_id, owner_id, data_contract.id(), @@ -85,21 +85,23 @@ pub(super) fn assert_token_claim_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_config_update_validate_base_structure_error() { +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(); - let transition = BatchTransition::new_token_config_update_transition( + // 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::TokenConfigurationNoChange, + TokenConfigurationChangeItem::MintingAllowChoosingDestination(true), None, None, &test_identity_public_key(), @@ -109,16 +111,15 @@ pub(super) fn assert_token_config_update_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_destroy_validate_base_structure_error() { +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 transition = BatchTransition::new_token_destroy_frozen_funds_transition( + let result = BatchTransition::new_token_destroy_frozen_funds_transition( token_id, owner_id, data_contract.id(), @@ -133,16 +134,15 @@ pub(super) fn assert_token_destroy_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_emergency_action_validate_base_structure_error() { +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 transition = BatchTransition::new_token_emergency_action_transition( + let result = BatchTransition::new_token_emergency_action_transition( token_id, owner_id, data_contract.id(), @@ -157,16 +157,15 @@ pub(super) fn assert_token_emergency_action_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_freeze_validate_base_structure_error() { +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 transition = BatchTransition::new_token_freeze_transition( + let result = BatchTransition::new_token_freeze_transition( token_id, owner_id, data_contract.id(), @@ -181,16 +180,15 @@ pub(super) fn assert_token_freeze_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_mint_validate_base_structure_error() { +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 transition = BatchTransition::new_token_mint_transition( + let result = BatchTransition::new_token_mint_transition( token_id, owner_id, data_contract.id(), @@ -206,16 +204,15 @@ pub(super) fn assert_token_mint_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_purchase_validate_base_structure_error() { +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 transition = BatchTransition::new_token_direct_purchase_transition( + let result = BatchTransition::new_token_direct_purchase_transition( token_id, owner_id, data_contract.id(), @@ -229,16 +226,15 @@ pub(super) fn assert_token_purchase_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_set_price_validate_base_structure_error() { +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 transition = BatchTransition::new_token_change_direct_purchase_price_transition( + let result = BatchTransition::new_token_change_direct_purchase_price_transition( token_id, owner_id, data_contract.id(), @@ -253,16 +249,15 @@ pub(super) fn assert_token_set_price_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_transfer_validate_base_structure_error() { +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 transition = BatchTransition::new_token_transfer_transition( + let result = BatchTransition::new_token_transfer_transition( token_id, owner_id, data_contract.id(), @@ -279,16 +274,15 @@ pub(super) fn assert_token_transfer_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } -pub(super) fn assert_token_unfreeze_validate_base_structure_error() { +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 transition = BatchTransition::new_token_unfreeze_transition( + let result = BatchTransition::new_token_unfreeze_transition( token_id, owner_id, data_contract.id(), @@ -303,10 +297,9 @@ pub(super) fn assert_token_unfreeze_validate_base_structure_error() { platform_version, None, ) - .expect("transition should build"); + .await; - let result = validate_transition_like_builder(&transition); - assert!(result.is_err(), "expected validation error, got {result:?}"); + assert_nonce_out_of_bounds_construction_error(result); } #[tokio::test] diff --git a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs index 17c02e0ff7f..7d356786187 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/transfer.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/transfer.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::balances::credits::TokenAmount; @@ -213,17 +212,14 @@ impl TokenTransferTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_transfer_validate_base_structure_error(); + #[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 20e30442b0b..09e537544c4 100644 --- a/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs +++ b/packages/rs-sdk/src/platform/tokens/builders/unfreeze.rs @@ -1,5 +1,4 @@ use crate::platform::transition::put_settings::PutSettings; -use crate::platform::transition::validation::validate_batch_base_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -187,17 +186,14 @@ impl TokenUnfreezeTransitionBuilder { ) .await?; - // Validate the transition structure before returning - validate_batch_base_structure(&state_transition, platform_version)?; - Ok(state_transition) } } #[cfg(test)] mod validation_tests { - #[test] - fn validate_base_structure_error_case() { - super::super::tests::assert_token_unfreeze_validate_base_structure_error(); + #[tokio::test] + async fn validate_base_structure_error_case() { + super::super::tests::assert_token_unfreeze_validate_base_structure_error().await; } } diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index fa136744a29..846d9ddae2d 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -3,44 +3,8 @@ use dpp::{ consensus::{basic::BasicError, ConsensusError}, state_transition::{StateTransition, StateTransitionStructureValidation}, version::PlatformVersion, - ProtocolError, }; -/// Validates the base structure of a Batch state transition. -/// -/// Used by document and token transition builders to validate the constructed -/// `BatchTransition` before returning it to the caller. Catches invalid -/// transitions early with clear errors instead of confusing network rejections. -pub(crate) fn validate_batch_base_structure( - state_transition: &StateTransition, - platform_version: &PlatformVersion, -) -> Result<(), Error> { - let validation_result = match state_transition { - StateTransition::Batch(batch_transition) => { - batch_transition.validate_base_structure(platform_version)? - } - _ => { - return Err(Error::Protocol(ProtocolError::InvalidStateTransitionType( - "expected Batch transition".to_string(), - ))); - } - }; - let mut errors = validation_result.errors.into_iter(); - if let Some(first_error) = errors.next() { - // Log any additional errors that won't be reported - for additional_error in errors { - tracing::warn!( - ?additional_error, - "additional validation error dropped (only first error is reported)" - ); - } - return Err(Error::Protocol(ProtocolError::ConsensusError(Box::new( - first_error, - )))); - } - Ok(()) -} - /// Checks if an error is an UnsupportedFeatureError fn is_unsupported_feature_error(error: &ConsensusError) -> bool { matches!( diff --git a/packages/wasm-dpp/src/errors/from.rs b/packages/wasm-dpp/src/errors/from.rs index ca7d7416f23..b5d2a22df55 100644 --- a/packages/wasm-dpp/src/errors/from.rs +++ b/packages/wasm-dpp/src/errors/from.rs @@ -1,4 +1,5 @@ use dpp::DashPlatformProtocolInitError; +use js_sys::Array; use wasm_bindgen::JsValue; use dpp::errors::ProtocolError; @@ -15,6 +16,9 @@ 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) => { + Array::from_iter(consensus_errors.into_iter().map(from_consensus_error)).into() + } 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..1e2ab1facc9 100644 --- a/packages/wasm-dpp/src/errors/protocol_error.rs +++ b/packages/wasm-dpp/src/errors/protocol_error.rs @@ -1,3 +1,4 @@ +use js_sys::Array; use wasm_bindgen::JsValue; use crate::errors::consensus::consensus_error::from_consensus_error; @@ -7,6 +8,9 @@ pub fn from_protocol_error(protocol_error: dpp::ProtocolError) -> JsValue { dpp::ProtocolError::ConsensusError(consensus_error) => { from_consensus_error(*consensus_error) } + dpp::ProtocolError::ConsensusErrors(consensus_errors) => { + Array::from_iter(consensus_errors.into_iter().map(from_consensus_error)).into() + } dpp::ProtocolError::Error(anyhow_error) => { format!("Non-protocol error: {}", anyhow_error).into() } diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index 6f41a1d76ea..63787c6201e 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,29 @@ 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 = err.to_string(); + let consensus_errors = match &err { + ProtocolError::ConsensusError(error) => { + vec![WasmConsensusError::from_consensus_error(error.as_ref())] + } + ProtocolError::ConsensusErrors(errors) => errors + .iter() + .map(WasmConsensusError::from_consensus_error) + .collect(), + _ => Vec::new(), + }; + + Self { + kind: WasmSdkErrorKind::Protocol, + message, + code: -1, + is_retriable, + consensus_errors, } } @@ -101,7 +137,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 +238,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) } } @@ -232,6 +268,51 @@ impl From for WasmSdkError { } } +impl WasmConsensusError { + fn from_consensus_error(err: &ConsensusError) -> Self { + let name = match err { + ConsensusError::DefaultError => "DefaultError", + ConsensusError::BasicError(_) => "BasicError", + ConsensusError::StateError(_) => "StateError", + ConsensusError::SignatureError(_) => "SignatureError", + ConsensusError::FeeError(_) => "FeeError", + } + .to_string(); + + Self { + kind: name.clone(), + name, + 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() + } +} + #[wasm_bindgen] impl WasmSdkError { /// Error kind (enum) @@ -293,4 +374,77 @@ 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 error = WasmSdkError::from(ProtocolError::ConsensusErrors(vec![ + DocumentTransitionsAreAbsentError::new().into(), + 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].name, "BasicError"); + assert_eq!( + error.consensus_errors[0].message, + DocumentTransitionsAreAbsentError::new().to_string() + ); + assert_eq!(error.consensus_errors[1].name, "DefaultError"); + assert_eq!(error.consensus_errors[1].code, 1); + } + + #[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].name, "BasicError"); + assert_eq!(error.consensus_errors[0].message, expected_message); + assert_eq!(error.consensus_errors[0].code, expected_code); + } + + #[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); + } } 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, From 9ab76e3867c33246e64735c5bf88031a06964559 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 3 May 2026 22:20:20 -0500 Subject: [PATCH 16/25] feat(sdk): expose structured FFI consensus errors --- packages/rs-sdk-ffi/src/error.rs | 355 ++++++++++++++++-- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 127 ++++++- 2 files changed, 440 insertions(+), 42 deletions(-) diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index 9f8aa8d004e..9f253bc5d5e 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -1,8 +1,26 @@ //! 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 `message` pointer. 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. + +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; use thiserror::Error; /// Error codes returned by FFI functions @@ -33,7 +51,15 @@ 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`]. #[repr(C)] pub struct DashSDKError { /// Error code @@ -43,6 +69,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 error name (currently mirrors `kind`, 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 { @@ -77,6 +119,50 @@ pub enum FFIError { NulError(#[from] NulError), } +#[derive(Debug, Clone)] +struct ConsensusErrorEntry { + code: u32, + kind: String, + name: String, + message: String, +} + +/// Sidecar map from the `DashSDKError.message` raw pointer (as `usize`) to the +/// structured consensus error details. Populated when a `ProtocolError` +/// containing one or more `ConsensusError`s is converted into a +/// `DashSDKError`; freed by `dash_sdk_error_free`. +static CONSENSUS_ERROR_SIDECAR: Lazy>>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +fn register_consensus_errors(message_ptr: *mut c_char, errors: Vec) { + if message_ptr.is_null() || errors.is_empty() { + return; + } + if let Ok(mut map) = CONSENSUS_ERROR_SIDECAR.lock() { + map.insert(message_ptr as usize, errors); + } +} + +fn take_consensus_errors(message_ptr: *mut c_char) { + if message_ptr.is_null() { + return; + } + if let Ok(mut map) = CONSENSUS_ERROR_SIDECAR.lock() { + map.remove(&(message_ptr as usize)); + } +} + +fn with_consensus_errors( + message_ptr: *const c_char, + f: impl FnOnce(&[ConsensusErrorEntry]) -> R, +) -> Option { + if message_ptr.is_null() { + return None; + } + let guard = CONSENSUS_ERROR_SIDECAR.lock().ok()?; + guard.get(&(message_ptr as usize)).map(|v| f(v.as_slice())) +} + impl DashSDKError { /// Create a new error pub fn new(code: DashSDKErrorCode, message: String) -> Self { @@ -104,8 +190,12 @@ impl From for DashSDKError { FFIError::InvalidParameter(_) => (DashSDKErrorCode::InvalidParameter, err.to_string()), FFIError::SDKError(sdk_err) => { if let dash_sdk::Error::Protocol(protocol_error) = sdk_err { - if let Some(message) = format_protocol_consensus_error(protocol_error) { - return DashSDKError::new(DashSDKErrorCode::ProtocolError, message); + if let Some((message, entries)) = + format_protocol_consensus_error(protocol_error) + { + let error = DashSDKError::new(DashSDKErrorCode::ProtocolError, message); + register_consensus_errors(error.message, entries); + return error; } } @@ -168,21 +258,52 @@ impl From for DashSDKError { } } -fn format_protocol_consensus_error(error: &ProtocolError) -> Option { +fn consensus_error_kind_name(error: &ConsensusError) -> &'static str { match error { - ProtocolError::ConsensusError(consensus_error) => Some(consensus_error.to_string()), - ProtocolError::ConsensusErrors(consensus_errors) => Some( - consensus_errors + ConsensusError::DefaultError => "DefaultError", + ConsensusError::BasicError(_) => "BasicError", + ConsensusError::StateError(_) => "StateError", + ConsensusError::SignatureError(_) => "SignatureError", + ConsensusError::FeeError(_) => "FeeError", + } +} + +fn consensus_error_entry(error: &ConsensusError) -> ConsensusErrorEntry { + let name = consensus_error_kind_name(error).to_string(); + ConsensusErrorEntry { + code: error.code(), + kind: name.clone(), + name, + 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("; "), - ), + .join("; "); + let entries = consensus_errors.iter().map(consensus_error_entry).collect(); + Some((message, entries)) + } _ => None, } } -/// Free an error message +/// 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). @@ -194,6 +315,107 @@ pub unsafe extern "C" fn dash_sdk_error_free(error: *mut DashSDKError) { } let error = Box::from_raw(error); + if !error.message.is_null() { + take_consensus_errors(error.message); + let _ = CString::from_raw(error.message); + } +} + +/// 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. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_error_consensus_error_count(error: *const DashSDKError) -> usize { + if error.is_null() { + return 0; + } + let error = &*error; + if error.code != DashSDKErrorCode::ProtocolError { + return 0; + } + with_consensus_errors(error.message, |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. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_error_consensus_error_at( + error: *const DashSDKError, + index: usize, +) -> *mut DashSDKConsensusError { + if error.is_null() { + return std::ptr::null_mut(); + } + let error = &*error; + if error.code != DashSDKErrorCode::ProtocolError { + return std::ptr::null_mut(); + } + + let entry = + with_consensus_errors(error.message, |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); } @@ -228,21 +450,28 @@ mod tests { .to_owned() } - fn free_error_message(error: DashSDKError) { - if !error.message.is_null() { - unsafe { - let _ = CString::from_raw(error.message); - } - } + 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 and free via the public C ABI so the sidecar lifecycle exercised by + /// real callers is exercised by the test. + fn free_via_ffi(error: DashSDKError) { + let raw = Box::into_raw(Box::new(error)); + unsafe { dash_sdk_error_free(raw) }; } #[test] fn sdk_protocol_consensus_error_maps_to_protocol_error_code() { - let sdk_error = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new( - ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( - NonceOutOfBoundsError::new(u64::MAX), - )), - ))); + 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 = DashSDKError::from(FFIError::SDKError(sdk_error)); let message = error_message(&ffi_error); @@ -251,19 +480,38 @@ mod tests { assert!(message.contains("Nonce is out of bounds")); assert!(!message.contains("Failed to fetch balances")); - free_error_message(ffi_error); + // 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), "BasicError"); + 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()); + + free_via_ffi(ffi_error); } #[test] fn sdk_protocol_consensus_errors_join_messages_readably() { - let sdk_error = dash_sdk::Error::Protocol(ProtocolError::ConsensusErrors(vec![ - ConsensusError::BasicError(BasicError::NonceOutOfBoundsError( - NonceOutOfBoundsError::new(u64::MAX), - )), - ConsensusError::BasicError(BasicError::InvalidTokenAmountError( - InvalidTokenAmountError::new(100, 0), - )), - ])); + 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 = DashSDKError::from(FFIError::SDKError(sdk_error)); let message = error_message(&ffi_error); @@ -274,6 +522,49 @@ mod tests { assert!(message.contains("; ")); assert!(!message.contains("Multiple consensus errors: [")); - free_error_message(ffi_error); + 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), "BasicError"); + 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), "BasicError"); + 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) }; + + free_via_ffi(ffi_error); + } + + #[test] + fn non_consensus_error_reports_zero_consensus_errors() { + let ffi_error = DashSDKError::from(FFIError::NotFound("nope".to_string())); + assert_eq!(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()); + + free_via_ffi(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()); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 9639efb1be2..0073765c9d5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -175,13 +175,12 @@ 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" + let sdkError = SDKError.fromDashSDKError(result.error!.pointee) defer { dash_sdk_error_free(result.error) } - throw SDKError.internalError("Failed to create SDK: \(errorMessage)") + throw sdkError } guard result.data != nil else { @@ -233,13 +232,12 @@ 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" + let sdkError = SDKError.fromDashSDKError(result.error!.pointee) defer { dash_sdk_error_free(result.error) } - throw SDKError.internalError("Failed to add known contracts: \(errorMessage)") + throw sdkError } print("✅ Successfully loaded \(contracts.count) known contracts into SDK") @@ -261,12 +259,11 @@ public final class SDK: @unchecked Sendable { // Check for error if result.error != nil { - let error = result.error!.pointee - let errorMessage = error.message != nil ? String(cString: error.message!) : "Unknown error" + let sdkError = SDKError.fromDashSDKError(result.error!.pointee) defer { dash_sdk_error_free(result.error) } - throw SDKError.internalError("Failed to get SDK status: \(errorMessage)") + throw sdkError } // Parse the JSON result @@ -318,6 +315,27 @@ 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 error name (currently mirrors `kind`). + 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 + } +} + /// SDK Error handling public enum SDKError: Error { case invalidParameter(String) @@ -325,6 +343,7 @@ public enum SDKError: Error { case networkError(String) case serializationError(String) case protocolError(String) + case protocolErrorWithDetails(String, [SDKConsensusError]) case cryptoError(String) case notFound(String) case timeout(String) @@ -345,6 +364,9 @@ public enum SDKError: Error { case DashSDKErrorCode(rawValue: 4): // Serialization error return .serializationError(message) case DashSDKErrorCode(rawValue: 5): // Protocol error + if let consensusErrors = consensusErrors(fromDashSDKError: error) { + return .protocolErrorWithDetails(message, consensusErrors) + } return .protocolError(message) case DashSDKErrorCode(rawValue: 6): // Crypto error return .cryptoError(message) @@ -360,6 +382,83 @@ public enum SDKError: Error { return .unknown(message) } } + + /// Returns the structured consensus errors carried by `error`, if any. + /// + /// The plain `SDKError.protocolError(String)` case remains available for + /// protocol errors that carry no structured sidecar details; this helper + /// exposes the additional structured detail surfaced by the Rust FFI sidecar + /// (`DashSDKConsensusError`). Returns + /// `nil` if `error` is not a `ProtocolError` or has no structured details. + /// + /// Must be called before `dash_sdk_error_free` is invoked on `error`. + public static func consensusErrors(fromDashSDKError error: DashSDKError) + -> [SDKConsensusError]? + { + var errorCopy = error + let count = withUnsafePointer(to: &errorCopy) { ptr -> Int in + Int(dash_sdk_error_consensus_error_count(ptr)) + } + guard count > 0 else { return nil } + + var result: [SDKConsensusError] = [] + result.reserveCapacity(count) + for index in 0.. (SDKError, [SDKConsensusError]?) + { + let mapped = SDKError.fromDashSDKError(error) + return (mapped, mapped.consensusErrors) + } + + /// 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 + case .protocolErrorWithDetails(let message, _): + return message + } + } + + /// Structured consensus errors surfaced by protocol errors, if available. + public var consensusErrors: [SDKConsensusError]? { + switch self { + case .protocolErrorWithDetails(_, let consensusErrors): + return consensusErrors + default: + return nil + } + } } extension SDKError: LocalizedError { @@ -375,6 +474,15 @@ extension SDKError: LocalizedError { return "Serialization Error: \(message)" case .protocolError(let message): return "Protocol Error: \(message)" + case .protocolErrorWithDetails(let message, let consensusErrors): + let details = + consensusErrors + .map { "\($0.kind)[\($0.code)]: \($0.message)" } + .joined(separator: "; ") + if details.isEmpty { + return "Protocol Error: \(message)" + } + return "Protocol Error: \(message) (\(details))" case .cryptoError(let message): return "Cryptographic Error: \(message)" case .notFound(let message): @@ -391,7 +499,6 @@ extension SDKError: LocalizedError { } } - /// Identities operations public class Identities { private weak var sdk: SDK? From 0ac3718f511b7a7db6f6cddb90a08ce647a2f681 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 4 May 2026 01:42:28 -0500 Subject: [PATCH 17/25] feat(sdk): add document transition pre-sign structure validators Mirror the safe, contract-local subset of drive-abci's document advanced_structure_v0 checks at constructor time so SDK builders fail before signing for unsafe local structure (e.g. immutable type, non- transferable type, wrong trade mode, non-deletable type, self-purchase). Each of the five document transitions (replace, transfer, purchase, update_price, delete) now exposes a versioned `validate_structure` hook that takes a `DocumentTypeRef` and is invoked from `from_document`. The purchase self-purchase check is enforced one level up in `new_document_purchase_transition_from_document` because that hook is where the new owner id is known. Property validation for replace is intentionally not added here because it requires a `DataContract` ref that is not available in the constructor; it remains in drive-abci. Dispatch fields are added under `dpp.state_transitions.documents.documents_batch_transition.validation` and initialized to 0 in v1/v2/v3, leaving server-reachable `validate_base_structure` behavior unchanged. Co-Authored-By: Claude Opus 4.6 --- .../from_document.rs | 31 ++- .../document_delete_transition/mod.rs | 1 + .../validate_structure/mod.rs | 40 +++ .../validate_structure/v0/mod.rs | 34 +++ .../from_document.rs | 33 ++- .../document_purchase_transition/mod.rs | 1 + .../validate_structure/mod.rs | 40 +++ .../validate_structure/v0/mod.rs | 39 +++ .../from_document.rs | 31 ++- .../document_replace_transition/mod.rs | 1 + .../validate_structure/mod.rs | 44 ++++ .../validate_structure/v0/mod.rs | 35 +++ .../from_document.rs | 33 ++- .../document_transfer_transition/mod.rs | 1 + .../validate_structure/mod.rs | 40 +++ .../validate_structure/v0/mod.rs | 34 +++ .../from_document.rs | 33 ++- .../document_update_price_transition/mod.rs | 1 + .../validate_structure/mod.rs | 40 +++ .../validate_structure/v0/mod.rs | 35 +++ .../batch_transition/v0/v0_methods.rs | 18 ++ .../batch_transition/v1/v0_methods.rs | 21 ++ .../batch_transition/v1/v1_methods.rs | 99 ++++++- .../validate_basic_structure/mod.rs | 42 ++- .../validate_basic_structure/v0/mod.rs | 9 +- .../dpp_state_transition_versions/mod.rs | 21 ++ .../dpp_state_transition_versions/v1.rs | 5 + .../dpp_state_transition_versions/v2.rs | 5 + .../dpp_state_transition_versions/v3.rs | 5 + .../platform/documents/transitions/tests.rs | 243 +++++++++++++++++- packages/rs-sdk/src/platform/test_helpers.rs | 36 +++ 31 files changed, 976 insertions(+), 75 deletions(-) create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/validate_structure/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/validate_structure/v0/mod.rs 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..ddaf0faa5e0 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,27 @@ 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 + .into_iter() + .next() + { + return Err(error.into()); + } + 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..4887e9a1d5b 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 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..0eaab2b6663 --- /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 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..d231268b363 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 @@ -5,6 +5,7 @@ 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; @@ -29,16 +30,28 @@ 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 + .into_iter() + .next() + { + return Err(error.into()); + } + 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..8ed4cb49f1e 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 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/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..77031eb5b0b --- /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 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..81505b1f5ae --- /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,39 @@ +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 drive-abci self-purchase check (original_owner_id == + // new_owner_id) is enforced separately by + // `new_document_purchase_transition_from_document` because that hook + // is where the new owner id (the batch owner / buyer) is known. + 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..f00d658f84b 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,27 @@ 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 + .into_iter() + .next() + { + return Err(error.into()); + } + 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..8f027c56554 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 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..73fdb789690 --- /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 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..68767db785d 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,28 @@ 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 + .into_iter() + .next() + { + return Err(error.into()); + } + 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..bb744a214e5 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 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..215bb1aa32d --- /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 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..6d6a43a64ed 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,28 @@ 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 + .into_iter() + .next() + { + return Err(error.into()); + } + 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..2d51e53e13a 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 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..a8b16c3d9d2 --- /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 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/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs index 72750227e94..b2c60e3d18c 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 @@ -126,6 +126,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -168,6 +169,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -212,6 +214,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -254,6 +257,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -298,6 +302,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -319,6 +324,18 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { platform_version: &PlatformVersion, options: Option, ) -> Result { + // Mirrors the drive-abci action validator self-purchase check. + if document.owner_id() == new_owner_id { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::basic::document::InvalidDocumentTransitionActionError::new( + format!( + "on document type: {} identity trying to purchase a document that is already owned by the purchaser", + document_type.name() + ), + ) + .into(), + ))); + } let resolved_options = options.unwrap_or_default(); let purchase_transition = DocumentPurchaseTransition::from_document( document, @@ -342,6 +359,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), 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 e773d487bcf..6b675199c84 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 @@ -135,6 +135,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -177,6 +178,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -219,6 +221,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -263,6 +266,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -307,6 +311,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -328,6 +333,21 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { platform_version: &PlatformVersion, options: Option, ) -> Result { + // Mirrors the drive-abci action validator self-purchase check. + // The document's owner is the seller; `new_owner_id` is the buyer. + // Surfacing this here means the SDK builder fails before signing + // when the same identity tries to buy from itself. + if document.owner_id() == new_owner_id { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::basic::document::InvalidDocumentTransitionActionError::new( + format!( + "on document type: {} identity trying to purchase a document that is already owned by the purchaser", + document_type.name() + ), + ) + .into(), + ))); + } let resolved_options = options.unwrap_or_default(); let purchase_transition = DocumentPurchaseTransition::from_document( document, @@ -351,6 +371,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, + Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), 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 162dd49533b..730ddf739fa 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 @@ -136,7 +136,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); documents_batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -200,7 +207,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } #[cfg(feature = "state-transition-signing")] @@ -248,7 +262,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -313,7 +334,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -378,7 +406,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -445,7 +480,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -510,7 +552,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -575,7 +624,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -615,7 +671,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -684,7 +747,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + None, + platform_version, + options, + ) .await } @@ -725,7 +795,14 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign(identity_public_key, signer, None, platform_version, options) + .validate_and_sign( + identity_public_key, + signer, + None, + 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 bc4cf325bb6..ba60d9d8bd9 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,3 +1,5 @@ +#[cfg(any(test, feature = "state-transition-signing"))] +use crate::data_contract::document_type::DocumentTypeRef; 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; @@ -49,7 +51,7 @@ impl BatchTransition { } } - /// Runs constructor-side batch base-structure validation, adds + /// Runs constructor-side batch base-structure validation, adds the /// constructor-only create document ID checks, and maps consensus /// validation failures into `ProtocolError`. /// @@ -60,9 +62,17 @@ impl BatchTransition { /// 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. + /// `document_type` is still accepted to keep the constructor call sites + /// stable while create-ID checks remain here; token constructors use + /// `None`. #[cfg(any(test, feature = "state-transition-signing"))] pub(crate) fn validate_base_structure_pre_sign( &self, + _document_type: Option>, platform_version: &PlatformVersion, ) -> Result<(), ProtocolError> { let mut result = match platform_version @@ -86,18 +96,29 @@ impl BatchTransition { 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, - ), - ) = batch_transition - else { - continue; + ) => 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, }; - let create_result = - create_transition.validate_structure(self.owner_id(), platform_version)?; - if !create_result.is_valid() { - result.merge(create_result); + if let Some(transition_result) = transition_result { + if !transition_result.is_valid() { + result.merge(transition_result); + } } } @@ -125,11 +146,12 @@ impl BatchTransition { self, identity_public_key: &IdentityPublicKey, signer: &S, + document_type: Option>, required_security_level: Option, platform_version: &PlatformVersion, options: Option, ) -> Result { - self.validate_base_structure_pre_sign(platform_version)?; + self.validate_base_structure_pre_sign(document_type, platform_version)?; let resolved_options = options.unwrap_or_default(); let mut state_transition: StateTransition = self.into(); match required_security_level { 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 53a7d2d8438..1f5a6391e6a 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 @@ -96,8 +96,9 @@ impl BatchTransition { 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 are - // reserved for constructor-only pre-sign validation. + // 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 @@ -526,7 +527,7 @@ mod tests { 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) { + match batch.validate_base_structure_pre_sign(None, pv) { Err(ProtocolError::ConsensusErrors(errors)) => { assert_eq!(errors.len(), 2, "expected all accumulated errors"); assert!(errors.iter().any(|error| matches!( @@ -548,7 +549,7 @@ mod tests { 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) { + match batch.validate_base_structure_pre_sign(None, pv) { Err(ProtocolError::ConsensusErrors(errors)) => { assert_eq!(errors.len(), 2, "expected all accumulated errors"); assert!(errors.iter().any(|error| matches!( 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 fd577fd2d65..e3b54bd9f2e 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 @@ -76,4 +76,25 @@ pub struct DocumentsBatchTransitionValidationVersions { /// 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 92f10539f78..f61b526de93 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 @@ -12,6 +12,11 @@ pub const STATE_TRANSITION_VERSIONS_V1: DPPStateTransitionVersions = DPPStateTra find_duplicates_by_id: 0, validate_base_structure: 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 de9a658eea3..bfcc40e8e46 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 @@ -12,6 +12,11 @@ pub const STATE_TRANSITION_VERSIONS_V2: DPPStateTransitionVersions = DPPStateTra find_duplicates_by_id: 0, validate_base_structure: 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 8a43b56ad1a..c8e577a4acb 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 @@ -12,6 +12,11 @@ pub const STATE_TRANSITION_VERSIONS_V3: DPPStateTransitionVersions = DPPStateTra find_duplicates_by_id: 0, validate_base_structure: 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/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index 75f3460cb2e..a9fc74b9a6f 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -5,16 +5,52 @@ 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_identity_public_key, TestSigner, - TEST_DOCUMENT_TYPE_NAME, + 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 + ), + } +} + fn test_document(owner_id: Identifier) -> Document { Document::V0(DocumentV0 { id: Identifier::random(), @@ -328,3 +364,206 @@ async fn document_transfer_builder_sign_succeeds_for_valid_input() { "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 index c6a50bd896e..041eefd5d63 100644 --- a/packages/rs-sdk/src/platform/test_helpers.rs +++ b/packages/rs-sdk/src/platform/test_helpers.rs @@ -66,13 +66,45 @@ pub(crate) fn test_identity_public_key() -> IdentityPublicKey { }) } +/// 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": { @@ -83,6 +115,10 @@ pub(crate) fn test_data_contract( } }, "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( From b7d74716ace34c2313b2ebf02330a0eaf2a45f5d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 4 May 2026 02:18:01 -0500 Subject: [PATCH 18/25] fix(sdk): refine FFI consensus error details Co-Authored-By: Claude Opus 4.6 --- .../src/errors/consensus/basic/basic_error.rs | 10 +- .../src/errors/consensus/fee/fee_error.rs | 10 +- .../consensus/signature/signature_error.rs | 10 +- .../src/errors/consensus/state/state_error.rs | 10 +- packages/rs-dpp/src/errors/protocol_error.rs | 13 +- .../from_document.rs | 6 +- .../from_document.rs | 6 +- .../from_document.rs | 6 +- .../from_document.rs | 6 +- .../from_document.rs | 6 +- .../validate_basic_structure/mod.rs | 11 +- .../src/validation/validation_result.rs | 72 ++ .../queries/fetch_with_serialization.rs | 5 +- packages/rs-sdk-ffi/src/document/util.rs | 2 +- packages/rs-sdk-ffi/src/error.rs | 695 +++++++++++++++--- packages/rs-sdk-ffi/src/types.rs | 14 +- .../platform/documents/transitions/create.rs | 4 + .../SwiftDashSDK/Address/Addresses.swift | 74 +- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 187 +++-- packages/wasm-sdk/src/error.rs | 102 ++- 20 files changed, 951 insertions(+), 298 deletions(-) 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 a4cab4027a5..77b717e1762 100644 --- a/packages/rs-dpp/src/errors/protocol_error.rs +++ b/packages/rs-dpp/src/errors/protocol_error.rs @@ -131,7 +131,7 @@ pub enum ProtocolError { #[error(transparent)] ConsensusError(Box), - #[error("Multiple consensus errors: {0:?}")] + #[error("Multiple consensus errors: {}", join_consensus_errors(.0))] ConsensusErrors(Vec), #[error(transparent)] @@ -358,3 +358,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_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 ddaf0faa5e0..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 @@ -41,11 +41,9 @@ impl DocumentDeleteTransition { .into(); if let Some(error) = transition .validate_structure(document_type, platform_version)? - .errors - .into_iter() - .next() + .errors_to_consensus_protocol_error() { - return Err(error.into()); + return Err(error); } Ok(transition) } 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 d231268b363..349c73c86eb 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 @@ -44,11 +44,9 @@ impl DocumentPurchaseTransition { .into(); if let Some(error) = transition .validate_structure(document_type, platform_version)? - .errors - .into_iter() - .next() + .errors_to_consensus_protocol_error() { - return Err(error.into()); + return Err(error); } Ok(transition) } 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 f00d658f84b..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 @@ -40,11 +40,9 @@ impl DocumentReplaceTransition { .into(); if let Some(error) = transition .validate_structure(document_type, platform_version)? - .errors - .into_iter() - .next() + .errors_to_consensus_protocol_error() { - return Err(error.into()); + return Err(error); } Ok(transition) } 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 68767db785d..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 @@ -44,11 +44,9 @@ impl DocumentTransferTransition { .into(); if let Some(error) = transition .validate_structure(document_type, platform_version)? - .errors - .into_iter() - .next() + .errors_to_consensus_protocol_error() { - return Err(error.into()); + return Err(error); } Ok(transition) } 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 6d6a43a64ed..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 @@ -43,11 +43,9 @@ impl DocumentUpdatePriceTransition { .into(); if let Some(error) = transition .validate_structure(document_type, platform_version)? - .errors - .into_iter() - .next() + .errors_to_consensus_protocol_error() { - return Err(error.into()); + return Err(error); } Ok(transition) } 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 ba60d9d8bd9..3e58b1f1988 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 @@ -122,13 +122,10 @@ impl BatchTransition { } } - match result.errors.len() { - 0 => Ok(()), - 1 => Err(ProtocolError::ConsensusError(Box::new( - result.errors.pop().expect("validated single error count"), - ))), - _ => Err(ProtocolError::ConsensusErrors(result.errors)), - } + result + .errors_to_consensus_protocol_error() + .map(Err) + .unwrap_or(Ok(())) } /// Runs the constructor pre-sign validation, converts the batch into a diff --git a/packages/rs-dpp/src/validation/validation_result.rs b/packages/rs-dpp/src/validation/validation_result.rs index 505e65edef4..343c7175c34 100644 --- a/packages/rs-dpp/src/validation/validation_result.rs +++ b/packages/rs-dpp/src/validation/validation_result.rs @@ -79,6 +79,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 { @@ -702,4 +724,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-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 9f253bc5d5e..07313090f5c 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -9,20 +9,59 @@ //! consensus errors (singular: the error's own `Display`; plural: `;`-joined). //! //! Structured details about consensus errors are exposed through a *sidecar* -//! lookup keyed on the `message` pointer. Callers query +//! 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. +//! +//! # 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. +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; +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)] @@ -79,7 +118,7 @@ pub struct DashSDKConsensusError { pub code: u32, /// High-level kind, e.g. `BasicError`, `StateError` (owned C string). pub kind: *mut c_char, - /// Specific error name (currently mirrors `kind`, owned C string). + /// Specific consensus error variant name (owned C string). pub name: *mut c_char, /// Human-readable message (owned C string). pub message: *mut c_char, @@ -127,40 +166,121 @@ struct ConsensusErrorEntry { message: String, } -/// Sidecar map from the `DashSDKError.message` raw pointer (as `usize`) to the -/// structured consensus error details. Populated when a `ProtocolError` -/// containing one or more `ConsensusError`s is converted into a -/// `DashSDKError`; freed by `dash_sdk_error_free`. -static CONSENSUS_ERROR_SIDECAR: Lazy>>> = +/// 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())); -fn register_consensus_errors(message_ptr: *mut c_char, errors: Vec) { +/// 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; } - if let Ok(mut map) = CONSENSUS_ERROR_SIDECAR.lock() { - map.insert(message_ptr as usize, errors); - } + let mut map = lock_recover(&PENDING_CONSENSUS_ERRORS); + map.insert(message_ptr as usize, errors); } -fn take_consensus_errors(message_ptr: *mut c_char) { +fn take_pending_consensus_errors(message_ptr: *mut c_char) -> Option> { if message_ptr.is_null() { - return; + return None; } - if let Ok(mut map) = CONSENSUS_ERROR_SIDECAR.lock() { - map.remove(&(message_ptr as usize)); + 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_consensus_errors( - message_ptr: *const c_char, +fn with_active_consensus_errors( + error_ptr: *const DashSDKError, f: impl FnOnce(&[ConsensusErrorEntry]) -> R, ) -> Option { - if message_ptr.is_null() { + if error_ptr.is_null() { return None; } - let guard = CONSENSUS_ERROR_SIDECAR.lock().ok()?; - guard.get(&(message_ptr as usize)).map(|v| f(v.as_slice())) + // Snapshot the current message pointer for identity verification *before* + // taking the lock to keep the unsafe deref scope small. This dereference + // is sound under the documented contract (caller passes a live pointer + // returned by the SDK that has not yet been freed); a stale dangling + // pointer is UB at this point and unavoidable. + let current_message = unsafe { (*error_ptr).message } as usize; + let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS); + let entry = guard.get(&(error_ptr 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 { @@ -184,6 +304,38 @@ 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. +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 { @@ -194,50 +346,12 @@ impl From for DashSDKError { format_protocol_consensus_error(protocol_error) { let error = DashSDKError::new(DashSDKErrorCode::ProtocolError, message); - register_consensus_errors(error.message, entries); + register_pending_consensus_errors(error.message, entries); return error; } } - // 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") - { - (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), - ) - } - } 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()) @@ -258,6 +372,48 @@ impl From for DashSDKError { } } +/// 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()), + // 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::MissingDependency(_, _) + | dash_sdk::Error::TotalCreditsNotFound + | dash_sdk::Error::EpochNotFound + | dash_sdk::Error::IdentityNonceNotFound(_) => { + (DashSDKErrorCode::NotFound, sdk_err.to_string()) + } + // Neutral fallback: we could not classify this SDK error as + // network/timeout/protocol/not-found, so report it as an internal + // SDK error rather than misattributing it to a specific operation + // (e.g. "Failed to fetch balances"). + _ => ( + DashSDKErrorCode::InternalError, + format!("SDK error: {}", sdk_err), + ), + } +} + fn consensus_error_kind_name(error: &ConsensusError) -> &'static str { match error { ConsensusError::DefaultError => "DefaultError", @@ -268,12 +424,30 @@ fn consensus_error_kind_name(error: &ConsensusError) -> &'static str { } } +/// 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 { - let name = consensus_error_kind_name(error).to_string(); ConsensusErrorEntry { code: error.code(), - kind: name.clone(), - name, + kind: consensus_error_kind_name(error).to_string(), + name: consensus_error_variant_name(error).to_string(), message: error.to_string(), } } @@ -308,17 +482,23 @@ fn format_protocol_consensus_error( /// # 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; } - let error = Box::from_raw(error); - if !error.message.is_null() { - take_consensus_errors(error.message); - let _ = CString::from_raw(error.message); - } + // 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 @@ -328,16 +508,18 @@ pub unsafe extern "C" fn dash_sdk_error_free(error: *mut DashSDKError) { /// # 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 { if error.is_null() { return 0; } - let error = &*error; - if error.code != DashSDKErrorCode::ProtocolError { + if (*error).code != DashSDKErrorCode::ProtocolError { return 0; } - with_consensus_errors(error.message, |entries| entries.len()).unwrap_or(0) + with_active_consensus_errors(error, |entries| entries.len()).unwrap_or(0) } /// Returns a newly-allocated [`DashSDKConsensusError`] for the consensus error @@ -350,6 +532,9 @@ pub unsafe extern "C" fn dash_sdk_error_consensus_error_count(error: *const Dash /// # 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, @@ -358,13 +543,12 @@ pub unsafe extern "C" fn dash_sdk_error_consensus_error_at( if error.is_null() { return std::ptr::null_mut(); } - let error = &*error; - if error.code != DashSDKErrorCode::ProtocolError { + if (*error).code != DashSDKErrorCode::ProtocolError { return std::ptr::null_mut(); } let entry = - with_consensus_errors(error.message, |entries| entries.get(index).cloned()).flatten(); + with_active_consensus_errors(error, |entries| entries.get(index).cloned()).flatten(); let Some(entry) = entry else { return std::ptr::null_mut(); }; @@ -429,7 +613,7 @@ 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); } } }; @@ -440,11 +624,18 @@ 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(error: &DashSDKError) -> String { - unsafe { CStr::from_ptr(error.message) } + 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() @@ -457,11 +648,11 @@ mod tests { .to_owned() } - /// Box and free via the public C ABI so the sidecar lifecycle exercised by - /// real callers is exercised by the test. - fn free_via_ffi(error: DashSDKError) { - let raw = Box::into_raw(Box::new(error)); - unsafe { dash_sdk_error_free(raw) }; + /// 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] @@ -473,31 +664,34 @@ mod tests { let sdk_error = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error))); - let ffi_error = DashSDKError::from(FFIError::SDKError(sdk_error)); - let message = error_message(&ffi_error); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + let message = error_message_ptr(ffi_error); - assert_eq!(ffi_error.code, DashSDKErrorCode::ProtocolError); + 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) }; + 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) }; + 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), "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) }; + let oob = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 1) }; assert!(oob.is_null()); - free_via_ffi(ffi_error); + unsafe { dash_sdk_error_free(ffi_error) }; } #[test] @@ -513,51 +707,260 @@ mod tests { let sdk_error = dash_sdk::Error::Protocol(ProtocolError::ConsensusErrors(vec![nonce_err, token_err])); - let ffi_error = DashSDKError::from(FFIError::SDKError(sdk_error)); - let message = error_message(&ffi_error); + let ffi_error = boxed(DashSDKError::from(FFIError::SDKError(sdk_error))); + let message = error_message_ptr(ffi_error); - assert_eq!(ffi_error.code, DashSDKErrorCode::ProtocolError); + 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) }; + 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) }; + 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), "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), "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) }; - free_via_ffi(ffi_error); + 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 = DashSDKError::from(FFIError::NotFound("nope".to_string())); - assert_eq!(ffi_error.code, DashSDKErrorCode::NotFound); + 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) }; + 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) }; + let null = unsafe { dash_sdk_error_consensus_error_at(ffi_error, 0) }; assert!(null.is_null()); - free_via_ffi(ffi_error); + 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 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 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] @@ -567,4 +970,86 @@ mod tests { 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/types.rs b/packages/rs-sdk-ffi/src/types.rs index b55a0a61570..d22a643f8d7 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -516,7 +516,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), } } } @@ -1263,11 +1266,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/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 702510a3e82..7e5c02742f9 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -154,6 +154,10 @@ impl DocumentCreateTransitionBuilder { .map_err(|e| Error::Protocol(e.into()))?; let mut document = self.document.clone(); + // The public create builder always normalizes the document id here before + // calling the DPP constructor, so the constructor's create-id pre-sign + // validation acts as defense-in-depth for this SDK path rather than a + // user-reachable builder failure. document.set_id(Document::generate_document_id_v0( self.data_contract.id_ref(), &document.owner_id(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift index c88ec266f91..195658fdd8a 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/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 0073765c9d5..9cabd9edde1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -174,13 +174,8 @@ public final class SDK: @unchecked Sendable { } // Check for errors - if result.error != nil { - let sdkError = SDKError.fromDashSDKError(result.error!.pointee) - defer { - dash_sdk_error_free(result.error) - } - - throw sdkError + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } guard result.data != nil else { @@ -231,13 +226,8 @@ public final class SDK: @unchecked Sendable { } // Check for errors - if result.error != nil { - let sdkError = SDKError.fromDashSDKError(result.error!.pointee) - defer { - dash_sdk_error_free(result.error) - } - - throw sdkError + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } print("✅ Successfully loaded \(contracts.count) known contracts into SDK") @@ -258,12 +248,8 @@ public final class SDK: @unchecked Sendable { let result = dash_sdk_get_status(handle) // Check for error - if result.error != nil { - let sdkError = SDKError.fromDashSDKError(result.error!.pointee) - defer { - dash_sdk_error_free(result.error) - } - throw sdkError + if let errorPtr = result.error { + throw SDKError.consumeDashSDKError(errorPtr) } // Parse the JSON result @@ -323,7 +309,9 @@ public struct SDKConsensusError: Equatable, Sendable { /// High-level kind, e.g. `BasicError`, `StateError`, `SignatureError`, /// `FeeError`, `DefaultError`. public let kind: String - /// Specific error name (currently mirrors `kind`). + /// 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 @@ -336,6 +324,22 @@ public struct SDKConsensusError: Equatable, Sendable { } } +/// Richer FFI-originated error that preserves structured consensus details +/// after the underlying `DashSDKError` pointer has been freed. +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? { + sdkError.localizedDescription + } +} + /// SDK Error handling public enum SDKError: Error { case invalidParameter(String) @@ -343,7 +347,6 @@ public enum SDKError: Error { case networkError(String) case serializationError(String) case protocolError(String) - case protocolErrorWithDetails(String, [SDKConsensusError]) case cryptoError(String) case notFound(String) case timeout(String) @@ -351,10 +354,65 @@ public enum SDKError: Error { case internalError(String) case unknown(String) + /// Deprecated. **Always returns `nil`.** + /// + /// The previous implementation routed structured consensus-error details + /// through `Thread.current.threadDictionary`, which is fundamentally + /// unsafe for this purpose: + /// - Swift-raised throws (e.g. `SDKError.invalidState`) bypass the FFI + /// mapper, so a prior protocol error's stash could leak into a later + /// unrelated `catch`. + /// - Async/await and Dispatch/`Task` thread-hops mean a `catch` block can + /// read a different thread's stash. + /// - The value-typed `fromDashSDKError(_ error: DashSDKError)` overload + /// could clobber a richer stash from a prior pointer-typed call. + /// + /// The replacement is to read structured details directly from the FFI + /// error pointer via `consensusErrors(fromDashSDKError:)` or to use + /// `fromDashSDKErrorWithConsensusErrors(_:)` to get both the mapped + /// `SDKError` and the details in a single call. + @available( + *, deprecated, + message: + "Use consensusErrors(fromDashSDKError:) or fromDashSDKErrorWithConsensusErrors(_:) instead. This thread-local accessor is unsafe and now always returns nil." + ) + public static var lastConsensusErrors: [SDKConsensusError]? { + get { nil } + set {} + } + + /// Map a Rust FFI `DashSDKError` into a Swift `SDKError`. + /// + /// Protocol errors always map to `.protocolError(String)`. If callers need + /// structured consensus details, use + /// `consensusErrors(fromDashSDKError:)` or + /// `fromDashSDKErrorWithConsensusErrors(_:)` before freeing the FFI pointer. + 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-error sidecar (which is keyed on the heap pointer returned by + /// the FFI, not the value), so a protocol error always maps to the plain + /// `.protocolError(String)` case here. Sidecar-aware code paths must use + /// the pointer-typed `fromDashSDKError(_:)` overload before freeing the + /// FFI pointer. This overload exists so older callers that pass + /// `error.pointee` continue to compile. + @available( + *, deprecated, + message: + "Use the pointer-typed fromDashSDKError(_:) overload before freeing the FFI error so structured consensus details are preserved." + ) 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 @@ -364,9 +422,6 @@ public enum SDKError: Error { case DashSDKErrorCode(rawValue: 4): // Serialization error return .serializationError(message) case DashSDKErrorCode(rawValue: 5): // Protocol error - if let consensusErrors = consensusErrors(fromDashSDKError: error) { - return .protocolErrorWithDetails(message, consensusErrors) - } return .protocolError(message) case DashSDKErrorCode(rawValue: 6): // Crypto error return .cryptoError(message) @@ -388,26 +443,23 @@ public enum SDKError: Error { /// The plain `SDKError.protocolError(String)` case remains available for /// protocol errors that carry no structured sidecar details; this helper /// exposes the additional structured detail surfaced by the Rust FFI sidecar - /// (`DashSDKConsensusError`). Returns - /// `nil` if `error` is not a `ProtocolError` or has no structured details. + /// (`DashSDKConsensusError`). Returns `nil` if `error` is not a + /// `ProtocolError` or has no structured details. /// - /// Must be called before `dash_sdk_error_free` is invoked on `error`. - public static func consensusErrors(fromDashSDKError error: DashSDKError) + /// 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]? { - var errorCopy = error - let count = withUnsafePointer(to: &errorCopy) { ptr -> Int in - Int(dash_sdk_error_consensus_error_count(ptr)) - } + 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 = SDKError.fromDashSDKError(error) - return (mapped, mapped.consensusErrors) + let details = consensusErrors(fromDashSDKError: error) + return (mapped, details) + } + + /// Reads any structured consensus-error sidecar and frees the owned FFI + /// error pointer before returning the Swift error to throw. + static func consumeDashSDKError(_ error: UnsafeMutablePointer) -> Error { + let (mapped, details) = fromDashSDKErrorWithConsensusErrors(UnsafePointer(error)) + dash_sdk_error_free(error) + + if let details, !details.isEmpty { + return SDKDetailedError(sdkError: mapped, consensusErrors: details) + } + + return mapped } /// Human-readable message carried by this error, regardless of case. @@ -445,18 +515,6 @@ public enum SDKError: Error { .internalError(let message), .unknown(let message): return message - case .protocolErrorWithDetails(let message, _): - return message - } - } - - /// Structured consensus errors surfaced by protocol errors, if available. - public var consensusErrors: [SDKConsensusError]? { - switch self { - case .protocolErrorWithDetails(_, let consensusErrors): - return consensusErrors - default: - return nil } } } @@ -474,15 +532,6 @@ extension SDKError: LocalizedError { return "Serialization Error: \(message)" case .protocolError(let message): return "Protocol Error: \(message)" - case .protocolErrorWithDetails(let message, let consensusErrors): - let details = - consensusErrors - .map { "\($0.kind)[\($0.code)]: \($0.message)" } - .joined(separator: "; ") - if details.isEmpty { - return "Protocol Error: \(message)" - } - return "Protocol Error: \(message) (\(details))" case .cryptoError(let message): return "Cryptographic Error: \(message)" case .notFound(let message): @@ -526,12 +575,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 { @@ -589,12 +634,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/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index 63787c6201e..bdfc41802ce 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -85,22 +85,34 @@ impl WasmSdkError { } fn from_protocol_error(err: ProtocolError, is_retriable: bool) -> Self { - let message = err.to_string(); - let consensus_errors = match &err { + let (message, code, consensus_errors) = match &err { ProtocolError::ConsensusError(error) => { - vec![WasmConsensusError::from_consensus_error(error.as_ref())] + let consensus = error.as_ref(); + ( + consensus.to_string(), + consensus.code() as i32, + vec![WasmConsensusError::from_consensus_error(consensus)], + ) } - ProtocolError::ConsensusErrors(errors) => errors - .iter() - .map(WasmConsensusError::from_consensus_error) - .collect(), - _ => Vec::new(), + 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: -1, + code, is_retriable, consensus_errors, } @@ -268,20 +280,41 @@ impl From for WasmSdkError { } } +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 { - let name = match err { - ConsensusError::DefaultError => "DefaultError", - ConsensusError::BasicError(_) => "BasicError", - ConsensusError::StateError(_) => "StateError", - ConsensusError::SignatureError(_) => "SignatureError", - ConsensusError::FeeError(_) => "FeeError", - } - .to_string(); - Self { - kind: name.clone(), - name, + kind: consensus_error_kind_name(err).to_string(), + name: consensus_error_variant_name(err).to_string(), message: err.to_string(), code: err.code(), } @@ -402,21 +435,32 @@ mod tests { #[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![ - DocumentTransitionsAreAbsentError::new().into(), + 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].name, "BasicError"); + assert_eq!(error.consensus_errors[0].kind, "BasicError"); assert_eq!( - error.consensus_errors[0].message, - DocumentTransitionsAreAbsentError::new().to_string() + 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] @@ -429,9 +473,16 @@ mod tests { assert_eq!(error.kind, WasmSdkErrorKind::Protocol); assert!(!error.is_retriable); assert_eq!(error.consensus_errors.len(), 1); - assert_eq!(error.consensus_errors[0].name, "BasicError"); + 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] @@ -446,5 +497,6 @@ mod tests { assert_eq!(error.kind, WasmSdkErrorKind::Protocol); assert_eq!(error.is_retriable, retriable); assert_eq!(error.consensus_errors.len(), 2); + assert!(error.message.contains("; ")); } } From 02f81590eeb56e000942aeb0d1ba5850d0d2aada Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 5 May 2026 18:35:46 -0500 Subject: [PATCH 19/25] fix(sdk): preserve Swift consensus error details --- CHANGELOG.md | 10 + .../document_create_transition/mod.rs | 2 +- .../validate_structure/mod.rs | 2 +- .../document_delete_transition/mod.rs | 2 +- .../validate_structure/mod.rs | 2 +- .../document_purchase_transition/mod.rs | 2 +- .../validate_structure/mod.rs | 2 +- .../document_replace_transition/mod.rs | 2 +- .../validate_structure/mod.rs | 2 +- .../document_transfer_transition/mod.rs | 2 +- .../validate_structure/mod.rs | 2 +- .../document_update_price_transition/mod.rs | 2 +- .../validate_structure/mod.rs | 2 +- packages/swift-sdk/README.md | 15 ++ .../FFI/PlatformQueryExtensions.swift | 15 +- .../FFI/StateTransitionExtensions.swift | 173 +++++++++--------- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 136 +++++++------- .../Views/DiagnosticsView.swift | 66 ++++--- .../Views/QueryDetailView.swift | 74 +++++--- packages/swift-sdk/SwiftTests/Package.swift | 54 +++--- .../SwiftDashSDKTests/SDKErrorTests.swift | 92 ++++++++++ 21 files changed, 408 insertions(+), 251 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cdab82e51..485fb3cf8e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ * **sdk:** getSignableBytes is not compatible with sign and verify (#3048) * **platform:** update PlatformAddress encoding and HRP constants (#3059) * **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 now throw `SDKDetailedError`, which + wraps the mapped `SDKError` plus a `consensusErrors` array; callers + that want the details should catch `SDKDetailedError` in addition to + `SDKError`. Pointer-based FFI helpers + (`SDKError.consensusErrors(fromDashSDKError:)` and + `SDKError.fromDashSDKErrorWithConsensusErrors(_:)`) remain available + before `dash_sdk_error_free`. * **sdk:** comprehensive Evo SDK refactoring (#2999) * upgrade bincode to 2.0.1 (#2991) @@ -29,6 +38,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/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 c7e4aaa2406..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,7 +2,7 @@ mod convertible; pub mod from_document; pub mod v0; mod v0_methods; -pub mod validate_structure; +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 index 15d2da151e4..445414f589e 100644 --- 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 @@ -7,7 +7,7 @@ use platform_version::version::PlatformVersion; mod v0; -pub trait DocumentCreateTransitionStructureValidation { +pub(crate) trait DocumentCreateTransitionStructureValidation { fn validate_structure( &self, owner_id: Identifier, 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 4887e9a1d5b..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,7 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; -pub mod validate_structure; +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 index 0eaab2b6663..3f23fe888d1 100644 --- 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 @@ -7,7 +7,7 @@ use platform_version::version::PlatformVersion; mod v0; -pub trait DocumentDeleteTransitionStructureValidation { +pub(crate) trait DocumentDeleteTransitionStructureValidation { fn validate_structure( &self, document_type: DocumentTypeRef, 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 8ed4cb49f1e..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,7 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; -pub mod validate_structure; +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/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 index 77031eb5b0b..1c531d45143 100644 --- 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 @@ -7,7 +7,7 @@ use platform_version::version::PlatformVersion; mod v0; -pub trait DocumentPurchaseTransitionStructureValidation { +pub(crate) trait DocumentPurchaseTransitionStructureValidation { fn validate_structure( &self, document_type: DocumentTypeRef, 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 8f027c56554..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,7 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; -pub mod validate_structure; +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 index 73fdb789690..f70c2ce17f6 100644 --- 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 @@ -7,7 +7,7 @@ use platform_version::version::PlatformVersion; mod v0; -pub trait DocumentReplaceTransitionStructureValidation { +pub(crate) trait DocumentReplaceTransitionStructureValidation { fn validate_structure( &self, document_type: DocumentTypeRef, 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 bb744a214e5..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,7 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; -pub mod validate_structure; +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 index 215bb1aa32d..8f485635e9f 100644 --- 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 @@ -7,7 +7,7 @@ use platform_version::version::PlatformVersion; mod v0; -pub trait DocumentTransferTransitionStructureValidation { +pub(crate) trait DocumentTransferTransitionStructureValidation { fn validate_structure( &self, document_type: DocumentTypeRef, 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 2d51e53e13a..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,7 +1,7 @@ mod from_document; pub mod v0; pub mod v0_methods; -pub mod validate_structure; +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 index a8b16c3d9d2..82b01ed10bd 100644 --- 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 @@ -7,7 +7,7 @@ use platform_version::version::PlatformVersion; mod v0; -pub trait DocumentUpdatePriceTransitionStructureValidation { +pub(crate) trait DocumentUpdatePriceTransitionStructureValidation { fn validate_structure( &self, document_type: DocumentTypeRef, diff --git a/packages/swift-sdk/README.md b/packages/swift-sdk/README.md index 1e7ceffd153..4c04fe38f22 100644 --- a/packages/swift-sdk/README.md +++ b/packages/swift-sdk/README.md @@ -38,6 +38,21 @@ 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. When the FFI layer surfaces structured `DashSDKConsensusError` entries +alongside an error, throwing wrappers throw an `SDKDetailedError` that carries +both the mapped `SDKError` and the structured `consensusErrors` array. Callers +who need the structured details should explicitly catch `SDKDetailedError` (and +unwrap `detailed.sdkError`) in addition to catching `SDKError`. + +If you are working directly with the FFI `DashSDKError` pointer, inspect +`SDKError.consensusErrors(fromDashSDKError:)` or +`SDKError.fromDashSDKErrorWithConsensusErrors(_:)` before +`dash_sdk_error_free`. + ## API Reference ### Identity Operations diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 874ef15e29e..68ea90040a8 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 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index be0232a1c34..fc8565bea51 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 @@ -198,9 +208,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 +273,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 +352,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 +409,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 +471,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 +547,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 } @@ -604,8 +634,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 +703,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 +733,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 } @@ -772,10 +800,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 @@ -862,8 +889,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) @@ -937,7 +964,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 +995,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 +1029,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 +1055,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 +1075,7 @@ extension SDK { } print("❌ [DOCUMENT TRANSFER] Broadcast failed: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.consumeDashSDKError(error)) return } @@ -1093,11 +1122,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 +1153,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 } @@ -1180,11 +1207,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 } @@ -1254,8 +1280,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 +1304,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 +1344,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 +1503,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 +1590,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 +1657,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 +1720,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 +1787,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 +1850,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 +1907,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 +1968,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 +2035,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 +2098,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 +2148,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 +2221,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 +2282,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 +2348,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 +2399,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 +2486,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 9cabd9edde1..4f93248931c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -324,8 +324,16 @@ public struct SDKConsensusError: Equatable, Sendable { } } -/// Richer FFI-originated error that preserves structured consensus details -/// after the underlying `DashSDKError` pointer has been freed. +/// Wrapper error thrown by public Swift SDK helpers when an FFI error carries +/// 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. When the FFI also surfaces +/// structured `DashSDKConsensusError` entries, `SDKError.consumeDashSDKError` +/// throws this wrapper instead. Callers that need the structured detail can +/// pattern-match on `SDKDetailedError`; callers that only need the mapped +/// `SDKError` should unwrap via `detailed.sdkError`. public struct SDKDetailedError: Error, LocalizedError { public let sdkError: SDKError public let consensusErrors: [SDKConsensusError] @@ -336,11 +344,28 @@ public struct SDKDetailedError: Error, LocalizedError { } public var errorDescription: String? { - sdkError.localizedDescription + 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, when present, are surfaced via the +/// `SDKDetailedError` wrapper thrown by `consumeDashSDKError(_:)`. public enum SDKError: Error { case invalidParameter(String) case invalidState(String) @@ -354,39 +379,12 @@ public enum SDKError: Error { case internalError(String) case unknown(String) - /// Deprecated. **Always returns `nil`.** - /// - /// The previous implementation routed structured consensus-error details - /// through `Thread.current.threadDictionary`, which is fundamentally - /// unsafe for this purpose: - /// - Swift-raised throws (e.g. `SDKError.invalidState`) bypass the FFI - /// mapper, so a prior protocol error's stash could leak into a later - /// unrelated `catch`. - /// - Async/await and Dispatch/`Task` thread-hops mean a `catch` block can - /// read a different thread's stash. - /// - The value-typed `fromDashSDKError(_ error: DashSDKError)` overload - /// could clobber a richer stash from a prior pointer-typed call. - /// - /// The replacement is to read structured details directly from the FFI - /// error pointer via `consensusErrors(fromDashSDKError:)` or to use - /// `fromDashSDKErrorWithConsensusErrors(_:)` to get both the mapped - /// `SDKError` and the details in a single call. - @available( - *, deprecated, - message: - "Use consensusErrors(fromDashSDKError:) or fromDashSDKErrorWithConsensusErrors(_:) instead. This thread-local accessor is unsafe and now always returns nil." - ) - public static var lastConsensusErrors: [SDKConsensusError]? { - get { nil } - set {} - } - /// Map a Rust FFI `DashSDKError` into a Swift `SDKError`. /// - /// Protocol errors always map to `.protocolError(String)`. If callers need - /// structured consensus details, use - /// `consensusErrors(fromDashSDKError:)` or - /// `fromDashSDKErrorWithConsensusErrors(_:)` before freeing the FFI pointer. + /// 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" @@ -394,24 +392,25 @@ public enum SDKError: Error { } /// Source-compatibility overload that maps a value-typed `DashSDKError` - /// into an `SDKError`. This overload **cannot** resolve the structured + /// into an `SDKError`. This overload cannot resolve the structured /// consensus-error sidecar (which is keyed on the heap pointer returned by - /// the FFI, not the value), so a protocol error always maps to the plain - /// `.protocolError(String)` case here. Sidecar-aware code paths must use - /// the pointer-typed `fromDashSDKError(_:)` overload before freeing the - /// FFI pointer. This overload exists so older callers that pass - /// `error.pointee` continue to compile. + /// the FFI, not the value); sidecar-aware code paths 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 are preserved." + "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) } - private static func mapScalar(code: DashSDKErrorCode, message: String) -> SDKError { + private static func mapScalar( + code: DashSDKErrorCode, + message: String + ) -> SDKError { switch code { case DashSDKErrorCode(rawValue: 1): // Invalid parameter return .invalidParameter(message) @@ -440,12 +439,6 @@ public enum SDKError: Error { /// Returns the structured consensus errors carried by `error`, if any. /// - /// The plain `SDKError.protocolError(String)` case remains available for - /// protocol errors that carry no structured sidecar details; this helper - /// exposes the additional structured detail surfaced by the Rust FFI sidecar - /// (`DashSDKConsensusError`). Returns `nil` if `error` is not a - /// `ProtocolError` or has no structured details. - /// /// 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 @@ -472,31 +465,28 @@ public enum SDKError: Error { return result.isEmpty ? nil : result } - /// Convenience that returns both the mapped `SDKError` and any structured - /// consensus errors carried by `error`. Useful when callers need to display - /// the readable message *and* introspect each consensus error individually. - /// - /// The structured details are read directly from the FFI sidecar; the - /// mapped `SDKError` is the same scalar value `fromDashSDKError(_:)` - /// would return. + /// Convenience that returns the mapped `SDKError` plus any structured + /// protocol consensus details still attached to the FFI sidecar. public static func fromDashSDKErrorWithConsensusErrors(_ error: UnsafePointer) -> (SDKError, [SDKConsensusError]?) { - let mapped = SDKError.fromDashSDKError(error) + let mapped = fromDashSDKError(error) let details = consensusErrors(fromDashSDKError: error) return (mapped, details) } - /// Reads any structured consensus-error sidecar and frees the owned FFI - /// error pointer before returning the Swift error to throw. + /// Frees the owned FFI error pointer after mapping it to a Swift error. + /// + /// Returns `SDKDetailedError` when the FFI carries structured consensus + /// details, so callers that need the structured surface can downcast to it + /// after the FFI pointer is freed. Otherwise returns the bare `SDKError`, + /// preserving clean `case .protocolError(let message)` pattern matches. static func consumeDashSDKError(_ error: UnsafeMutablePointer) -> Error { let (mapped, details) = fromDashSDKErrorWithConsensusErrors(UnsafePointer(error)) dash_sdk_error_free(error) - if let details, !details.isEmpty { return SDKDetailedError(sdkError: mapped, consensusErrors: details) } - return mapped } @@ -521,30 +511,32 @@ public enum SDKError: Error { 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)" - case .protocolError(let message): - return "Protocol Error: \(message)" + description = "Serialization Error: \(message)" + case .protocolError: + 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 } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift index c3d98ec70c9..19e14e3b128 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift @@ -595,35 +595,53 @@ struct DiagnosticsView: View { } private func formatError(_ error: Error) -> String { + if let detailed = error as? SDKDetailedError { + return formatSDKError(detailed.sdkError, consensusErrors: detailed.consensusErrors) + } 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..bc1f5d6888e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift @@ -176,34 +176,20 @@ struct QueryDetailView: View { isLoading = false print("✅ QueryDetailView: Result displayed, showResult: \(showResult)") } + } catch let detailed as SDKDetailedError { + print("❌ QueryDetailView: SDK detailed error occurred: \(detailed)") + await MainActor.run { + self.error = QueryDetailView.formatSDKError( + detailed.sdkError, + consensusErrors: detailed.consensusErrors + ) + isLoading = false + print("❌ QueryDetailView: Error set to: \(self.error)") + } } 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 +1129,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..ffcc766f659 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift @@ -0,0 +1,92 @@ +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 testSDKDetailedErrorIsNotConfusedWithSDKErrorByDownstreamCatches() { + // Callers that pattern-match `as? SDKError` must not accidentally pick up + // SDKDetailedError. They should explicitly catch SDKDetailedError and + // unwrap `sdkError` to inspect cases. + let detailed: any Error = SDKDetailedError( + sdkError: .protocolError("scoped"), + consensusErrors: [ + SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") + ] + ) + + XCTAssertNil(detailed as? SDKError) + XCTAssertNotNil(detailed as? SDKDetailedError) + } +} From f54eb964c93545c306657263b8f88d79b53d849b Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 6 May 2026 20:00:34 -0500 Subject: [PATCH 20/25] fix(sdk): preserve query consensus error details --- .../batch_transition/v0/v0_methods.rs | 6 -- .../batch_transition/v1/v0_methods.rs | 6 -- .../batch_transition/v1/v1_methods.rs | 99 +++---------------- .../validate_basic_structure/mod.rs | 9 +- .../validate_basic_structure/v0/mod.rs | 4 +- packages/swift-sdk/README.md | 5 +- .../FFI/PlatformQueryExtensions.swift | 38 ++----- 7 files changed, 26 insertions(+), 141 deletions(-) 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 b2c60e3d18c..9523b34f0b2 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 @@ -126,7 +126,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -169,7 +168,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -214,7 +212,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -257,7 +254,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -302,7 +298,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -359,7 +354,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), 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 6b675199c84..367740e69e2 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 @@ -135,7 +135,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -178,7 +177,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -221,7 +219,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -266,7 +263,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -311,7 +307,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), @@ -371,7 +366,6 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { .validate_and_sign( identity_public_key, signer, - Some(document_type), Some(document_type.security_level_requirement()), platform_version, Some(resolved_options), 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 730ddf739fa..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 @@ -136,14 +136,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); documents_batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -207,14 +200,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } #[cfg(feature = "state-transition-signing")] @@ -262,14 +248,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -334,14 +313,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -406,14 +378,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { .into(); documents_batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -480,14 +445,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -552,14 +510,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -624,14 +575,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -671,14 +615,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -747,14 +684,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .validate_and_sign(identity_public_key, signer, None, platform_version, options) .await } @@ -795,14 +725,7 @@ impl DocumentsBatchTransitionMethodsV1 for BatchTransitionV1 { } .into(); batch_transition - .validate_and_sign( - identity_public_key, - signer, - None, - None, - platform_version, - options, - ) + .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 3e58b1f1988..ae8c400fe04 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,5 +1,3 @@ -#[cfg(any(test, feature = "state-transition-signing"))] -use crate::data_contract::document_type::DocumentTypeRef; 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; @@ -66,13 +64,9 @@ impl BatchTransition { /// 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. - /// `document_type` is still accepted to keep the constructor call sites - /// stable while create-ID checks remain here; token constructors use - /// `None`. #[cfg(any(test, feature = "state-transition-signing"))] pub(crate) fn validate_base_structure_pre_sign( &self, - _document_type: Option>, platform_version: &PlatformVersion, ) -> Result<(), ProtocolError> { let mut result = match platform_version @@ -143,12 +137,11 @@ impl BatchTransition { self, identity_public_key: &IdentityPublicKey, signer: &S, - document_type: Option>, required_security_level: Option, platform_version: &PlatformVersion, options: Option, ) -> Result { - self.validate_base_structure_pre_sign(document_type, platform_version)?; + 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 { 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 1f5a6391e6a..d8cfa89266e 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 @@ -527,7 +527,7 @@ mod tests { 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(None, pv) { + 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!( @@ -549,7 +549,7 @@ mod tests { 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(None, pv) { + 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!( diff --git a/packages/swift-sdk/README.md b/packages/swift-sdk/README.md index 4c04fe38f22..9526d9e0c14 100644 --- a/packages/swift-sdk/README.md +++ b/packages/swift-sdk/README.md @@ -45,8 +45,9 @@ human-readable — `case .protocolError(let message)` produces the original FFI message. When the FFI layer surfaces structured `DashSDKConsensusError` entries alongside an error, throwing wrappers throw an `SDKDetailedError` that carries both the mapped `SDKError` and the structured `consensusErrors` array. Callers -who need the structured details should explicitly catch `SDKDetailedError` (and -unwrap `detailed.sdkError`) in addition to catching `SDKError`. +who need the structured details should catch `SDKDetailedError` before +`SDKError`, or catch `Error` and downcast to both. The wrapped scalar error is +available as `detailed.sdkError`. If you are working directly with the FFI `DashSDKError` pointer, inspect `SDKError.consensusErrors(fromDashSDKError:)` or diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 68ea90040a8..82f5a319b9f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -271,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 @@ -352,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 { @@ -443,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 { @@ -462,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 { @@ -689,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 @@ -783,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 { @@ -981,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 @@ -1210,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 { @@ -1236,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 { From f11960bdd3fe9c425fc5912bd94e0b14ac3fdf33 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 6 May 2026 21:22:44 -0500 Subject: [PATCH 21/25] fix(sdk): keep Swift consensus failures as SDKError --- packages/swift-sdk/README.md | 8 +- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 139 +++++++++++++++--- .../Views/DiagnosticsView.swift | 5 +- .../Views/QueryDetailView.swift | 15 +- .../SwiftDashSDKTests/SDKErrorTests.swift | 54 +++++-- 5 files changed, 172 insertions(+), 49 deletions(-) diff --git a/packages/swift-sdk/README.md b/packages/swift-sdk/README.md index 9526d9e0c14..f56c6754e84 100644 --- a/packages/swift-sdk/README.md +++ b/packages/swift-sdk/README.md @@ -43,11 +43,9 @@ import SwiftDashSDK Public Swift errors keep `SDKError` associated `String` payloads clean and human-readable — `case .protocolError(let message)` produces the original FFI message. When the FFI layer surfaces structured `DashSDKConsensusError` entries -alongside an error, throwing wrappers throw an `SDKDetailedError` that carries -both the mapped `SDKError` and the structured `consensusErrors` array. Callers -who need the structured details should catch `SDKDetailedError` before -`SDKError`, or catch `Error` and downcast to both. The wrapped scalar error is -available as `detailed.sdkError`. +alongside an error, throwing wrappers still throw `SDKError`. Callers who need +the structured details should catch `SDKError` and inspect +`sdkError.consensusErrors` in the catch block. If you are working directly with the FFI `DashSDKError` pointer, inspect `SDKError.consensusErrors(fromDashSDKError:)` or diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 4f93248931c..c9f3cfdd725 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -324,16 +324,16 @@ public struct SDKConsensusError: Equatable, Sendable { } } -/// Wrapper error thrown by public Swift SDK helpers when an FFI error carries +/// 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. When the FFI also surfaces -/// structured `DashSDKConsensusError` entries, `SDKError.consumeDashSDKError` -/// throws this wrapper instead. Callers that need the structured detail can -/// pattern-match on `SDKDetailedError`; callers that only need the mapped -/// `SDKError` should unwrap via `detailed.sdkError`. +/// produce the original human-readable message. +/// +/// Public Swift wrappers now keep throwing `SDKError` for source compatibility. +/// This wrapper remains available for callers that explicitly want to bundle an +/// `SDKError` with structured consensus details into a single `Error` value. public struct SDKDetailedError: Error, LocalizedError { public let sdkError: SDKError public let consensusErrors: [SDKConsensusError] @@ -359,13 +359,45 @@ public struct SDKDetailedError: Error, LocalizedError { } } +private final class SDKErrorConsensusSidecar: @unchecked Sendable { + struct Signature: Equatable { + let code: UInt32 + let message: String + } + + struct Entry { + let signature: Signature + let consensusErrors: [SDKConsensusError] + } + + private let lock = NSLock() + private var current: Entry? + + func replace(with entry: Entry?) { + lock.lock() + current = entry + lock.unlock() + } + + func consensusErrors(for signature: Signature) -> [SDKConsensusError]? { + lock.lock() + defer { lock.unlock() } + + guard let current, current.signature == signature else { + return nil + } + + return current.consensusErrors + } +} + /// 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, when present, are surfaced via the -/// `SDKDetailedError` wrapper thrown by `consumeDashSDKError(_:)`. +/// consensus details, when present, are surfaced via `SDKError.consensusErrors` +/// on the most recently consumed matching `SDKError`. public enum SDKError: Error { case invalidParameter(String) case invalidState(String) @@ -437,6 +469,42 @@ public enum SDKError: Error { } } + private static let consensusSidecar = SDKErrorConsensusSidecar() + + private var sidecarSignature: SDKErrorConsensusSidecar.Signature { + SDKErrorConsensusSidecar.Signature(code: code, message: message) + } + + static func updateConsensusSidecar( + for error: SDKError, + consensusErrors: [SDKConsensusError]? + ) { + guard let consensusErrors, !consensusErrors.isEmpty else { + consensusSidecar.replace(with: nil) + return + } + + consensusSidecar.replace( + with: SDKErrorConsensusSidecar.Entry( + signature: error.sidecarSignature, + consensusErrors: consensusErrors + ) + ) + } + + /// Structured consensus details captured when a matching FFI error was most + /// recently consumed by `consumeDashSDKError(_:)`. + /// + /// This accessor is source-compatible with existing `catch let sdkError as + /// SDKError` flows: wrappers continue throwing `SDKError`, while callers that + /// need protocol details can inspect `sdkError.consensusErrors` immediately in + /// the catch scope. The sidecar is replaced on every consume path and matches + /// by SDK error code plus message, so unrelated `SDKError` values do not + /// inherit stale details. + public var consensusErrors: [SDKConsensusError]? { + SDKError.consensusSidecar.consensusErrors(for: sidecarSignature) + } + /// Returns the structured consensus errors carried by `error`, if any. /// /// Takes the **original** heap pointer that the FFI returned. Sidecar @@ -475,19 +543,27 @@ public enum SDKError: Error { return (mapped, details) } + // Shared finalization path so tests can verify sidecar behavior without + // depending on FFI-owned pointers. + static func finalizeConsumedDashSDKError( + _ error: SDKError, + consensusErrors: [SDKConsensusError]? + ) -> SDKError { + updateConsensusSidecar(for: error, consensusErrors: consensusErrors) + return error + } + /// Frees the owned FFI error pointer after mapping it to a Swift error. /// - /// Returns `SDKDetailedError` when the FFI carries structured consensus - /// details, so callers that need the structured surface can downcast to it - /// after the FFI pointer is freed. Otherwise returns the bare `SDKError`, - /// preserving clean `case .protocolError(let message)` pattern matches. - static func consumeDashSDKError(_ error: UnsafeMutablePointer) -> Error { + /// Always returns the mapped `SDKError`, preserving the public thrown runtime + /// type for existing `catch let sdkError as SDKError` handlers. If the FFI + /// error also carries structured consensus details, they are captured before + /// freeing the pointer and made available via `sdkError.consensusErrors`. + static func consumeDashSDKError(_ error: UnsafeMutablePointer) -> SDKError { let (mapped, details) = fromDashSDKErrorWithConsensusErrors(UnsafePointer(error)) + let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: details) dash_sdk_error_free(error) - if let details, !details.isEmpty { - return SDKDetailedError(sdkError: mapped, consensusErrors: details) - } - return mapped + return finalized } /// Human-readable message carried by this error, regardless of case. @@ -507,6 +583,33 @@ public enum SDKError: Error { 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 { @@ -521,7 +624,7 @@ extension SDKError: LocalizedError { description = "Network Error: \(message)" case .serializationError(let message): description = "Serialization Error: \(message)" - case .protocolError: + case .protocolError(let message): description = "Protocol Error: \(message)" case .cryptoError(let message): description = "Cryptographic Error: \(message)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift index 19e14e3b128..0270dfea44c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift @@ -595,11 +595,8 @@ struct DiagnosticsView: View { } private func formatError(_ error: Error) -> String { - if let detailed = error as? SDKDetailedError { - return formatSDKError(detailed.sdkError, consensusErrors: detailed.consensusErrors) - } if let sdkError = error as? SDKError { - return formatSDKError(sdkError, consensusErrors: nil) + return formatSDKError(sdkError, consensusErrors: sdkError.consensusErrors) } return error.localizedDescription } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift index bc1f5d6888e..622cc688df4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift @@ -176,20 +176,13 @@ struct QueryDetailView: View { isLoading = false print("✅ QueryDetailView: Result displayed, showResult: \(showResult)") } - } catch let detailed as SDKDetailedError { - print("❌ QueryDetailView: SDK detailed error occurred: \(detailed)") - await MainActor.run { - self.error = QueryDetailView.formatSDKError( - detailed.sdkError, - consensusErrors: detailed.consensusErrors - ) - isLoading = false - print("❌ QueryDetailView: Error set to: \(self.error)") - } } catch let sdkError as SDKError { print("❌ QueryDetailView: SDK error occurred: \(sdkError)") await MainActor.run { - self.error = QueryDetailView.formatSDKError(sdkError, consensusErrors: nil) + self.error = QueryDetailView.formatSDKError( + sdkError, + consensusErrors: sdkError.consensusErrors + ) isLoading = false print("❌ QueryDetailView: Error set to: \(self.error)") } diff --git a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift index ffcc766f659..f5d4c40b679 100644 --- a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift +++ b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift @@ -75,18 +75,50 @@ final class SDKErrorTests: XCTestCase { XCTAssertEqual(detailed.errorDescription, "Internal Error: boom") } - func testSDKDetailedErrorIsNotConfusedWithSDKErrorByDownstreamCatches() { - // Callers that pattern-match `as? SDKError` must not accidentally pick up - // SDKDetailedError. They should explicitly catch SDKDetailedError and - // unwrap `sdkError` to inspect cases. - let detailed: any Error = SDKDetailedError( - sdkError: .protocolError("scoped"), - consensusErrors: [ - SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") - ] + func testConsumeDashSDKErrorReturnsSDKErrorForExistingCatchLogic() { + let sdkError = SDKError.finalizeConsumedDashSDKError( + .protocolError("Protocol mismatch"), + consensusErrors: nil ) - XCTAssertNil(detailed as? SDKError) - XCTAssertNotNil(detailed as? SDKDetailedError) + if case .protocolError(let message) = sdkError { + XCTAssertEqual(message, "Protocol mismatch") + } else { + XCTFail("Expected SDKError.protocolError") + } + XCTAssertNil(sdkError.consensusErrors) + } + + func testConsensusSidecarMatchesErrorCodeAndMessage() { + let matchingError = SDKError.protocolError("Protocol mismatch") + let differentMessage = SDKError.protocolError("Different message") + let differentCode = SDKError.networkError("Protocol mismatch") + let consensusErrors = [ + SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") + ] + + SDKError.updateConsensusSidecar(for: matchingError, consensusErrors: consensusErrors) + + XCTAssertEqual(matchingError.consensusErrors, consensusErrors) + XCTAssertNil(differentMessage.consensusErrors) + XCTAssertNil(differentCode.consensusErrors) + } + + func testConsensusSidecarClearsForLaterNonDetailedConsume() { + let first = SDKError.protocolError("Scoped protocol error") + let consensusErrors = [ + SDKConsensusError(code: 99, kind: "Consensus", name: "Scoped", message: "details") + ] + + SDKError.updateConsensusSidecar(for: first, consensusErrors: consensusErrors) + XCTAssertEqual(first.consensusErrors, consensusErrors) + + let later = SDKError.finalizeConsumedDashSDKError( + .networkError("Network failure"), + consensusErrors: nil + ) + + XCTAssertNil(first.consensusErrors) + XCTAssertNil(later.consensusErrors) } } From ea12f778a9b0286eea4a99e0c962215497752b7e Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 6 May 2026 23:06:42 -0500 Subject: [PATCH 22/25] fix(sdk): preserve structured consensus errors --- packages/swift-sdk/README.md | 13 ++- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 99 +++++-------------- .../SwiftDashSDKTests/SDKErrorTests.swift | 42 ++++---- .../src/state_transitions/addresses.rs | 23 ++--- .../src/state_transitions/broadcast.rs | 8 +- .../src/state_transitions/contract.rs | 2 +- .../src/state_transitions/identity.rs | 12 +-- 7 files changed, 64 insertions(+), 135 deletions(-) diff --git a/packages/swift-sdk/README.md b/packages/swift-sdk/README.md index f56c6754e84..49347480bb1 100644 --- a/packages/swift-sdk/README.md +++ b/packages/swift-sdk/README.md @@ -42,15 +42,18 @@ import SwiftDashSDK Public Swift errors keep `SDKError` associated `String` payloads clean and human-readable — `case .protocolError(let message)` produces the original FFI -message. When the FFI layer surfaces structured `DashSDKConsensusError` entries -alongside an error, throwing wrappers still throw `SDKError`. Callers who need -the structured details should catch `SDKError` and inspect -`sdkError.consensusErrors` in the catch block. +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`. +`dash_sdk_error_free` to retrieve race-free structured consensus details. Code +that wants to pass both values around together can wrap them in +`SDKDetailedError`. ## API Reference diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index c9f3cfdd725..26e0ffd33dc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -359,45 +359,13 @@ public struct SDKDetailedError: Error, LocalizedError { } } -private final class SDKErrorConsensusSidecar: @unchecked Sendable { - struct Signature: Equatable { - let code: UInt32 - let message: String - } - - struct Entry { - let signature: Signature - let consensusErrors: [SDKConsensusError] - } - - private let lock = NSLock() - private var current: Entry? - - func replace(with entry: Entry?) { - lock.lock() - current = entry - lock.unlock() - } - - func consensusErrors(for signature: Signature) -> [SDKConsensusError]? { - lock.lock() - defer { lock.unlock() } - - guard let current, current.signature == signature else { - return nil - } - - return current.consensusErrors - } -} - /// 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, when present, are surfaced via `SDKError.consensusErrors` -/// on the most recently consumed matching `SDKError`. +/// 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) @@ -425,9 +393,9 @@ public enum SDKError: Error { /// Source-compatibility overload that maps a value-typed `DashSDKError` /// into an `SDKError`. This overload cannot resolve the structured - /// consensus-error sidecar (which is keyed on the heap pointer returned by - /// the FFI, not the value); sidecar-aware code paths must use the - /// pointer-typed `fromDashSDKError(_:)` overload combined with + /// 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, @@ -469,40 +437,17 @@ public enum SDKError: Error { } } - private static let consensusSidecar = SDKErrorConsensusSidecar() - - private var sidecarSignature: SDKErrorConsensusSidecar.Signature { - SDKErrorConsensusSidecar.Signature(code: code, message: message) - } - - static func updateConsensusSidecar( - for error: SDKError, - consensusErrors: [SDKConsensusError]? - ) { - guard let consensusErrors, !consensusErrors.isEmpty else { - consensusSidecar.replace(with: nil) - return - } - - consensusSidecar.replace( - with: SDKErrorConsensusSidecar.Entry( - signature: error.sidecarSignature, - consensusErrors: consensusErrors - ) - ) - } - - /// Structured consensus details captured when a matching FFI error was most - /// recently consumed by `consumeDashSDKError(_:)`. + /// Structured consensus details are not retained on `SDKError`. /// - /// This accessor is source-compatible with existing `catch let sdkError as - /// SDKError` flows: wrappers continue throwing `SDKError`, while callers that - /// need protocol details can inspect `sdkError.consensusErrors` immediately in - /// the catch scope. The sidecar is replaced on every consume path and matches - /// by SDK error code plus message, so unrelated `SDKError` values do not - /// inherit stale details. + /// 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. public var consensusErrors: [SDKConsensusError]? { - SDKError.consensusSidecar.consensusErrors(for: sidecarSignature) + nil } /// Returns the structured consensus errors carried by `error`, if any. @@ -534,7 +479,7 @@ public enum SDKError: Error { } /// Convenience that returns the mapped `SDKError` plus any structured - /// protocol consensus details still attached to the FFI sidecar. + /// protocol consensus details still attached to the original FFI pointer. public static func fromDashSDKErrorWithConsensusErrors(_ error: UnsafePointer) -> (SDKError, [SDKConsensusError]?) { @@ -543,25 +488,25 @@ public enum SDKError: Error { return (mapped, details) } - // Shared finalization path so tests can verify sidecar behavior without + // Shared finalization path so tests can verify wrapper behavior without // depending on FFI-owned pointers. static func finalizeConsumedDashSDKError( _ error: SDKError, consensusErrors: [SDKConsensusError]? ) -> SDKError { - updateConsensusSidecar(for: error, consensusErrors: consensusErrors) + _ = consensusErrors return error } /// Frees the owned FFI error pointer after mapping it to a Swift error. /// /// Always returns the mapped `SDKError`, preserving the public thrown runtime - /// type for existing `catch let sdkError as SDKError` handlers. If the FFI - /// error also carries structured consensus details, they are captured before - /// freeing the pointer and made available via `sdkError.consensusErrors`. + /// type for existing `catch let sdkError as SDKError` handlers. Structured + /// consensus details, if needed, must be read from the original pointer with + /// `fromDashSDKErrorWithConsensusErrors(_:)` before it is freed. static func consumeDashSDKError(_ error: UnsafeMutablePointer) -> SDKError { - let (mapped, details) = fromDashSDKErrorWithConsensusErrors(UnsafePointer(error)) - let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: details) + let mapped = fromDashSDKError(UnsafePointer(error)) + let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: nil) dash_sdk_error_free(error) return finalized } diff --git a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift index f5d4c40b679..b0643b90fca 100644 --- a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift +++ b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift @@ -78,7 +78,9 @@ final class SDKErrorTests: XCTestCase { func testConsumeDashSDKErrorReturnsSDKErrorForExistingCatchLogic() { let sdkError = SDKError.finalizeConsumedDashSDKError( .protocolError("Protocol mismatch"), - consensusErrors: nil + consensusErrors: [ + SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") + ] ) if case .protocolError(let message) = sdkError { @@ -89,36 +91,26 @@ final class SDKErrorTests: XCTestCase { XCTAssertNil(sdkError.consensusErrors) } - func testConsensusSidecarMatchesErrorCodeAndMessage() { - let matchingError = SDKError.protocolError("Protocol mismatch") - let differentMessage = SDKError.protocolError("Different message") - let differentCode = SDKError.networkError("Protocol mismatch") - let consensusErrors = [ - SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") - ] - - SDKError.updateConsensusSidecar(for: matchingError, consensusErrors: consensusErrors) + func testSDKErrorConsensusErrorsDoesNotExposeStructuredDetailsFromScalarValue() { + let sdkError = SDKError.protocolError("Protocol mismatch") - XCTAssertEqual(matchingError.consensusErrors, consensusErrors) - XCTAssertNil(differentMessage.consensusErrors) - XCTAssertNil(differentCode.consensusErrors) + XCTAssertNil(sdkError.consensusErrors) } - func testConsensusSidecarClearsForLaterNonDetailedConsume() { - let first = SDKError.protocolError("Scoped protocol error") + func testFinalizeConsumedDashSDKErrorIgnoresConsensusDetailsForSDKError() { + let sdkError = SDKError.finalizeConsumedDashSDKError( + .protocolError("Protocol mismatch"), + consensusErrors: [ + SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") + ] + ) let consensusErrors = [ - SDKConsensusError(code: 99, kind: "Consensus", name: "Scoped", message: "details") + SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") ] - SDKError.updateConsensusSidecar(for: first, consensusErrors: consensusErrors) - XCTAssertEqual(first.consensusErrors, consensusErrors) - - let later = SDKError.finalizeConsumedDashSDKError( - .networkError("Network failure"), - consensusErrors: nil - ) + let detailed = SDKDetailedError(sdkError: sdkError, consensusErrors: consensusErrors) - XCTAssertNil(first.consensusErrors) - XCTAssertNil(later.consensusErrors) + XCTAssertNil(sdkError.consensusErrors) + XCTAssertEqual(detailed.consensusErrors, consensusErrors) } } diff --git a/packages/wasm-sdk/src/state_transitions/addresses.rs b/packages/wasm-sdk/src/state_transitions/addresses.rs index fc2816e8f88..0c36a2eb44a 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")?, @@ -755,7 +753,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") } @@ -924,9 +922,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(), @@ -951,12 +947,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(()) } From 1f4c3571731b0ba44fefc4fadd34a4aac7ea3e8d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 10 May 2026 05:04:07 -0500 Subject: [PATCH 23/25] fix(swift-sdk): clarify consensus error details --- CHANGELOG.md | 11 ++++++----- .../src/platform/documents/transitions/create.rs | 5 +++-- packages/swift-sdk/README.md | 3 ++- packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift | 5 +++++ .../SwiftExampleApp/Views/DiagnosticsView.swift | 2 +- .../SwiftExampleApp/Views/QueryDetailView.swift | 2 +- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 485fb3cf8e3..2fd2198568d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,14 @@ * **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 now throw `SDKDetailedError`, which - wraps the mapped `SDKError` plus a `consensusErrors` array; callers - that want the details should catch `SDKDetailedError` in addition to - `SDKError`. Pointer-based FFI helpers + 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`. + before `dash_sdk_error_free`, or explicitly wrap both values in + `SDKDetailedError`. * **sdk:** comprehensive Evo SDK refactoring (#2999) * upgrade bincode to 2.0.1 (#2991) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 7e5c02742f9..5ed36587d8e 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -156,8 +156,9 @@ impl DocumentCreateTransitionBuilder { let mut document = self.document.clone(); // The public create builder always normalizes the document id here before // calling the DPP constructor, so the constructor's create-id pre-sign - // validation acts as defense-in-depth for this SDK path rather than a - // user-reachable builder failure. + // validation is defense-in-depth for this SDK path rather than a + // user-reachable builder failure. Non-create transition-local checks are + // still exercised in the corresponding DPP `from_document` constructors. document.set_id(Document::generate_document_id_v0( self.data_contract.id_ref(), &document.owner_id(), diff --git a/packages/swift-sdk/README.md b/packages/swift-sdk/README.md index 49347480bb1..73b5082b088 100644 --- a/packages/swift-sdk/README.md +++ b/packages/swift-sdk/README.md @@ -52,7 +52,8 @@ 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 wrap them in +that wants to pass both values around together can explicitly wrap them in +`SDKDetailedError`. Public Swift throwing wrappers do not throw `SDKDetailedError`. ## API Reference diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 26e0ffd33dc..65ff0547fd7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -446,6 +446,11 @@ public enum SDKError: Error { /// 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 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift index 0270dfea44c..e57c707a591 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift @@ -596,7 +596,7 @@ struct DiagnosticsView: View { private func formatError(_ error: Error) -> String { if let sdkError = error as? SDKError { - return formatSDKError(sdkError, consensusErrors: sdkError.consensusErrors) + return formatSDKError(sdkError, consensusErrors: nil) } return error.localizedDescription } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift index 622cc688df4..6e456f28b7f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift @@ -181,7 +181,7 @@ struct QueryDetailView: View { await MainActor.run { self.error = QueryDetailView.formatSDKError( sdkError, - consensusErrors: sdkError.consensusErrors + consensusErrors: nil ) isLoading = false print("❌ QueryDetailView: Error set to: \(self.error)") From be00490e40f631bc6ce1c663195d1a4dd3fb80fc Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 17 May 2026 13:46:22 -0500 Subject: [PATCH 24/25] fix(sdk): address batch validation review follow-up --- .../from_document.rs | 16 ++++- .../v0/from_document.rs | 3 +- .../validate_structure/v0/mod.rs | 7 +- .../batch_transition/v0/v0_methods.rs | 13 +--- .../batch_transition/v1/v0_methods.rs | 16 +---- packages/rs-sdk-ffi/src/error.rs | 58 ++++++++++++++-- packages/rs-sdk-ffi/src/identity/transfer.rs | 37 ++++------- packages/rs-sdk-ffi/src/identity/withdraw.rs | 10 +-- .../platform/documents/transitions/create.rs | 52 +++++++++++---- .../platform/documents/transitions/tests.rs | 66 +++++++++++++++++-- packages/wasm-dpp/src/errors/from.rs | 6 +- .../wasm-dpp/src/errors/protocol_error.rs | 33 +++++++++- 12 files changed, 224 insertions(+), 93 deletions(-) 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 349c73c86eb..a10f6d67297 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,6 +1,9 @@ +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}; @@ -15,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, @@ -22,6 +26,15 @@ impl DocumentPurchaseTransition { feature_version: Option, base_feature_version: Option, ) -> Result { + 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 @@ -35,6 +48,7 @@ impl DocumentPurchaseTransition { DocumentPurchaseTransitionV0::from_document( document, document_type, + new_owner_id, price, token_payment_info, identity_contract_nonce, 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..55262afd411 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 @@ -2,7 +2,7 @@ use crate::data_contract::document_type::DocumentTypeRef; use crate::document::errors::DocumentError; use crate::document::{Document, DocumentV0Getters}; use crate::fee::Credits; -use crate::prelude::IdentityNonce; +use crate::prelude::{Identifier, IdentityNonce}; use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::DocumentPurchaseTransitionV0; use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use crate::tokens::token_payment_info::TokenPaymentInfo; @@ -13,6 +13,7 @@ impl DocumentPurchaseTransitionV0 { pub(crate) fn from_document( document: Document, document_type: DocumentTypeRef, + _new_owner_id: Identifier, price: Credits, token_payment_info: Option, identity_contract_nonce: IdentityNonce, 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 index 81505b1f5ae..34810faa5d9 100644 --- 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 @@ -20,10 +20,9 @@ impl DocumentPurchaseTransitionStructureValidationV0 for DocumentPurchaseTransit ) -> Result { // Mirrors the drive-abci action validator trade-mode check; safe // pre-sign because it depends only on the document type definition. - // The drive-abci self-purchase check (original_owner_id == - // new_owner_id) is enforced separately by - // `new_document_purchase_transition_from_document` because that hook - // is where the new owner id (the batch owner / buyer) is known. + // 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!( 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 9523b34f0b2..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 @@ -319,22 +319,11 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { platform_version: &PlatformVersion, options: Option, ) -> Result { - // Mirrors the drive-abci action validator self-purchase check. - if document.owner_id() == new_owner_id { - return Err(ProtocolError::ConsensusError(Box::new( - crate::consensus::basic::document::InvalidDocumentTransitionActionError::new( - format!( - "on document type: {} identity trying to purchase a document that is already owned by the purchaser", - document_type.name() - ), - ) - .into(), - ))); - } let resolved_options = options.unwrap_or_default(); let purchase_transition = DocumentPurchaseTransition::from_document( document, document_type, + new_owner_id, price, token_payment_info, identity_contract_nonce, 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 367740e69e2..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 @@ -328,25 +328,11 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { platform_version: &PlatformVersion, options: Option, ) -> Result { - // Mirrors the drive-abci action validator self-purchase check. - // The document's owner is the seller; `new_owner_id` is the buyer. - // Surfacing this here means the SDK builder fails before signing - // when the same identity tries to buy from itself. - if document.owner_id() == new_owner_id { - return Err(ProtocolError::ConsensusError(Box::new( - crate::consensus::basic::document::InvalidDocumentTransitionActionError::new( - format!( - "on document type: {} identity trying to purchase a document that is already owned by the purchaser", - document_type.name() - ), - ) - .into(), - ))); - } let resolved_options = options.unwrap_or_default(); let purchase_transition = DocumentPurchaseTransition::from_document( document, document_type, + new_owner_id, price, token_payment_info, identity_contract_nonce, diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index 07313090f5c..096cfe28bab 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -41,6 +41,21 @@ //! 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}; @@ -99,6 +114,12 @@ pub enum DashSDKErrorCode { /// 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 @@ -237,14 +258,14 @@ fn with_active_consensus_errors( if error_ptr.is_null() { return None; } - // Snapshot the current message pointer for identity verification *before* - // taking the lock to keep the unsafe deref scope small. This dereference - // is sound under the documented contract (caller passes a live pointer - // returned by the SDK that has not yet been freed); a stale dangling - // pointer is UB at this point and unavoidable. - let current_message = unsafe { (*error_ptr).message } as usize; let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS); let entry = guard.get(&(error_ptr as usize))?; + // Only dereference the FFI pointer after an active sidecar entry exists + // for that exact heap `DashSDKError` pointer. A miss is treated as "no + // details" and must not read from the caller-provided pointer at all. + // Once an entry exists, re-check the current `message` field against the + // value captured at boxing time to reject recycled heap allocations. + 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 @@ -316,6 +337,10 @@ impl DashSDKError { /// 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`; free the outer +/// error through [`dash_sdk_error_free`] instead. impl Drop for DashSDKError { fn drop(&mut self) { if !self.message.is_null() { @@ -397,6 +422,14 @@ fn classify_sdk_error(sdk_err: &dash_sdk::Error) -> (DashSDKErrorCode, String) { 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 @@ -946,6 +979,19 @@ mod tests { 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 = diff --git a/packages/rs-sdk-ffi/src/identity/transfer.rs b/packages/rs-sdk-ffi/src/identity/transfer.rs index 59a1e0d192a..5d491dbb5d3 100644 --- a/packages/rs-sdk-ffi/src/identity/transfer.rs +++ b/packages/rs-sdk-ffi/src/identity/transfer.rs @@ -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..e9117ce26f9 100644 --- a/packages/rs-sdk-ffi/src/identity/withdraw.rs +++ b/packages/rs-sdk-ffi/src/identity/withdraw.rs @@ -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/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 5ed36587d8e..7221f39699f 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, 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; @@ -120,6 +123,31 @@ impl DocumentCreateTransitionBuilder { self } + fn generated_document_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .document_versions + .document_structure_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 @@ -153,18 +181,20 @@ impl DocumentCreateTransitionBuilder { .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(); - // The public create builder always normalizes the document id here before - // calling the DPP constructor, so the constructor's create-id pre-sign - // validation is defense-in-depth for this SDK path rather than a - // user-reachable builder failure. Non-create transition-local checks are - // still exercised in the corresponding DPP `from_document` constructors. - document.set_id(Document::generate_document_id_v0( - self.data_contract.id_ref(), - &document.owner_id(), - &self.document_type_name, - self.document_state_transition_entropy.as_slice(), - )); + 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 state_transition = BatchTransition::new_document_creation_transition_from_document( document, diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index a9fc74b9a6f..a789d63e720 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -51,6 +51,27 @@ fn assert_invalid_document_transition_action( } } +#[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(), @@ -144,7 +165,8 @@ async fn document_delete_builder_sign_succeeds_for_valid_input() { 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 document = test_document(owner_id); + 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( @@ -176,7 +198,7 @@ async fn document_create_builder_sign_succeeds_for_valid_input() { } #[tokio::test] -async fn document_create_builder_sign_replaces_incorrect_document_id() { +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); @@ -189,8 +211,44 @@ async fn document_create_builder_sign_replaces_incorrect_document_id() { ); document.set_id(Identifier::random()); - assert_ne!(document.id(), expected_id); + 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( @@ -211,7 +269,7 @@ async fn document_create_builder_sign_replaces_incorrect_document_id() { assert!( result.is_ok(), - "builder should normalize document id before signing; got error: {:?}", + "builder should normalize default document id before signing; got error: {:?}", result.err() ); diff --git a/packages/wasm-dpp/src/errors/from.rs b/packages/wasm-dpp/src/errors/from.rs index b5d2a22df55..5d7aeb22854 100644 --- a/packages/wasm-dpp/src/errors/from.rs +++ b/packages/wasm-dpp/src/errors/from.rs @@ -1,5 +1,4 @@ use dpp::DashPlatformProtocolInitError; -use js_sys::Array; use wasm_bindgen::JsValue; use dpp::errors::ProtocolError; @@ -11,14 +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) => { - Array::from_iter(consensus_errors.into_iter().map(from_consensus_error)).into() - } + 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 1e2ab1facc9..7d3689ea9d1 100644 --- a/packages/wasm-dpp/src/errors/protocol_error.rs +++ b/packages/wasm-dpp/src/errors/protocol_error.rs @@ -1,15 +1,42 @@ -use js_sys::Array; -use wasm_bindgen::JsValue; +use dpp::consensus::ConsensusError; +use js_sys::{Array, Reflect}; +use wasm_bindgen::{JsError, 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 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"), + }; + + let error = JsError::new(&message); + let error_value = JsValue::from(error); + + let _ = Reflect::set( + &error_value, + &JsValue::from_str("name"), + &JsValue::from_str("ConsensusErrors"), + ); + let _ = Reflect::set( + &error_value, + &JsValue::from_str("consensusErrors"), + &consensus_errors_array.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) => { - Array::from_iter(consensus_errors.into_iter().map(from_consensus_error)).into() + from_consensus_errors(consensus_errors) } dpp::ProtocolError::Error(anyhow_error) => { format!("Non-protocol error: {}", anyhow_error).into() From 11fb8c4e8232fc5264ac848d952a2a245c307490 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 17 May 2026 16:32:55 -0500 Subject: [PATCH 25/25] fix(sdk): address structured consensus review follow-up --- CHANGELOG.md | 13 +++ packages/rs-dpp/src/errors/protocol_error.rs | 7 +- .../from_document.rs | 4 +- .../v0/from_document.rs | 4 +- .../validate_basic_structure/mod.rs | 2 +- .../validate_basic_structure/v0/mod.rs | 34 ++++-- .../dpp_state_transition_versions/mod.rs | 4 + .../dpp_state_transition_versions/v1.rs | 1 + .../dpp_state_transition_versions/v2.rs | 1 + .../dpp_state_transition_versions/v3.rs | 1 + packages/rs-sdk-ffi/README.md | 6 +- packages/rs-sdk-ffi/src/error.rs | 105 +++++++++++++----- packages/rs-sdk-ffi/src/identity/transfer.rs | 2 +- packages/rs-sdk-ffi/src/identity/withdraw.rs | 2 +- .../tests/integration_tests/ffi_utils.rs | 10 +- .../platform/documents/transitions/create.rs | 39 ++++--- .../platform/documents/transitions/tests.rs | 47 ++++++++ .../FFI/StateTransitionExtensions.swift | 53 +++++---- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 26 ++--- .../SwiftDashSDKTests/SDKErrorTests.swift | 24 ++-- .../wasm-dpp/src/errors/protocol_error.rs | 19 +++- packages/wasm-dpp2/src/error.rs | 45 +++++++- packages/wasm-sdk/src/error.rs | 36 +++++- 23 files changed, 366 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd2198568d..fbaf3334329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ * **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 @@ -19,6 +22,16 @@ `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) diff --git a/packages/rs-dpp/src/errors/protocol_error.rs b/packages/rs-dpp/src/errors/protocol_error.rs index 77b717e1762..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}")] @@ -131,9 +132,6 @@ pub enum ProtocolError { #[error(transparent)] ConsensusError(Box), - #[error("Multiple consensus errors: {}", join_consensus_errors(.0))] - ConsensusErrors(Vec), - #[error(transparent)] Document(Box), @@ -309,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 { 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 a10f6d67297..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 @@ -26,6 +26,9 @@ 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!( @@ -48,7 +51,6 @@ impl DocumentPurchaseTransition { DocumentPurchaseTransitionV0::from_document( document, document_type, - new_owner_id, price, token_payment_info, identity_contract_nonce, 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 55262afd411..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 @@ -2,7 +2,7 @@ use crate::data_contract::document_type::DocumentTypeRef; use crate::document::errors::DocumentError; use crate::document::{Document, DocumentV0Getters}; use crate::fee::Credits; -use crate::prelude::{Identifier, IdentityNonce}; +use crate::prelude::IdentityNonce; use crate::state_transition::batch_transition::batched_transition::document_purchase_transition::DocumentPurchaseTransitionV0; use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use crate::tokens::token_payment_info::TokenPaymentInfo; @@ -10,10 +10,10 @@ 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, - _new_owner_id: Identifier, price: Credits, token_payment_info: Option, identity_contract_nonce: IdentityNonce, 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 ae8c400fe04..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 @@ -75,7 +75,7 @@ impl BatchTransition { .documents .documents_batch_transition .validation - .validate_base_structure + .validate_base_structure_pre_sign { 0 => self.validate_base_structure_pre_sign_v0(platform_version)?, version => { 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 d8cfa89266e..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,11 +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)] fn validate_base_structure_v0_internal( &self, - accumulate_token_structure_errors: bool, + token_structure_error_mode: TokenStructureErrorMode, platform_version: &PlatformVersion, ) -> Result { if self.transitions_are_empty() { @@ -188,10 +199,13 @@ impl BatchTransition { }; if !consensus_result.is_valid() { - if accumulate_token_structure_errors { - result.merge(consensus_result); - } else { - return Ok(consensus_result); + match token_structure_error_mode { + TokenStructureErrorMode::AccumulateForPreSign => { + result.merge(consensus_result); + } + TokenStructureErrorMode::PreserveConsensusEarlyReturn => { + return Ok(consensus_result); + } } } @@ -236,7 +250,10 @@ impl BatchTransition { &self, platform_version: &PlatformVersion, ) -> Result { - self.validate_base_structure_v0_internal(false, platform_version) + self.validate_base_structure_v0_internal( + TokenStructureErrorMode::PreserveConsensusEarlyReturn, + platform_version, + ) } #[cfg(any(test, feature = "state-transition-signing"))] @@ -245,7 +262,10 @@ impl BatchTransition { &self, platform_version: &PlatformVersion, ) -> Result { - self.validate_base_structure_v0_internal(true, platform_version) + self.validate_base_structure_v0_internal( + TokenStructureErrorMode::AccumulateForPreSign, + platform_version, + ) } } 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 e3b54bd9f2e..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,6 +71,10 @@ 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 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 f61b526de93..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,7 @@ 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, 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 bfcc40e8e46..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,7 @@ 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, 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 c8e577a4acb..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,7 @@ 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, 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/error.rs b/packages/rs-sdk-ffi/src/error.rs index 096cfe28bab..a8a19d3aefc 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -13,7 +13,12 @@ //! 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. +//! [`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) //! @@ -260,11 +265,11 @@ fn with_active_consensus_errors( } let guard = lock_recover(&ACTIVE_CONSENSUS_ERRORS); let entry = guard.get(&(error_ptr as usize))?; - // Only dereference the FFI pointer after an active sidecar entry exists - // for that exact heap `DashSDKError` pointer. A miss is treated as "no - // details" and must not read from the caller-provided pointer at all. - // Once an entry exists, re-check the current `message` field against the - // value captured at boxing time to reject recycled heap allocations. + // 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 @@ -339,8 +344,8 @@ impl DashSDKError { /// before the Drop runs. /// /// Compatibility note: `Drop` owns `message`. External callers and tests must -/// not reclaim `message` separately with `CString::from_raw`; free the outer -/// error through [`dash_sdk_error_free`] instead. +/// 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() { @@ -410,6 +415,14 @@ fn classify_sdk_error(sdk_err: &dash_sdk::Error) -> (DashSDKErrorCode, String) { // 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) @@ -436,11 +449,19 @@ fn classify_sdk_error(sdk_err: &dash_sdk::Error) -> (DashSDKErrorCode, String) { | dash_sdk::Error::IdentityNonceNotFound(_) => { (DashSDKErrorCode::NotFound, sdk_err.to_string()) } - // Neutral fallback: we could not classify this SDK error as - // network/timeout/protocol/not-found, so report it as an internal - // SDK error rather than misattributing it to a specific operation - // (e.g. "Failed to fetch balances"). - _ => ( + 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), ), @@ -546,12 +567,10 @@ pub unsafe extern "C" fn dash_sdk_error_free(error: *mut DashSDKError) { /// module-level move-only sidecar contract. #[no_mangle] pub unsafe extern "C" fn dash_sdk_error_consensus_error_count(error: *const DashSDKError) -> usize { - if error.is_null() { - return 0; - } - if (*error).code != DashSDKErrorCode::ProtocolError { - return 0; - } + // 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) } @@ -573,13 +592,9 @@ pub unsafe extern "C" fn dash_sdk_error_consensus_error_at( error: *const DashSDKError, index: usize, ) -> *mut DashSDKConsensusError { - if error.is_null() { - return std::ptr::null_mut(); - } - if (*error).code != DashSDKErrorCode::ProtocolError { - return std::ptr::null_mut(); - } - + // 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 { @@ -966,6 +981,44 @@ mod tests { 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); diff --git a/packages/rs-sdk-ffi/src/identity/transfer.rs b/packages/rs-sdk-ffi/src/identity/transfer.rs index 5d491dbb5d3..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!"); diff --git a/packages/rs-sdk-ffi/src/identity/withdraw.rs b/packages/rs-sdk-ffi/src/identity/withdraw.rs index e9117ce26f9..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"); 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/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 7221f39699f..b69ddff8d9f 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -25,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, @@ -127,11 +130,17 @@ impl DocumentCreateTransitionBuilder { &self, platform_version: &PlatformVersion, ) -> Result { - match platform_version + // 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 - .document_versions - .document_structure_version - { + .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(), @@ -160,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, @@ -167,15 +180,6 @@ impl DocumentCreateTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { - let identity_contract_nonce = sdk - .get_identity_contract_nonce( - self.document.owner_id(), - self.data_contract.id(), - true, - self.settings, - ) - .await?; - let document_type = self .data_contract .document_type_for_name(&self.document_type_name) @@ -196,6 +200,15 @@ impl DocumentCreateTransitionBuilder { ))); } + let identity_contract_nonce = sdk + .get_identity_contract_nonce( + self.document.owner_id(), + self.data_contract.id(), + true, + self.settings, + ) + .await?; + let state_transition = BatchTransition::new_document_creation_transition_from_document( document, document_type, diff --git a/packages/rs-sdk/src/platform/documents/transitions/tests.rs b/packages/rs-sdk/src/platform/documents/transitions/tests.rs index a789d63e720..e7a7d81cb93 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/tests.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/tests.rs @@ -281,6 +281,53 @@ async fn document_create_builder_sign_normalizes_default_document_id() { 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); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index fc8565bea51..e2049811c53 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -61,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 @@ -80,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") @@ -584,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 } @@ -767,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 } @@ -857,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) @@ -944,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 } @@ -1179,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 } @@ -1263,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 } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 35a3d58069c..5c630070183 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -285,9 +285,9 @@ public struct SDKConsensusError: Equatable, Sendable { /// private payload markers, so `case .protocolError(let message)` matches /// produce the original human-readable message. /// -/// Public Swift wrappers now keep throwing `SDKError` for source compatibility. -/// This wrapper remains available for callers that explicitly want to bundle an -/// `SDKError` with structured consensus details into a single `Error` value. +/// 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] @@ -449,23 +449,21 @@ public enum SDKError: Error { // Shared finalization path so tests can verify wrapper behavior without // depending on FFI-owned pointers. - static func finalizeConsumedDashSDKError( - _ error: SDKError, - consensusErrors: [SDKConsensusError]? - ) -> SDKError { - _ = consensusErrors + static func finalizeConsumedDashSDKError(_ error: SDKError) -> SDKError { return error } /// Frees the owned FFI error pointer after mapping it to a Swift error. /// - /// Always returns the mapped `SDKError`, preserving the public thrown runtime - /// type for existing `catch let sdkError as SDKError` handlers. Structured - /// consensus details, if needed, must be read from the original pointer with - /// `fromDashSDKErrorWithConsensusErrors(_:)` before it is freed. + /// 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 mapped = fromDashSDKError(UnsafePointer(error)) - let finalized = finalizeConsumedDashSDKError(mapped, consensusErrors: nil) + let pointer = UnsafePointer(error) + let mapped = fromDashSDKError(pointer) + let finalized = finalizeConsumedDashSDKError(mapped) dash_sdk_error_free(error) return finalized } diff --git a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift index b0643b90fca..752e577e572 100644 --- a/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift +++ b/packages/swift-sdk/SwiftTests/Tests/SwiftDashSDKTests/SDKErrorTests.swift @@ -76,13 +76,12 @@ final class SDKErrorTests: XCTestCase { } func testConsumeDashSDKErrorReturnsSDKErrorForExistingCatchLogic() { - let sdkError = SDKError.finalizeConsumedDashSDKError( + let finalized = SDKError.finalizeConsumedDashSDKError( .protocolError("Protocol mismatch"), - consensusErrors: [ - SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") - ] + consensusErrors: nil ) + let sdkError = finalized if case .protocolError(let message) = sdkError { XCTAssertEqual(message, "Protocol mismatch") } else { @@ -97,20 +96,19 @@ final class SDKErrorTests: XCTestCase { XCTAssertNil(sdkError.consensusErrors) } - func testFinalizeConsumedDashSDKErrorIgnoresConsensusDetailsForSDKError() { - let sdkError = SDKError.finalizeConsumedDashSDKError( + func testFinalizeConsumedDashSDKErrorDropsStructuredDetailsForPublicWrappers() { + let finalized = SDKError.finalizeConsumedDashSDKError( .protocolError("Protocol mismatch"), consensusErrors: [ SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") ] ) - let consensusErrors = [ - SDKConsensusError(code: 1, kind: "Consensus", name: "X", message: "y") - ] - - let detailed = SDKDetailedError(sdkError: sdkError, consensusErrors: consensusErrors) - XCTAssertNil(sdkError.consensusErrors) - XCTAssertEqual(detailed.consensusErrors, consensusErrors) + 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/protocol_error.rs b/packages/wasm-dpp/src/errors/protocol_error.rs index 7d3689ea9d1..a750a45c05a 100644 --- a/packages/wasm-dpp/src/errors/protocol_error.rs +++ b/packages/wasm-dpp/src/errors/protocol_error.rs @@ -1,30 +1,39 @@ use dpp::consensus::ConsensusError; use js_sys::{Array, Reflect}; -use wasm_bindgen::{JsError, JsValue}; +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"), }; - let error = JsError::new(&message); - let error_value = JsValue::from(error); - + // 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_array.into(), + &consensus_errors_property.into(), ); error_value 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 bdfc41802ce..8b0c126ce2e 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -268,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, @@ -276,7 +278,22 @@ 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, + } + } } } @@ -499,4 +516,19 @@ mod tests { 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("; ")); + } }