@@ -119,12 +119,46 @@ impl AuthoringMetaV2 {
119119 Ok ( AuthoringMetaV2 { words } )
120120 }
121121
122+ /// Reads the meta hash the contract is described by over a single RPC.
123+ ///
124+ /// # Arguments
125+ ///
126+ /// * `contract_address` - The address of the contract.
127+ /// * `rpc` - The RPC URL to read over.
128+ ///
129+ /// # Returns
130+ ///
131+ /// The meta hash if successful, or an AuthoringMetaV2Error if an error occurs.
132+ async fn fetch_metahash (
133+ contract_address : Address ,
134+ rpc : & str ,
135+ ) -> Result < [ u8 ; 32 ] , AuthoringMetaV2Error > {
136+ let provider = ProviderBuilder :: new ( ) . connect_http ( rpc. parse ( ) ?) ;
137+
138+ if !implements_i_described_by_meta_v1 ( & provider, contract_address) . await ? {
139+ return Err ( AuthoringMetaV2Error :: HasNoWords ) ;
140+ }
141+
142+ let call = IDescribedByMetaV1 :: describedByMetaV1Call { } ;
143+ let tx = TransactionRequest :: default ( )
144+ . to ( contract_address)
145+ . input ( call. abi_encode ( ) . into ( ) ) ;
146+ let bytes = provider. call ( tx) . await ?;
147+ let FixedBytes ( metahash) =
148+ IDescribedByMetaV1 :: describedByMetaV1Call:: abi_decode_returns ( & bytes) ?;
149+
150+ Ok ( metahash)
151+ }
152+
122153 /// Fetches the authoring meta for a contract that implements IDescribedByMetaV1
123154 /// from the metaboard.
124155 ///
125156 /// # Arguments
126157 ///
127158 /// * `contract_address` - The address of the contract.
159+ /// * `rpcs` - The RPC URLs, tried in order until one yields the meta hash.
160+ /// When none does, the failure of the last one tried is reported.
161+ /// * `metaboard_url` - The metaboard subgraph URL to query for the meta.
128162 ///
129163 /// # Returns
130164 ///
@@ -134,117 +168,59 @@ impl AuthoringMetaV2 {
134168 rpcs : Vec < String > ,
135169 metaboard_url : String ,
136170 ) -> Result < Self , FetchAuthoringMetaV2WordError > {
137- // build a read provider over the first RPC
138- let url = rpcs
139- . first ( )
140- . cloned ( )
141- . ok_or_else ( || FetchAuthoringMetaV2WordError {
142- contract_address,
143- rpcs : rpcs. clone ( ) ,
144- metaboard_url : metaboard_url. clone ( ) ,
145- error : AuthoringMetaV2Error :: NoRpcs ,
146- } ) ?
147- . parse ( )
148- . map_err ( |error : url:: ParseError | FetchAuthoringMetaV2WordError {
149- contract_address,
150- rpcs : rpcs. clone ( ) ,
151- metaboard_url : metaboard_url. clone ( ) ,
152- error : error. into ( ) ,
153- } ) ?;
154- let provider = ProviderBuilder :: new ( ) . connect_http ( url) ;
155-
156- let implements = implements_i_described_by_meta_v1 ( & provider, contract_address)
157- . await
158- . map_err ( |error| FetchAuthoringMetaV2WordError {
159- contract_address,
160- rpcs : rpcs. clone ( ) ,
161- metaboard_url : metaboard_url. clone ( ) ,
162- error : error. into ( ) ,
163- } ) ?;
164- if !implements {
165- return Err ( FetchAuthoringMetaV2WordError {
166- contract_address,
167- rpcs : rpcs. clone ( ) ,
168- metaboard_url : metaboard_url. clone ( ) ,
169- error : AuthoringMetaV2Error :: HasNoWords ,
170- } ) ;
171- }
172-
173- let call = IDescribedByMetaV1 :: describedByMetaV1Call { } ;
174- let tx = TransactionRequest :: default ( )
175- . to ( contract_address)
176- . input ( call. abi_encode ( ) . into ( ) ) ;
177- let bytes = provider
178- . call ( tx)
179- . await
180- . map_err ( |error| FetchAuthoringMetaV2WordError {
181- contract_address,
182- rpcs : rpcs. clone ( ) ,
183- metaboard_url : metaboard_url. clone ( ) ,
184- error : error. into ( ) ,
185- } ) ?;
186- let FixedBytes ( metahash) = IDescribedByMetaV1 :: describedByMetaV1Call:: abi_decode_returns (
187- & bytes,
188- )
189- . map_err ( |error| FetchAuthoringMetaV2WordError {
171+ let wrap_error = |error : AuthoringMetaV2Error | FetchAuthoringMetaV2WordError {
190172 contract_address,
191173 rpcs : rpcs. clone ( ) ,
192174 metaboard_url : metaboard_url. clone ( ) ,
193- error : error. into ( ) ,
194- } ) ?;
175+ error,
176+ } ;
177+
178+ let mut metahash = None ;
179+ let mut rpc_error = AuthoringMetaV2Error :: NoRpcs ;
180+ for rpc in & rpcs {
181+ match Self :: fetch_metahash ( contract_address, rpc) . await {
182+ Ok ( hash) => {
183+ metahash = Some ( hash) ;
184+ break ;
185+ }
186+ // The contract not implementing the interface is an answer, not
187+ // a failed read, and it is the same answer on every RPC of the
188+ // chain. Asking the rest cannot change it, and continuing would
189+ // replace it with whichever transport error the last RPC gave.
190+ Err ( error @ AuthoringMetaV2Error :: HasNoWords ) => return Err ( wrap_error ( error) ) ,
191+ Err ( error) => rpc_error = error,
192+ }
193+ }
194+ let Some ( metahash) = metahash else {
195+ return Err ( wrap_error ( rpc_error) ) ;
196+ } ;
195197
196198 // query the metaboard for the metas
197- let subgraph_client = MetaboardSubgraphClient :: new ( metaboard_url. parse ( ) . map_err (
198- |error : url:: ParseError | FetchAuthoringMetaV2WordError {
199- contract_address,
200- rpcs : rpcs. clone ( ) ,
201- metaboard_url : metaboard_url. clone ( ) ,
202- error : error. into ( ) ,
203- } ,
204- ) ?) ;
199+ let subgraph_client = MetaboardSubgraphClient :: new (
200+ metaboard_url
201+ . parse ( )
202+ . map_err ( |error : url:: ParseError | wrap_error ( error. into ( ) ) ) ?,
203+ ) ;
205204
206205 let metas = subgraph_client
207206 . get_metabytes_by_hash ( & metahash)
208207 . await
209- . map_err ( |error| FetchAuthoringMetaV2WordError {
210- contract_address,
211- rpcs : rpcs. clone ( ) ,
212- metaboard_url : metaboard_url. clone ( ) ,
213- error : error. into ( ) ,
214- } ) ?;
208+ . map_err ( |error| wrap_error ( error. into ( ) ) ) ?;
215209
216210 let meta_bytes = metas[ 0 ] . as_slice ( ) ;
217211 let meta_bytes_hash = keccak256 ( meta_bytes) ;
218212 if meta_bytes_hash. 0 != metahash {
219- return Err ( FetchAuthoringMetaV2WordError {
220- contract_address,
221- rpcs : rpcs. clone ( ) ,
222- metaboard_url : metaboard_url. clone ( ) ,
223- error : AuthoringMetaV2Error :: MetaHashMismatch {
224- expected : metahash. into ( ) ,
225- actual : meta_bytes_hash,
226- } ,
227- } ) ;
213+ return Err ( wrap_error ( AuthoringMetaV2Error :: MetaHashMismatch {
214+ expected : metahash. into ( ) ,
215+ actual : meta_bytes_hash,
216+ } ) ) ;
228217 }
229218
230- let meta = RainMetaDocumentV1Item :: cbor_decode ( meta_bytes) . map_err ( |error| {
231- FetchAuthoringMetaV2WordError {
232- contract_address,
233- rpcs : rpcs. clone ( ) ,
234- metaboard_url : metaboard_url. clone ( ) ,
235- error : error. into ( ) ,
236- }
237- } ) ?[ 0 ]
219+ let meta = RainMetaDocumentV1Item :: cbor_decode ( meta_bytes)
220+ . map_err ( |error| wrap_error ( error. into ( ) ) ) ?[ 0 ]
238221 . clone ( )
239222 . try_into ( )
240- . map_err (
241- |error : AuthoringMetaV2Error | FetchAuthoringMetaV2WordError {
242- contract_address,
243- rpcs,
244- metaboard_url,
245- error,
246- } ,
247- ) ?;
223+ . map_err ( wrap_error) ?;
248224
249225 Ok ( meta)
250226 }
@@ -541,6 +517,26 @@ mod tests {
541517 format ! ( "0x{}" , encode( item. cbor_encode( ) . unwrap( ) ) )
542518 }
543519
520+ /// Mocks a metaboard answering `metahash` with the three word authoring meta.
521+ /// `metahash` must be that document's own hash, or the returned bytes are
522+ /// rejected before they are decoded.
523+ fn mock_metaboard_words ( metaboard_server : & MockServer , metahash : [ u8 ; 32 ] ) {
524+ metaboard_server. mock ( |when, then| {
525+ when. method ( POST ) . path ( "/" ) . body_contains ( encode ( metahash) ) ;
526+ then. status ( 200 ) . json_body_obj ( & serde_json:: json!( {
527+ "data" : { "metaV1S" : [ metaboard_meta_entry( & authoring_meta_v2_cbor_hex( ) ) ] }
528+ } ) ) ;
529+ } ) ;
530+ }
531+
532+ /// Mocks an RPC that fails every request at the transport level.
533+ fn mock_dead_rpc ( rpc_server : & MockServer ) -> httpmock:: Mock < ' _ > {
534+ rpc_server. mock ( |when, then| {
535+ when. method ( POST ) . path ( "/" ) ;
536+ then. status ( 500 ) . body ( "rpc is down" ) ;
537+ } )
538+ }
539+
544540 #[ tokio:: test]
545541 async fn test_abi_decode_full_32_byte_word_kept_whole ( ) {
546542 let word = [ b'a' ; 32 ] ;
@@ -913,6 +909,134 @@ mod tests {
913909 }
914910 }
915911
912+ #[ tokio:: test]
913+ async fn test_fetch_for_contract_falls_over_to_a_later_rpc ( ) {
914+ let hash = meta_hex_hash ( & authoring_meta_v2_cbor_hex ( ) ) ;
915+ let dead_rpc_server = MockServer :: start_async ( ) . await ;
916+ let dead_rpc = mock_dead_rpc ( & dead_rpc_server) ;
917+ let live_rpc_server = MockServer :: start_async ( ) . await ;
918+ mock_described_by_rpc ( & live_rpc_server, hash) ;
919+ let metaboard_server = MockServer :: start_async ( ) . await ;
920+ mock_metaboard_words ( & metaboard_server, hash) ;
921+
922+ let meta = AuthoringMetaV2 :: fetch_for_contract (
923+ Address :: from ( [ 0u8 ; 20 ] ) ,
924+ vec ! [ dead_rpc_server. url( "/" ) , live_rpc_server. url( "/" ) ] ,
925+ metaboard_server. url ( "/" ) ,
926+ )
927+ . await
928+ . unwrap ( ) ;
929+
930+ assert ! ( dead_rpc. hits( ) > 0 ) ;
931+ assert_eq ! ( meta. words. len( ) , 3 ) ;
932+ assert_eq ! ( meta. words[ 0 ] . word, "test" ) ;
933+ assert_eq ! ( meta. words[ 2 ] . description, "description 3" ) ;
934+ }
935+
936+ #[ tokio:: test]
937+ async fn test_fetch_for_contract_falls_over_an_unparseable_rpc_url ( ) {
938+ let hash = meta_hex_hash ( & authoring_meta_v2_cbor_hex ( ) ) ;
939+ let live_rpc_server = MockServer :: start_async ( ) . await ;
940+ mock_described_by_rpc ( & live_rpc_server, hash) ;
941+ let metaboard_server = MockServer :: start_async ( ) . await ;
942+ mock_metaboard_words ( & metaboard_server, hash) ;
943+
944+ let meta = AuthoringMetaV2 :: fetch_for_contract (
945+ Address :: from ( [ 0u8 ; 20 ] ) ,
946+ vec ! [ "not a url" . to_string( ) , live_rpc_server. url( "/" ) ] ,
947+ metaboard_server. url ( "/" ) ,
948+ )
949+ . await
950+ . unwrap ( ) ;
951+
952+ assert_eq ! ( meta. words. len( ) , 3 ) ;
953+ assert_eq ! ( meta. words[ 1 ] . description, "description 2" ) ;
954+ }
955+
956+ /// A contract that says it does not implement the interface has answered,
957+ /// and the answer is a property of the contract, so it is the same on every
958+ /// RPC of the chain. The later RPCs are not asked, and HasNoWords is
959+ /// reported rather than being overwritten by whatever the last RPC said.
960+ ///
961+ /// Failing over here was harmless only while a dead RPC also produced
962+ /// HasNoWords. Once #175 and #154 made a failed probe its own error, the
963+ /// two stopped being the same thing.
964+ #[ tokio:: test]
965+ async fn test_fetch_for_contract_does_not_fall_over_a_contract_without_words ( ) {
966+ let rpc_server = MockServer :: start_async ( ) . await ;
967+ // erc165 check1 answers false, so the contract declines the interface
968+ rpc_server. mock ( |when, then| {
969+ when. method ( POST )
970+ . path ( "/" )
971+ . body_contains ( "01ffc9a701ffc9a7" ) ;
972+ then. status ( 200 ) . json_body_obj ( & serde_json:: json!( {
973+ "jsonrpc" : "2.0" , "id" : 1 , "result" : bool_word( false )
974+ } ) ) ;
975+ } ) ;
976+ let unused_rpc_server = MockServer :: start_async ( ) . await ;
977+ let unused_rpc = mock_dead_rpc ( & unused_rpc_server) ;
978+
979+ let result = AuthoringMetaV2 :: fetch_for_contract (
980+ Address :: from ( [ 0u8 ; 20 ] ) ,
981+ vec ! [ rpc_server. url( "/" ) , unused_rpc_server. url( "/" ) ] ,
982+ "http://metaboard.test/" . to_string ( ) ,
983+ )
984+ . await ;
985+
986+ assert_eq ! ( unused_rpc. hits( ) , 0 , "a later rpc was asked anyway" ) ;
987+ match result. unwrap_err ( ) . error {
988+ AuthoringMetaV2Error :: HasNoWords => { }
989+ other => panic ! ( "expected HasNoWords, got {:?}" , other) ,
990+ }
991+ }
992+
993+ #[ tokio:: test]
994+ async fn test_fetch_for_contract_stops_at_the_first_rpc_that_answers ( ) {
995+ let hash = meta_hex_hash ( & authoring_meta_v2_cbor_hex ( ) ) ;
996+ let live_rpc_server = MockServer :: start_async ( ) . await ;
997+ mock_described_by_rpc ( & live_rpc_server, hash) ;
998+ let unused_rpc_server = MockServer :: start_async ( ) . await ;
999+ let unused_rpc = mock_dead_rpc ( & unused_rpc_server) ;
1000+ let metaboard_server = MockServer :: start_async ( ) . await ;
1001+ mock_metaboard_words ( & metaboard_server, hash) ;
1002+
1003+ let meta = AuthoringMetaV2 :: fetch_for_contract (
1004+ Address :: from ( [ 0u8 ; 20 ] ) ,
1005+ vec ! [ live_rpc_server. url( "/" ) , unused_rpc_server. url( "/" ) ] ,
1006+ metaboard_server. url ( "/" ) ,
1007+ )
1008+ . await
1009+ . unwrap ( ) ;
1010+
1011+ assert_eq ! ( unused_rpc. hits( ) , 0 ) ;
1012+ assert_eq ! ( meta. words. len( ) , 3 ) ;
1013+ }
1014+
1015+ #[ tokio:: test]
1016+ async fn test_fetch_for_contract_all_rpcs_failing_reports_the_last_failure ( ) {
1017+ let dead_rpc_server = MockServer :: start_async ( ) . await ;
1018+ let dead_rpc = mock_dead_rpc ( & dead_rpc_server) ;
1019+
1020+ let result = AuthoringMetaV2 :: fetch_for_contract (
1021+ Address :: from ( [ 7u8 ; 20 ] ) ,
1022+ vec ! [ dead_rpc_server. url( "/" ) , "not a url" . to_string( ) ] ,
1023+ "http://metaboard.test/" . to_string ( ) ,
1024+ )
1025+ . await ;
1026+
1027+ let error = result. unwrap_err ( ) ;
1028+ assert ! ( dead_rpc. hits( ) > 0 ) ;
1029+ assert_eq ! (
1030+ error. rpcs,
1031+ vec![ dead_rpc_server. url( "/" ) , "not a url" . to_string( ) ]
1032+ ) ;
1033+ // the last rpc tried is the unparseable one, so its error is the reported one
1034+ match error. error {
1035+ AuthoringMetaV2Error :: UrlParseError ( _) => { }
1036+ other => panic ! ( "expected UrlParseError, got {:?}" , other) ,
1037+ }
1038+ }
1039+
9161040 #[ tokio:: test]
9171041 async fn test_fetch_for_contract_rejects_meta_bytes_not_matching_the_hash ( ) {
9181042 let hash = [ 1u8 ; 32 ] ;
0 commit comments