@@ -17,6 +17,21 @@ use rs_dapi_client::{CanRetry, DapiClientError, ExecutionError};
1717use std:: fmt:: Debug ;
1818use 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