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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 + ); } } } From 5581447d174b9882057b2b8d013283d7b1501e1a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:32:23 +0200 Subject: [PATCH 07/15] test(sdk): add unit tests for drive-error-data-bin DriveInternalError path Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/error.rs | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 5897087f6e..486805b12d 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -441,5 +441,92 @@ mod tests { )) ); } + + #[test] + fn test_drive_error_data_bin_maps_to_drive_internal_error() { + let cbor_map = ciborium::Value::Map(vec![ + ( + ciborium::Value::Text("code".to_string()), + ciborium::Value::Integer(13.into()), + ), + ( + ciborium::Value::Text("message".to_string()), + ciborium::Value::Text( + "storage: identity: a unique key with that hash already exists: \ + the key already exists in the non unique set [1, 2, 3]" + .to_string(), + ), + ), + ]); + let mut cbor_bytes = Vec::new(); + ciborium::into_writer(&cbor_map, &mut cbor_bytes).expect("CBOR serialization"); + + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "drive-error-data-bin", + MetadataValue::from_bytes(&cbor_bytes), + ); + + let status = + dapi_grpc::tonic::Status::with_metadata(Code::Internal, "internal", metadata); + let error = DapiClientError::Transport(TransportError::Grpc(status)); + + let sdk_error = Error::from(error); + + assert_matches!(sdk_error, Error::DriveInternalError(msg) if msg.contains("unique key")); + } + + #[test] + fn test_internal_error_without_drive_metadata_falls_through() { + let status = dapi_grpc::tonic::Status::new(Code::Internal, "Internal error"); + let error = DapiClientError::Transport(TransportError::Grpc(status)); + + let sdk_error = Error::from(error); + + assert_matches!(sdk_error, Error::DapiClientError(_)); + } + + #[test] + fn test_non_internal_code_with_drive_metadata_not_intercepted() { + let cbor_map = ciborium::Value::Map(vec![( + ciborium::Value::Text("message".to_string()), + ciborium::Value::Text("some drive error".to_string()), + )]); + let mut cbor_bytes = Vec::new(); + ciborium::into_writer(&cbor_map, &mut cbor_bytes).expect("CBOR serialization"); + + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "drive-error-data-bin", + MetadataValue::from_bytes(&cbor_bytes), + ); + + let status = + dapi_grpc::tonic::Status::with_metadata(Code::Unavailable, "unavailable", metadata); + let error = DapiClientError::Transport(TransportError::Grpc(status)); + + let sdk_error = Error::from(error); + + assert_matches!(sdk_error, Error::DapiClientError(_)); + } + + #[test] + fn test_malformed_cbor_in_drive_error_data_bin_falls_through() { + let garbage_bytes = vec![0xFF, 0xFE, 0x00, 0x01, 0x02]; + + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "drive-error-data-bin", + MetadataValue::from_bytes(&garbage_bytes), + ); + + let status = + dapi_grpc::tonic::Status::with_metadata(Code::Internal, "internal", metadata); + let error = DapiClientError::Transport(TransportError::Grpc(status)); + + let sdk_error = Error::from(error); + + assert_matches!(sdk_error, Error::DapiClientError(_)); + } } } From f7833a2042fc08fd6428c3f7e53173c725577379 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:42:12 +0200 Subject: [PATCH 08/15] fix(wasm-sdk): handle DriveInternalError variant in error mapping Add DriveInternalError to WasmSdkErrorKind enum and match arms to fix compilation after the new variant was added to dash_sdk::Error. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wasm-sdk/src/error.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index 6f41a1d76e..69fa4db290 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -33,6 +33,7 @@ pub enum WasmSdkErrorKind { StateTransitionBroadcastError, NonceOverflow, IdentityNonceNotFound, + DriveInternalError, // Local helper kinds InvalidArgument, @@ -191,6 +192,9 @@ impl From for WasmSdkError { IdentityNonceNotFound(msg) => { Self::new(WasmSdkErrorKind::IdentityNonceNotFound, msg, None, true) } + DriveInternalError(msg) => { + Self::new(WasmSdkErrorKind::DriveInternalError, msg, None, retriable) + } NoAvailableAddressesToRetry(inner) => Self::new( WasmSdkErrorKind::DapiClientError, format!("no available addresses to retry, last error: {}", inner), @@ -269,6 +273,7 @@ impl WasmSdkError { K::StateTransitionBroadcastError => "StateTransitionBroadcastError", K::NonceOverflow => "NonceOverflow", K::IdentityNonceNotFound => "IdentityNonceNotFound", + K::DriveInternalError => "DriveInternalError", K::InvalidArgument => "InvalidArgument", K::SerializationError => "SerializationError", K::NotFound => "NotFound", From d855555ebe655c9010b00862e0d0e34fa4245531 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:42:26 +0200 Subject: [PATCH 09/15] feat(sdk): auto-detect protocol version from network response metadata Store protocol version as Arc on Sdk (shared between clones). On each gRPC response, compare metadata.protocol_version against stored value and atomically update if newer and known to this binary. Unknown versions are logged at WARN and ignored. Downgrades and zero values are silently skipped. Closes #3410 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/sdk.rs | 212 ++++++++++++++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 16 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 995126cd9c..a11c33a299 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -107,6 +107,9 @@ pub struct Sdk { /// Note that setting this to None can panic. context_provider: ArcSwapOption>, + /// Protocol version number detected from the network. Shared between clones. + protocol_version: Arc, + /// Last seen height; used to determine if the remote node is stale. /// /// This is clone-able and can be shared between threads. @@ -140,6 +143,7 @@ impl Clone for Sdk { nonce_cache: Arc::clone(&self.nonce_cache), context_provider: ArcSwapOption::new(self.context_provider.load_full()), cancel_token: self.cancel_token.clone(), + protocol_version: Arc::clone(&self.protocol_version), metadata_last_seen_height: Arc::clone(&self.metadata_last_seen_height), metadata_height_tolerance: self.metadata_height_tolerance, metadata_time_tolerance_ms: self.metadata_time_tolerance_ms, @@ -178,9 +182,6 @@ enum SdkInstance { Dapi { /// DAPI client used to communicate with Dash Platform. dapi: DapiClient, - - /// Platform version configured for this Sdk - version: &'static PlatformVersion, }, /// Mock SDK #[cfg(feature = "mocks")] @@ -192,8 +193,6 @@ enum SdkInstance { /// Mock SDK implementation processing mock expectations and responses. mock: Arc>, address_list: AddressList, - /// Platform version configured for this Sdk - version: &'static PlatformVersion, }, } @@ -250,9 +249,65 @@ impl Sdk { verify_metadata_time(metadata, now, time_tolerance)?; }; + self.maybe_update_protocol_version(metadata.protocol_version); + Ok(()) } + /// Update the stored protocol version if `received_version` is newer and known. + /// + /// Uses a CAS loop so the highest version always wins under concurrent updates. + /// In multi-SDK scenarios the `PlatformVersion::set_current` global is process-wide + /// (last writer wins). + fn maybe_update_protocol_version(&self, received_version: u32) { + if received_version == 0 { + return; + } + + let mut current = self.protocol_version.load(Ordering::Relaxed); + + if received_version <= current { + return; + } + + let new_version = match PlatformVersion::get(received_version) { + Ok(v) => v, + Err(_) => { + tracing::warn!( + received_version, + current_version = current, + "received unknown protocol version from network; keeping current" + ); + return; + } + }; + + loop { + match self.protocol_version.compare_exchange_weak( + current, + received_version, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + tracing::info!( + old_version = current, + new_version = received_version, + "protocol version updated from network metadata" + ); + PlatformVersion::set_current(new_version); + return; + } + Err(actual) => { + if actual >= received_version { + return; + } + current = actual; + } + } + } + } + // TODO: Changed to public for tests /// Retrieve object `O` from proof contained in `request` (of type `R`) and `response`. /// @@ -382,16 +437,16 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// - /// - /// This is the version configured in [`SdkBuilder`]. - /// Useful whenever you need to provide [PlatformVersion] to other SDK and DPP methods. + /// The version is auto-detected from network responses and may change at runtime. + /// Falls back to [`PlatformVersion::latest()`] if the stored version number is unknown. pub fn version<'v>(&self) -> &'v PlatformVersion { - match &self.inner { - SdkInstance::Dapi { version, .. } => version, - #[cfg(feature = "mocks")] - SdkInstance::Mock { version, .. } => version, - } + let v = self.protocol_version.load(Ordering::Relaxed); + PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) + } + + /// Return the raw protocol version number currently used by this SDK. + pub fn protocol_version_number(&self) -> u32 { + self.protocol_version.load(Ordering::Relaxed) } // TODO: Move to settings @@ -890,11 +945,12 @@ impl SdkBuilder { let mut sdk= Sdk{ network: self.network, dapi_client_settings, - inner:SdkInstance::Dapi { dapi, version:self.version }, + inner:SdkInstance::Dapi { dapi }, proofs:self.proofs, context_provider: ArcSwapOption::new( self.context_provider.map(Arc::new)), cancel_token: self.cancel_token, nonce_cache: Default::default(), + protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), // Note: in the future, we need to securely initialize initial height during Sdk bootstrap or first request. metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), metadata_height_tolerance: self.metadata_height_tolerance, @@ -957,11 +1013,11 @@ impl SdkBuilder { mock:mock_sdk.clone(), dapi, address_list: AddressList::new(), - version: self.version, }, dump_dir: self.dump_dir.clone(), proofs:self.proofs, nonce_cache: Default::default(), + protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), @@ -1137,6 +1193,130 @@ mod test { .expect_err("metadata should be invalid"); } + #[test] + fn test_version_update_from_metadata() { + use dpp::version::PlatformVersion; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 1); + + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), 2); + assert_eq!(sdk.version().protocol_version, 2); + } + + #[test] + fn test_unknown_version_ignored() { + use dpp::version::PlatformVersion; + + let latest = PlatformVersion::latest(); + let sdk = SdkBuilder::new_mock() + .with_version(latest) + .build() + .expect("mock Sdk should be created"); + + let original_version = sdk.protocol_version_number(); + + let metadata = ResponseMetadata { + protocol_version: 999, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), original_version); + assert_eq!(sdk.version().protocol_version, original_version); + } + + #[test] + fn test_version_shared_between_clones() { + use dpp::version::PlatformVersion; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + let clone = sdk.clone(); + + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + clone + .verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!( + sdk.protocol_version_number(), + 2, + "original should see update from clone" + ); + } + + #[test] + fn test_version_downgrade_ignored() { + use dpp::version::PlatformVersion; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(2).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 2); + + let metadata = ResponseMetadata { + protocol_version: 1, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), 2); + } + + #[test] + fn test_version_zero_ignored() { + use dpp::version::PlatformVersion; + + let latest = PlatformVersion::latest(); + let sdk = SdkBuilder::new_mock() + .with_version(latest) + .build() + .expect("mock Sdk should be created"); + + let original_version = sdk.protocol_version_number(); + + let metadata = ResponseMetadata { + protocol_version: 0, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), original_version); + } + #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From 6928fdd75aca0857b07c7e53368faaedbe05f140 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:55:48 +0200 Subject: [PATCH 10/15] test(sdk): add TC-6 concurrent updates and TC-7 global DPP version sync tests Cover the two remaining test cases for protocol version auto-detection: concurrent thread races converge to highest version, and PlatformVersionCurrentVersion::get_current() is synced after update. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/sdk.rs | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index a11c33a299..318042d561 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1317,6 +1317,73 @@ mod test { assert_eq!(sdk.protocol_version_number(), original_version); } + #[test] + fn test_concurrent_updates_converge_to_highest() { + use dpp::version::PlatformVersion; + use std::thread; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 1); + + let mut handles = Vec::new(); + // Spawn threads that race to update to version 2 and version 3 + for version in [2u32, 3, 2, 3, 2, 3] { + let sdk_clone = sdk.clone(); + handles.push(thread::spawn(move || { + let metadata = ResponseMetadata { + protocol_version: version, + height: 1, + ..Default::default() + }; + sdk_clone + .verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + })); + } + + for h in handles { + h.join().expect("thread should not panic"); + } + + // Highest known version (3) must win regardless of thread ordering + assert_eq!( + sdk.protocol_version_number(), + 3, + "concurrent updates must converge to highest version" + ); + } + + #[test] + fn test_global_dpp_version_synced_after_update() { + use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + let global: &PlatformVersion = PlatformVersionCurrentVersion::get_current() + .expect("global version should be set"); + assert_eq!( + global.protocol_version, + sdk.protocol_version_number(), + "global DPP version must match SDK version after update" + ); + } + #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From a9fbe7cf8f76b8b52a05b4fc2ef09c4ee5d96ff9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:15 +0200 Subject: [PATCH 11/15] fix(sdk-ffi): map DriveInternalError to dedicated FFI error code Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk-ffi/src/error.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index d4788dff54..c7180ad3e1 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -28,6 +28,8 @@ pub enum DashSDKErrorCode { Timeout = 8, /// Feature not implemented NotImplemented = 9, + /// Drive returned an internal error (e.g., storage-level constraint violation) + DriveInternalError = 10, /// Internal error InternalError = 99, } @@ -132,6 +134,8 @@ impl From for DashSDKError { (DashSDKErrorCode::ProtocolError, error_str) } else if error_str.contains("not found") || error_str.contains("Not found") { (DashSDKErrorCode::NotFound, error_str) + } else if error_str.contains("Drive internal error") { + (DashSDKErrorCode::DriveInternalError, error_str) } else { // Default to network error with the original message ( From 34459408ea21d6b8bbff26ac4f5475f89c63adf9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:56 +0200 Subject: [PATCH 12/15] fix(sdk): fix TC-7 test race and formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relax test_global_dpp_version_synced_after_update assertion to account for concurrent test execution — global PlatformVersion is process-wide, so parallel tests may push it higher. Assert >= instead of ==. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 318042d561..3cfa9f9602 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1375,12 +1375,15 @@ mod test { sdk.verify_response_metadata("test", &metadata) .expect("metadata should be valid"); - let global: &PlatformVersion = PlatformVersionCurrentVersion::get_current() - .expect("global version should be set"); - assert_eq!( + let global: &PlatformVersion = + PlatformVersionCurrentVersion::get_current().expect("global version should be set"); + // The global is process-wide (last writer wins), so concurrent tests + // may have pushed it higher. We only assert it's at least the version + // we set — not an exact match. + assert!( + global.protocol_version >= 2, + "global DPP version must be at least the version we set (2), got {}", global.protocol_version, - sdk.protocol_version_number(), - "global DPP version must match SDK version after update" ); } From 8c8545558134a33386f31eb869a53dac50d20708 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:10:20 +0200 Subject: [PATCH 13/15] Revert "fix(sdk): fix TC-7 test race and formatting" This reverts commit 34459408ea21d6b8bbff26ac4f5475f89c63adf9. --- packages/rs-sdk/src/sdk.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 3cfa9f9602..318042d561 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1375,15 +1375,12 @@ mod test { sdk.verify_response_metadata("test", &metadata) .expect("metadata should be valid"); - let global: &PlatformVersion = - PlatformVersionCurrentVersion::get_current().expect("global version should be set"); - // The global is process-wide (last writer wins), so concurrent tests - // may have pushed it higher. We only assert it's at least the version - // we set — not an exact match. - assert!( - global.protocol_version >= 2, - "global DPP version must be at least the version we set (2), got {}", + let global: &PlatformVersion = PlatformVersionCurrentVersion::get_current() + .expect("global version should be set"); + assert_eq!( global.protocol_version, + sdk.protocol_version_number(), + "global DPP version must match SDK version after update" ); } From 32b661c2c1230b34dc3ff01ce94a65e97641abf9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:10:20 +0200 Subject: [PATCH 14/15] Revert "test(sdk): add TC-6 concurrent updates and TC-7 global DPP version sync tests" This reverts commit 6928fdd75aca0857b07c7e53368faaedbe05f140. --- packages/rs-sdk/src/sdk.rs | 67 -------------------------------------- 1 file changed, 67 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 318042d561..a11c33a299 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1317,73 +1317,6 @@ mod test { assert_eq!(sdk.protocol_version_number(), original_version); } - #[test] - fn test_concurrent_updates_converge_to_highest() { - use dpp::version::PlatformVersion; - use std::thread; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); - - assert_eq!(sdk.protocol_version_number(), 1); - - let mut handles = Vec::new(); - // Spawn threads that race to update to version 2 and version 3 - for version in [2u32, 3, 2, 3, 2, 3] { - let sdk_clone = sdk.clone(); - handles.push(thread::spawn(move || { - let metadata = ResponseMetadata { - protocol_version: version, - height: 1, - ..Default::default() - }; - sdk_clone - .verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - })); - } - - for h in handles { - h.join().expect("thread should not panic"); - } - - // Highest known version (3) must win regardless of thread ordering - assert_eq!( - sdk.protocol_version_number(), - 3, - "concurrent updates must converge to highest version" - ); - } - - #[test] - fn test_global_dpp_version_synced_after_update() { - use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); - - let metadata = ResponseMetadata { - protocol_version: 2, - height: 1, - ..Default::default() - }; - - sdk.verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - let global: &PlatformVersion = PlatformVersionCurrentVersion::get_current() - .expect("global version should be set"); - assert_eq!( - global.protocol_version, - sdk.protocol_version_number(), - "global DPP version must match SDK version after update" - ); - } - #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From 60c4d32a5ec399aad8ec4e7cd29195ef47f32951 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:10:20 +0200 Subject: [PATCH 15/15] Revert "feat(sdk): auto-detect protocol version from network response metadata" This reverts commit d855555ebe655c9010b00862e0d0e34fa4245531. --- packages/rs-sdk/src/sdk.rs | 212 +++---------------------------------- 1 file changed, 16 insertions(+), 196 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index a11c33a299..995126cd9c 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -107,9 +107,6 @@ pub struct Sdk { /// Note that setting this to None can panic. context_provider: ArcSwapOption>, - /// Protocol version number detected from the network. Shared between clones. - protocol_version: Arc, - /// Last seen height; used to determine if the remote node is stale. /// /// This is clone-able and can be shared between threads. @@ -143,7 +140,6 @@ impl Clone for Sdk { nonce_cache: Arc::clone(&self.nonce_cache), context_provider: ArcSwapOption::new(self.context_provider.load_full()), cancel_token: self.cancel_token.clone(), - protocol_version: Arc::clone(&self.protocol_version), metadata_last_seen_height: Arc::clone(&self.metadata_last_seen_height), metadata_height_tolerance: self.metadata_height_tolerance, metadata_time_tolerance_ms: self.metadata_time_tolerance_ms, @@ -182,6 +178,9 @@ enum SdkInstance { Dapi { /// DAPI client used to communicate with Dash Platform. dapi: DapiClient, + + /// Platform version configured for this Sdk + version: &'static PlatformVersion, }, /// Mock SDK #[cfg(feature = "mocks")] @@ -193,6 +192,8 @@ enum SdkInstance { /// Mock SDK implementation processing mock expectations and responses. mock: Arc>, address_list: AddressList, + /// Platform version configured for this Sdk + version: &'static PlatformVersion, }, } @@ -249,65 +250,9 @@ impl Sdk { verify_metadata_time(metadata, now, time_tolerance)?; }; - self.maybe_update_protocol_version(metadata.protocol_version); - Ok(()) } - /// Update the stored protocol version if `received_version` is newer and known. - /// - /// Uses a CAS loop so the highest version always wins under concurrent updates. - /// In multi-SDK scenarios the `PlatformVersion::set_current` global is process-wide - /// (last writer wins). - fn maybe_update_protocol_version(&self, received_version: u32) { - if received_version == 0 { - return; - } - - let mut current = self.protocol_version.load(Ordering::Relaxed); - - if received_version <= current { - return; - } - - let new_version = match PlatformVersion::get(received_version) { - Ok(v) => v, - Err(_) => { - tracing::warn!( - received_version, - current_version = current, - "received unknown protocol version from network; keeping current" - ); - return; - } - }; - - loop { - match self.protocol_version.compare_exchange_weak( - current, - received_version, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => { - tracing::info!( - old_version = current, - new_version = received_version, - "protocol version updated from network metadata" - ); - PlatformVersion::set_current(new_version); - return; - } - Err(actual) => { - if actual >= received_version { - return; - } - current = actual; - } - } - } - } - // TODO: Changed to public for tests /// Retrieve object `O` from proof contained in `request` (of type `R`) and `response`. /// @@ -437,16 +382,16 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// The version is auto-detected from network responses and may change at runtime. - /// Falls back to [`PlatformVersion::latest()`] if the stored version number is unknown. + /// + /// + /// This is the version configured in [`SdkBuilder`]. + /// Useful whenever you need to provide [PlatformVersion] to other SDK and DPP methods. pub fn version<'v>(&self) -> &'v PlatformVersion { - let v = self.protocol_version.load(Ordering::Relaxed); - PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) - } - - /// Return the raw protocol version number currently used by this SDK. - pub fn protocol_version_number(&self) -> u32 { - self.protocol_version.load(Ordering::Relaxed) + match &self.inner { + SdkInstance::Dapi { version, .. } => version, + #[cfg(feature = "mocks")] + SdkInstance::Mock { version, .. } => version, + } } // TODO: Move to settings @@ -945,12 +890,11 @@ impl SdkBuilder { let mut sdk= Sdk{ network: self.network, dapi_client_settings, - inner:SdkInstance::Dapi { dapi }, + inner:SdkInstance::Dapi { dapi, version:self.version }, proofs:self.proofs, context_provider: ArcSwapOption::new( self.context_provider.map(Arc::new)), cancel_token: self.cancel_token, nonce_cache: Default::default(), - protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), // Note: in the future, we need to securely initialize initial height during Sdk bootstrap or first request. metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), metadata_height_tolerance: self.metadata_height_tolerance, @@ -1013,11 +957,11 @@ impl SdkBuilder { mock:mock_sdk.clone(), dapi, address_list: AddressList::new(), + version: self.version, }, dump_dir: self.dump_dir.clone(), proofs:self.proofs, nonce_cache: Default::default(), - protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), @@ -1193,130 +1137,6 @@ mod test { .expect_err("metadata should be invalid"); } - #[test] - fn test_version_update_from_metadata() { - use dpp::version::PlatformVersion; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); - - assert_eq!(sdk.protocol_version_number(), 1); - - let metadata = ResponseMetadata { - protocol_version: 2, - height: 1, - ..Default::default() - }; - - sdk.verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - assert_eq!(sdk.protocol_version_number(), 2); - assert_eq!(sdk.version().protocol_version, 2); - } - - #[test] - fn test_unknown_version_ignored() { - use dpp::version::PlatformVersion; - - let latest = PlatformVersion::latest(); - let sdk = SdkBuilder::new_mock() - .with_version(latest) - .build() - .expect("mock Sdk should be created"); - - let original_version = sdk.protocol_version_number(); - - let metadata = ResponseMetadata { - protocol_version: 999, - height: 1, - ..Default::default() - }; - - sdk.verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - assert_eq!(sdk.protocol_version_number(), original_version); - assert_eq!(sdk.version().protocol_version, original_version); - } - - #[test] - fn test_version_shared_between_clones() { - use dpp::version::PlatformVersion; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); - - let clone = sdk.clone(); - - let metadata = ResponseMetadata { - protocol_version: 2, - height: 1, - ..Default::default() - }; - - clone - .verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - assert_eq!( - sdk.protocol_version_number(), - 2, - "original should see update from clone" - ); - } - - #[test] - fn test_version_downgrade_ignored() { - use dpp::version::PlatformVersion; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(2).unwrap()) - .build() - .expect("mock Sdk should be created"); - - assert_eq!(sdk.protocol_version_number(), 2); - - let metadata = ResponseMetadata { - protocol_version: 1, - height: 1, - ..Default::default() - }; - - sdk.verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - assert_eq!(sdk.protocol_version_number(), 2); - } - - #[test] - fn test_version_zero_ignored() { - use dpp::version::PlatformVersion; - - let latest = PlatformVersion::latest(); - let sdk = SdkBuilder::new_mock() - .with_version(latest) - .build() - .expect("mock Sdk should be created"); - - let original_version = sdk.protocol_version_number(); - - let metadata = ResponseMetadata { - protocol_version: 0, - height: 1, - ..Default::default() - }; - - sdk.verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - assert_eq!(sdk.protocol_version_number(), original_version); - } - #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")]