Skip to content

Commit 4aa4910

Browse files
lklimekclaude
andcommitted
fix(rs-sdk): decode consensus errors from drive-error-data-bin metadata fallback
When `dash-serialized-consensus-error-bin` gRPC metadata is absent (e.g. broadcast errors routed through Tenderdash), fall back to decoding `drive-error-data-bin` CBOR metadata to extract the consensus error bytes. Security hardening: - CBOR recursion limit (16) via `from_reader_with_recursion_limit` - Payload size cap (8 KiB) before deserialization - Graceful fallthrough on any decode failure Includes 9 new tests with real-world DET log fixtures proving the fix works with production error data (GroveDB storage errors, DPP state errors with consensus_error bytes). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4158ad5 commit 4aa4910

1 file changed

Lines changed: 376 additions & 0 deletions

File tree

packages/rs-sdk/src/error.rs

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ use rs_dapi_client::{CanRetry, DapiClientError, ExecutionError};
1717
use std::fmt::Debug;
1818
use std::time::Duration;
1919

20+
/// Maximum size of `drive-error-data-bin` metadata to parse.
21+
/// gRPC metadata is typically limited to 8 KiB, but we enforce explicitly.
22+
const MAX_DRIVE_ERROR_METADATA_BYTES: usize = 8 * 1024;
23+
24+
/// CBOR recursion limit for parsing drive error metadata from untrusted DAPI nodes.
25+
const DRIVE_ERROR_CBOR_RECURSION_LIMIT: usize = 16;
26+
27+
/// CBOR structure mirroring the server-side `TenderdashStatus` layout
28+
/// found in `drive-error-data-bin` gRPC metadata.
29+
#[derive(serde::Serialize, serde::Deserialize)]
30+
struct DriveErrorData {
31+
#[serde(default)]
32+
consensus_error: Option<Vec<u8>>,
33+
}
34+
2035
/// Error type for the SDK
2136
// TODO: Propagate server address and retry information so that the user can retrieve it
2237
#[allow(clippy::large_enum_variant)]
@@ -184,6 +199,43 @@ impl From<DapiClientError> for Error {
184199
Self::Generic(format!("Invalid consensus error encoding: {e}"))
185200
});
186201
}
202+
// Fallback: try drive-error-data-bin (CBOR-encoded TenderdashStatus).
203+
// SECURITY: bytes originate from a potentially untrusted DAPI node.
204+
// Apply strict size and recursion limits before deserializing.
205+
if let Some(drive_error_value) = status.metadata().get_bin("drive-error-data-bin") {
206+
if let Ok(bytes) = drive_error_value.to_bytes() {
207+
if bytes.len() > MAX_DRIVE_ERROR_METADATA_BYTES {
208+
tracing::debug!(
209+
len = bytes.len(),
210+
"drive-error-data-bin metadata exceeds size limit, skipping"
211+
);
212+
} else if let Ok(drive_error) =
213+
ciborium::de::from_reader_with_recursion_limit::<DriveErrorData, _>(
214+
bytes.as_ref(),
215+
DRIVE_ERROR_CBOR_RECURSION_LIMIT,
216+
)
217+
{
218+
if let Some(consensus_bytes) = drive_error.consensus_error {
219+
return ConsensusError::deserialize_from_bytes(&consensus_bytes)
220+
.map(|ce| {
221+
Self::Protocol(ProtocolError::ConsensusError(Box::new(ce)))
222+
})
223+
.unwrap_or_else(|e| {
224+
tracing::debug!(
225+
"Failed to deserialize consensus error from drive-error-data-bin: {}",
226+
e
227+
);
228+
Self::Protocol(e)
229+
});
230+
}
231+
} else {
232+
tracing::debug!("Failed to CBOR-decode drive-error-data-bin metadata");
233+
}
234+
} else {
235+
tracing::debug!("Failed to decode drive-error-data-bin metadata bytes");
236+
}
237+
}
238+
187239
// Otherwise we parse the error code and act accordingly
188240
if status.code() == Code::AlreadyExists {
189241
return Self::AlreadyExists(status.message().to_string());
@@ -371,6 +423,330 @@ mod tests {
371423
);
372424
}
373425

