From 08a795d774333b47392b9faba82eabc122c5bbac Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 5 Nov 2025 10:01:17 +0100 Subject: [PATCH] Add `CyclesWalletRuntime` --- Cargo.lock | 8 ++ Cargo.toml | 2 + ic-canister-runtime/Cargo.toml | 5 ++ ic-canister-runtime/src/lib.rs | 5 ++ ic-canister-runtime/src/wallet.rs | 139 ++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 ic-canister-runtime/src/wallet.rs diff --git a/Cargo.lock b/Cargo.lock index b363872..9a6f707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,7 +831,9 @@ dependencies = [ "candid", "ic-cdk", "ic-error-types", + "regex-lite", "serde", + "serde_bytes", "thiserror 2.0.17", ] @@ -1651,6 +1653,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + [[package]] name = "regex-syntax" version = "0.8.6" diff --git a/Cargo.toml b/Cargo.toml index 1cb5ca2..f5a7bda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,9 @@ num-traits = "0.2.19" pin-project = "1.1.10" pocket-ic = "9.0.2" proptest = "1.6.0" +regex-lite = "0.1.8" serde = "1.0" +serde_bytes = "0.11.19" serde_json = "1.0" sha2 = "0.10.8" strum = { version = "0.27.1", features = ["derive"] } diff --git a/ic-canister-runtime/Cargo.toml b/ic-canister-runtime/Cargo.toml index 02f2b92..c99ff72 100644 --- a/ic-canister-runtime/Cargo.toml +++ b/ic-canister-runtime/Cargo.toml @@ -11,12 +11,17 @@ include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] repository.workspace = true documentation = "https://docs.rs/ic-canister-runtime" +[features] +wallet = ["dep:regex-lite", "dep:serde_bytes"] + [dependencies] async-trait = { workspace = true } candid = { workspace = true } ic-cdk = { workspace = true } ic-error-types = { workspace = true } +regex-lite = { workspace = true, optional = true } serde = { workspace = true } +serde_bytes = { workspace = true, optional = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/ic-canister-runtime/src/lib.rs b/ic-canister-runtime/src/lib.rs index bceddac..f5834d8 100644 --- a/ic-canister-runtime/src/lib.rs +++ b/ic-canister-runtime/src/lib.rs @@ -12,6 +12,11 @@ use ic_cdk::call::{Call, CallFailed, CandidDecodeFailed}; use ic_error_types::RejectCode; use serde::de::DeserializeOwned; use thiserror::Error; +#[cfg(feature = "wallet")] +pub use wallet::CyclesWalletRuntime; + +#[cfg(feature = "wallet")] +mod wallet; /// Abstract the canister runtime so that code making requests to canisters can be reused: /// * in production using [`ic_cdk`], diff --git a/ic-canister-runtime/src/wallet.rs b/ic-canister-runtime/src/wallet.rs new file mode 100644 index 0000000..461cb46 --- /dev/null +++ b/ic-canister-runtime/src/wallet.rs @@ -0,0 +1,139 @@ +use crate::{IcError, Runtime}; +use async_trait::async_trait; +use candid::{decode_one, encode_args, utils::ArgumentEncoder, CandidType, Deserialize, Principal}; +use ic_cdk::management_canister::CanisterId; +use ic_error_types::RejectCode; +use regex_lite::Regex; +use serde::de::DeserializeOwned; + +/// Runtime wrapping another [`Runtime`] instance, where update calls are routed through a +/// [cycles wallet](https://github.com/dfinity/cycles-wallet) to attach cycles to them. +pub struct CyclesWalletRuntime { + runtime: R, + cycles_wallet_canister_id: Principal, +} + +impl CyclesWalletRuntime { + /// Create a new [`CyclesWalletRuntime`] wrapping the given [`Runtime`] by routing update calls + /// through the given cycles wallet to attach cycles. + pub fn new(runtime: R, cycles_wallet_canister_id: Principal) -> Self { + CyclesWalletRuntime { + runtime, + cycles_wallet_canister_id, + } + } +} + +#[async_trait] +impl Runtime for CyclesWalletRuntime { + 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::<(WalletCall128Args,), Result>( + self.cycles_wallet_canister_id, + "wallet_call128", + (WalletCall128Args::new(id, method, args, cycles),), + 0, + ) + .await + .and_then(decode_cycles_wallet_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 + } +} + +// Argument to the cycles wallet canister `wallet_call128` method. +#[derive(CandidType, Deserialize)] +struct WalletCall128Args { + canister: Principal, + method_name: String, + #[serde(with = "serde_bytes")] + args: Vec, + cycles: u128, +} + +impl WalletCall128Args { + pub fn new( + canister_id: CanisterId, + method: impl ToString, + args: In, + cycles: u128, + ) -> Self { + Self { + canister: canister_id, + method_name: method.to_string(), + args: encode_args(args).unwrap_or_else(panic_when_encode_fails), + cycles, + } + } +} + +// Return type of the cycles wallet canister `wallet_call128` method. +#[derive(CandidType, Deserialize)] +struct WalletCall128Result { + #[serde(with = "serde_bytes", rename = "return")] + pub bytes: Vec, +} + +// The cycles wallet canister formats the rejection code and error message from the target +// canister into a single string. Extract them back from the formatted string. +fn decode_cycles_wallet_response( + result: Result, +) -> Result +where + Out: CandidType + DeserializeOwned, +{ + match result { + Ok(WalletCall128Result { bytes }) => { + decode_one(&bytes).map_err(|e| IcError::CandidDecodeFailed { + message: format!( + "failed to decode canister response as {}: {}", + std::any::type_name::(), + e + ), + }) + } + Err(message) => { + match Regex::new(r"^An error happened during the call: (\d+): (.*)$") + .unwrap() + .captures(&message) + { + Some(captures) => { + let (_, [code, message]) = captures.extract(); + Err(IcError::CallRejected { + code: code.parse::().unwrap().try_into().unwrap(), + message: message.to_string(), + }) + } + None => Err(IcError::CallRejected { + code: RejectCode::SysFatal, + message: message.to_string(), + }), + } + } + } +} + +fn panic_when_encode_fails(err: candid::error::Error) -> Vec { + panic!("failed to encode args: {err}") +}