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 b5d3385e64..58359187de 100644 --- a/packages/rs-dapi/src/services/platform_service/error_mapping.rs +++ b/packages/rs-dapi/src/services/platform_service/error_mapping.rs @@ -273,6 +273,31 @@ pub(super) fn decode_consensus_error(info_base64: String) -> Option> { Some(serialized_error) } +/// Try to decode a Tenderdash `data` field as base64 → CBOR and extract the +/// human-readable `message` text. Returns `None` if the string is not +/// base64-encoded CBOR or does not contain a `message` key, allowing the +/// caller to fall back to the raw string. +fn decode_data_message(data: &str) -> Option { + // Silently try base64 — failure is expected for plain-text data fields, + // so we intentionally avoid `base64_decode()` which logs at debug level. + let decoded_bytes = engine::GeneralPurpose::new( + &base64::alphabet::STANDARD, + engine::GeneralPurposeConfig::new() + .with_decode_allow_trailing_bits(true) + .with_decode_padding_mode(engine::DecodePaddingMode::Indifferent), + ) + .decode(data) + .ok()?; + + let raw_value: ciborium::Value = ciborium::de::from_reader(decoded_bytes.as_slice()) + .inspect_err(|e| { + tracing::trace!("data field is not CBOR: {}", e); + }) + .ok()?; + + walk_cbor_for_key(&raw_value, &["message"]).and_then(|v| v.as_text().map(|s| s.to_string())) +} + impl From for TenderdashStatus { // Convert from a JSON error object returned by Tenderdash RPC, typically in the `error` field of a JSON-RPC response. fn from(value: serde_json::Value) -> Self { @@ -295,11 +320,10 @@ impl From for TenderdashStatus { object .get("data") .and_then(|d| d.as_str()) - .filter(|s| s.is_ascii()) + .map(|s| decode_data_message(s).unwrap_or_else(|| s.to_string())) } else { - raw_message - } - .map(|s| s.to_string()); + raw_message.map(|s| s.to_string()) + }; // info contains additional error details, possibly including consensus error let consensus_error = object @@ -678,4 +702,168 @@ mod tests { // "tx already exists in cache" maps to AlreadyExists, which maps to already_exists assert_eq!(tonic_status.code(), tonic::Code::AlreadyExists); } + + // -- decode_data_message tests -- + + #[test] + fn decode_data_message_plain_text_returns_none() { + // Plain text that is not base64 CBOR → returns None so the caller + // can fall back to using the raw string. + assert!(super::decode_data_message("just plain text").is_none()); + } + + #[test] + fn decode_data_message_base64_cbor_with_message() { + // CBOR: {"message": "hello world"} + let cbor_bytes = { + let mut buf = Vec::new(); + ciborium::ser::into_writer( + &ciborium::Value::Map(vec![( + ciborium::Value::Text("message".to_string()), + ciborium::Value::Text("hello world".to_string()), + )]), + &mut buf, + ) + .unwrap(); + buf + }; + let b64 = base64::prelude::BASE64_STANDARD.encode(&cbor_bytes); + assert_eq!( + super::decode_data_message(&b64), + Some("hello world".to_string()) + ); + } + + #[test] + fn decode_data_message_base64_cbor_without_message_key() { + // CBOR: {"data": {"serializedError": [1, 2, 3]}} — no "message" key + let cbor_bytes = { + let mut buf = Vec::new(); + ciborium::ser::into_writer( + &ciborium::Value::Map(vec![( + ciborium::Value::Text("data".to_string()), + ciborium::Value::Map(vec![( + ciborium::Value::Text("serializedError".to_string()), + ciborium::Value::Array(vec![ + ciborium::Value::Integer(1.into()), + ciborium::Value::Integer(2.into()), + ciborium::Value::Integer(3.into()), + ]), + )]), + )]), + &mut buf, + ) + .unwrap(); + buf + }; + let b64 = base64::prelude::BASE64_STANDARD.encode(&cbor_bytes); + assert!(super::decode_data_message(&b64).is_none()); + } + + // -- Real-world DET log fixture tests -- + + #[test] + fn from_json_value_decodes_cbor_data_field_non_unique_key() { + setup_tracing(); + // Real fixture from DET logs: code 13 Internal, data is base64 CBOR + // containing {"message": "storage: identity: a unique key ... non unique set [...]"} + let data_b64 = concat!( + "oWdtZXNzYWdleMVzdG9yYWdlOiBpZGVudGl0eTogYSB1bmlxdWUga2V5IHdpdGggdGhh", + "dCBoYXNoIGFscmVhZHkgZXhpc3RzOiB0aGUga2V5IGFscmVhZHkgZXhpc3RzIGluIHRo", + "ZSBub24gdW5pcXVlIHNldCBbMTM1LCAyMDIsIDE3MiwgNTMsIDE3NiwgNDUsIDE5MSwg", + "MjcsIDUwLCAxMiwgNTAsIDIxNSwgNjUsIDEyNCwgMTQ3LCAzLCAyMDgsIDYsIDIyNiwg", + "MTUxXQ==", + ); + + let value = serde_json::json!({ + "code": 13, + "message": "Internal error", + "data": data_b64, + "info": "" + }); + + let status = TenderdashStatus::from(value); + assert_eq!(status.code, 13); + assert!( + status + .message + .as_deref() + .expect("message should be decoded") + .starts_with("storage: identity: a unique key with that hash already exists"), + "expected decoded message, got: {:?}", + status.message + ); + assert!( + status + .message + .as_deref() + .unwrap() + .contains("non unique set"), + ); + assert!(status.consensus_error.is_none()); + } + + #[test] + fn from_json_value_decodes_cbor_data_field_unique_key() { + setup_tracing(); + // Real fixture from DET logs: code 13 Internal, "unique set" variant + let data_b64 = concat!( + "oWdtZXNzYWdleMdzdG9yYWdlOiBpZGVudGl0eTogYSB1bmlxdWUga2V5IHdpdGggdGhh", + "dCBoYXNoIGFscmVhZHkgZXhpc3RzOiB0aGUga2V5IGFscmVhZHkgZXhpc3RzIGluIHRo", + "ZSB1bmlxdWUgc2V0IFsyMzIsIDQ4LCAxMTksIDEzNywgMTYxLCAxNDMsIDE1LCAxNzks", + "IDIzNSwgOTgsIDEwMSwgMjUxLCAyNTEsIDExMCwgMTMyLCAzNSwgMTE5LCA4NCwgMTQ3", + "LCAxMjRd", + ); + + let value = serde_json::json!({ + "code": 13, + "message": "Internal error", + "data": data_b64, + "info": "" + }); + + let status = TenderdashStatus::from(value); + assert_eq!(status.code, 13); + assert!( + status + .message + .as_deref() + .expect("message should be decoded") + .starts_with("storage: identity: a unique key with that hash already exists"), + "expected decoded message, got: {:?}", + status.message + ); + assert!(status.message.as_deref().unwrap().contains("unique set"),); + assert!(status.consensus_error.is_none()); + } + + #[test] + fn from_json_value_preserves_plain_text_data() { + // When data is plain text (not base64 CBOR), preserve it as-is + let value = serde_json::json!({ + "code": 13, + "message": "Internal error", + "data": "plain text error detail" + }); + + let status = TenderdashStatus::from(value); + assert_eq!(status.message.as_deref(), Some("plain text error detail")); + } + + #[test] + fn from_json_value_preserves_base64_non_cbor_data() { + // data field that is valid base64 but decodes to non-CBOR bytes. + // decode_data_message should return None → fall back to raw string. + let raw_bytes = b"this is not CBOR at all"; + let b64 = base64::prelude::BASE64_STANDARD.encode(raw_bytes); + + let value = serde_json::json!({ + "code": 13, + "message": "Internal error", + "data": b64 + }); + + let status = TenderdashStatus::from(value); + assert_eq!(status.message.as_deref(), Some(b64.as_str())); + } } diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 714538dbc6..5897087f6e 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -91,6 +91,16 @@ pub enum Error { #[error("Identity nonce not found on platform: {0}")] IdentityNonceNotFound(String), + /// Drive returned an internal error that was not classified as a consensus + /// error. Contains the decoded human-readable message from the + /// `drive-error-data-bin` gRPC metadata (CBOR → message extraction). + /// + /// This typically indicates a storage-level error (e.g., GroveDB constraint + /// violation) that bypassed the consensus validation layer. If pre-validation + /// is working correctly, these should be rare. + #[error("Drive internal error: {0}")] + DriveInternalError(String), + /// Generic error // TODO: Use domain specific errors instead of generic ones #[error("SDK error: {0}")] @@ -184,6 +194,25 @@ impl From for Error { Self::Generic(format!("Invalid consensus error encoding: {e}")) }); } + // Check drive-error-data-bin for decoded Drive error messages + if status.code() == Code::Internal { + if let Some(drive_error_value) = status.metadata().get_bin("drive-error-data-bin") { + match drive_error_value.to_bytes() { + Ok(bytes) => { + if let Some(message) = extract_drive_error_message(&bytes) { + return Self::DriveInternalError(message); + } + } + Err(e) => { + tracing::debug!( + "Failed to decode drive-error-data-bin metadata: {}", + e + ); + } + } + } + } + // Otherwise we parse the error code and act accordingly if status.code() == Code::AlreadyExists { return Self::AlreadyExists(status.message().to_string()); @@ -195,6 +224,25 @@ impl From for Error { } } +/// Extract the human-readable `message` field from CBOR-encoded `drive-error-data-bin` metadata. +/// +/// The metadata contains a CBOR map with optional fields: `code`, `message`, `consensus_error`. +/// Returns `Some(message)` if the CBOR decodes and contains a non-empty `message` string. +fn extract_drive_error_message(bytes: &[u8]) -> Option { + let value: ciborium::Value = ciborium::from_reader(bytes).ok()?; + let map = value.as_map()?; + for (key, val) in map { + if key.as_text() == Some("message") { + if let Some(msg) = val.as_text() { + if !msg.is_empty() { + return Some(msg.to_string()); + } + } + } + } + None +} + impl From for Error { fn from(value: PlatformVersionError) -> Self { Self::Protocol(value.into())