426+
#[test]
427+
fn test_consensus_error_from_drive_error_data_bin() {
428+
let platform_version = PlatformVersion::latest();
429+
430+
let consensus_error = ConsensusError::BasicError(
431+
BasicError::IdentityAssetLockProofLockedTransactionMismatchError(
432+
IdentityAssetLockProofLockedTransactionMismatchError::new(
433+
Txid::from_byte_array([0; 32]),
434+
Txid::from_byte_array([1; 32]),
435+
),
436+
),
437+
);
438+
439+
let consensus_error_bytes = consensus_error
440+
.serialize_to_bytes_with_platform_version(platform_version)
441+
.expect("serialize consensus error to bytes");
442+
443+
// Build CBOR payload matching TenderdashStatus struct
444+
let drive_error_data = DriveErrorData {
445+
consensus_error: Some(consensus_error_bytes),
446+
};
447+
let mut cbor_bytes = Vec::new();
448+
ciborium::ser::into_writer(&drive_error_data, &mut cbor_bytes).expect("serialize CBOR");
449+
450+
let mut metadata = MetadataMap::new();
451+
metadata.insert_bin(
452+
"drive-error-data-bin",
453+
MetadataValue::from_bytes(&cbor_bytes),
454+
);
455+
// No dash-serialized-consensus-error-bin set
456+
457+
let status =
458+
dapi_grpc::tonic::Status::with_metadata(Code::Internal, "Drive error", metadata);
459+
let error = DapiClientError::Transport(TransportError::Grpc(status));
460+
let sdk_error = Error::from(error);
461+
462+
assert_matches!(
463+
sdk_error,
464+
Error::Protocol(ProtocolError::ConsensusError(e)) if matches!(*e, ConsensusError::BasicError(
465+
BasicError::IdentityAssetLockProofLockedTransactionMismatchError(_)
466+
))
467+
);
468+
}
469+
470+
#[test]
471+
fn test_consensus_error_primary_preferred_over_fallback() {
472+
let platform_version = PlatformVersion::latest();
473+
474+
let consensus_error = ConsensusError::BasicError(
475+
BasicError::IdentityAssetLockProofLockedTransactionMismatchError(
476+
IdentityAssetLockProofLockedTransactionMismatchError::new(
477+
Txid::from_byte_array([0; 32]),
478+
Txid::from_byte_array([1; 32]),
479+
),
480+
),
481+
);
482+
483+
let consensus_error_bytes = consensus_error
484+
.serialize_to_bytes_with_platform_version(platform_version)
485+
.expect("serialize consensus error to bytes");
486+
487+
// Set primary metadata key
488+
let mut metadata = MetadataMap::new();
489+
metadata.insert_bin(
490+
"dash-serialized-consensus-error-bin",
491+
MetadataValue::from_bytes(&consensus_error_bytes),
492+
);
493+
494+
// Also set fallback with garbage CBOR (should not be reached)
495+
metadata.insert_bin(
496+
"drive-error-data-bin",
497+
MetadataValue::from_bytes(&[0xDE, 0xAD]),
498+
);
499+
500+
let status =
501+
dapi_grpc::tonic::Status::with_metadata(Code::InvalidArgument, "Test", metadata);
502+
let error = DapiClientError::Transport(TransportError::Grpc(status));
503+
let sdk_error = Error::from(error);
504+
505+
// Should succeed via primary path, not fail on garbage fallback
506+
assert_matches!(
507+
sdk_error,
508+
Error::Protocol(ProtocolError::ConsensusError(e)) if matches!(*e, ConsensusError::BasicError(
509+
BasicError::IdentityAssetLockProofLockedTransactionMismatchError(_)
510+
))
511+
);
512+
}
513+
514+
#[test]
515+
fn test_drive_error_data_bin_without_consensus_error() {
516+
let drive_error_data = DriveErrorData {
517+
consensus_error: None,
518+
};
519+
let mut cbor_bytes = Vec::new();
520+
ciborium::ser::into_writer(&drive_error_data, &mut cbor_bytes).expect("serialize CBOR");
521+
522+
let mut metadata = MetadataMap::new();
523+
metadata.insert_bin(
524+
"drive-error-data-bin",
525+
MetadataValue::from_bytes(&cbor_bytes),
526+
);
527+
528+
let status = dapi_grpc::tonic::Status::with_metadata(
529+
Code::Internal,
530+
"some drive error",
531+
metadata,
532+
);
533+
let error = DapiClientError::Transport(TransportError::Grpc(status));
534+
let sdk_error = Error::from(error);
535+
536+
// Should fall through to DapiClientError since consensus_error is None
537+
assert_matches!(sdk_error, Error::DapiClientError(_));
538+
}
539+
540+
#[test]
541+
fn test_drive_error_data_bin_invalid_cbor() {
542+
let mut metadata = MetadataMap::new();
543+
metadata.insert_bin(
544+
"drive-error-data-bin",
545+
MetadataValue::from_bytes(&[0xFF, 0xFE, 0xFD]),
546+
);
547+
548+
let status = dapi_grpc::tonic::Status::with_metadata(
549+
Code::Internal,
550+
"some drive error",
551+
metadata,
552+
);
553+
let error = DapiClientError::Transport(TransportError::Grpc(status));
554+
let sdk_error = Error::from(error);
555+
556+
// Should fall through gracefully to DapiClientError
557+
assert_matches!(sdk_error, Error::DapiClientError(_));
558+
}
559+
560+
#[test]
561+
fn test_drive_error_data_bin_valid_cbor_invalid_consensus_bytes() {
562+
// Valid CBOR envelope, but the inner consensus_error bytes are corrupt.
563+
// Expected: returns Protocol error (deserialization failure), NOT DapiClientError.
564+
// This documents the current behavior and ensures it does not silently regress.
565+
let corrupt_consensus_bytes: Vec<u8> = vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
566+
let drive_error_data = DriveErrorData {
567+
consensus_error: Some(corrupt_consensus_bytes),
568+
};
569+
let mut cbor_bytes = Vec::new();
570+
ciborium::ser::into_writer(&drive_error_data, &mut cbor_bytes).expect("serialize CBOR");
571+
572+
let mut metadata = MetadataMap::new();
573+
metadata.insert_bin(
574+
"drive-error-data-bin",
575+
MetadataValue::from_bytes(&cbor_bytes),
576+
);
577+
578+
let status =
579+
dapi_grpc::tonic::Status::with_metadata(Code::Internal, "drive error", metadata);
580+
let error = DapiClientError::Transport(TransportError::Grpc(status));
581+
let sdk_error = Error::from(error);
582+
583+
// Corrupt inner bytes cause deserialization failure; returned as Protocol error
584+
assert_matches!(sdk_error, Error::Protocol(_));
585+
}
586+
587+
#[test]
588+
fn test_real_world_internal_error_with_null_consensus_error() {
589+
// Real-world fixture: gRPC Internal (13) error from DAPI node.
590+
// drive-error-data-bin CBOR = {code: 13, message: <base64 inner>, consensus_error: null}
591+
// This is a GroveDB storage error ("unique key already exists"), NOT a DPP
592+
// ConsensusError — so consensus_error is null and the fallback should
593+
// gracefully fall through to DapiClientError.
594+
let drive_error_data_bytes = base64::engine::general_purpose::STANDARD
595+
.decode(concat!(
596+
"o2Rjb2RlDWdtZXNzYWdleQEYb1dkdFpYTnpZV2RsZU1kemRHOXlZV2RsT2lCcBlp",
597+
"R1Z1ZEdsMGVUb2dZU0IxYm1seGRXVWdhMlY1SUhkcGRHZ2dkR2hoZENCaFlYTm9J",
598+
"R0ZzY21WaFpIa2daWGhwYzNSek9pQjBhR1VnYTJWNUlHRnNjbVZoWkhrZ1pYaHBj",
599+
"M1J6SUdsdUlIUm9aU0IxYm1seGRXVWdjMlYwSUZzeU16SXNJRFExTENBeE1Ua3NJ",
600+
"REV6Tnl3Z01UWXhMQ0F4TkRNc0lERTFMQ0F4Tnprc0lESXpOU3dnT1Rnc0lERXdN",
601+
"U3dnTWpVeExDQXlOVEVzSURFeE1Dd2dNVE15TENBek5Td2dNVFE1TENBNE5Dd2dN",
602+
"VFEzTENBeE1qUmRvY29uc2Vuc3VzX2Vycm9y9g==",
603+
))
604+
.expect("decode fixture base64");
605+
606+
let mut metadata = MetadataMap::new();
607+
metadata.insert_bin(
608+
"drive-error-data-bin",
609+
MetadataValue::from_bytes(&drive_error_data_bytes),
610+
);
611+
612+
let status = dapi_grpc::tonic::Status::with_metadata(
613+
Code::Internal,
614+
"storage: identity: a unique key with that hash already exists",
615+
metadata,
616+
);
617+
let error = DapiClientError::Transport(TransportError::Grpc(status));
618+
let sdk_error = Error::from(error);
619+
620+
// consensus_error is null in the CBOR, so fallback should fall through
621+
// to DapiClientError (not panic, not return a ConsensusError)
622+
assert_matches!(sdk_error, Error::DapiClientError(_));
623+
}
624+
625+
#[test]
626+
fn test_real_world_internal_error_non_unique_set() {
627+
// Real-world fixture from DET logs: gRPC Internal (13) error.
628+
// drive-error-data-bin CBOR = {code: 13, message: <storage error>, consensus_error: null}
629+
// Storage error: "the key already exists in the non unique set [135, 202, ...]"
630+
// consensus_error is null => should fall through to DapiClientError.
631+
let drive_error_data_bytes = base64::engine::general_purpose::STANDARD
632+
.decode(concat!(
633+
"o2Rjb2RlDWdtZXNzYWdleQEYb1dkdFpYTnpZV2RsZU1WemRHOXlZV2RsT2lCcFpH",
634+
"VnVkR2wwZVRvZ1lTQjFibWx4ZFdVZ2EyVjVJSGRwZEdnZ2RHaGhkQ0JvWVhOb0lH",
635+
"RnNjbVZoWkhrZ1pYaHBjM1J6T2lCMGFHVWdhMlY1SUdGc2NtVmhaSGtnWlhocGMz",
636+
"UnpJR2x1SUhSb1pTQnViMjRnZFc1cGNYVmxJSE5sZENCYk1UTTFMQ0F5TURJc0lE",
637+
"RTNNaXdnTlRNc0lERTNOaXdnTkRVc0lERTVNU3dnTWpjc0lEVXdMQ0F4TWl3Z05U",
638+
"QXNJREl4TlN3Z05qVXNJREV5TkN3Z01UUTNMQ0F6TENBeU1EZ3NJRFlzSURJeU5p",
639+
"d2dNVFV4WFE9PW9jb25zZW5zdXNfZXJyb3L2",
640+
))
641+
.expect("decode fixture base64");
642+
643+
let mut metadata = MetadataMap::new();
644+
metadata.insert_bin(
645+
"drive-error-data-bin",
646+
MetadataValue::from_bytes(&drive_error_data_bytes),
647+
);
648+
649+
let status = dapi_grpc::tonic::Status::with_metadata(
650+
Code::Internal,
651+
"storage: identity: a unique key with that hash already exists in the non unique set",
652+
metadata,
653+
);
654+
let error = DapiClientError::Transport(TransportError::Grpc(status));
655+
let sdk_error = Error::from(error);
656+
657+
assert_matches!(sdk_error, Error::DapiClientError(_));
658+
}
659+
660+
#[test]
661+
fn test_real_world_dpp_error_with_both_metadata_keys() {
662+
// Real-world fixture from DET logs: a DPP state error where BOTH
663+
// `dash-serialized-consensus-error-bin` AND `drive-error-data-bin`
664+
// are present. The primary path (dash-serialized-consensus-error-bin)
665+
// should be used to produce Error::Protocol(ConsensusError).
666+
let direct_consensus_bytes = base64::engine::general_purpose::STANDARD
667+
.decode(concat!(
668+
"ATggUDAtq56qZ8U5oJOS70tCvH+rkum8",
669+
"tkQRw3Rlsru4TOkA/AU+xgD8BT7GAPwO",
670+
"XV5A",
671+
))
672+
.expect("decode direct consensus error base64");
673+
674+
let drive_error_data_bytes = base64::engine::general_purpose::STANDARD
675+
.decode(concat!(
676+
"o2Rjb2RlGSkiZ21lc3NhZ2V4oG9XUmtZWFJob1c5elpYSnBZV3hwZW1Wa1JYSnli",
677+
"M0tZTXdFWU9CZ2dHRkFZTUJpdEdLc1luaGlxR0djWXhSZzVHS0FZa3hpU0dPOFlT",
678+
"eGhDR0x3WWZ4aXJHSklZNlJpOEdMWVlSQkVZd3hoMEdHVVlzaGk3R0xnWVRCanBB",
679+
"Qmo0QlJnK0dNWUFHUHdGR0Q0WXhnQVkvQTRZWFJoZUdFQT1vY29uc2Vuc3VzX2Vy",
680+
"cm9ymDMBGDgYIBhQGDAYrRirGJ4YqhhnGMUYORigGJMYkhjvGEsYQhi8GH8YqxiS",
681+
"GOkYvBi2GEQRGMMYdBhlGLIYuxi4GEwY6QAY/AUYPhjGABj8BRg+GMYAGPwOGF0Y",
682+
"XhhA",
683+
))
684+
.expect("decode drive-error-data-bin base64");
685+
686+
let mut metadata = MetadataMap::new();
687+
metadata.insert_bin(
688+
"dash-serialized-consensus-error-bin",
689+
MetadataValue::from_bytes(&direct_consensus_bytes),
690+
);
691+
metadata.insert_bin(
692+
"drive-error-data-bin",
693+
MetadataValue::from_bytes(&drive_error_data_bytes),
694+
);
695+
696+
let status = dapi_grpc::tonic::Status::with_metadata(
697+
Code::InvalidArgument,
698+
"state transition error",
699+
metadata,
700+
);
701+
let error = DapiClientError::Transport(TransportError::Grpc(status));
702+
let sdk_error = Error::from(error);
703+
704+
// Primary path should succeed; result is a ConsensusError
705+
assert_matches!(sdk_error, Error::Protocol(ProtocolError::ConsensusError(_)));
706+
}
707+
708+
#[test]
709+
fn test_real_world_dpp_error_fallback_only() {
710+
// Real-world fixture from DET logs: only `drive-error-data-bin` present.
711+
// Simulates the case where `dash-serialized-consensus-error-bin` is absent
712+
// and our CBOR fallback must extract consensus_error bytes from the CBOR
713+
// array-of-integers encoding.
714+
//
715+
// NOTE: ciborium serializes Vec<u8> as a CBOR array of integers, and this
716+
// is how production Drive encodes the field. If ciborium cannot deserialize
717+
// a CBOR array of integers back into Vec<u8>, this test will reveal that.
718+
let drive_error_data_bytes = base64::engine::general_purpose::STANDARD
719+
.decode(concat!(
720+
"o2Rjb2RlGSkiZ21lc3NhZ2V4oG9XUmtZWFJob1c5elpYSnBZV3hwZW1Wa1JYSnli",
721+
"M0tZTXdFWU9CZ2dHRkFZTUJpdEdLc1luaGlxR0djWXhSZzVHS0FZa3hpU0dPOFlT",
722+
"eGhDR0x3WWZ4aXJHSklZNlJpOEdMWVlSQkVZd3hoMEdHVVlzaGk3R0xnWVRCanBB",
723+
"Qmo0QlJnK0dNWUFHUHdGR0Q0WXhnQVkvQTRZWFJoZUdFQT1vY29uc2Vuc3VzX2Vy",
724+
"cm9ymDMBGDgYIBhQGDAYrRirGJ4YqhhnGMUYORigGJMYkhjvGEsYQhi8GH8YqxiS",
725+
"GOkYvBi2GEQRGMMYdBhlGLIYuxi4GEwY6QAY/AUYPhjGABj8BRg+GMYAGPwOGF0Y",
726+
"XhhA",
727+
))
728+
.expect("decode drive-error-data-bin base64");
729+
730+
let mut metadata = MetadataMap::new();
731+
metadata.insert_bin(
732+
"drive-error-data-bin",
733+
MetadataValue::from_bytes(&drive_error_data_bytes),
734+
);
735+
// No dash-serialized-consensus-error-bin — forces fallback path
736+
737+
let status = dapi_grpc::tonic::Status::with_metadata(
738+
Code::InvalidArgument,
739+
"state transition error",
740+
metadata,
741+
);
742+
let error = DapiClientError::Transport(TransportError::Grpc(status));
743+
let sdk_error = Error::from(error);
744+
745+
// The CBOR fallback should extract consensus_error bytes from the
746+
// array-of-integers encoding and produce a ConsensusError.
747+
assert_matches!(sdk_error, Error::Protocol(ProtocolError::ConsensusError(_)));
748+
}
749+
374750
#[test]
375751
fn test_consensus_error_with_fixture() {
376752
let consensus_error_bytes = base64::engine::general_purpose::STANDARD.decode("ATUgJOJEYbuHBqyTeApO/ptxQ8IAw8nm9NbGROu1nyE/kqcgDTlFeUG0R4wwVcbZJMFErL+VSn63SUpP49cequ3fsKw=").expect("decode base64");

0 commit comments

Comments
 (0)