diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff13063..0927ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: env: CARGO_TERM_COLOR: always RUSTFLAGS: "-Dwarnings" + PROXY_CANISTER_VERSION: "v0.1.0" jobs: lint: @@ -75,6 +76,13 @@ jobs: echo "JSON_RPC_CANISTER_WASM_PATH=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/release/json_rpc_canister.wasm" >> "$GITHUB_ENV" echo "MULTI_CANISTER_WASM_PATH=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/release/multi_canister.wasm" >> "$GITHUB_ENV" + - name: 'Download proxy canister WASM' + run: | + PROXY_CANISTER_WASM_PATH=$GITHUB_WORKSPACE/target/test-artifacts + mkdir -p $PROXY_CANISTER_WASM_PATH + curl -L "https://github.com/dfinity/proxy-canister/releases/download/${PROXY_CANISTER_VERSION}/proxy.wasm" -o "${PROXY_CANISTER_WASM_PATH}/proxy.wasm" + echo "PROXY_CANISTER_WASM_PATH=${PROXY_CANISTER_WASM_PATH}/proxy.wasm" >> $GITHUB_ENV + - name: 'Install PocketIC server' uses: dfinity/pocketic@main with: diff --git a/Cargo.lock b/Cargo.lock index 0feeffa..1cb5da4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,9 +1128,11 @@ dependencies = [ name = "http_canister" version = "1.0.0" dependencies = [ + "assert_matches", "candid", "canhttp", "http", + "ic-canister-runtime", "ic-cdk", "ic-management-canister-types", "ic-test-utilities-load-wasm", @@ -1394,6 +1396,7 @@ dependencies = [ "ic-error-types", "pocket-ic", "serde", + "serde_bytes", "serde_json", "tokio", "url", @@ -2834,7 +2837,6 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", "windows-sys 0.59.0", ] diff --git a/examples/http_canister/Cargo.toml b/examples/http_canister/Cargo.toml index f1dd604..a29af21 100644 --- a/examples/http_canister/Cargo.toml +++ b/examples/http_canister/Cargo.toml @@ -15,6 +15,8 @@ ic-cdk = { workspace = true } tower = { workspace = true } [dev-dependencies] +assert_matches = { workspace = true } +ic-canister-runtime = { workspace = true } ic-management-canister-types = { workspace = true } ic-test-utilities-load-wasm = { workspace = true } pocket-ic = { workspace = true } diff --git a/examples/http_canister/src/main.rs b/examples/http_canister/src/main.rs index e5e3434..6982f81 100644 --- a/examples/http_canister/src/main.rs +++ b/examples/http_canister/src/main.rs @@ -1,19 +1,36 @@ //! Example of a canister using `canhttp` to issue HTTP requests. use canhttp::{ - cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, + cycles::{ChargeCaller, ChargeMyself, CyclesAccountingServiceBuilder, CyclesChargingPolicy}, http::HttpConversionLayer, observability::ObservabilityLayer, CanisterReadyLayer, Client, MaxResponseBytesRequestExtension, }; -use http::Request; use ic_cdk::update; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; /// Make an HTTP POST request. #[update] pub async fn make_http_post_request() -> String { - let response = http_client() + let response = http_client(ChargeMyself::default()) + .ready() + .await + .expect("Client should be ready") + .call(request()) + .await + .expect("Request should succeed"); + + assert_eq!(response.status(), http::StatusCode::OK); + + String::from_utf8_lossy(response.body()).to_string() +} + +/// Make an HTTP POST request and charge the user cycles for it. +#[update] +pub async fn make_http_post_request_and_charge_user_cycles() -> String { + // Use cycles attached by the caller to pay for HTTPs outcalls and charge an additional flat + // fee of 1M cycles. + let response = http_client(ChargeCaller::new(|_request, cost| cost + 1_000_000)) .ready() .await .expect("Client should be ready") @@ -32,7 +49,7 @@ pub async fn make_http_post_request() -> String { pub async fn infinite_loop_make_http_post_request() -> String { let mut client = ServiceBuilder::new() .layer(CanisterReadyLayer) - .service(http_client()); + .service(http_client(ChargeMyself::default())); loop { match client.ready().await { @@ -45,7 +62,8 @@ pub async fn infinite_loop_make_http_post_request() -> String { } } -fn http_client( +fn http_client> + Clone>( + cycles_charging_policy: C, ) -> impl Service>, Response = http::Response>, Error = BoxError> { ServiceBuilder::new() // Print request, response and errors to the console @@ -61,13 +79,13 @@ fn http_client( ) // Only deal with types from the http crate. .layer(HttpConversionLayer) - // Use cycles from the canister to pay for HTTPs outcalls - .cycles_accounting(ChargeMyself::default()) + // The strategy to use to charge cycles for the request + .cycles_accounting(cycles_charging_policy) // The actual client .service(Client::new_with_box_error()) } -fn request() -> Request> { +fn request() -> http::Request> { fn httpbin_base_url() -> String { option_env!("HTTPBIN_URL") .unwrap_or_else(|| "https://httpbin.org") diff --git a/examples/http_canister/tests/tests.rs b/examples/http_canister/tests/tests.rs index 4f4b9ac..f6c3e54 100644 --- a/examples/http_canister/tests/tests.rs +++ b/examples/http_canister/tests/tests.rs @@ -1,6 +1,7 @@ +use assert_matches::assert_matches; use candid::{Decode, Encode, Principal}; -use ic_management_canister_types::CanisterIdRecord; -use ic_management_canister_types::CanisterSettings; +use ic_canister_runtime::IcError; +use ic_management_canister_types::{CanisterIdRecord, CanisterSettings}; use pocket_ic::common::rest::{ CanisterHttpReply, CanisterHttpResponse, MockCanisterHttpResponse, RawEffectivePrincipal, }; @@ -20,6 +21,34 @@ async fn should_make_http_post_request() { assert!(http_request_result.contains("\"X-Id\": \"42\"")); } +#[tokio::test] +async fn should_attach_cycles_to_canister_call() { + const REQUIRED_CYCLES: u128 = 1_000_000; + + let setup = Setup::new("http_canister").await.with_proxy().await; + + let http_request_result = setup + .canister() + .try_update_call_with_cycles::<_, String>( + "make_http_post_request_and_charge_user_cycles", + (), + REQUIRED_CYCLES - 1, + ) + .await; + assert_matches!(http_request_result, Err(IcError::CallRejected { code: _, message }) if message.contains("InsufficientCyclesError")); + + let http_request_result = setup + .canister() + .update_call_with_cycles::<_, String>( + "make_http_post_request_and_charge_user_cycles", + (), + REQUIRED_CYCLES, + ) + .await; + assert!(http_request_result.contains("Hello, World!")); + assert!(http_request_result.contains("\"X-Id\": \"42\"")); +} + #[test] fn should_not_make_http_request_when_stopping() { let env = PocketIc::new(); diff --git a/ic-canister-runtime/Cargo.toml b/ic-canister-runtime/Cargo.toml index 0cd8521..d6833eb 100644 --- a/ic-canister-runtime/Cargo.toml +++ b/ic-canister-runtime/Cargo.toml @@ -11,7 +11,6 @@ repository.workspace = true documentation = "https://docs.rs/ic-canister-runtime" [features] -proxy = ["dep:serde_bytes"] wallet = ["dep:regex-lite", "dep:serde_bytes"] [dependencies] diff --git a/ic-canister-runtime/src/lib.rs b/ic-canister-runtime/src/lib.rs index e5cd54e..e77695b 100644 --- a/ic-canister-runtime/src/lib.rs +++ b/ic-canister-runtime/src/lib.rs @@ -10,16 +10,12 @@ use async_trait::async_trait; use candid::{utils::ArgumentEncoder, CandidType, Principal}; use ic_cdk::call::{Call, CallFailed, CandidDecodeFailed}; use ic_error_types::RejectCode; -#[cfg(feature = "proxy")] -pub use proxy::ProxyRuntime; use serde::de::DeserializeOwned; pub use stub::StubRuntime; use thiserror::Error; #[cfg(feature = "wallet")] pub use wallet::CyclesWalletRuntime; -#[cfg(feature = "proxy")] -mod proxy; mod stub; #[cfg(feature = "wallet")] mod wallet; diff --git a/ic-canister-runtime/src/proxy.rs b/ic-canister-runtime/src/proxy.rs deleted file mode 100644 index 46cfae7..0000000 --- a/ic-canister-runtime/src/proxy.rs +++ /dev/null @@ -1,155 +0,0 @@ -use crate::{IcError, Runtime}; -use async_trait::async_trait; -use candid::{decode_one, encode_args, utils::ArgumentEncoder, CandidType, Deserialize, Principal}; -use ic_error_types::RejectCode; -use serde::{de::DeserializeOwned, Serialize}; - -/// Runtime wrapping another [`Runtime`] instance, where update calls are forwarded through a -/// [proxy canister](https://github.com/dfinity/proxy-canister) to attach cycles to them. -pub struct ProxyRuntime { - runtime: R, - proxy_canister_id: Principal, -} - -impl ProxyRuntime { - /// Create a new [`ProxyRuntime`] wrapping the given [`Runtime`] by forwarding update calls - /// through the given proxy canister to attach cycles. - pub fn new(runtime: R, proxy_canister_id: Principal) -> Self { - ProxyRuntime { - runtime, - proxy_canister_id, - } - } - - /// Modify the underlying runtime by applying a transformation function. - /// - /// The transformation does not necessarily produce a runtime of the same type. - pub fn with_runtime S>(self, transformation: F) -> ProxyRuntime { - ProxyRuntime { - runtime: transformation(self.runtime), - proxy_canister_id: self.proxy_canister_id, - } - } -} - -impl AsRef for ProxyRuntime { - fn as_ref(&self) -> &R { - &self.runtime - } -} - -#[async_trait] -impl Runtime for ProxyRuntime { - async fn update_call( - &self, - id: Principal, - method: &str, - args: In, - cycles: u128, - ) -> Result - where - In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned, - { - self.runtime - .update_call::<(ProxyArgs,), Result>( - self.proxy_canister_id, - "proxy", - (ProxyArgs::new(id, method, args, cycles),), - 0, - ) - .await - .and_then(decode_proxy_canister_response) - } - - async fn query_call( - &self, - id: Principal, - method: &str, - args: In, - ) -> Result - where - In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned, - { - self.runtime.query_call(id, method, args).await - } -} - -fn decode_proxy_canister_response( - result: Result, -) -> Result -where - Out: CandidType + DeserializeOwned, -{ - match result { - Ok(ProxySucceed { result }) => { - decode_one(&result).map_err(|e| IcError::CandidDecodeFailed { - message: format!( - "failed to decode canister response as {}: {}", - std::any::type_name::(), - e - ), - }) - } - Err(error) => match error { - ProxyError::UnauthorizedUser => Err(IcError::CallRejected { - code: RejectCode::SysFatal, - message: "Unauthorized caller!".to_string(), - }), - ProxyError::InsufficientCycles { - available, - required, - } => Err(IcError::InsufficientLiquidCycleBalance { - available, - required, - }), - ProxyError::CallFailed { reason } => Err(IcError::CallRejected { - code: RejectCode::SysFatal, - message: reason, - }), - }, - } -} - -#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] -struct ProxyArgs { - canister_id: Principal, - method: String, - #[serde(with = "serde_bytes")] - args: Vec, - cycles: u128, -} - -impl ProxyArgs { - pub fn new( - canister_id: Principal, - method: impl ToString, - args: In, - cycles: u128, - ) -> Self { - Self { - canister_id, - method: method.to_string(), - args: encode_args(args).unwrap_or_else(panic_when_encode_fails), - cycles, - } - } -} - -#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] -struct ProxySucceed { - #[serde(with = "serde_bytes")] - result: Vec, -} - -#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] -enum ProxyError { - InsufficientCycles { available: u128, required: u128 }, - CallFailed { reason: String }, - UnauthorizedUser, -} - -fn panic_when_encode_fails(err: candid::error::Error) -> Vec { - panic!("failed to encode args: {err}") -} diff --git a/ic-pocket-canister-runtime/Cargo.toml b/ic-pocket-canister-runtime/Cargo.toml index cc76b5e..bb17bf1 100644 --- a/ic-pocket-canister-runtime/Cargo.toml +++ b/ic-pocket-canister-runtime/Cargo.toml @@ -19,6 +19,7 @@ ic-cdk = { workspace = true } ic-error-types = { workspace = true } pocket-ic = { workspace = true } serde = { workspace = true } +serde_bytes = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } url = { workspace = true } diff --git a/ic-pocket-canister-runtime/src/lib.rs b/ic-pocket-canister-runtime/src/lib.rs index 7366157..7034a65 100644 --- a/ic-pocket-canister-runtime/src/lib.rs +++ b/ic-pocket-canister-runtime/src/lib.rs @@ -5,6 +5,7 @@ #![forbid(missing_docs)] mod mock; +mod proxy; use async_trait::async_trait; use candid::{decode_one, encode_args, utils::ArgumentEncoder, CandidType, Principal}; @@ -76,6 +77,7 @@ pub struct PocketIcRuntime<'a> { // the `Runtime::update_call` method using interior mutability. // This is necessary since `Runtime::update_call` takes an immutable reference to the runtime. mocks: Option>>, + proxy_canister_id: Option, } impl<'a> PocketIcRuntime<'a> { @@ -86,6 +88,7 @@ impl<'a> PocketIcRuntime<'a> { env, caller, mocks: None, + proxy_canister_id: None, } } @@ -138,35 +141,30 @@ impl<'a> PocketIcRuntime<'a> { self.mocks = Some(Mutex::new(Box::new(mocks))); self } -} -impl<'a> AsRef for PocketIcRuntime<'a> { - fn as_ref(&self) -> &'a PocketIc { - self.env + /// Route update calls through a [proxy canister](https://github.com/dfinity/proxy-canister) + /// to attach cycles to them. + /// + /// When a proxy canister is configured, all `update_call` requests are forwarded through + /// the proxy canister, which attaches the specified cycles before calling the target canister. + /// Query calls are not affected and go directly to the target canister. + pub fn with_proxy_canister(mut self, proxy_canister_id: Principal) -> Self { + self.proxy_canister_id = Some(proxy_canister_id); + self } -} -#[async_trait] -impl Runtime for PocketIcRuntime<'_> { - async fn update_call( + async fn submit_and_await_call( &self, id: Principal, method: &str, args: In, - _cycles: u128, - ) -> Result + ) -> Result, IcError> where In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned, { let message_id = self .env - .submit_call( - id, - self.caller, - method, - encode_args(args).unwrap_or_else(panic_when_encode_fails), - ) + .submit_call(id, self.caller, method, encode_args_or_panic(args)) .await .map_err(parse_reject_response)?; if let Some(mock) = &self.mocks { @@ -180,8 +178,40 @@ impl Runtime for PocketIcRuntime<'_> { } else { self.env.await_call(message_id).await } - .map(decode_call_response) - .map_err(parse_reject_response)? + .map_err(parse_reject_response) + } +} + +impl<'a> AsRef for PocketIcRuntime<'a> { + fn as_ref(&self) -> &'a PocketIc { + self.env + } +} + +#[async_trait] +impl Runtime for PocketIcRuntime<'_> { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + let bytes = match self.proxy_canister_id { + Some(proxy_id) => { + let proxy_args = proxy::ProxyArgs::new(id, method, args, cycles); + let response = self + .submit_and_await_call(proxy_id, "proxy", (proxy_args,)) + .await?; + proxy::decode_response(response)? + } + None => self.submit_and_await_call(id, method, args).await?, + }; + decode_call_response(bytes) } async fn query_call( @@ -194,16 +224,12 @@ impl Runtime for PocketIcRuntime<'_> { In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - self.env - .query_call( - id, - self.caller, - method, - encode_args(args).unwrap_or_else(panic_when_encode_fails), - ) + let bytes = self + .env + .query_call(id, self.caller, method, encode_args_or_panic(args)) .await - .map(decode_call_response) - .map_err(parse_reject_response)? + .map_err(parse_reject_response)?; + decode_call_response(bytes) } } @@ -280,8 +306,8 @@ where }) } -fn panic_when_encode_fails(err: candid::error::Error) -> Vec { - panic!("failed to encode args: {err}") +fn encode_args_or_panic(arguments: Tuple) -> Vec { + encode_args(arguments).unwrap_or_else(|e| panic!("failed to encode args: {e}")) } async fn tick_until_http_requests(env: &PocketIc) -> Vec { diff --git a/ic-pocket-canister-runtime/src/proxy.rs b/ic-pocket-canister-runtime/src/proxy.rs new file mode 100644 index 0000000..94938dc --- /dev/null +++ b/ic-pocket-canister-runtime/src/proxy.rs @@ -0,0 +1,74 @@ +//! Proxy canister types for routing update calls through a proxy to attach cycles. + +use super::encode_args_or_panic; +use candid::{decode_one, utils::ArgumentEncoder, CandidType, Deserialize, Principal}; +use ic_canister_runtime::IcError; +use ic_error_types::RejectCode; +use serde::Serialize; + +/// Arguments for calling the proxy canister. +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct ProxyArgs { + canister_id: Principal, + method: String, + #[serde(with = "serde_bytes")] + args: Vec, + cycles: u128, +} + +impl ProxyArgs { + pub fn new( + canister_id: Principal, + method: impl ToString, + args: In, + cycles: u128, + ) -> Self { + Self { + canister_id, + method: method.to_string(), + args: encode_args_or_panic(args), + cycles, + } + } +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +struct ProxySucceed { + #[serde(with = "serde_bytes")] + result: Vec, +} + +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +enum ProxyError { + InsufficientCycles { available: u128, required: u128 }, + CallFailed { reason: String }, + UnauthorizedUser, +} + +pub fn decode_response(bytes: Vec) -> Result, IcError> { + let result: Result = + decode_one(&bytes).map_err(|e| IcError::CandidDecodeFailed { + message: format!("failed to decode proxy response: {}", e), + })?; + + match result { + Ok(ProxySucceed { result }) => Ok(result), + Err(error) => match error { + ProxyError::UnauthorizedUser => Err(IcError::CallRejected { + code: RejectCode::SysFatal, + message: "Unauthorized caller!".to_string(), + }), + ProxyError::InsufficientCycles { + available, + required, + } => Err(IcError::InsufficientLiquidCycleBalance { + available, + required, + }), + ProxyError::CallFailed { reason } => Err(IcError::CallRejected { + code: RejectCode::SysFatal, + message: reason, + }), + }, + } +} diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index dbb6bc9..fb924c1 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -1,17 +1,19 @@ use candid::{utils::ArgumentEncoder, CandidType, Encode, Principal}; -use ic_canister_runtime::Runtime; +use ic_canister_runtime::{IcError, Runtime}; use ic_management_canister_types::{CanisterId, CanisterSettings}; use ic_pocket_canister_runtime::PocketIcRuntime; use pocket_ic::{nonblocking::PocketIc, PocketIcBuilder}; use serde::de::DeserializeOwned; -use std::{env::var, path::PathBuf, sync::Arc}; +use std::{env::var, fs, path::PathBuf, sync::Arc}; pub struct Setup { env: Arc, canister_id: CanisterId, + proxy_canister_id: Option, } impl Setup { + pub const DEFAULT_CALLER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x01]); pub const DEFAULT_CONTROLLER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); pub async fn new(canister_binary_name: &str) -> Self { @@ -46,14 +48,56 @@ impl Setup { Self { env: Arc::new(env), canister_id, + proxy_canister_id: None, + } + } + + pub async fn with_proxy(self) -> Self { + let Setup { + env, + canister_id, + proxy_canister_id, + } = self; + assert!(proxy_canister_id.is_none(), "Proxy canister already setup"); + + let proxy_canister_id = env + .create_canister_with_settings( + None, + Some(CanisterSettings { + // Only controllers have access to the proxy service, so we also allow + // the default caller + controllers: Some(vec![Self::DEFAULT_CONTROLLER, Setup::DEFAULT_CALLER]), + ..CanisterSettings::default() + }), + ) + .await; + env.add_cycles(proxy_canister_id, u64::MAX as u128).await; + + env.install_canister( + proxy_canister_id, + proxy_wasm().await, + Encode!().unwrap(), + Some(Self::DEFAULT_CONTROLLER), + ) + .await; + + Self { + env, + canister_id, + proxy_canister_id: Some(proxy_canister_id), } } pub fn runtime(&self) -> PocketIcRuntime<'_> { - PocketIcRuntime::new(self.env.as_ref(), Principal::anonymous()) + let runtime = PocketIcRuntime::new(self.env.as_ref(), Self::DEFAULT_CALLER); + if let Some(proxy_canister_id) = self.proxy_canister_id { + runtime.with_proxy_canister(proxy_canister_id) + } else { + runtime + } } - pub fn canister(&self) -> Canister<'_> { + pub fn canister(&self) -> Canister> { Canister { runtime: self.runtime(), id: self.canister_id, @@ -61,22 +105,49 @@ impl Setup { } } -pub struct Canister<'a> { - runtime: PocketIcRuntime<'a>, +pub struct Canister { + runtime: R, id: CanisterId, } -impl Canister<'_> { +impl Canister { pub async fn update_call(&self, method: &str, args: In) -> Out where In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - self.runtime - .update_call(self.id, method, args, 0) + self.update_call_with_cycles(method, args, 0).await + } + + pub async fn update_call_with_cycles( + &self, + method: &str, + args: In, + cycles: u128, + ) -> Out + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + self.try_update_call_with_cycles(method, args, cycles) .await .expect("Update call failed") } + + pub async fn try_update_call_with_cycles( + &self, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + self.runtime + .update_call(self.id, method, args, cycles) + .await + } } pub fn canister_wasm(canister_binary_name: &str) -> Vec { @@ -86,3 +157,22 @@ pub fn canister_wasm(canister_binary_name: &str) -> Vec { &[], ) } + +async fn proxy_wasm() -> Vec { + const DEFAULT_PATH: &str = "../../test_fixtures/wasms/proxy.wasm"; + const DOWNLOAD_URL: &str = + "https://github.com/dfinity/proxy-canister/releases/download/v0.1.0/proxy.wasm"; + + let path = option_env!("PROXY_CANISTER_WASM_PATH") + .map(PathBuf::from) + .unwrap_or(PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join(DEFAULT_PATH)); + + if !std::path::Path::new(&path).exists() { + std::process::Command::new("curl") + .args(["-L", "-o", path.to_str().unwrap(), DOWNLOAD_URL]) + .status() + .unwrap_or_else(|e| panic!("Failed to download canister WASM: {e:?}")); + } + + fs::read(&path).unwrap_or_else(|e| panic!("Failed to read proxy canister WASM: {e}")) +}