diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a77377c0..1d5e98b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,5 +98,13 @@ jobs: run: | solana-test-validator & - - name: Cargo test + - name: 'Download wallet canister' + run: | + wget https://github.com/dfinity/sdk/raw/0a82e042adec6f24ba53665312713923bf276a34/src/distributed/wallet.wasm.gz + + - name: 'Set WALLET_WASM_PATH for load_wasm' + run: | + echo "WALLET_WASM_PATH=$GITHUB_WORKSPACE/wallet.wasm.gz" >> "$GITHUB_ENV" + + - name: 'Cargo test' run: cargo test --package sol_rpc_int_tests -- --test-threads 2 --nocapture diff --git a/Cargo.lock b/Cargo.lock index 3fac722d..e3887b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4044,6 +4044,7 @@ dependencies = [ "ic-cdk", "ic-test-utilities-load-wasm", "pocket-ic", + "regex", "serde", "serde_bytes", "serde_json", diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 6375d2d5..f01313aa 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -14,6 +14,7 @@ canlog = { path = "../canlog" } ic-cdk = { workspace = true } ic-test-utilities-load-wasm = { workspace = true } pocket-ic = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_bytes = { workspace = true } serde_json = { workspace = true } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 2ee93312..81bcc50b 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -10,29 +10,32 @@ use pocket_ic::{ nonblocking::PocketIc, PocketIcBuilder, RejectCode, RejectResponse, }; -use serde::de::DeserializeOwned; +use regex::Regex; +use serde::{de::DeserializeOwned, Deserialize}; use sol_rpc_canister::{ http_types::{HttpRequest, HttpResponse}, logs::Priority, }; use sol_rpc_client::{Runtime, SolRpcClient}; use sol_rpc_types::{InstallArgs, SupportedRpcProviderId}; -use std::{path::PathBuf, time::Duration}; +use std::env::var; +use std::{env::set_var, 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_CALLER_TEST_ID: Principal = + Principal::from_slice(&[0x0, 0x0, 0x0, 0x0, 0x3, 0x31, 0x1, 0x8, 0x2, 0x2]); 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]); pub struct Setup { env: PocketIc, caller: Principal, controller: Principal, - canister_id: CanisterId, + sol_rpc_canister_id: CanisterId, + wallet_canister_id: CanisterId, } impl Setup { @@ -53,7 +56,10 @@ impl Setup { pub async fn with_pocket_ic_and_args(env: PocketIc, args: InstallArgs) -> Self { let controller = DEFAULT_CONTROLLER_TEST_ID; - let canister_id = env + let caller = DEFAULT_CALLER_TEST_ID; + let wallet = DEFAULT_CALLER_TEST_ID; + + let sol_rpc_canister_id = env .create_canister_with_settings( None, Some(CanisterSettings { @@ -62,21 +68,36 @@ impl Setup { }), ) .await; - env.add_cycles(canister_id, u128::MAX).await; + env.add_cycles(sol_rpc_canister_id, u64::MAX as u128).await; env.install_canister( - canister_id, + sol_rpc_canister_id, sol_rpc_wasm(), Encode!(&args).unwrap(), Some(controller), ) .await; - let caller = DEFAULT_CALLER_TEST_ID; + + let wallet_canister_id = env + .create_canister_with_id( + None, + Some(CanisterSettings { + controllers: Some(vec![controller]), + ..CanisterSettings::default() + }), + wallet, + ) + .await + .unwrap(); + env.add_cycles(wallet_canister_id, u64::MAX as u128).await; + env.install_canister(wallet_canister_id, wallet_wasm(), vec![], Some(controller)) + .await; Self { env, caller, controller, - canister_id, + sol_rpc_canister_id, + wallet_canister_id, } } @@ -87,7 +108,7 @@ impl Setup { self.env.tick().await; self.env .upgrade_canister( - self.canister_id, + self.sol_rpc_canister_id, sol_rpc_wasm(), Encode!(&args).unwrap(), Some(self.controller), @@ -97,11 +118,11 @@ impl Setup { } pub fn client(&self) -> SolRpcClient { - SolRpcClient::new(self.new_pocket_ic(), self.canister_id) + SolRpcClient::new(self.new_pocket_ic(), self.sol_rpc_canister_id) } pub fn client_live_mode(&self) -> SolRpcClient { - SolRpcClient::new(self.new_live_pocket_ic(), self.canister_id) + SolRpcClient::new(self.new_live_pocket_ic(), self.sol_rpc_canister_id) } fn new_pocket_ic(&self) -> PocketIcRuntime { @@ -109,6 +130,8 @@ impl Setup { env: &self.env, caller: self.caller, mock_strategy: None, + controller: self.controller, + wallet: self.wallet_canister_id, } } @@ -116,6 +139,8 @@ impl Setup { PocketIcLiveModeRuntime { env: &self.env, caller: self.caller, + controller: self.controller, + wallet: self.wallet_canister_id, } } @@ -143,17 +168,29 @@ async fn tick_until_http_request(env: &PocketIc) -> Vec { fn sol_rpc_wasm() -> Vec { ic_test_utilities_load_wasm::load_wasm( - PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("../canister"), + PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("../canister"), "sol_rpc_canister", &[], ) } +fn wallet_wasm() -> Vec { + if var("WALLET_WASM_PATH").is_err() { + set_var( + "WALLET_WASM_PATH", + PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("wallet.wasm.gz"), + ) + }; + ic_test_utilities_load_wasm::load_wasm(PathBuf::new(), "wallet", &[]) +} + #[derive(Clone)] pub struct PocketIcRuntime<'a> { env: &'a PocketIc, caller: Principal, mock_strategy: Option, + wallet: Principal, + controller: Principal, } #[async_trait] @@ -163,20 +200,31 @@ impl Runtime for PocketIcRuntime<'_> { id: Principal, method: &str, args: In, - _cycles: u128, + cycles: u128, ) -> Result where In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { + // Forward the call through the wallet canister to attach cycles let message_id = self .env - .submit_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) + .submit_call( + self.wallet, + self.controller, + "wallet_call128", + Encode!(&CallCanisterArgs { + canister: id, + method_name: method.to_string(), + args: PocketIcRuntime::encode_args(args), + cycles, + }) + .unwrap(), + ) .await - .expect("failed to submit call"); + .unwrap(); self.execute_mock().await; - let result = self.env.await_call(message_id).await; - PocketIcRuntime::decode_call_result(result) + PocketIcRuntime::decode_forwarded_result(self.env.await_call(message_id).await) } async fn query_call( @@ -212,16 +260,7 @@ impl PocketIcRuntime<'_> { Out: CandidType + DeserializeOwned, { match result { - Ok(bytes) => decode_args(&bytes).map(|(res,)| res).map_err(|e| { - ( - RejectionCode::CanisterError, - format!( - "failed to decode canister response as {}: {}", - std::any::type_name::(), - e - ), - ) - }), + Ok(bytes) => Self::decode_call_response(bytes), Err(e) => { let rejection_code = match e.reject_code { RejectCode::SysFatal => RejectionCode::SysFatal, @@ -301,6 +340,47 @@ impl PocketIcRuntime<'_> { self.env.mock_canister_http_response(mock_response).await; true } + + fn decode_call_response(bytes: Vec) -> Result + where + Out: CandidType + DeserializeOwned, + { + decode_args(&bytes).map(|(res,)| res).map_err(|e| { + ( + RejectionCode::CanisterError, + format!( + "failed to decode canister response as {}: {}", + std::any::type_name::(), + e + ), + ) + }) + } + + fn decode_forwarded_result( + call_result: Result, RejectResponse>, + ) -> Result + where + Out: CandidType + DeserializeOwned, + { + match PocketIcRuntime::decode_call_result::>(call_result)? { + Ok(CallResult { bytes }) => PocketIcRuntime::decode_call_response(bytes), + Err(message) => { + // The wallet canister formats the rejection code and error message from the target + // canister into a single string. Extract them back from the formatted string. + match Regex::new(r"^An error happened during the call: (\d+): (.*)$") + .unwrap() + .captures(&message) + { + Some(captures) => { + let (_, [code, message]) = captures.extract(); + Err((code.parse::().unwrap().into(), message.to_string())) + } + None => Err((RejectionCode::Unknown, message)), + } + } + } + } } /// 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). @@ -312,6 +392,8 @@ impl PocketIcRuntime<'_> { pub struct PocketIcLiveModeRuntime<'a> { env: &'a PocketIc, caller: Principal, + wallet: Principal, + controller: Principal, } #[async_trait] @@ -321,18 +403,31 @@ impl Runtime for PocketIcLiveModeRuntime<'_> { id: Principal, method: &str, args: In, - _cycles: u128, + cycles: u128, ) -> Result where In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - let id = self + // Forward the call through the wallet canister to attach cycles + let message_id = self .env - .submit_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) + .submit_call( + self.wallet, + self.controller, + "wallet_call128", + Encode!(&CallCanisterArgs { + canister: id, + method_name: method.to_string(), + args: PocketIcRuntime::encode_args(args), + cycles, + }) + .unwrap(), + ) .await .unwrap(); - PocketIcRuntime::decode_call_result(self.env.await_call_no_ticks(id).await) + + PocketIcRuntime::decode_forwarded_result(self.env.await_call_no_ticks(message_id).await) } async fn query_call( @@ -357,7 +452,6 @@ impl Runtime for PocketIcLiveModeRuntime<'_> { 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; } @@ -388,11 +482,6 @@ impl SolRpcTestClient> for SolRpcClient> .entries } - fn with_caller>(mut self, id: T) -> Self { - self.runtime.caller = id.into(); - self - } - fn mock_http(self, mock: impl Into) -> Self { Self { runtime: self.runtime.with_strategy(MockStrategy::Mock(mock.into())), @@ -415,3 +504,22 @@ enum MockStrategy { Mock(MockOutcall), MockOnce(MockOutcall), } + +/// Argument to the wallet canister `wallet_call128` method. +/// See the [cycles wallet repository](https://github.com/dfinity/cycles-wallet). +#[derive(CandidType, Deserialize)] +struct CallCanisterArgs { + canister: Principal, + method_name: String, + #[serde(with = "serde_bytes")] + args: Vec, + cycles: u128, +} + +/// Return type of the wallet canister `wallet_call128` method. +/// See the [cycles wallet repository](https://github.com/dfinity/cycles-wallet) +#[derive(CandidType, Deserialize)] +struct CallResult { + #[serde(with = "serde_bytes", rename = "return")] + bytes: Vec, +} diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 86c24d4c..9a9c3348 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -6,7 +6,7 @@ use futures::future; use pocket_ic::PocketIcBuilder; use sol_rpc_client::SolRpcClient; use sol_rpc_int_tests::PocketIcLiveModeRuntime; -use sol_rpc_types::{InstallArgs, Mode, MultiRpcResult, OverrideProvider, RegexSubstitution}; +use sol_rpc_types::{InstallArgs, MultiRpcResult, OverrideProvider, RegexSubstitution}; use solana_client::rpc_client::RpcClient as SolanaRpcClient; use std::future::Future; @@ -54,8 +54,6 @@ impl Setup { setup: sol_rpc_int_tests::Setup::with_pocket_ic_and_args( pic, InstallArgs { - // TODO XC-323: handle cycles properly - mode: Some(Mode::Demo), override_provider: Some(OverrideProvider { override_url: Some(RegexSubstitution { pattern: ".*".into(), diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 9057c40e..1fe2e5ee 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1,5 +1,5 @@ use sol_rpc_canister::constants::*; -use sol_rpc_int_tests::{Setup, SolRpcTestClient, ADDITIONAL_TEST_ID}; +use sol_rpc_int_tests::{Setup, SolRpcTestClient, DEFAULT_CALLER_TEST_ID}; use sol_rpc_types::{ InstallArgs, Mode, ProviderError, RpcAccess, RpcAuth, RpcEndpoint, RpcError, RpcSource, SolanaCluster, SupportedRpcProviderId, @@ -199,7 +199,11 @@ mod retrieve_logs_tests { #[tokio::test] async fn should_retrieve_logs() { - let setup = Setup::new().await; + let setup = Setup::with_args(InstallArgs { + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), + ..Default::default() + }) + .await; let client = setup.client(); assert_eq!(client.retrieve_logs("DEBUG").await, vec![]); assert_eq!(client.retrieve_logs("INFO").await, vec![]); @@ -207,7 +211,6 @@ mod retrieve_logs_tests { // Generate some log setup .client() - .with_caller(setup.controller()) .update_api_keys(&[( SupportedRpcProviderId::AlchemyMainnet, Some("unauthorized-api-key".to_string()), @@ -226,16 +229,15 @@ mod update_api_key_tests { #[tokio::test] async fn should_update_api_key() { - let authorized_caller = ADDITIONAL_TEST_ID; let setup = Setup::with_args(InstallArgs { - manage_api_keys: Some(vec![authorized_caller]), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), ..Default::default() }) .await; let provider = SupportedRpcProviderId::AlchemyMainnet; let api_key = "test-api-key"; - let client = setup.client().with_caller(authorized_caller); + let client = setup.client(); client .update_api_keys(&[(provider, Some(api_key.to_string()))]) .await; @@ -263,10 +265,13 @@ mod update_api_key_tests { #[tokio::test] #[should_panic(expected = "Trying to set API key for unauthenticated provider")] async fn should_prevent_unauthenticated_update_api_keys() { - let setup = Setup::new().await; + let setup = Setup::with_args(InstallArgs { + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), + ..Default::default() + }) + .await; setup .client() - .with_caller(setup.controller()) .update_api_keys(&[( SupportedRpcProviderId::PublicNodeMainnet, Some("invalid-api-key".to_string()), @@ -280,10 +285,14 @@ mod canister_upgrade_tests { #[tokio::test] async fn upgrade_should_keep_api_keys() { - let setup = Setup::new().await; + let setup = Setup::with_args(InstallArgs { + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), + ..Default::default() + }) + .await; let provider = SupportedRpcProviderId::AlchemyMainnet; let api_key = "test-api-key"; - let client = setup.client().with_caller(setup.controller()); + let client = setup.client(); client .update_api_keys(&[(provider, Some(api_key.to_string()))]) .await; @@ -300,9 +309,8 @@ mod canister_upgrade_tests { #[tokio::test] async fn upgrade_should_keep_manage_api_key_principals() { - let authorized_caller = ADDITIONAL_TEST_ID; let setup = Setup::with_args(InstallArgs { - manage_api_keys: Some(vec![authorized_caller]), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), ..Default::default() }) .await; @@ -314,7 +322,6 @@ mod canister_upgrade_tests { .await; setup .client() - .with_caller(authorized_caller) .update_api_keys(&[( SupportedRpcProviderId::AlchemyMainnet, Some("authorized-api-key".to_string()), @@ -325,9 +332,8 @@ mod canister_upgrade_tests { #[tokio::test] #[should_panic(expected = "You are not authorized")] async fn upgrade_should_change_manage_api_key_principals() { - let deauthorized_caller = ADDITIONAL_TEST_ID; let setup = Setup::with_args(InstallArgs { - manage_api_keys: Some(vec![deauthorized_caller]), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), ..Default::default() }) .await; @@ -339,7 +345,6 @@ mod canister_upgrade_tests { .await; setup .client() - .with_caller(deauthorized_caller) .update_api_keys(&[( SupportedRpcProviderId::AlchemyMainnet, Some("unauthorized-api-key".to_string()), diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 3d15001a..71ad6ab3 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -90,7 +90,7 @@ impl SolRpcClient { self.sol_rpc_canister, "updateApiKeys", (api_keys.to_vec(),), - 10_000, + 0, ) .await .unwrap() @@ -110,10 +110,10 @@ impl SolRpcClient { None::, params, ), - 1_000_000_000, + 10_000_000_000, ) .await - .expect("Client error: failed to call getSlot") + .expect("Client error: failed to call `getSlot`") } /// Call `request` on the SOL RPC canister.