diff --git a/Cargo.lock b/Cargo.lock index f744fc2f..06e1850a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,8 +602,9 @@ dependencies = [ [[package]] name = "canhttp" version = "0.1.0" -source = "git+https://github.com/dfinity/evm-rpc-canister?rev=c1d9a12eb9d3af92671dc07963c8887ec178fcbc#c1d9a12eb9d3af92671dc07963c8887ec178fcbc" +source = "git+https://github.com/dfinity/evm-rpc-canister?rev=1aeeca3bdcb86ce4493b71e4117f65ab78475396#1aeeca3bdcb86ce4493b71e4117f65ab78475396" dependencies = [ + "assert_matches", "futures-util", "http 1.3.1", "ic-cdk", @@ -4029,6 +4030,7 @@ dependencies = [ name = "sol_rpc_int_tests" version = "0.1.0" dependencies = [ + "assert_matches", "async-trait", "candid", "canlog", diff --git a/Cargo.toml b/Cargo.toml index 4f24564c..69f4abe6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ assert_matches = "1.5.0" async-trait = "0.1.88" candid = "0.10.13" candid_parser = "0.1.4" -canhttp = { git = "https://github.com/dfinity/evm-rpc-canister", rev = "c1d9a12eb9d3af92671dc07963c8887ec178fcbc" } +canhttp = { git = "https://github.com/dfinity/evm-rpc-canister", rev = "1aeeca3bdcb86ce4493b71e4117f65ab78475396" } ciborium = "0.2.2" # Transitive dependency of ic-ed25519 # See https://forum.dfinity.org/t/module-imports-function-wbindgen-describe-from-wbindgen-placeholder-that-is-not-exported-by-the-runtime/11545/8 diff --git a/canister/Cargo.toml b/canister/Cargo.toml index 2136e070..af8bc78c 100644 --- a/canister/Cargo.toml +++ b/canister/Cargo.toml @@ -43,4 +43,4 @@ thiserror = { workspace = true } [dev-dependencies] candid_parser = { workspace = true } -proptest = { workspace = true } \ No newline at end of file +proptest = { workspace = true } diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index 3abc44d3..f6a1fdce 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -150,6 +150,9 @@ type GetSlotParams = record { minContextSlot: opt nat64; }; +// Represents the result of a generic RPC request. +type RequestResult = variant { Ok : text; Err : RpcError }; + // A string used as a regex pattern. type Regex = text; @@ -205,4 +208,7 @@ service : (InstallArgs,) -> { // Call the Solana `getSlot` RPC method and return the resulting slot. getSlot : (RpcSources, opt RpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); -}; \ No newline at end of file + + // Make a generic RPC request that sends the given json_rpc_payload. + request : (RpcSource, json_rpc_paylod: text, max_response_bytes: nat64) -> (RequestResult) +}; diff --git a/canister/src/constants.rs b/canister/src/constants.rs index bfc06759..7b0d8638 100644 --- a/canister/src/constants.rs +++ b/canister/src/constants.rs @@ -2,6 +2,7 @@ // as processing fee. pub const COLLATERAL_CYCLES_PER_NODE: u128 = 10_000_000; +pub const CONTENT_TYPE_HEADER_LOWERCASE: &str = "content-type"; pub const CONTENT_TYPE_VALUE: &str = "application/json"; pub const API_KEY_REPLACE_STRING: &str = "{API_KEY}"; diff --git a/canister/src/http/errors.rs b/canister/src/http/errors.rs index 02c2cc15..d71ff9d4 100644 --- a/canister/src/http/errors.rs +++ b/canister/src/http/errors.rs @@ -1,6 +1,9 @@ use canhttp::{ http::{ - json::{JsonRequestConversionError, JsonResponseConversionError}, + json::{ + ConsistentResponseIdFilterError, JsonRequestConversionError, + JsonResponseConversionError, + }, FilterNonSuccessfulHttpResponseError, HttpRequestConversionError, HttpResponseConversionError, }, @@ -22,6 +25,8 @@ pub enum HttpClientError { UnsuccessfulHttpResponse(FilterNonSuccessfulHttpResponseError>), #[error("Error converting response to JSON: {0}")] InvalidJsonResponse(JsonResponseConversionError), + #[error("Invalid JSON-RPC response ID: {0}")] + InvalidJsonResponseId(ConsistentResponseIdFilterError), } impl From for HttpClientError { @@ -71,6 +76,7 @@ impl From for RpcError { body: String::from_utf8_lossy(response.body()).to_string(), parsing_error: None, }), + HttpClientError::InvalidJsonResponseId(e) => RpcError::ValidationError(e.to_string()), } } } @@ -82,6 +88,7 @@ impl HttpsOutcallError for HttpClientError { HttpClientError::NotHandledError(_) | HttpClientError::CyclesAccountingError(_) | HttpClientError::UnsuccessfulHttpResponse(_) + | HttpClientError::InvalidJsonResponseId(_) | HttpClientError::InvalidJsonResponse(_) => false, } } diff --git a/canister/src/http/mod.rs b/canister/src/http/mod.rs index 8d5d6365..74efc734 100644 --- a/canister/src/http/mod.rs +++ b/canister/src/http/mod.rs @@ -10,7 +10,8 @@ use canhttp::{ convert::ConvertRequestLayer, http::{ json::{ - HttpJsonRpcRequest, HttpJsonRpcResponse, JsonRequestConverter, JsonResponseConverter, + CreateJsonRpcIdFilter, HttpJsonRpcRequest, HttpJsonRpcResponse, JsonRequestConverter, + JsonResponseConverter, }, FilterNonSuccessfulHttpResponse, HttpRequestConverter, HttpResponseConverter, }, @@ -73,6 +74,7 @@ where ); }), ) + .filter_response(CreateJsonRpcIdFilter::new()) .layer(service_request_builder()) .convert_response(JsonResponseConverter::new()) .convert_response(FilterNonSuccessfulHttpResponse) diff --git a/canister/src/main.rs b/canister/src/main.rs index 1642dbb9..aad52cb9 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -6,11 +6,12 @@ use sol_rpc_canister::{ http_types, lifecycle, logs::Priority, providers::{get_provider, PROVIDERS}, + rpc_client, state::{mutate_state, read_state}, }; use sol_rpc_types::{ - GetSlotParams, MultiRpcResult, RpcAccess, RpcConfig, RpcSources, SupportedRpcProvider, - SupportedRpcProviderId, + GetSlotParams, MultiRpcResult, RpcAccess, RpcConfig, RpcError, RpcResult, RpcSource, + RpcSources, SupportedRpcProvider, SupportedRpcProviderId, }; use solana_clock::Slot; use std::str::FromStr; @@ -84,6 +85,21 @@ async fn get_slot( } } +#[update] +#[candid_method] +async fn request( + provider: RpcSource, + json_rpc_payload: String, + max_response_bytes: u64, +) -> RpcResult { + let request: canhttp::http::json::JsonRpcRequest = + serde_json::from_str(&json_rpc_payload) + .map_err(|e| RpcError::ValidationError(format!("Invalid JSON RPC request: {e}")))?; + rpc_client::call(&provider, request, max_response_bytes) + .await + .map(|value: serde_json::Value| value.to_string()) +} + #[query(hidden = true)] fn http_request(request: http_types::HttpRequest) -> http_types::HttpResponse { match request.path() { diff --git a/canister/src/rpc_client/mod.rs b/canister/src/rpc_client/mod.rs index 0329aee0..745def9c 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -7,6 +7,7 @@ use crate::{ providers::Providers, rpc_client::sol_rpc::{ResponseSizeEstimate, ResponseTransform, HEADER_SIZE_LIMIT}, }; +use canhttp::http::json::JsonRpcRequest; use canlog::log; use serde::{de::DeserializeOwned, Serialize}; use sol_rpc_types::{ @@ -19,6 +20,25 @@ use std::{ fmt::Debug, }; +pub async fn call( + provider: &RpcSource, + request: JsonRpcRequest, + max_response_size: u64, +) -> Result +where + I: Serialize + Clone + Debug, + O: Debug + DeserializeOwned, +{ + sol_rpc::call::<_, _>( + false, + provider, + request, + ResponseSizeEstimate::new(max_response_size), + &Some(ResponseTransform::Raw), + ) + .await +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SolRpcClient { providers: Providers, @@ -58,7 +78,7 @@ impl SolRpcClient { /// there is no single point of failure. async fn parallel_call( &self, - method: impl Into + Clone, + method: impl Into, params: I, response_size_estimate: ResponseSizeEstimate, response_transform: &Option, @@ -68,6 +88,7 @@ impl SolRpcClient { O: Debug + DeserializeOwned, { let providers = self.providers(); + let request = JsonRpcRequest::new(method, params); let results = { let mut fut = Vec::with_capacity(providers.len()); for provider in providers { @@ -78,9 +99,9 @@ impl SolRpcClient { ); fut.push(async { sol_rpc::call::<_, _>( + true, provider, - method.clone(), - params.clone(), + request.clone(), response_size_estimate, response_transform, ) diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index 3cab5fe3..bd12649f 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -41,19 +41,28 @@ pub const MAX_PAYLOAD_SIZE: u64 = HTTP_MAX_SIZE - HEADER_SIZE_LIMIT; pub enum ResponseTransform { #[n(0)] GetSlot, + #[n(1)] + Raw, } impl ResponseTransform { fn apply(&self, body_bytes: &mut Vec) { + use serde_json::{from_slice, to_vec, Value}; + fn redact_response(body: &mut Vec) where T: Serialize + DeserializeOwned, { - let response: JsonRpcResponse = match serde_json::from_slice(body) { + let response: JsonRpcResponse = match from_slice(body) { Ok(response) => response, Err(_) => return, }; - *body = serde_json::to_vec(&response).expect("BUG: failed to serialize response"); + *body = to_vec(&response).expect("BUG: failed to serialize response"); + } + + fn canonicalize(text: &[u8]) -> Option> { + let json = from_slice::(text).ok()?; + to_vec(&json).ok() } match self { @@ -61,6 +70,11 @@ impl ResponseTransform { // add a unit test simulating consensus when the providers // return slightly differing results. Self::GetSlot => redact_response::(body_bytes), + Self::Raw => { + if let Some(bytes) = canonicalize(body_bytes) { + *body_bytes = bytes + } + } } } } @@ -104,9 +118,9 @@ impl fmt::Display for ResponseSizeEstimate { /// Calls a JSON-RPC method at the specified URL. pub async fn call( + retry: bool, provider: &RpcSource, - method: impl Into, - params: I, + request_body: JsonRpcRequest, response_size_estimate: ResponseSizeEstimate, response_transform: &Option, ) -> Result @@ -135,10 +149,10 @@ where "cleanup_response".to_owned(), transform_op.clone(), )) - .body(JsonRpcRequest::new(method, params)) + .body(request_body) .expect("BUG: invalid request"); - let mut client = http_client(true); + let mut client = http_client(retry); let response = client.call(request).await?; match response.into_body().into_result() { Ok(r) => Ok(r), diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 796f39a8..6375d2d5 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -22,6 +22,7 @@ sol_rpc_client = { path = "../libs/client" } sol_rpc_types = { path = "../libs/types" } [dev-dependencies] +assert_matches = { workspace = true } futures = { workspace = true } solana-client = { workspace = true } tokio = { workspace = true } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index a1548abe..2ee93312 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -3,6 +3,9 @@ use candid::{decode_args, encode_args, utils::ArgumentEncoder, CandidType, Encod use canlog::{Log, LogEntry}; use ic_cdk::api::call::RejectionCode; use pocket_ic::{ + common::rest::{ + CanisterHttpReject, CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse, + }, management_canister::{CanisterId, CanisterSettings}, nonblocking::PocketIc, PocketIcBuilder, RejectCode, RejectResponse, @@ -16,6 +19,11 @@ use sol_rpc_client::{Runtime, SolRpcClient}; use sol_rpc_types::{InstallArgs, SupportedRpcProviderId}; use std::{path::PathBuf, time::Duration}; +pub mod mock; +use mock::MockOutcall; + +const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000; +const MAX_TICKS: usize = 10; pub const DEFAULT_CALLER_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x01]); pub const DEFAULT_CONTROLLER_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); pub const ADDITIONAL_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x03]); @@ -100,6 +108,7 @@ impl Setup { PocketIcRuntime { env: &self.env, caller: self.caller, + mock_strategy: None, } } @@ -119,6 +128,19 @@ impl Setup { } } +async fn tick_until_http_request(env: &PocketIc) -> Vec { + let mut requests = Vec::new(); + for _ in 0..MAX_TICKS { + requests = env.get_canister_http().await; + if !requests.is_empty() { + break; + } + env.tick().await; + env.advance_time(Duration::from_nanos(1)).await; + } + requests +} + fn sol_rpc_wasm() -> Vec { ic_test_utilities_load_wasm::load_wasm( PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("../canister"), @@ -131,6 +153,7 @@ fn sol_rpc_wasm() -> Vec { pub struct PocketIcRuntime<'a> { env: &'a PocketIc, caller: Principal, + mock_strategy: Option, } #[async_trait] @@ -143,14 +166,17 @@ impl Runtime for PocketIcRuntime<'_> { _cycles: u128, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static, + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, { - PocketIcRuntime::decode_call_result( - self.env - .update_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) - .await, - ) + let message_id = self + .env + .submit_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) + .await + .expect("failed to submit call"); + self.execute_mock().await; + let result = self.env.await_call(message_id).await; + PocketIcRuntime::decode_call_result(result) } async fn query_call( @@ -160,8 +186,8 @@ impl Runtime for PocketIcRuntime<'_> { args: In, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static, + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, { PocketIcRuntime::decode_call_result( self.env @@ -183,7 +209,7 @@ impl PocketIcRuntime<'_> { result: Result, RejectResponse>, ) -> Result where - Out: CandidType + DeserializeOwned + 'static, + Out: CandidType + DeserializeOwned, { match result { Ok(bytes) => decode_args(&bytes).map(|(res,)| res).map_err(|e| { @@ -209,6 +235,72 @@ impl PocketIcRuntime<'_> { } } } + + fn with_strategy(self, strategy: MockStrategy) -> Self { + Self { + mock_strategy: Some(strategy), + ..self + } + } + + async fn execute_mock(&self) { + match &self.mock_strategy { + None => (), + Some(MockStrategy::Mock(mock)) => { + self.mock_http_once_inner(mock).await; + while self.try_mock_http_inner(mock).await {} + } + Some(MockStrategy::MockOnce(mock)) => { + self.mock_http_once_inner(mock).await; + } + } + } + + async fn mock_http_once_inner(&self, mock: &MockOutcall) { + if !self.try_mock_http_inner(mock).await { + panic!("no pending HTTP request") + } + } + + async fn try_mock_http_inner(&self, mock: &MockOutcall) -> bool { + let http_requests = tick_until_http_request(self.env).await; + let request = match http_requests.first() { + Some(request) => request, + None => return false, + }; + mock.assert_matches(request); + + let response = match mock.response.clone() { + CanisterHttpResponse::CanisterHttpReply(reply) => { + let max_response_bytes = request + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + if reply.body.len() as u64 > max_response_bytes { + //approximate replica behaviour since headers are not accounted for. + CanisterHttpResponse::CanisterHttpReject(CanisterHttpReject { + reject_code: 1, //SYS_FATAL + message: format!( + "Http body exceeds size limit of {} bytes.", + max_response_bytes + ), + }) + } else { + CanisterHttpResponse::CanisterHttpReply(reply) + } + } + CanisterHttpResponse::CanisterHttpReject(reject) => { + CanisterHttpResponse::CanisterHttpReject(reject) + } + }; + let mock_response = MockCanisterHttpResponse { + subnet_id: request.subnet_id, + request_id: request.request_id, + response, + additional_responses: vec![], + }; + self.env.mock_canister_http_response(mock_response).await; + true + } } /// Runtime for when Pocket IC is used in [live mode](https://github.com/dfinity/ic/blob/f0c82237ae16745ac54dd3838b3f91ce32a6bc52/packages/pocket-ic/HOWTO.md?plain=1#L43). @@ -232,8 +324,8 @@ impl Runtime for PocketIcLiveModeRuntime<'_> { _cycles: u128, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static, + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, { let id = self .env @@ -250,8 +342,8 @@ impl Runtime for PocketIcLiveModeRuntime<'_> { args: In, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static, + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, { PocketIcRuntime::decode_call_result( self.env @@ -266,6 +358,8 @@ pub trait SolRpcTestClient { async fn verify_api_key(&self, api_key: (SupportedRpcProviderId, Option)); async fn retrieve_logs(&self, priority: &str) -> Vec>; fn with_caller>(self, id: T) -> Self; + fn mock_http(self, mock: impl Into) -> Self; + fn mock_http_once(self, mock: impl Into) -> Self; } #[async_trait] @@ -298,4 +392,26 @@ impl SolRpcTestClient> for SolRpcClient> self.runtime.caller = id.into(); self } + + fn mock_http(self, mock: impl Into) -> Self { + Self { + runtime: self.runtime.with_strategy(MockStrategy::Mock(mock.into())), + ..self + } + } + + fn mock_http_once(self, mock: impl Into) -> Self { + Self { + runtime: self + .runtime + .with_strategy(MockStrategy::MockOnce(mock.into())), + ..self + } + } +} + +#[derive(Clone, Debug)] +enum MockStrategy { + Mock(MockOutcall), + MockOnce(MockOutcall), } diff --git a/integration_tests/src/mock.rs b/integration_tests/src/mock.rs new file mode 100644 index 00000000..8a7207d7 --- /dev/null +++ b/integration_tests/src/mock.rs @@ -0,0 +1,235 @@ +use ic_cdk::api::call::RejectionCode; +use pocket_ic::common::rest::{ + CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReject, CanisterHttpReply, + CanisterHttpRequest, CanisterHttpResponse, +}; +use std::collections::BTreeSet; + +pub struct MockOutcallBody(pub Vec); + +impl From<&serde_json::Value> for MockOutcallBody { + fn from(value: &serde_json::Value) -> Self { + value.to_string().into() + } +} +impl From for MockOutcallBody { + fn from(string: String) -> Self { + string.as_bytes().to_vec().into() + } +} +impl<'a> From<&'a str> for MockOutcallBody { + fn from(string: &'a str) -> Self { + string.to_string().into() + } +} +impl From> for MockOutcallBody { + fn from(bytes: Vec) -> Self { + MockOutcallBody(bytes) + } +} + +#[derive(Clone, Debug)] +pub struct MockOutcallBuilder(MockOutcall); + +impl MockOutcallBuilder { + pub fn new(status: u16, body: impl Into) -> Self { + Self(MockOutcall { + method: None, + url: None, + request_headers: None, + request_body: None, + max_response_bytes: None, + response: CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { + status, + headers: vec![], + body: body.into().0, + }), + }) + } + + pub fn new_error(code: RejectionCode, message: impl ToString) -> Self { + Self(MockOutcall { + method: None, + url: None, + request_headers: None, + request_body: None, + max_response_bytes: None, + response: CanisterHttpResponse::CanisterHttpReject(CanisterHttpReject { + reject_code: code as u64, + message: message.to_string(), + }), + }) + } + + pub fn with_method(mut self, method: CanisterHttpMethod) -> Self { + self.0.method = Some(method); + self + } + + pub fn with_url(mut self, url: impl ToString) -> Self { + self.0.url = Some(url.to_string()); + self + } + + pub fn with_request_headers(mut self, headers: Vec<(impl ToString, impl ToString)>) -> Self { + self.0.request_headers = Some( + headers + .into_iter() + .map(|(name, value)| CanisterHttpHeader { + name: name.to_string(), + value: value.to_string(), + }) + .collect(), + ); + self + } + + pub fn with_raw_request_body(self, body: &str) -> Self { + self.with_request_body(MockJsonRequestBody::from_raw_request_unchecked(body)) + } + + pub fn with_request_body(mut self, body: impl Into) -> Self { + self.0.request_body = Some(body.into()); + self + } + + pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { + self.0.max_response_bytes = Some(max_response_bytes); + self + } + + pub fn build(self) -> MockOutcall { + self.0 + } +} + +impl From for MockOutcall { + fn from(builder: MockOutcallBuilder) -> Self { + builder.build() + } +} + +#[derive(Clone, Debug)] +pub struct MockOutcall { + pub method: Option, + pub url: Option, + pub request_headers: Option>, + pub request_body: Option, + pub max_response_bytes: Option, + pub response: CanisterHttpResponse, +} + +impl MockOutcall { + pub fn assert_matches(&self, request: &CanisterHttpRequest) { + if let Some(ref url) = self.url { + assert_eq!(url, &request.url); + } + if let Some(ref method) = self.method { + assert_eq!(method, &request.http_method); + } + if let Some(ref headers) = self.request_headers { + assert_eq!( + headers.iter().collect::>(), + request.headers.iter().collect::>() + ); + } + if let Some(ref expected_body) = self.request_body { + let actual_body: serde_json::Value = serde_json::from_slice(&request.body) + .expect("BUG: failed to parse JSON request body"); + expected_body.assert_matches(&actual_body); + } + if let Some(max_response_bytes) = self.max_response_bytes { + assert_eq!(Some(max_response_bytes), request.max_response_bytes); + } + } +} + +/// Assertions on parts of the JSON-RPC request body. +#[derive(Clone, Debug)] +pub struct MockJsonRequestBody { + pub jsonrpc: String, + pub method: String, + pub id: Option, + pub params: Option, +} + +impl MockJsonRequestBody { + pub fn new(method: impl ToString) -> Self { + Self { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + id: None, + params: None, + } + } + + pub fn builder(method: impl ToString) -> MockJsonRequestBuilder { + MockJsonRequestBuilder(Self::new(method)) + } + + pub fn from_raw_request_unchecked(raw_request: &str) -> Self { + let request: serde_json::Value = + serde_json::from_str(raw_request).expect("BUG: failed to parse JSON request"); + Self { + jsonrpc: request["jsonrpc"] + .as_str() + .expect("BUG: missing jsonrpc field") + .to_string(), + method: request["method"] + .as_str() + .expect("BUG: missing method field") + .to_string(), + id: request["id"].as_u64(), + params: request.get("params").cloned(), + } + } + + pub fn assert_matches(&self, request_body: &serde_json::Value) { + assert_eq!( + self.jsonrpc, + request_body["jsonrpc"] + .as_str() + .expect("BUG: missing jsonrpc field") + ); + assert_eq!( + self.method, + request_body["method"] + .as_str() + .expect("BUG: missing method field") + ); + if let Some(id) = self.id { + assert_eq!( + id, + request_body["id"].as_u64().expect("BUG: missing id field") + ); + } + if let Some(expected_params) = &self.params { + assert_eq!( + expected_params, + request_body + .get("params") + .expect("BUG: missing params field") + ); + } + } +} + +#[derive(Clone, Debug)] +pub struct MockJsonRequestBuilder(MockJsonRequestBody); + +impl MockJsonRequestBuilder { + pub fn with_params(mut self, params: impl Into) -> Self { + self.0.params = Some(params.into()); + self + } + + pub fn build(self) -> MockJsonRequestBody { + self.0 + } +} + +impl From for MockJsonRequestBody { + fn from(builder: MockJsonRequestBuilder) -> Self { + builder.build() + } +} diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index ca0b90cc..9057c40e 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1,5 +1,107 @@ +use sol_rpc_canister::constants::*; use sol_rpc_int_tests::{Setup, SolRpcTestClient, ADDITIONAL_TEST_ID}; -use sol_rpc_types::{InstallArgs, RpcAccess, RpcAuth, SolanaCluster, SupportedRpcProviderId}; +use sol_rpc_types::{ + InstallArgs, Mode, ProviderError, RpcAccess, RpcAuth, RpcEndpoint, RpcError, RpcSource, + SolanaCluster, SupportedRpcProviderId, +}; + +const MOCK_REQUEST_URL: &str = "https://api.devnet.solana.com/"; +const MOCK_REQUEST_PAYLOAD: &str = r#"{"jsonrpc":"2.0","id":1,"method":"getVersion"}"#; +const MOCK_REQUEST_RESPONSE: &str = + r#"{"jsonrpc":"2.0","id":1,"result":{"feature-set":2891131721,"solana-core":"1.16.7"}}"#; +const MOCK_REQUEST_MAX_RESPONSE_BYTES: u64 = 1000; + +mod mock_request_tests { + use super::*; + use assert_matches::*; + use ic_cdk::api::management_canister::http_request::HttpHeader; + use pocket_ic::common::rest::CanisterHttpMethod; + use sol_rpc_int_tests::mock::*; + + async fn mock_request(builder_fn: impl Fn(MockOutcallBuilder) -> MockOutcallBuilder) { + let setup = Setup::with_args(InstallArgs { + mode: Some(Mode::Demo), + ..Default::default() + }) + .await; + let client = setup.client(); + let expected_result: serde_json::Value = + serde_json::from_str(MOCK_REQUEST_RESPONSE).unwrap(); + assert_matches!( + client + .mock_http(builder_fn(MockOutcallBuilder::new( + 200, + MOCK_REQUEST_RESPONSE + ))) + .request( + RpcSource::Custom(RpcEndpoint { + url: MOCK_REQUEST_URL.to_string(), + headers: Some(vec![HttpHeader { + name: "custom".to_string(), + value: "Value".to_string(), + }]), + }), + MOCK_REQUEST_PAYLOAD, + MOCK_REQUEST_MAX_RESPONSE_BYTES, + 0, + ) + .await, + Ok(msg) if msg == serde_json::Value::to_string(&expected_result["result"]) + ); + } + + #[tokio::test] + async fn mock_request_should_succeed() { + mock_request(|builder| builder).await + } + + #[tokio::test] + async fn mock_request_should_succeed_with_url() { + mock_request(|builder| builder.with_url(MOCK_REQUEST_URL)).await + } + + #[tokio::test] + async fn mock_request_should_succeed_with_method() { + mock_request(|builder| builder.with_method(CanisterHttpMethod::POST)).await + } + + #[tokio::test] + async fn mock_request_should_succeed_with_request_headers() { + mock_request(|builder| { + builder.with_request_headers(vec![ + (CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE), + ("custom", "Value"), + ]) + }) + .await + } + + #[tokio::test] + async fn mock_request_should_succeed_with_request_body() { + mock_request(|builder| builder.with_raw_request_body(MOCK_REQUEST_PAYLOAD)).await + } + + #[tokio::test] + async fn mock_request_should_succeed_with_max_response_bytes() { + mock_request(|builder| builder.with_max_response_bytes(MOCK_REQUEST_MAX_RESPONSE_BYTES)) + .await + } + + #[tokio::test] + async fn mock_request_should_succeed_with_all() { + mock_request(|builder| { + builder + .with_url(MOCK_REQUEST_URL) + .with_method(CanisterHttpMethod::POST) + .with_request_headers(vec![ + (CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE), + ("custom", "Value"), + ]) + .with_raw_request_body(MOCK_REQUEST_PAYLOAD) + }) + .await + } +} mod get_provider_tests { use super::*; @@ -35,6 +137,63 @@ mod get_provider_tests { } } +mod generic_request_tests { + use super::*; + use assert_matches::*; + use sol_rpc_int_tests::mock::MockOutcallBuilder; + + #[tokio::test] + async fn request_should_require_cycles() { + let setup = Setup::new().await; + let client = setup.client(); + + let result = client + .request( + RpcSource::Supported(SupportedRpcProviderId::AlchemyMainnet), + MOCK_REQUEST_PAYLOAD, + MOCK_REQUEST_MAX_RESPONSE_BYTES, + 0, + ) + .await; + + assert_matches!( + result, + Err(RpcError::ProviderError(ProviderError::TooFewCycles { + expected: _, + received: 0 + })) + ); + + setup.drop().await; + } + + #[tokio::test] + async fn request_should_succeed_in_demo_mode() { + let setup = Setup::with_args(InstallArgs { + mode: Some(Mode::Demo), + ..Default::default() + }) + .await; + let client = setup.client(); + + let result = client + .mock_http(MockOutcallBuilder::new(200, MOCK_REQUEST_RESPONSE)) + .request( + RpcSource::Supported(SupportedRpcProviderId::AlchemyMainnet), + MOCK_REQUEST_PAYLOAD, + MOCK_REQUEST_MAX_RESPONSE_BYTES, + 0, + ) + .await; + + let expected_result: serde_json::Value = + serde_json::from_str(MOCK_REQUEST_RESPONSE).unwrap(); + assert_matches!(result, Ok(msg) if msg == serde_json::Value::to_string(&expected_result["result"])); + + setup.drop().await; + } +} + mod retrieve_logs_tests { use super::*; diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index a5411f5e..3d15001a 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -8,8 +8,8 @@ use candid::{utils::ArgumentEncoder, CandidType, Principal}; use ic_cdk::api::call::RejectionCode; use serde::de::DeserializeOwned; use sol_rpc_types::{ - GetSlotParams, RpcConfig, RpcSources, SolanaCluster, SupportedRpcProvider, - SupportedRpcProviderId, + GetSlotParams, RpcConfig, RpcResult, RpcSource, RpcSources, SolanaCluster, + SupportedRpcProvider, SupportedRpcProviderId, }; use solana_clock::Slot; @@ -28,8 +28,8 @@ pub trait Runtime { cycles: u128, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static; + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned; /// Defines how asynchronous inter-canister query calls are made. async fn query_call( @@ -39,8 +39,8 @@ pub trait Runtime { args: In, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static; + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned; } /// Client to interact with the SOL RPC canister. @@ -115,6 +115,25 @@ impl SolRpcClient { .await .expect("Client error: failed to call getSlot") } + + /// Call `request` on the SOL RPC canister. + pub async fn request( + &self, + service: RpcSource, + json_rpc_payload: &str, + max_response_bytes: u64, + cycles: u128, + ) -> RpcResult { + self.runtime + .update_call( + self.sol_rpc_canister, + "request", + (service, json_rpc_payload, max_response_bytes), + cycles, + ) + .await + .unwrap() + } } #[derive(Copy, Clone, Eq, PartialEq, Debug)] @@ -130,8 +149,8 @@ impl Runtime for IcRuntime { cycles: u128, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static, + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, { ic_cdk::api::call::call_with_payment128(id, method, args, cycles) .await @@ -145,8 +164,8 @@ impl Runtime for IcRuntime { args: In, ) -> Result where - In: ArgumentEncoder + Send + 'static, - Out: CandidType + DeserializeOwned + 'static, + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, { ic_cdk::api::call::call(id, method, args) .await