diff --git a/Cargo.lock b/Cargo.lock index a07c66f2..c049644e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1668,6 +1668,7 @@ dependencies = [ name = "evm_rpc" version = "0.1.0" dependencies = [ + "assert_matches", "async-trait", "candid", "hex", @@ -1784,9 +1785,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -2348,7 +2349,7 @@ dependencies = [ [[package]] name = "ic-base-types" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "base32", "byte-unit", @@ -2391,7 +2392,7 @@ dependencies = [ [[package]] name = "ic-btc-types-internal" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "candid", "ic-btc-interface", @@ -2509,7 +2510,7 @@ dependencies = [ [[package]] name = "ic-canisters-http-types" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "candid", "serde", @@ -2641,7 +2642,7 @@ dependencies = [ [[package]] name = "ic-cketh-minter" version = "0.1.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "askama", "async-trait", @@ -2776,7 +2777,7 @@ dependencies = [ [[package]] name = "ic-crypto-ecdsa-secp256k1" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "k256", "lazy_static", @@ -3095,7 +3096,7 @@ dependencies = [ [[package]] name = "ic-crypto-internal-sha2" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "sha2 0.10.8", ] @@ -3261,7 +3262,7 @@ dependencies = [ [[package]] name = "ic-crypto-sha2" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "ic-crypto-internal-sha2 0.9.0", ] @@ -3269,7 +3270,7 @@ dependencies = [ [[package]] name = "ic-crypto-sha3" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "sha3 0.9.1", ] @@ -3483,7 +3484,7 @@ dependencies = [ [[package]] name = "ic-error-types" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "ic-utils 0.9.0", "serde", @@ -3586,7 +3587,7 @@ dependencies = [ [[package]] name = "ic-ic00-types" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "candid", "ic-base-types 0.9.0", @@ -3876,7 +3877,7 @@ dependencies = [ [[package]] name = "ic-protobuf" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "bincode", "candid", @@ -4198,7 +4199,7 @@ dependencies = [ [[package]] name = "ic-sys" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "hex", "ic-crypto-sha2 0.9.0", @@ -4340,7 +4341,7 @@ dependencies = [ [[package]] name = "ic-utils" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "cvt", "hex", @@ -4357,7 +4358,7 @@ dependencies = [ [[package]] name = "ic-utils-ensure" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" [[package]] name = "ic-utils-lru-cache" @@ -4435,7 +4436,7 @@ dependencies = [ [[package]] name = "icrc-ledger-client" version = "0.1.2" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "async-trait", "candid", @@ -4446,7 +4447,7 @@ dependencies = [ [[package]] name = "icrc-ledger-client-cdk" version = "0.1.2" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "async-trait", "candid", @@ -4472,7 +4473,7 @@ dependencies = [ [[package]] name = "icrc-ledger-types" version = "0.1.4" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "base32", "candid", @@ -4493,9 +4494,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -5477,9 +5478,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" @@ -5549,7 +5550,7 @@ dependencies = [ [[package]] name = "phantom_newtype" version = "0.9.0" -source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#313e959fad7b1790ccdb8b8595e1a6b2959a611f" +source = "git+https://github.com/rvanasa/ic?branch=evm-rpc-canister#fc43f0f42bb1606713cd89cf4917b1554d129f1d" dependencies = [ "candid", "serde", @@ -7623,9 +7624,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index 32da394b..c5144a28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,19 +38,12 @@ async-trait = "0.1" hex = "0.4" [dev-dependencies] -# assert_matches = "1.5.0" -# ethers-core = "2.0.8" ic-ic00-types = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } ic-base-types = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } ic-config = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } -# ic-crypto-test-utils-reproducible-rng = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } -# ic-icrc1-ledger = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } ic-state-machine-tests = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } ic-test-utilities-load-wasm = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } -# maplit = "1" -# proptest = "1.0" -# rand = "0.8" -# scraper = "0.17.1" +assert_matches = "1.5" [workspace.dependencies] candid = { version = "0.9", features = ["parser"] } diff --git a/lib/rust/src/lib.rs b/lib/rust/src/lib.rs index f398f59d..b5d07ea7 100644 --- a/lib/rust/src/lib.rs +++ b/lib/rust/src/lib.rs @@ -8,8 +8,8 @@ pub use ethers_core as core; pub use rpc::{call_contract, get_provider, request}; -#[ic_cdk_macros::query(name = "__transform_ic_evm_rpc")] -pub fn transform_evm_rpc(args: TransformArgs) -> HttpResponse { +#[ic_cdk_macros::query(name = "__ic_evm_transform_json_rpc")] +pub fn transform_json_rpc(args: TransformArgs) -> HttpResponse { HttpResponse { status: args.response.status, body: args.response.body, diff --git a/lib/rust/src/rpc.rs b/lib/rust/src/rpc.rs index 675d646a..99d8bb7b 100644 --- a/lib/rust/src/rpc.rs +++ b/lib/rust/src/rpc.rs @@ -137,7 +137,7 @@ pub async fn request<'a, T: Serialize>( headers: request_headers, body: Some(json_rpc_payload.as_bytes().to_vec()), transform: Some(TransformContext::from_name( - "__transform_ic_evm_rpc".to_string(), + "__ic_evm_transform_json_rpc".to_string(), vec![], )), }; diff --git a/src/constants.rs b/src/constants.rs index 699eb082..da834dc1 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -20,6 +20,7 @@ pub const DEFAULT_ETHEREUM_PROVIDER: EthereumProvider = EthereumProvider::Ankr; pub const DEFAULT_SEPOLIA_PROVIDER: SepoliaProvider = SepoliaProvider::PublicNode; pub const CONTENT_TYPE_HEADER: &str = "Content-Type"; +pub const CONTENT_TYPE_VALUE: &str = "application/json"; pub const ETH_MAINNET_CHAIN_ID: u64 = 1; pub const ETH_SEPOLIA_CHAIN_ID: u64 = 11155111; diff --git a/src/http.rs b/src/http.rs index 82d8a9a3..bf15ad39 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,8 +1,8 @@ use cketh_common::eth_rpc::{HttpOutcallError, ProviderError, RpcError, ValidationError}; use ic_canister_log::log; use ic_cdk::api::management_canister::http_request::{ - http_request as make_http_request, CanisterHttpRequestArgument, HttpHeader, HttpMethod, - HttpResponse, TransformContext, + CanisterHttpRequestArgument, HttpHeader, HttpMethod, HttpResponse, TransformArgs, + TransformContext, }; use num_traits::ToPrimitive; @@ -19,7 +19,6 @@ pub async fn do_http_request( inc_metric!(request_err_no_permission); return Err(ProviderError::NoPermission.into()); } - let cycles_available = ic_cdk::api::call::msg_cycles_available128(); let cost = get_request_cost(&source, json_rpc_payload, max_response_bytes); let (api, provider) = match source { ResolvedSource::Api(api) => (api, None), @@ -39,6 +38,7 @@ pub async fn do_http_request( return Err(ValidationError::HostNotAllowed(host.to_string()).into()); } if !is_authorized(&caller, Auth::FreeRpc) { + let cycles_available = ic_cdk::api::call::msg_cycles_available128(); if cycles_available < cost { return Err(ProviderError::TooFewCycles { expected: cost, @@ -62,7 +62,7 @@ pub async fn do_http_request( inc_metric_entry!(host_requests, host.to_string()); let mut request_headers = vec![HttpHeader { name: CONTENT_TYPE_HEADER.to_string(), - value: "application/json".to_string(), + value: CONTENT_TYPE_VALUE.to_string(), }]; request_headers.extend(api.headers); let request = CanisterHttpRequestArgument { @@ -72,11 +72,11 @@ pub async fn do_http_request( headers: request_headers, body: Some(json_rpc_payload.as_bytes().to_vec()), transform: Some(TransformContext::from_name( - "__transform_evm_rpc".to_string(), + "__transform_json_rpc".to_string(), vec![], )), }; - match make_http_request(request, cost).await { + match ic_cdk::api::management_canister::http_request::http_request(request, cost).await { Ok((response,)) => Ok(response), Err((code, message)) => { inc_metric!(request_err_http); @@ -85,6 +85,15 @@ pub async fn do_http_request( } } +pub fn do_transform_http_request(args: TransformArgs) -> HttpResponse { + HttpResponse { + status: args.response.status, + body: canonicalize_json(&args.response.body).unwrap_or(args.response.body), + // Remove headers (which may contain a timestamp) for consensus + headers: vec![], + } +} + pub fn get_http_response_status(status: candid::Nat) -> u16 { status.0.to_u16().unwrap_or(u16::MAX) } diff --git a/src/main.rs b/src/main.rs index df178f83..9c51db6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use ic_canister_log::log; use ic_canisters_http_types::{ HttpRequest as AssetHttpRequest, HttpResponse as AssetHttpResponse, HttpResponseBuilder, }; -use ic_cdk::api::management_canister::http_request::{HttpHeader, HttpResponse, TransformArgs}; +use ic_cdk::api::management_canister::http_request::{HttpResponse, TransformArgs}; use ic_cdk::{query, update}; use ic_nervous_system_common::{serve_logs, serve_logs_v2, serve_metrics}; @@ -222,15 +222,9 @@ async fn withdraw_accumulated_cycles(provider_id: u64, canister_id: Principal) { }; } -#[query(name = "__transform_evm_rpc")] +#[query(name = "__transform_json_rpc")] fn transform(args: TransformArgs) -> HttpResponse { - HttpResponse { - status: args.response.status, - body: canonicalize_json(&args.response.body).unwrap_or(args.response.body), - // Strip headers as they contain the Date which is not necessarily the same - // and will prevent consensus on the result. - headers: Vec::::new(), - } + do_transform_http_request(args) } #[ic_cdk::init] diff --git a/tests/mock.rs b/tests/mock.rs new file mode 100644 index 00000000..a8431f1d --- /dev/null +++ b/tests/mock.rs @@ -0,0 +1,126 @@ +use std::collections::HashSet; + +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpHeader, HttpMethod, HttpResponse, +}; + +pub struct MockOutcallBody(pub Vec); + +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, + response: HttpResponse { + status: status.into(), + headers: vec![], + body: body.into().0, + }, + }) + } + + pub fn with_method(mut self, method: HttpMethod) -> 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)| HttpHeader { + name: name.to_string(), + value: value.to_string(), + }) + .collect(), + ); + self + } + + pub fn with_request_body(mut self, body: impl Into) -> Self { + self.0.request_body = Some(body.into().0); + self + } + + pub fn with_response_header(mut self, name: String, value: String) -> Self { + self.0.response.headers.push(HttpHeader { name, value }); + 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 response: HttpResponse, +} + +impl MockOutcall { + pub fn assert_matches(&self, request: &CanisterHttpRequestArgument) { + if let Some(ref url) = self.url { + assert_eq!(url, &request.url); + } + if let Some(ref method) = self.method { + assert_eq!(method, &request.method); + } + if let Some(ref headers) = self.request_headers { + assert_eq!( + headers.iter().collect::>(), + request.headers.iter().collect::>() + ); + } + if let Some(ref body) = self.request_body { + assert_eq!(body, &request.body.as_deref().unwrap_or_default()); + } + } +} + +impl From for MockOutcall { + fn from(response: HttpResponse) -> Self { + Self { + method: None, + url: None, + request_headers: None, + request_body: None, + response, + } + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 1a38bd2d..8befc70c 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,16 +1,37 @@ -use std::rc::Rc; +mod mock; +use std::{marker::PhantomData, rc::Rc, time::Duration}; + +use assert_matches::assert_matches; use candid::{CandidType, Decode, Encode, Nat}; -use evm_rpc::*; use ic_base_types::{CanisterId, PrincipalId}; -use ic_ic00_types::BoundedVec; -use ic_state_machine_tests::{CanisterSettingsArgs, StateMachine, StateMachineBuilder, WasmResult}; +use ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpHeader, HttpMethod, HttpResponse as OutCallHttpResponse, + TransformArgs, TransformContext, TransformFunc, +}; +use ic_ic00_types::CanisterSettingsArgsBuilder; +use ic_state_machine_tests::{ + CanisterHttpResponsePayload, Cycles, IngressState, IngressStatus, MessageId, PayloadBuilder, + StateMachine, StateMachineBuilder, WasmResult, +}; use ic_test_utilities_load_wasm::load_wasm; use serde::de::DeserializeOwned; +use evm_rpc::*; +use mock::*; + const DEFAULT_CALLER_TEST_ID: u64 = 10352385; const DEFAULT_CONTROLLER_TEST_ID: u64 = 10352386; +const INITIAL_CYCLES: u128 = 100_000_000_000_000_000; + +const MAX_TICKS: usize = 10; + +const MOCK_REQUEST_URL: &str = "https://cloudflare-eth.com"; +const MOCK_REQUEST_PAYLOAD: &str = r#"{"id":1,"jsonrpc":"2.0","method":"eth_gasPrice"}"#; +const MOCK_REQUEST_RESPONSE: &str = r#"{"id":1,"jsonrpc":"2.0","result":"0x00112233"}"#; +const MOCK_REQUEST_RESPONSE_BYTES: u64 = 1000; + fn evm_rpc_wasm() -> Vec { load_wasm(std::env::var("CARGO_MANIFEST_DIR").unwrap(), "evm_rpc", &[]) } @@ -29,7 +50,7 @@ pub struct EvmRpcSetup { pub env: Rc, pub caller: PrincipalId, pub controller: PrincipalId, - pub evm_rpc_id: CanisterId, + pub canister_id: CanisterId, } impl Default for EvmRpcSetup { @@ -47,12 +68,16 @@ impl EvmRpcSetup { ); let controller = PrincipalId::new_user_test_id(DEFAULT_CONTROLLER_TEST_ID); - let evm_rpc_id = env.create_canister(Some({ - let mut args: CanisterSettingsArgs = Default::default(); - args.controllers = Some(BoundedVec::new(vec![controller])); - args - })); - env.install_existing_canister(evm_rpc_id, evm_rpc_wasm(), Encode!(&()).unwrap()) + let canister_id = env.create_canister_with_cycles( + None, + Cycles::new(INITIAL_CYCLES), + Some( + CanisterSettingsArgsBuilder::default() + .with_controller(controller) + .build(), + ), + ); + env.install_existing_canister(canister_id, evm_rpc_wasm(), Encode!(&()).unwrap()) .unwrap(); let caller = PrincipalId::new_user_test_id(DEFAULT_CALLER_TEST_ID); @@ -61,48 +86,44 @@ impl EvmRpcSetup { env, caller, controller, - evm_rpc_id, + canister_id, } } + /// Shorthand for deriving an `EvmRpcSetup` with the caller as the canister controller. pub fn as_controller(&self) -> Self { let mut setup = self.clone(); setup.caller = self.controller; setup } + /// Shorthand for deriving an `EvmRpcSetup` with an anonymous caller. pub fn as_anonymous(&self) -> Self { let mut setup = self.clone(); setup.caller = PrincipalId::new_anonymous(); setup } + /// Shorthand for deriving an `EvmRpcSetup` with an arbitrary caller. pub fn as_caller(&self, id: PrincipalId) -> Self { let mut setup = self.clone(); setup.caller = id; setup } - fn call_update(&self, method: &str, input: Vec) -> R { - Decode!( - &assert_reply( - self.env - .execute_ingress_as(self.caller, self.evm_rpc_id, method, input,) - .unwrap_or_else(|err| panic!( - "error during update call to `{}()`: {}", - method, err - )) - ), - R - ) - .unwrap() + fn call_update( + &self, + method: &str, + input: Vec, + ) -> CallFlow { + CallFlow::from_update(self.clone(), method, input) } fn call_query(&self, method: &str, input: Vec) -> R { Decode!( &assert_reply( self.env - .query_as(self.caller, self.evm_rpc_id, method, input,) + .query_as(self.caller, self.canister_id, method, input,) .unwrap_or_else(|err| panic!( "error during query call to `{}()`: {}", method, err @@ -113,30 +134,38 @@ impl EvmRpcSetup { .unwrap() } - pub fn authorize(&self, principal: &PrincipalId, auth: Auth) { + pub fn tick_until_http_request(&self) { + for _ in 0..MAX_TICKS { + if !self.env.canister_http_request_contexts().is_empty() { + break; + } + self.env.tick(); + self.env.advance_time(Duration::from_nanos(1)); + } + } + + pub fn authorize(&self, principal: &PrincipalId, auth: Auth) -> CallFlow<()> { self.call_update("authorize", Encode!(&principal.0, &auth).unwrap()) } - pub fn deauthorize(&self, principal: &PrincipalId, auth: Auth) { + pub fn deauthorize(&self, principal: &PrincipalId, auth: Auth) -> CallFlow<()> { self.call_update("deauthorize", Encode!(&principal.0, &auth).unwrap()) } pub fn get_providers(&self) -> Vec { - self.call_update("get_providers", Encode!().unwrap()) + self.call_query("get_providers", Encode!().unwrap()) } - pub fn register_provider(&self, args: RegisterProviderArgs) -> u64 { + pub fn register_provider(&self, args: RegisterProviderArgs) -> CallFlow { self.call_update("register_provider", Encode!(&args).unwrap()) } - pub fn authorize_caller(self, auth: Auth) -> Self { - self.as_controller().authorize(&self.caller, auth); - self + pub fn authorize_caller(&self, auth: Auth) -> CallFlow<()> { + self.as_controller().authorize(&self.caller, auth) } - pub fn deauthorize_caller(self, auth: Auth) -> Self { - self.as_controller().deauthorize(&self.caller, auth); - self + pub fn deauthorize_caller(&self, auth: Auth) -> CallFlow<()> { + self.as_controller().deauthorize(&self.caller, auth) } pub fn request_cost( @@ -150,11 +179,143 @@ impl EvmRpcSetup { Encode!(&source, &json_rpc_payload, &max_response_bytes).unwrap(), ) } + + pub fn request( + &self, + source: Source, + json_rpc_payload: &str, + max_response_bytes: u64, + ) -> CallFlow> { + self.call_update( + "request", + Encode!(&source, &json_rpc_payload, &max_response_bytes).unwrap(), + ) + } +} + +pub struct CallFlow { + setup: EvmRpcSetup, + method: String, + message_id: MessageId, + phantom: PhantomData, +} + +impl CallFlow { + pub fn from_update(setup: EvmRpcSetup, method: &str, input: Vec) -> Self { + let message_id = setup + .env + .send_ingress(setup.caller, setup.canister_id, method, input); + CallFlow::new(setup, method, message_id) + } + + pub fn new(setup: EvmRpcSetup, method: impl ToString, message_id: MessageId) -> Self { + Self { + setup, + method: method.to_string(), + message_id, + phantom: Default::default(), + } + } + + pub fn mock_http(self, mock: impl Into) -> Self { + let mock = mock.into(); + assert_eq!(self.setup.env.canister_http_request_contexts().len(), 0); + self.setup.tick_until_http_request(); + match self.setup.env.ingress_status(&self.message_id) { + IngressStatus::Known { state, .. } if state != IngressState::Processing => return self, + _ => (), + } + let contexts = self.setup.env.canister_http_request_contexts(); + let (id, context) = contexts.first_key_value().expect("no pending HTTP request"); + + mock.assert_matches(&CanisterHttpRequestArgument { + url: context.url.clone(), + max_response_bytes: context.max_response_bytes.map(|n| n.get()), + // Convert HTTP method type by name + method: serde_json::from_str( + &serde_json::to_string(&context.http_method) + .unwrap() + .to_lowercase(), + ) + .unwrap(), + headers: context + .headers + .iter() + .map(|h| HttpHeader { + name: h.name.clone(), + value: h.value.clone(), + }) + .collect(), + body: context.body.clone(), + transform: context.transform.clone().map(|t| TransformContext { + context: t.context, + function: TransformFunc::new(self.setup.canister_id.get().0, t.method_name), + }), + }); + let mut response = OutCallHttpResponse { + status: mock.response.status, + headers: mock.response.headers, + body: mock.response.body, + }; + if let Some(transform) = &context.transform { + let transform_args = TransformArgs { + response, + context: transform.context.to_vec(), + }; + response = Decode!( + &assert_reply( + self.setup + .env + .execute_ingress( + self.setup.canister_id, + transform.method_name.clone(), + Encode!(&transform_args).unwrap(), + ) + .expect("failed to query transform HTTP response") + ), + OutCallHttpResponse + ) + .unwrap(); + } + let http_response = CanisterHttpResponsePayload { + status: response.status.0.try_into().unwrap(), + headers: response + .headers + .into_iter() + .map(|h| ic_ic00_types::HttpHeader { + name: h.name, + value: h.value, + }) + .collect(), + body: response.body, + }; + let payload = PayloadBuilder::new().http_response(*id, &http_response); + self.setup.env.execute_payload(payload); + + self + } + + pub fn wait(self) -> R { + Decode!( + &assert_reply( + self.setup + .env + .await_ingress(self.message_id, MAX_TICKS) + .unwrap_or_else(|err| { + panic!("error during update call to `{}()`: {}", self.method, err) + }) + ), + R + ) + .unwrap() + } } #[test] -fn test_provider_registry() { - let setup = EvmRpcSetup::new().authorize_caller(Auth::RegisterProvider); +fn should_register_provider() { + let setup = EvmRpcSetup::new(); + setup.authorize_caller(Auth::RegisterProvider); + assert_eq!( setup .get_providers() @@ -167,22 +328,26 @@ fn test_provider_registry() { .collect::>() ); let n_providers = 2; - let a_id = setup.register_provider(RegisterProviderArgs { - chain_id: 1, - hostname: "cloudflare-eth.com".to_string(), - credential_path: "".to_string(), - credential_headers: None, - cycles_per_call: 0, - cycles_per_message_byte: 0, - }); - let b_id = setup.register_provider(RegisterProviderArgs { - chain_id: 5, - hostname: "ethereum.publicnode.com".to_string(), - credential_path: "".to_string(), - credential_headers: None, - cycles_per_call: 0, - cycles_per_message_byte: 0, - }); + let a_id = setup + .register_provider(RegisterProviderArgs { + chain_id: 1, + hostname: "cloudflare-eth.com".to_string(), + credential_path: "".to_string(), + credential_headers: None, + cycles_per_call: 0, + cycles_per_message_byte: 0, + }) + .wait(); + let b_id = setup + .register_provider(RegisterProviderArgs { + chain_id: 5, + hostname: "ethereum.publicnode.com".to_string(), + credential_path: "".to_string(), + credential_headers: None, + cycles_per_call: 0, + cycles_per_message_byte: 0, + }) + .wait(); assert_eq!(a_id + 1, b_id); let providers = setup.get_providers(); assert_eq!(providers.len(), get_default_providers().len() + n_providers); @@ -211,3 +376,124 @@ fn test_provider_registry() { ] ) } + +fn mock_request(builder_fn: impl Fn(MockOutcallBuilder) -> MockOutcallBuilder) { + let setup = EvmRpcSetup::new(); + setup.authorize_caller(Auth::FreeRpc); + + assert_matches!( + setup + .request( + Source::Custom { + url: MOCK_REQUEST_URL.to_string(), + headers: Some(vec![HttpHeader { + name: "Custom".to_string(), + value: "Value".to_string(), + }]), + }, + MOCK_REQUEST_PAYLOAD, + MOCK_REQUEST_RESPONSE_BYTES, + ) + .mock_http(builder_fn(MockOutcallBuilder::new( + 200, + MOCK_REQUEST_RESPONSE + ))) + .wait(), + Ok(_) + ); +} + +#[test] +fn mock_request_should_succeed() { + mock_request(|builder| builder) +} + +#[test] +fn mock_request_should_succeed_with_url() { + mock_request(|builder| builder.with_url(MOCK_REQUEST_URL)) +} + +#[test] +fn mock_request_should_succeed_with_method() { + mock_request(|builder| builder.with_method(HttpMethod::POST)) +} + +#[test] +fn mock_request_should_succeed_with_request_headers() { + mock_request(|builder| { + builder.with_request_headers(vec![ + (CONTENT_TYPE_HEADER, CONTENT_TYPE_VALUE), + ("Custom", "Value"), + ]) + }) +} + +#[test] +fn mock_request_should_succeed_with_request_body() { + mock_request(|builder| builder.with_request_body(MOCK_REQUEST_PAYLOAD)) +} + +#[test] +fn mock_request_should_succeed_with_all() { + mock_request(|builder| { + builder + .with_url(MOCK_REQUEST_URL) + .with_method(HttpMethod::POST) + .with_request_headers(vec![ + (CONTENT_TYPE_HEADER, CONTENT_TYPE_VALUE), + ("Custom", "Value"), + ]) + .with_request_body(MOCK_REQUEST_PAYLOAD) + }) +} + +#[test] +#[should_panic(expected = "assertion failed: `(left == right)`")] +fn mock_request_should_fail_with_url() { + mock_request(|builder| builder.with_url("https://not-the-url.com")) +} + +#[test] +#[should_panic(expected = "assertion failed: `(left == right)`")] +fn mock_request_should_fail_with_method() { + mock_request(|builder| builder.with_method(HttpMethod::GET)) +} + +#[test] +#[should_panic(expected = "assertion failed: `(left == right)`")] +fn mock_request_should_fail_with_request_headers() { + mock_request(|builder| builder.with_request_headers(vec![("Custom", "NotValue")])) +} + +#[test] +#[should_panic(expected = "assertion failed: `(left == right)`")] +fn mock_request_should_fail_with_request_body() { + mock_request(|builder| builder.with_request_body(r#"{"different":"body"}"#)) +} + +#[test] +fn should_canonicalize_json_response() { + let setup = EvmRpcSetup::new(); + setup.authorize_caller(Auth::FreeRpc); + let responses = [ + r#"{"id":1,"jsonrpc":"2.0","result":"0x00112233"}"#, + r#"{"result":"0x00112233","id":1,"jsonrpc":"2.0"}"#, + r#"{"result":"0x00112233","jsonrpc":"2.0","id":1}"#, + ] + .into_iter() + .map(|response| { + setup + .request( + Source::Custom { + url: MOCK_REQUEST_URL.to_string(), + headers: None, + }, + MOCK_REQUEST_PAYLOAD, + MOCK_REQUEST_RESPONSE_BYTES, + ) + .mock_http(MockOutcallBuilder::new(200, response)) + .wait() + }) + .collect::>(); + assert!(responses.windows(2).all(|w| w[0] == w[1])); +}