@@ -340,7 +340,10 @@ impl JournalEncode for AcctDiff<'_> {
340340
341341 fn encode ( & self , buf : & mut dyn BufMut ) {
342342 self . outcome . encode ( buf) ;
343- buf. put_u32 ( self . storage_diff . len ( ) as u32 ) ;
343+ // Only changed slots are serialized, so the count prefix must exclude unchanged slots to
344+ // match the entries written below (and `serialized_size`).
345+ let changed = self . storage_diff . values ( ) . filter ( |slot| slot. is_changed ( ) ) . count ( ) ;
346+ buf. put_u32 ( changed as u32 ) ;
344347 for ( slot, value) in & self . storage_diff {
345348 if value. is_changed ( ) {
346349 slot. encode ( buf) ;
@@ -553,9 +556,9 @@ impl JournalDecode for StorageSlot {
553556
554557impl JournalDecode for AcctDiff < ' static > {
555558 fn decode ( buf : & mut & [ u8 ] ) -> Result < Self > {
559+ check_len ! ( buf, "AcctDiff" , ACCT_DIFF_MIN_BYTES ) ;
556560 let outcome = JournalDecode :: decode ( buf) ?;
557561
558- check_len ! ( buf, "StorageDiffLen" , ACCT_DIFF_MIN_BYTES ) ;
559562 let storage_diff_len: u32 = JournalDecode :: decode ( buf) ?;
560563
561564 let mut storage_diff = BTreeMap :: new ( ) ;
@@ -645,6 +648,34 @@ mod test {
645648 assert_eq ! ( & dec, expected, "{ty_name}" ) ;
646649 }
647650
651+ /// A non-trivial [`AccountInfo`] used to build test fixtures.
652+ fn sample_info ( ) -> AccountInfo {
653+ AccountInfo {
654+ balance : U256 :: from ( 38238923 ) ,
655+ nonce : 38238923 ,
656+ code_hash : B256 :: repeat_byte ( 0xa ) ,
657+ code : None ,
658+ account_id : None ,
659+ }
660+ }
661+
662+ /// Assert that the full encoding of `value` decodes, while every strict prefix of it is
663+ /// rejected. Truncating a valid buffer must always overrun, so this pins the length-check
664+ /// boundary of every decode path and guards against mis-ordered or off-by-one checks - the
665+ /// class of the empty-storage `AcctDiff` regression.
666+ #[ track_caller]
667+ fn assert_truncation_rejected < T : JournalDecode > ( value : & T ) {
668+ let enc = JournalEncode :: encoded ( value) ;
669+ let ty_name = core:: any:: type_name :: < T > ( ) ;
670+ T :: decode ( & mut enc. as_ref ( ) ) . expect ( "full buffer should decode" ) ;
671+ for len in 0 ..enc. len ( ) {
672+ let mut prefix = & enc[ ..len] ;
673+ if T :: decode ( & mut prefix) . is_ok ( ) {
674+ panic ! ( "{ty_name}: prefix of length {len} must be rejected but decoded" ) ;
675+ }
676+ }
677+ }
678+
648679 #[ test]
649680 fn roundtrips ( ) {
650681 roundtrip ( & Cow :: < ' static , u8 > :: Owned ( 1u8 ) ) ;
@@ -726,4 +757,299 @@ mod test {
726757 } ;
727758 roundtrip ( & bsi) ;
728759 }
760+
761+ #[ test]
762+ fn empty_storage_diff_roundtrips ( ) {
763+ let acc_info = AccountInfo {
764+ balance : U256 :: from ( 1 ) ,
765+ nonce : 1 ,
766+ code_hash : B256 :: repeat_byte ( 0xa ) ,
767+ code : None ,
768+ account_id : None ,
769+ } ;
770+
771+ // Standalone AcctDiff with zero storage slots, for both single-info outcomes.
772+ let created = AcctDiff {
773+ outcome : InfoOutcome :: Created ( Cow :: Owned ( acc_info. clone ( ) ) ) ,
774+ storage_diff : BTreeMap :: new ( ) ,
775+ } ;
776+ roundtrip ( & created) ;
777+ let destroyed = AcctDiff {
778+ outcome : InfoOutcome :: Destroyed ( Cow :: Owned ( acc_info) ) ,
779+ storage_diff : BTreeMap :: new ( ) ,
780+ } ;
781+ roundtrip ( & destroyed) ;
782+
783+ // A BundleStateIndex whose only account has empty storage and which has no new
784+ // contracts - the trailing bytes after the account's outcome are just the two u32
785+ // length prefixes, far fewer than ACCT_DIFF_MIN_BYTES.
786+ let bsi = BundleStateIndex {
787+ state : vec ! [ ( Address :: repeat_byte( 0xa ) , created) ] . into_iter ( ) . collect ( ) ,
788+ new_contracts : BTreeMap :: new ( ) ,
789+ } ;
790+ roundtrip ( & bsi) ;
791+ }
792+
793+ #[ test]
794+ fn additional_roundtrips ( ) {
795+ // The `Option` codec is part of the public trait surface but is not exercised by the
796+ // composite types above, so cover both variants directly.
797+ roundtrip ( & Option :: < u8 > :: None ) ;
798+ roundtrip ( & Some ( 42u8 ) ) ;
799+
800+ // Only the legacy bytecode variant is covered by `roundtrips`; cover the EIP-7702
801+ // delegation variant and the empty-bytecode edge case here.
802+ roundtrip ( & Bytecode :: new_eip7702 ( Address :: repeat_byte ( 0xb ) ) ) ;
803+ roundtrip ( & Bytecode :: new_raw ( Bytes :: new ( ) ) ) ;
804+
805+ // Only created/diff outcomes are covered by `roundtrips`; cover a standalone destroyed
806+ // outcome here.
807+ roundtrip ( & InfoOutcome :: Destroyed ( Cow :: Owned ( sample_info ( ) ) ) ) ;
808+
809+ // An empty index has neither state nor contracts.
810+ roundtrip ( & BundleStateIndex :: default ( ) ) ;
811+
812+ // The header codec delegates to alloy RLP.
813+ roundtrip ( & Header :: default ( ) ) ;
814+ }
815+
816+ #[ test]
817+ fn truncated_buffers_are_rejected ( ) {
818+ assert_truncation_rejected ( & 7u8 ) ;
819+ assert_truncation_rejected ( & 7u32 ) ;
820+ assert_truncation_rejected ( & 7u64 ) ;
821+ assert_truncation_rejected ( & B256 :: repeat_byte ( 0xa ) ) ;
822+ assert_truncation_rejected ( & Address :: repeat_byte ( 0xa ) ) ;
823+ assert_truncation_rejected ( & U256 :: from ( 38238923 ) ) ;
824+ assert_truncation_rejected ( & sample_info ( ) ) ;
825+
826+ assert_truncation_rejected ( & Option :: < u8 > :: None ) ;
827+ assert_truncation_rejected ( & Some ( 7u8 ) ) ;
828+
829+ // Every `InfoOutcome` variant.
830+ assert_truncation_rejected ( & InfoOutcome :: Created ( Cow :: Owned ( sample_info ( ) ) ) ) ;
831+ assert_truncation_rejected ( & InfoOutcome :: Destroyed ( Cow :: Owned ( sample_info ( ) ) ) ) ;
832+ assert_truncation_rejected ( & InfoOutcome :: Diff {
833+ old : Cow :: Owned ( sample_info ( ) ) ,
834+ new : Cow :: Owned ( sample_info ( ) ) ,
835+ } ) ;
836+
837+ // Every `StorageSlot` variant: created, changed, deleted.
838+ assert_truncation_rejected ( & StorageSlot :: new_changed ( U256 :: ZERO , U256 :: from ( 9 ) ) ) ;
839+ assert_truncation_rejected ( & StorageSlot :: new_changed ( U256 :: from ( 9 ) , U256 :: from ( 3 ) ) ) ;
840+ assert_truncation_rejected ( & StorageSlot :: new_changed ( U256 :: from ( 9 ) , U256 :: ZERO ) ) ;
841+
842+ // `AcctDiff` at its minimum size (empty storage) and populated, for both the single-info
843+ // and two-info outcomes.
844+ assert_truncation_rejected ( & AcctDiff {
845+ outcome : InfoOutcome :: Created ( Cow :: Owned ( sample_info ( ) ) ) ,
846+ storage_diff : BTreeMap :: new ( ) ,
847+ } ) ;
848+ assert_truncation_rejected ( & AcctDiff {
849+ outcome : InfoOutcome :: Diff {
850+ old : Cow :: Owned ( sample_info ( ) ) ,
851+ new : Cow :: Owned ( sample_info ( ) ) ,
852+ } ,
853+ storage_diff : vec ! [ (
854+ U256 :: from( 3 ) ,
855+ Cow :: Owned ( StorageSlot :: new_changed( U256 :: ZERO , U256 :: from( 9 ) ) ) ,
856+ ) ]
857+ . into_iter ( )
858+ . collect ( ) ,
859+ } ) ;
860+
861+ // `Bytecode`: legacy, empty, and EIP-7702.
862+ assert_truncation_rejected ( & Bytecode :: new_raw ( Bytes :: from ( vec ! [ 1 , 2 , 3 ] ) ) ) ;
863+ assert_truncation_rejected ( & Bytecode :: new_raw ( Bytes :: new ( ) ) ) ;
864+ assert_truncation_rejected ( & Bytecode :: new_eip7702 ( Address :: repeat_byte ( 0xb ) ) ) ;
865+
866+ // `BundleStateIndex`: empty and populated.
867+ assert_truncation_rejected ( & BundleStateIndex :: default ( ) ) ;
868+ assert_truncation_rejected ( & BundleStateIndex {
869+ state : vec ! [ (
870+ Address :: repeat_byte( 0xa ) ,
871+ AcctDiff {
872+ outcome: InfoOutcome :: Created ( Cow :: Owned ( sample_info( ) ) ) ,
873+ storage_diff: BTreeMap :: new( ) ,
874+ } ,
875+ ) ]
876+ . into_iter ( )
877+ . collect ( ) ,
878+ new_contracts : vec ! [ (
879+ B256 :: repeat_byte( 0xa ) ,
880+ Cow :: Owned ( Bytecode :: new_raw( Bytes :: from( vec![ 1 , 2 , 3 ] ) ) ) ,
881+ ) ]
882+ . into_iter ( )
883+ . collect ( ) ,
884+ } ) ;
885+ }
886+
887+ #[ test]
888+ fn acct_diff_minimum_size_boundary ( ) {
889+ let created = AcctDiff {
890+ outcome : InfoOutcome :: Created ( Cow :: Owned ( sample_info ( ) ) ) ,
891+ storage_diff : BTreeMap :: new ( ) ,
892+ } ;
893+ let enc = created. encoded ( ) ;
894+
895+ // An empty-storage created/destroyed diff is exactly the minimum encodable size.
896+ assert_eq ! ( enc. len( ) , ACCT_DIFF_MIN_BYTES ) ;
897+
898+ // Exactly the minimum decodes...
899+ let decoded =
900+ <AcctDiff < ' static > as JournalDecode >:: decode ( & mut enc. as_ref ( ) ) . expect ( "min decodes" ) ;
901+ assert_eq ! ( decoded, created) ;
902+
903+ // ...and one byte short overruns against the whole `AcctDiff` via the up-front length
904+ // check, not against a phantom second outcome. This is the exact regression the fix
905+ // guards: previously the check ran after the outcome was consumed and demanded another
906+ // `ACCT_DIFF_MIN_BYTES`, rejecting this valid minimum diff.
907+ let mut short = & enc[ ..enc. len ( ) - 1 ] ;
908+ let error = <AcctDiff < ' static > as JournalDecode >:: decode ( & mut short) . unwrap_err ( ) ;
909+ assert_eq ! (
910+ error,
911+ JournalDecodeError :: Overrun {
912+ ty_name: "AcctDiff" ,
913+ expected: ACCT_DIFF_MIN_BYTES ,
914+ remaining: ACCT_DIFF_MIN_BYTES - 1 ,
915+ }
916+ ) ;
917+ }
918+
919+ #[ test]
920+ fn invalid_tags_are_rejected ( ) {
921+ // `InfoOutcome` accepts only tags 0..=2; the tag is checked before any payload.
922+ let error = <InfoOutcome < ' static > as JournalDecode >:: decode ( & mut & [ 3u8 ] [ ..] ) . unwrap_err ( ) ;
923+ assert_eq ! (
924+ error,
925+ JournalDecodeError :: InvalidTag { ty_name: "InfoOutcome" , tag: 3 , max_expected: 2 }
926+ ) ;
927+
928+ // `StorageSlot` validates its tag only after consuming the 32-byte present value, so the
929+ // buffer must carry that value for the tag check to be reached.
930+ let mut slot_buf = vec ! [ 4u8 ] ;
931+ slot_buf. extend_from_slice ( & [ 0u8 ; 32 ] ) ;
932+ let error = StorageSlot :: decode ( & mut slot_buf. as_slice ( ) ) . unwrap_err ( ) ;
933+ assert_eq ! (
934+ error,
935+ JournalDecodeError :: InvalidTag { ty_name: "StorageSlot" , tag: 4 , max_expected: 3 }
936+ ) ;
937+
938+ // `Bytecode` uses tags 0 and 2; 1 and 3 are invalid. The body length prefix must be
939+ // present and consumable before the tag is validated.
940+ let error = Bytecode :: decode ( & mut & [ 1u8 , 0 , 0 , 0 , 0 ] [ ..] ) . unwrap_err ( ) ;
941+ assert_eq ! (
942+ error,
943+ JournalDecodeError :: InvalidTag { ty_name: "Bytecode" , tag: 1 , max_expected: 2 }
944+ ) ;
945+ let error = Bytecode :: decode ( & mut & [ 3u8 , 0 , 0 , 0 , 0 ] [ ..] ) . unwrap_err ( ) ;
946+ assert_eq ! (
947+ error,
948+ JournalDecodeError :: InvalidTag { ty_name: "Bytecode" , tag: 3 , max_expected: 2 }
949+ ) ;
950+
951+ // `Option` accepts only tags 0 and 1.
952+ let error = <Option < u8 > as JournalDecode >:: decode ( & mut & [ 2u8 ] [ ..] ) . unwrap_err ( ) ;
953+ assert_eq ! (
954+ error,
955+ JournalDecodeError :: InvalidTag { ty_name: "Option<T>" , tag: 2 , max_expected: 1 }
956+ ) ;
957+ }
958+
959+ #[ test]
960+ fn unchanged_storage_slot_is_rejected ( ) {
961+ // The journal must never contain unchanged storage; decoding one is a hard error. As
962+ // above, the tag is only reached after the present value is consumed.
963+ let mut buf = vec ! [ TAG_STORAGE_UNCHANGED ] ;
964+ buf. extend_from_slice ( & [ 0u8 ; 32 ] ) ;
965+ let error = StorageSlot :: decode ( & mut buf. as_slice ( ) ) . unwrap_err ( ) ;
966+ assert_eq ! ( error, JournalDecodeError :: UnchangedStorage ) ;
967+ }
968+
969+ // Regression: `AcctDiff::encode` must write the count of *changed* slots, not the full map
970+ // length. Unchanged slots are never serialized (and would panic in `StorageSlot::encode`),
971+ // so a count that includes them makes the decoder read phantom slots: an overrun for a
972+ // standalone diff, or silent corruption of the trailing data inside a `BundleStateIndex`.
973+ #[ test]
974+ fn acct_diff_encode_drops_unchanged_storage ( ) {
975+ let changed = StorageSlot :: new_changed ( U256 :: from ( 1 ) , U256 :: from ( 2 ) ) ;
976+ let unchanged = StorageSlot :: new ( U256 :: from ( 7 ) ) ;
977+
978+ let with_unchanged = AcctDiff {
979+ outcome : InfoOutcome :: Created ( Cow :: Owned ( sample_info ( ) ) ) ,
980+ storage_diff : vec ! [
981+ ( U256 :: from( 3 ) , Cow :: Owned ( changed) ) ,
982+ ( U256 :: from( 4 ) , Cow :: Owned ( unchanged) ) ,
983+ ]
984+ . into_iter ( )
985+ . collect ( ) ,
986+ } ;
987+
988+ // Only the changed slot is on the wire, so the decoded diff omits the unchanged slot.
989+ let expected = AcctDiff {
990+ outcome : InfoOutcome :: Created ( Cow :: Owned ( sample_info ( ) ) ) ,
991+ storage_diff : vec ! [ ( U256 :: from( 3 ) , Cow :: Owned ( changed) ) ] . into_iter ( ) . collect ( ) ,
992+ } ;
993+
994+ let enc = with_unchanged. encoded ( ) ;
995+ // `serialized_size` already excludes unchanged slots; the encoding must agree.
996+ assert_eq ! ( enc. len( ) , with_unchanged. serialized_size( ) ) ;
997+ let decoded = <AcctDiff < ' static > as JournalDecode >:: decode ( & mut enc. as_ref ( ) )
998+ . expect ( "diff with an unchanged slot should decode" ) ;
999+ assert_eq ! ( decoded, expected) ;
1000+ }
1001+
1002+ #[ test]
1003+ fn bundle_state_index_encode_drops_unchanged_storage ( ) {
1004+ let changed = StorageSlot :: new_changed ( U256 :: from ( 1 ) , U256 :: from ( 2 ) ) ;
1005+ let unchanged = StorageSlot :: new ( U256 :: from ( 7 ) ) ;
1006+
1007+ let bsi = BundleStateIndex {
1008+ state : vec ! [ (
1009+ Address :: repeat_byte( 0xa ) ,
1010+ AcctDiff {
1011+ outcome: InfoOutcome :: Created ( Cow :: Owned ( sample_info( ) ) ) ,
1012+ storage_diff: vec![
1013+ ( U256 :: from( 3 ) , Cow :: Owned ( changed) ) ,
1014+ ( U256 :: from( 4 ) , Cow :: Owned ( unchanged) ) ,
1015+ ]
1016+ . into_iter( )
1017+ . collect( ) ,
1018+ } ,
1019+ ) ]
1020+ . into_iter ( )
1021+ . collect ( ) ,
1022+ // Trailing contracts that an over-large slot count would consume as phantom storage.
1023+ new_contracts : vec ! [ (
1024+ B256 :: repeat_byte( 0xb ) ,
1025+ Cow :: Owned ( Bytecode :: new_raw( Bytes :: from( vec![ 1 , 2 , 3 ] ) ) ) ,
1026+ ) ]
1027+ . into_iter ( )
1028+ . collect ( ) ,
1029+ } ;
1030+
1031+ let expected = BundleStateIndex {
1032+ state : vec ! [ (
1033+ Address :: repeat_byte( 0xa ) ,
1034+ AcctDiff {
1035+ outcome: InfoOutcome :: Created ( Cow :: Owned ( sample_info( ) ) ) ,
1036+ storage_diff: vec![ ( U256 :: from( 3 ) , Cow :: Owned ( changed) ) ] . into_iter( ) . collect( ) ,
1037+ } ,
1038+ ) ]
1039+ . into_iter ( )
1040+ . collect ( ) ,
1041+ new_contracts : vec ! [ (
1042+ B256 :: repeat_byte( 0xb ) ,
1043+ Cow :: Owned ( Bytecode :: new_raw( Bytes :: from( vec![ 1 , 2 , 3 ] ) ) ) ,
1044+ ) ]
1045+ . into_iter ( )
1046+ . collect ( ) ,
1047+ } ;
1048+
1049+ let enc = bsi. encoded ( ) ;
1050+ assert_eq ! ( enc. len( ) , bsi. serialized_size( ) ) ;
1051+ let decoded = <BundleStateIndex < ' static > as JournalDecode >:: decode ( & mut enc. as_ref ( ) )
1052+ . expect ( "index with an unchanged slot should decode" ) ;
1053+ assert_eq ! ( decoded, expected) ;
1054+ }
7291055}
0 commit comments