@@ -90,7 +90,35 @@ impl TimeEventValidator {
9090 // Compare the root hash in the TimeEvent's AnchorProof to the root hash that was actually
9191 // included in the transaction onchain. We compare hashes (not full CIDs) because the
9292 // blockchain only stores the hash - the codec is not preserved on-chain.
93- if chain_proof. root_cid . hash ( ) != event. proof ( ) . root ( ) . hash ( ) {
93+ let chain_digest = chain_proof. root_cid . hash ( ) . digest ( ) ;
94+ let event_proof_root = event. proof ( ) . root ( ) ;
95+ let proof_digest = event_proof_root. hash ( ) . digest ( ) ;
96+
97+ if chain_digest != proof_digest {
98+ // During clay self-anchor rollout, some anchors were made with the incorrect data.
99+ // anchor-evm left the codec byte (0x20) as a prefix, resulting in the last byte of
100+ // data being discarded. As a fallback, we allow matching on 31 bytes before 2026-02-01
101+ if chain_id. to_string ( ) == "eip155:100"
102+ && chain_proof. timestamp . as_unix_ts ( ) < 1769904000u64
103+ && chain_digest. first ( ) == Some ( & 0x20 )
104+ {
105+ warn ! (
106+ "falling back to relaxed check for codec-shifted anchor (chain digest={}, proof digest={})" ,
107+ hex:: encode( chain_digest) ,
108+ hex:: encode( proof_digest) ,
109+ ) ;
110+
111+ if chain_digest. get ( 1 ..) == proof_digest. get ( ..31 ) {
112+ warn ! ( "relaxed check passed, accepting proof with shifted digest" ) ;
113+ return Ok ( chain_proof) ;
114+ }
115+
116+ return Err ( eth_rpc:: Error :: InvalidProof ( format ! (
117+ "relaxed check failed: shifted digest mismatch (chain digest={}, proof digest={})" ,
118+ hex:: encode( chain_digest) ,
119+ hex:: encode( proof_digest)
120+ ) ) ) ;
121+ }
94122 return Err ( eth_rpc:: Error :: InvalidProof ( format ! (
95123 "the root hash is not in the transaction (anchor proof root={}, blockchain transaction root={})" ,
96124 event. proof( ) . root( ) ,
@@ -347,4 +375,109 @@ mod test {
347375 } ,
348376 }
349377 }
378+
379+ /// Create a Gnosis chain time event for testing the codec-shifted digest fallback
380+ fn time_event_gnosis ( ) -> unvalidated:: TimeEvent {
381+ unvalidated:: Builder :: time ( )
382+ . with_id (
383+ Cid :: from_str ( "bagcqcerar2aga7747dm6fota3iipogz4q55gkaamcx2weebs6emvtvie2oha" )
384+ . unwrap ( ) ,
385+ )
386+ . with_tx (
387+ "eip155:100" . into ( ) , // Gnosis chain
388+ Cid :: from_str ( "bagjqcgzadp7fstu7fz5tfi474ugsjqx5h6yvevn54w5m4akayhegdsonwciq" )
389+ . unwrap ( ) ,
390+ "f(bytes32)" . into ( ) ,
391+ )
392+ . with_root ( 0 , ipld_core:: ipld! { [ Cid :: from_str( "bagcqcerae5oqoglzjjgz53enwsttl7mqglp5eoh2llzbbvfktmzxleeiffbq" ) . unwrap( ) , Ipld :: Null , Cid :: from_str( "bafyreifjkogkhyqvr2gtymsndsfg3wpr7fg4q5r3opmdxoddfj4s2dyuoa" ) . unwrap( ) ] } )
393+ . build ( )
394+ . expect ( "should be valid time event" )
395+ }
396+
397+ /// Creates a CID with a shifted digest simulating the anchor-evm bug:
398+ /// [0x20, original[0..31]] instead of [original[0..32]]
399+ fn create_shifted_digest_cid ( original_cid : & Cid ) -> Cid {
400+ let original_digest = original_cid. hash ( ) . digest ( ) ;
401+ let mut shifted = [ 0u8 ; 32 ] ;
402+ shifted[ 0 ] = 0x20 ; // codec byte that was accidentally included
403+ shifted[ 1 ..] . copy_from_slice ( & original_digest[ ..31 ] ) ;
404+
405+ let mh = multihash:: Multihash :: < 64 > :: wrap ( 0x12 , & shifted) . expect ( "valid multihash" ) ;
406+ Cid :: new_v1 ( original_cid. codec ( ) , mh)
407+ }
408+
409+ async fn get_mock_gnosis_provider (
410+ input : unvalidated:: AnchorProof ,
411+ root_cid : Cid ,
412+ timestamp : Timestamp ,
413+ ) -> TimeEventValidator {
414+ let mut mock_provider = MockEthRpcProviderTest :: new ( ) ;
415+ let chain_id = caip2:: ChainId :: from_str ( "eip155:100" ) . expect ( "eip155:100 is a valid chain" ) ;
416+
417+ mock_provider
418+ . expect_chain_id ( )
419+ . once ( )
420+ . return_const ( chain_id. clone ( ) ) ;
421+ mock_provider
422+ . expect_get_chain_inclusion_proof ( )
423+ . once ( )
424+ . with ( predicate:: eq ( input) )
425+ . return_once ( move |_| {
426+ Ok ( eth_rpc:: ChainInclusionProof {
427+ timestamp,
428+ root_cid,
429+ block_hash : "0x0" . to_string ( ) ,
430+ metadata : ChainProofMetadata {
431+ chain_id,
432+ tx_hash : "0x0" . to_string ( ) ,
433+ tx_input : "0x0" . to_string ( ) ,
434+ } ,
435+ } )
436+ } ) ;
437+ TimeEventValidator :: new_with_providers ( vec ! [ Arc :: new( mock_provider) ] )
438+ }
439+
440+ #[ test( tokio:: test) ]
441+ async fn valid_proof_codec_shifted_digest_gnosis ( ) {
442+ let event = time_event_gnosis ( ) ;
443+ let shifted_root = create_shifted_digest_cid ( & event. proof ( ) . root ( ) ) ;
444+ // Timestamp before 2026-02-01 cutoff (1769904000)
445+ let old_timestamp = Timestamp :: from_unix_ts ( 1700000000 ) ;
446+
447+ let verifier =
448+ get_mock_gnosis_provider ( event. proof ( ) . clone ( ) , shifted_root, old_timestamp. clone ( ) )
449+ . await ;
450+
451+ match verifier. validate_chain_inclusion ( & event) . await {
452+ Ok ( proof) => {
453+ assert_eq ! ( proof. timestamp, old_timestamp) ;
454+ }
455+ Err ( e) => panic ! ( "should have passed with relaxed check: {:?}" , e) ,
456+ }
457+ }
458+
459+ #[ test( tokio:: test) ]
460+ async fn invalid_proof_codec_shifted_after_cutoff ( ) {
461+ let event = time_event_gnosis ( ) ;
462+ let shifted_root = create_shifted_digest_cid ( & event. proof ( ) . root ( ) ) ;
463+ // Timestamp AFTER 2026-02-01 cutoff - should reject
464+ let future_timestamp = Timestamp :: from_unix_ts ( 1769904001 ) ;
465+
466+ let verifier =
467+ get_mock_gnosis_provider ( event. proof ( ) . clone ( ) , shifted_root, future_timestamp) . await ;
468+
469+ match verifier. validate_chain_inclusion ( & event) . await {
470+ Ok ( v) => panic ! ( "should have failed after cutoff: {:?}" , v) ,
471+ Err ( e) => match e {
472+ eth_rpc:: Error :: InvalidProof ( msg) => {
473+ assert ! (
474+ msg. contains( "the root hash is not in the transaction" ) ,
475+ "{}" ,
476+ msg
477+ ) ;
478+ }
479+ err => panic ! ( "got wrong error: {:?}" , err) ,
480+ } ,
481+ }
482+ }
350483}
0 commit comments