From bfba2efb00b4fa57f958d4de9d97fa025908e557 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:57:10 +0100 Subject: [PATCH 1/6] fix(rs-dapi): decode base64 CBOR message in Tenderdash error data field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Tenderdash returns an error with `message: "Internal error"`, rs-dapi falls through to the `data` field. Previously, the raw base64 string was stored as the message, producing unintelligible gRPC status messages like "oWdtZXNzYWdleMVzdG9yYWdl...". Now `decode_data_message()` tries to decode the `data` field as base64 → CBOR and extract the human-readable `message` text. If decoding fails (plain text data), the raw string is preserved as before. This fixes the 99% case from production DET logs: GroveDB duplicate-key errors (code 13) where the actual message "storage: identity: a unique key with that hash already exists..." was buried in double-encoded CBOR. Co-Authored-By: Claude Opus 4.6 --- .../platform_service/error_mapping.rs | 167 +++++++++++++++++- 1 file changed, 164 insertions(+), 3 deletions(-) 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..f35fb90881 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,21 @@ 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 { + let decoded_bytes = base64_decode(data)?; + 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 { @@ -296,10 +311,10 @@ impl From for TenderdashStatus { .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 +693,150 @@ 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_passthrough() { + // Plain ASCII text that is not base64 CBOR should be returned as-is + 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")); + } } From 25de8d2ed459eae3a9409b242dff3e41fd880c41 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:57:01 +0100 Subject: [PATCH 2/6] fix(rs-dapi): address PR review comments on decode_data_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T4: Use non-logging base64 decode in decode_data_message() to avoid noisy debug logs when data field is plain text (expected failure) - T5: Fix misleading test comment — decode_data_message returns None, caller falls back to raw string - T6: Remove pre-existing .filter(|s| s.is_ascii()) that silently discarded valid non-ASCII UTF-8 data strings - T7: Add test for valid-base64-but-not-CBOR data field fallback Co-Authored-By: Claude Opus 4.6 --- .../platform_service/error_mapping.rs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) 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 f35fb90881..58359187de 100644 --- a/packages/rs-dapi/src/services/platform_service/error_mapping.rs +++ b/packages/rs-dapi/src/services/platform_service/error_mapping.rs @@ -278,7 +278,17 @@ pub(super) fn decode_consensus_error(info_base64: String) -> Option> { /// 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 { - let decoded_bytes = base64_decode(data)?; + // 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); @@ -310,7 +320,6 @@ 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()) @@ -697,8 +706,9 @@ mod tests { // -- decode_data_message tests -- #[test] - fn decode_data_message_plain_text_passthrough() { - // Plain ASCII text that is not base64 CBOR should be returned as-is + 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()); } @@ -839,4 +849,21 @@ mod tests { 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())); + } } From b18c1734e0b4d51337e896d9b93519a13c2ec31c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:26:34 +0100 Subject: [PATCH 3/6] feat(sdk): detect duplicate identity key errors from drive-error-data-bin metadata When Drive returns a raw GroveDB storage error for a duplicate identity key (without a serialized ConsensusError), the SDK now checks the drive-error-data-bin gRPC metadata for CBOR-encoded error details. If the decoded message matches the identity key uniqueness pattern, the error is surfaced as Error::AlreadyExists instead of falling through to the opaque Error::DapiClientError. Also adds test fixtures for both the DAPI-level CBOR data field decoding and the SDK-level drive-error-data-bin handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../platform_service/error_mapping.rs | 39 ++++++++ packages/rs-sdk/src/error.rs | 99 +++++++++++++++++++ 2 files changed, 138 insertions(+) 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 58359187de..79d9168ed9 100644 --- a/packages/rs-dapi/src/services/platform_service/error_mapping.rs +++ b/packages/rs-dapi/src/services/platform_service/error_mapping.rs @@ -850,6 +850,45 @@ mod tests { assert_eq!(status.message.as_deref(), Some("plain text error detail")); } + #[test] + fn from_json_value_decodes_cbor_data_field_identity_unique_key() { + setup_tracing(); + // Real fixture from gRPC status: duplicate identity key registration attempt. + // The base64 decodes to CBOR with message: + // "storage: identity: a unique key with that hash already exists: + // the key already exists in the non unique set [70, 101, 149, ...]" + let data_b64 = concat!( + "oWdtZXNzYWdleMtzdG9yYWdlOiBpZGVudGl0eTogYSB1bmlxdWUga2V5IH", + "dpdGggdGhhdCBoYXNoIGFscmVhZHkgZXhpc3RzOiB0aGUga2V5IGFscmVh", + "ZHkgZXhpc3RzIGluIHRoZSBub24gdW5pcXVlIHNldCBbNzAsIDEwMSwgMT", + "Q5LCAxNTcsIDcyLCAxMjksIDE1NSwgMjQyLCAxNjgsIDQ4LCAxMSwgMTQ1", + "LCAxODAsIDI1MiwgMTIyLCAxMzQsIDE1MiwgNTUsIDEzNSwgMjQyXQ==", + ); + + let value = serde_json::json!({ + "code": 13, + "message": "Internal error", + "data": data_b64, + "info": "" + }); + + let status = TenderdashStatus::from(value); + assert_eq!(status.code, 13); + let msg = status + .message + .as_deref() + .expect("message should be decoded from CBOR data field"); + assert!( + msg.contains("unique key"), + "expected 'unique key' in message, got: {msg}" + ); + assert!( + msg.contains("already exists"), + "expected 'already exists' in message, got: {msg}" + ); + assert!(status.consensus_error.is_none()); + } + #[test] fn from_json_value_preserves_base64_non_cbor_data() { // data field that is valid base64 but decodes to non-CBOR bytes. diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 714538dbc6..5354349222 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -184,6 +184,19 @@ impl From for Error { Self::Generic(format!("Invalid consensus error encoding: {e}")) }); } + // Check drive-error-data-bin for CBOR-encoded error details. + // This covers cases where Drive returns a raw storage error (e.g. + // duplicate identity key) without a serialized ConsensusError. + if let Some(drive_error_value) = status.metadata().get_bin("drive-error-data-bin") { + if let Ok(bytes) = drive_error_value.to_bytes() { + if let Some(message) = extract_drive_error_message(&bytes) { + if message.contains("unique key") && message.contains("already exists") { + return Self::AlreadyExists(message); + } + } + } + } + // Otherwise we parse the error code and act accordingly if status.code() == Code::AlreadyExists { return Self::AlreadyExists(status.message().to_string()); @@ -263,6 +276,26 @@ where } } +/// Extract the human-readable `message` field from a CBOR-encoded +/// `drive-error-data-bin` metadata value (a serialized `TenderdashStatus`). +fn extract_drive_error_message(bytes: &[u8]) -> Option { + let value: ciborium::Value = ciborium::de::from_reader(bytes) + .inspect_err(|e| { + tracing::trace!("drive-error-data-bin is not valid CBOR: {}", e); + }) + .ok()?; + + let map = value.as_map()?; + for (k, v) in map { + if let ciborium::Value::Text(key) = k { + if key == "message" { + return v.as_text().map(|s| s.to_string()); + } + } + } + None +} + impl CanRetry for Error { fn can_retry(&self) -> bool { matches!( @@ -393,5 +426,71 @@ mod tests { )) ); } + + #[test] + fn test_drive_error_data_bin_duplicate_identity_key() { + // Simulate a gRPC error where Drive returns a raw GroveDB storage error + // for a duplicate identity key, without a serialized ConsensusError. + // The drive-error-data-bin metadata contains CBOR with a human-readable message. + let message = "storage: identity: a unique key with that hash already exists: \ + the key already exists in the non unique set \ + [70, 101, 149, 157, 72, 129, 155, 242, 168, 48, 11, 145, 180, 252, 122, 134, 152, 55, 135, 242]"; + + let mut cbor_buf = Vec::new(); + ciborium::ser::into_writer( + &ciborium::Value::Map(vec![ + ( + ciborium::Value::Text("code".to_string()), + ciborium::Value::Integer(13.into()), + ), + ( + ciborium::Value::Text("message".to_string()), + ciborium::Value::Text(message.to_string()), + ), + ]), + &mut cbor_buf, + ) + .expect("CBOR serialize"); + + let mut metadata = MetadataMap::new(); + metadata.insert_bin("drive-error-data-bin", MetadataValue::from_bytes(&cbor_buf)); + + let status = + dapi_grpc::tonic::Status::with_metadata(Code::Internal, "Internal error", metadata); + + let error = DapiClientError::Transport(TransportError::Grpc(status)); + let sdk_error = Error::from(error); + + assert_matches!(sdk_error, Error::AlreadyExists(msg) => { + assert!(msg.contains("unique key"), "expected 'unique key' in: {msg}"); + assert!(msg.contains("already exists"), "expected 'already exists' in: {msg}"); + }); + } + + #[test] + fn test_drive_error_data_bin_unrelated_message_falls_through() { + // When drive-error-data-bin contains a message that does NOT match + // the identity key pattern, the error should fall through to DapiClientError. + let mut cbor_buf = Vec::new(); + ciborium::ser::into_writer( + &ciborium::Value::Map(vec![( + ciborium::Value::Text("message".to_string()), + ciborium::Value::Text("some other storage error".to_string()), + )]), + &mut cbor_buf, + ) + .expect("CBOR serialize"); + + let mut metadata = MetadataMap::new(); + metadata.insert_bin("drive-error-data-bin", MetadataValue::from_bytes(&cbor_buf)); + + let status = + dapi_grpc::tonic::Status::with_metadata(Code::Internal, "Internal error", metadata); + + let error = DapiClientError::Transport(TransportError::Grpc(status)); + let sdk_error = Error::from(error); + + assert_matches!(sdk_error, Error::DapiClientError(_)); + } } } From 5e99219c6b222afb820926345465c51103a3dea2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:42:31 +0100 Subject: [PATCH 4/6] Revert "feat(sdk): detect duplicate identity key errors from drive-error-data-bin metadata" This reverts commit b18c1734e0b4d51337e896d9b93519a13c2ec31c. --- .../platform_service/error_mapping.rs | 39 -------- packages/rs-sdk/src/error.rs | 99 ------------------- 2 files changed, 138 deletions(-) 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 79d9168ed9..58359187de 100644 --- a/packages/rs-dapi/src/services/platform_service/error_mapping.rs +++ b/packages/rs-dapi/src/services/platform_service/error_mapping.rs @@ -850,45 +850,6 @@ mod tests { assert_eq!(status.message.as_deref(), Some("plain text error detail")); } - #[test] - fn from_json_value_decodes_cbor_data_field_identity_unique_key() { - setup_tracing(); - // Real fixture from gRPC status: duplicate identity key registration attempt. - // The base64 decodes to CBOR with message: - // "storage: identity: a unique key with that hash already exists: - // the key already exists in the non unique set [70, 101, 149, ...]" - let data_b64 = concat!( - "oWdtZXNzYWdleMtzdG9yYWdlOiBpZGVudGl0eTogYSB1bmlxdWUga2V5IH", - "dpdGggdGhhdCBoYXNoIGFscmVhZHkgZXhpc3RzOiB0aGUga2V5IGFscmVh", - "ZHkgZXhpc3RzIGluIHRoZSBub24gdW5pcXVlIHNldCBbNzAsIDEwMSwgMT", - "Q5LCAxNTcsIDcyLCAxMjksIDE1NSwgMjQyLCAxNjgsIDQ4LCAxMSwgMTQ1", - "LCAxODAsIDI1MiwgMTIyLCAxMzQsIDE1MiwgNTUsIDEzNSwgMjQyXQ==", - ); - - let value = serde_json::json!({ - "code": 13, - "message": "Internal error", - "data": data_b64, - "info": "" - }); - - let status = TenderdashStatus::from(value); - assert_eq!(status.code, 13); - let msg = status - .message - .as_deref() - .expect("message should be decoded from CBOR data field"); - assert!( - msg.contains("unique key"), - "expected 'unique key' in message, got: {msg}" - ); - assert!( - msg.contains("already exists"), - "expected 'already exists' in message, got: {msg}" - ); - assert!(status.consensus_error.is_none()); - } - #[test] fn from_json_value_preserves_base64_non_cbor_data() { // data field that is valid base64 but decodes to non-CBOR bytes. diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 5354349222..714538dbc6 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -184,19 +184,6 @@ impl From for Error { Self::Generic(format!("Invalid consensus error encoding: {e}")) }); } - // Check drive-error-data-bin for CBOR-encoded error details. - // This covers cases where Drive returns a raw storage error (e.g. - // duplicate identity key) without a serialized ConsensusError. - if let Some(drive_error_value) = status.metadata().get_bin("drive-error-data-bin") { - if let Ok(bytes) = drive_error_value.to_bytes() { - if let Some(message) = extract_drive_error_message(&bytes) { - if message.contains("unique key") && message.contains("already exists") { - return Self::AlreadyExists(message); - } - } - } - } - // Otherwise we parse the error code and act accordingly if status.code() == Code::AlreadyExists { return Self::AlreadyExists(status.message().to_string()); @@ -276,26 +263,6 @@ where } } -/// Extract the human-readable `message` field from a CBOR-encoded -/// `drive-error-data-bin` metadata value (a serialized `TenderdashStatus`). -fn extract_drive_error_message(bytes: &[u8]) -> Option { - let value: ciborium::Value = ciborium::de::from_reader(bytes) - .inspect_err(|e| { - tracing::trace!("drive-error-data-bin is not valid CBOR: {}", e); - }) - .ok()?; - - let map = value.as_map()?; - for (k, v) in map { - if let ciborium::Value::Text(key) = k { - if key == "message" { - return v.as_text().map(|s| s.to_string()); - } - } - } - None -} - impl CanRetry for Error { fn can_retry(&self) -> bool { matches!( @@ -426,71 +393,5 @@ mod tests { )) ); } - - #[test] - fn test_drive_error_data_bin_duplicate_identity_key() { - // Simulate a gRPC error where Drive returns a raw GroveDB storage error - // for a duplicate identity key, without a serialized ConsensusError. - // The drive-error-data-bin metadata contains CBOR with a human-readable message. - let message = "storage: identity: a unique key with that hash already exists: \ - the key already exists in the non unique set \ - [70, 101, 149, 157, 72, 129, 155, 242, 168, 48, 11, 145, 180, 252, 122, 134, 152, 55, 135, 242]"; - - let mut cbor_buf = Vec::new(); - ciborium::ser::into_writer( - &ciborium::Value::Map(vec![ - ( - ciborium::Value::Text("code".to_string()), - ciborium::Value::Integer(13.into()), - ), - ( - ciborium::Value::Text("message".to_string()), - ciborium::Value::Text(message.to_string()), - ), - ]), - &mut cbor_buf, - ) - .expect("CBOR serialize"); - - let mut metadata = MetadataMap::new(); - metadata.insert_bin("drive-error-data-bin", MetadataValue::from_bytes(&cbor_buf)); - - let status = - dapi_grpc::tonic::Status::with_metadata(Code::Internal, "Internal error", metadata); - - let error = DapiClientError::Transport(TransportError::Grpc(status)); - let sdk_error = Error::from(error); - - assert_matches!(sdk_error, Error::AlreadyExists(msg) => { - assert!(msg.contains("unique key"), "expected 'unique key' in: {msg}"); - assert!(msg.contains("already exists"), "expected 'already exists' in: {msg}"); - }); - } - - #[test] - fn test_drive_error_data_bin_unrelated_message_falls_through() { - // When drive-error-data-bin contains a message that does NOT match - // the identity key pattern, the error should fall through to DapiClientError. - let mut cbor_buf = Vec::new(); - ciborium::ser::into_writer( - &ciborium::Value::Map(vec![( - ciborium::Value::Text("message".to_string()), - ciborium::Value::Text("some other storage error".to_string()), - )]), - &mut cbor_buf, - ) - .expect("CBOR serialize"); - - let mut metadata = MetadataMap::new(); - metadata.insert_bin("drive-error-data-bin", MetadataValue::from_bytes(&cbor_buf)); - - let status = - dapi_grpc::tonic::Status::with_metadata(Code::Internal, "Internal error", metadata); - - let error = DapiClientError::Transport(TransportError::Grpc(status)); - let sdk_error = Error::from(error); - - assert_matches!(sdk_error, Error::DapiClientError(_)); - } } } From c27a959b56cc49d3c42491cc166cdad047cef20d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:49:13 +0100 Subject: [PATCH 5/6] feat(sdk): add DriveInternalError variant and decode drive-error-data-bin metadata When a gRPC Internal status contains drive-error-data-bin metadata, extract the human-readable message from the CBOR payload and return Error::DriveInternalError instead of opaque Error::DapiClientError. This gives downstream consumers (like DET) a structured error variant for Drive-level failures that bypassed consensus validation. Related: #3396 (pre-validation gap for non-unique key set) Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/error.rs | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 714538dbc6..00050c3873 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,20 @@ 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") + { + if let Ok(bytes) = drive_error_value.to_bytes() { + if let Some(message) = extract_drive_error_message(&bytes) { + return Self::DriveInternalError(message); + } + } + } + } + // Otherwise we parse the error code and act accordingly if status.code() == Code::AlreadyExists { return Self::AlreadyExists(status.message().to_string()); @@ -195,6 +219,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()) From e32635ff911c3b3ea3b6b944d351b2c9918d6998 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:52:48 +0100 Subject: [PATCH 6/6] fix(sdk): log drive-error-data-bin to_bytes() failures instead of silently discarding Addresses PR review feedback: the adjacent consensus error path logs at debug level on to_bytes() failure, but the drive-error-data-bin path silently swallowed the error. Now consistent. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/error.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 00050c3873..5897087f6e 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -196,13 +196,18 @@ impl From for Error { } // 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") - { - if let Ok(bytes) = drive_error_value.to_bytes() { - if let Some(message) = extract_drive_error_message(&bytes) { - return Self::DriveInternalError(message); + 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 + ); } } }