diff --git a/packages/platform-test-suite/test/functional/platform/DataContract.spec.js b/packages/platform-test-suite/test/functional/platform/DataContract.spec.js index 348740129b..18cf12f113 100644 --- a/packages/platform-test-suite/test/functional/platform/DataContract.spec.js +++ b/packages/platform-test-suite/test/functional/platform/DataContract.spec.js @@ -59,6 +59,31 @@ describe('Platform', () => { expect(broadcastError.getCause()).to.be.an.instanceOf(IdentityNotFoundError); }); + it('should expose validation error when document property positions are not contiguous', async () => { + // Additional wait time to mitigate testnet latency + await waitForSTPropagated(); + + const identityNonce = await client.platform.nonceManager + .bumpIdentityNonce(identity.getId()); + const invalidDataContract = await getDataContractFixture(identityNonce, identity.getId()); + + const documentSchema = invalidDataContract.getDocumentSchema('niceDocument'); + documentSchema.properties.name.position = 5; + invalidDataContract.setDocumentSchema('niceDocument', documentSchema, { skipValidation: true }); + + let broadcastError; + + try { + await client.platform.contracts.publish(invalidDataContract, identity); + } catch (e) { + broadcastError = e; + } + + expect(broadcastError).to.be.an.instanceOf(StateTransitionBroadcastError); + expect(broadcastError.getCode()).to.equal(10411); + expect(broadcastError.getMessage()).to.equal('position field is not present for document type "niceDocument"'); + }); + it('should create new data contract with previously created identity as an owner', async () => { // Additional wait time to mitigate testnet latency await waitForSTPropagated(); diff --git a/packages/rs-dapi/src/services/platform_service/broadcast_state_transition.rs b/packages/rs-dapi/src/services/platform_service/broadcast_state_transition.rs index a0bb9317fc..9a8c063cc9 100644 --- a/packages/rs-dapi/src/services/platform_service/broadcast_state_transition.rs +++ b/packages/rs-dapi/src/services/platform_service/broadcast_state_transition.rs @@ -10,6 +10,7 @@ use crate::error::DapiError; use crate::services::PlatformServiceImpl; use crate::services::platform_service::TenderdashStatus; use crate::services::platform_service::error_mapping::decode_consensus_error; +use crate::services::platform_service::error_mapping::map_tenderdash_message; use base64::prelude::*; use dapi_grpc::platform::v0::{BroadcastStateTransitionRequest, BroadcastStateTransitionResponse}; use sha2::{Digest, Sha256}; @@ -217,34 +218,11 @@ fn map_broadcast_error(code: u32, error_message: &str, info: Option<&str>) -> Da code, error_message ); - if error_message == "tx already exists in cache" { - return DapiError::AlreadyExists(error_message.to_string()); - } - - if error_message.starts_with("Tx too large.") { - let message = error_message.replace("Tx too large. ", ""); - return DapiError::InvalidArgument( - "state transition is too large. ".to_string() + &message, - ); - } - - if error_message.starts_with("mempool is full") { - return DapiError::ResourceExhausted(error_message.to_string()); - } - - if error_message.contains("context deadline exceeded") { - return DapiError::Timeout("broadcasting state transition is timed out".to_string()); - } - if error_message.contains("too_many_requests") { - return DapiError::ResourceExhausted( - "tenderdash is not responding: too many requests".to_string(), - ); + if let Some(mapped_error) = map_tenderdash_message(error_message) { + return mapped_error; } - if error_message.starts_with("broadcast confirmation not received:") { - return DapiError::Timeout(error_message.to_string()); - } let consensus_error = info.and_then(|x| decode_consensus_error(x.to_string())); let message = if error_message.is_empty() { None diff --git a/packages/rs-dapi/src/services/platform_service/error_mapping.rs b/packages/rs-dapi/src/services/platform_service/error_mapping.rs index c2cdf252d9..b260be0040 100644 --- a/packages/rs-dapi/src/services/platform_service/error_mapping.rs +++ b/packages/rs-dapi/src/services/platform_service/error_mapping.rs @@ -6,12 +6,15 @@ use dpp::{consensus::ConsensusError, serialization::PlatformDeserializable}; use std::{fmt::Debug, str::FromStr}; use tonic::{Code, metadata::MetadataValue}; +use crate::DapiError; + #[derive(Clone, serde::Serialize)] pub struct TenderdashStatus { pub code: i64, - // human-readable error message; will be put into `data` field + /// human-readable error message; will be put into `data` field + /// Access using [`TenderdashStatus::grpc_message()`]. pub message: Option, - // CBOR-encoded dpp ConsensusError + /// CBOR-encoded dpp ConsensusError pub consensus_error: Option>, } @@ -39,6 +42,14 @@ impl TenderdashStatus { let status_code = self.grpc_code(); let status_message = self.grpc_message(); + // check if we can map to a DapiError first + if let Some(dapi_error) = map_tenderdash_message(&status_message) { + // avoid infinite recursion + if !matches!(dapi_error, DapiError::TenderdashClientError(_)) { + return dapi_error.to_status(); + } + } + let mut status: tonic::Status = tonic::Status::new(status_code, status_message); self.write_grpc_metadata(status.metadata_mut()); @@ -141,7 +152,7 @@ impl From for StateTransitionBroadcastError { fn from(err: TenderdashStatus) -> Self { StateTransitionBroadcastError { code: err.code.clamp(0, u32::MAX as i64) as u32, - message: err.message.unwrap_or_else(|| "Unknown error".to_string()), + message: err.grpc_message(), data: err.consensus_error.clone().unwrap_or_default(), } } @@ -273,10 +284,22 @@ impl From for TenderdashStatus { tracing::debug!("Tenderdash error missing 'code' field, defaulting to 0"); 0 }); - let message = object + let raw_message = object .get("message") .and_then(|m| m.as_str()) - .map(|s| s.to_string()); + .map(|m| m.trim()); + // empty message or "Internal error" is not very informative, so we try to check `data` field + let message = if raw_message + .is_none_or(|m| m.is_empty() || m.eq_ignore_ascii_case("Internal error")) + { + object + .get("data") + .and_then(|d| d.as_str()) + .filter(|s| s.is_ascii()) + } else { + raw_message + } + .map(|s| s.to_string()); // info contains additional error details, possibly including consensus error let consensus_error = object @@ -300,6 +323,41 @@ impl From for TenderdashStatus { } } +// Map some common Tenderdash error messages to DapiError variants +pub(super) fn map_tenderdash_message(message: &str) -> Option { + let msg = message.trim().to_lowercase(); + if msg == "tx already exists in cache" { + return Some(DapiError::AlreadyExists(msg.to_string())); + } + + if msg.starts_with("tx too large.") { + let message = msg.replace("tx too large.", "").trim().to_string(); + return Some(DapiError::InvalidArgument( + "state transition is too large. ".to_string() + &message, + )); + } + + if msg.starts_with("mempool is full") { + return Some(DapiError::ResourceExhausted(msg.to_string())); + } + + if msg.contains("context deadline exceeded") { + return Some(DapiError::Timeout( + "broadcasting state transition is timed out".to_string(), + )); + } + + if msg.contains("too_many_requests") { + return Some(DapiError::ResourceExhausted( + "tenderdash is not responding: too many requests".to_string(), + )); + } + + if msg.starts_with("broadcast confirmation not received:") { + return Some(DapiError::Timeout(msg.to_string())); + } + None +} #[cfg(test)] mod tests { use super::*;