Skip to content

Commit 8648da6

Browse files
Merge pull request #311 from rainlanguage/restore-250
fail over across every RPC in fetch_for_contract
2 parents 1aa94e4 + a52b3e5 commit 8648da6

1 file changed

Lines changed: 218 additions & 94 deletions

File tree

  • crates/cli/src/meta/types/authoring

‎crates/cli/src/meta/types/authoring/v2.rs‎

Lines changed: 218 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)