diff --git a/CHANGELOG.md b/CHANGELOG.md index 7409f6a9..736e9d13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased * feat: `icp canister snapshot` - create, delete, restore, list canister snapshots +* feat: `icp canister call` now supports `--proxy` flag to route calls through a proxy canister + * Use `--proxy ` to forward the call through a proxy canister's `proxy` method + * Use `--cycles ` to specify cycles to forward with the proxied call (defaults to 0) # v0.1.0-beta.6 diff --git a/crates/icp-canister-interfaces/src/lib.rs b/crates/icp-canister-interfaces/src/lib.rs index 1b7d724f..550da7e1 100644 --- a/crates/icp-canister-interfaces/src/lib.rs +++ b/crates/icp-canister-interfaces/src/lib.rs @@ -5,4 +5,5 @@ pub mod governance; pub mod icp_ledger; pub mod internet_identity; pub mod nns_root; +pub mod proxy; pub mod registry; diff --git a/crates/icp-canister-interfaces/src/proxy.rs b/crates/icp-canister-interfaces/src/proxy.rs new file mode 100644 index 00000000..fe979bc3 --- /dev/null +++ b/crates/icp-canister-interfaces/src/proxy.rs @@ -0,0 +1,72 @@ +use candid::{CandidType, Nat, Principal}; +use serde::Deserialize; + +/// Arguments for the proxy canister's `proxy` method. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct ProxyArgs { + /// The target canister to forward the call to. + pub canister_id: Principal, + /// The method name to invoke on the target canister. + pub method: String, + /// The serialized Candid arguments for the method. + pub args: Vec, + /// The number of cycles to forward with the call. + pub cycles: Nat, +} + +/// Result from the proxy canister's `proxy` method. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum ProxyResult { + /// The proxied call succeeded. + Ok(ProxyOk), + /// The proxied call failed. + Err(ProxyError), +} + +/// Success result containing the response from the target canister. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct ProxyOk { + /// The serialized Candid response from the target canister. + pub result: Vec, +} + +/// Error variants from the proxy canister. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum ProxyError { + /// The proxy canister does not have enough cycles to process the request. + InsufficientCycles { + /// The number of cycles available. + available: Nat, + /// The number of cycles required. + required: Nat, + }, + /// The call to the target canister failed. + CallFailed { + /// A description of the failure reason. + reason: String, + }, + /// The caller is not authorized to use this proxy canister. + UnauthorizedUser, +} + +impl ProxyError { + /// Format the error for display. + pub fn format_error(&self) -> String { + match self { + ProxyError::InsufficientCycles { + available, + required, + } => { + format!( + "Proxy canister has insufficient cycles. Available: {available}, required: {required}" + ) + } + ProxyError::CallFailed { reason } => { + format!("Proxy call failed: {reason}") + } + ProxyError::UnauthorizedUser => { + "Unauthorized: you are not in the proxy canister's controllers list".to_string() + } + } + } +} diff --git a/crates/icp-cli/src/commands/canister/call.rs b/crates/icp-cli/src/commands/canister/call.rs index 613dcff7..6d086c14 100644 --- a/crates/icp-cli/src/commands/canister/call.rs +++ b/crates/icp-cli/src/commands/canister/call.rs @@ -1,5 +1,5 @@ use anyhow::{Context as _, bail}; -use candid::{IDLArgs, Principal, TypeEnv, types::Function}; +use candid::{Encode, IDLArgs, Nat, Principal, TypeEnv, types::Function}; use candid_parser::assist; use candid_parser::utils::CandidSource; use clap::Args; @@ -7,11 +7,13 @@ use dialoguer::console::Term; use ic_agent::Agent; use icp::context::Context; use icp::prelude::*; +use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; use std::io::{self, Write}; use tracing::warn; use crate::{ commands::args, + commands::parsers::parse_cycles_amount, operations::misc::{ParsedArguments, fetch_canister_metadata, parse_args}, }; @@ -35,6 +37,20 @@ pub(crate) struct CallArgs { /// /// If not provided, an interactive prompt will be launched to help build the arguments. pub(crate) args: Option, + + /// Principal of a proxy canister to route the call through. + /// + /// When specified, instead of calling the target canister directly, + /// the call will be sent to the proxy canister's `proxy` method, + /// which forwards it to the target canister. + #[arg(long)] + pub(crate) proxy: Option, + + /// Cycles to forward with the proxied call. + /// + /// Only used when --proxy is specified. Defaults to 0. + #[arg(long, requires = "proxy", value_parser = parse_cycles_amount, default_value = "0")] + pub(crate) cycles: u128, } pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::Error> { @@ -106,7 +122,33 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E .context("failed to serialize candid arguments with specific types")?, }; - let res = agent.update(&cid, &args.method).with_arg(arg_bytes).await?; + let res = if let Some(proxy_cid) = args.proxy { + // Route the call through the proxy canister + let proxy_args = ProxyArgs { + canister_id: cid, + method: args.method.clone(), + args: arg_bytes, + cycles: Nat::from(args.cycles), + }; + let proxy_arg_bytes = + Encode!(&proxy_args).context("failed to encode proxy call arguments")?; + + let proxy_res = agent + .update(&proxy_cid, "proxy") + .with_arg(proxy_arg_bytes) + .await?; + + let proxy_result: (ProxyResult,) = + candid::decode_args(&proxy_res).context("failed to decode proxy canister response")?; + + match proxy_result.0 { + ProxyResult::Ok(ok) => ok.result, + ProxyResult::Err(err) => bail!(err.format_error()), + } + } else { + // Direct call to the target canister + agent.update(&cid, &args.method).with_arg(arg_bytes).await? + }; let ret = IDLArgs::from_bytes(&res[..])?; diff --git a/crates/icp-cli/tests/assets/proxy.wasm b/crates/icp-cli/tests/assets/proxy.wasm new file mode 100644 index 00000000..5b6a7f9f Binary files /dev/null and b/crates/icp-cli/tests/assets/proxy.wasm differ diff --git a/crates/icp-cli/tests/canister_call_tests.rs b/crates/icp-cli/tests/canister_call_tests.rs index 2d77068f..a410ca1d 100644 --- a/crates/icp-cli/tests/canister_call_tests.rs +++ b/crates/icp-cli/tests/canister_call_tests.rs @@ -182,3 +182,104 @@ async fn canister_call_with_arguments_from_file() { .success() .stdout(eq("(\"Hello, world!\")").trim()); } + +#[tokio::test] +async fn canister_call_through_proxy() { + let ctx = TestContext::new(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + + // Use vendored WASMs + let target_wasm = ctx.make_asset("example_icp_mo.wasm"); + let proxy_wasm = ctx.make_asset("proxy.wasm"); + + // Project manifest with both target and proxy canisters + let pm = formatdoc! {r#" + canisters: + - name: target + build: + steps: + - type: script + command: cp '{target_wasm}' "$ICP_WASM_OUTPUT_PATH" + + - name: proxy + build: + steps: + - type: script + command: cp '{proxy_wasm}' "$ICP_WASM_OUTPUT_PATH" + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + // Start network + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + // Deploy both canisters + ctx.icp() + .current_dir(&project_dir) + .args(["deploy", "--environment", "random-environment"]) + .assert() + .success(); + + // Get the proxy canister ID using canister status --id-only + let output = ctx + .icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "--environment", + "random-environment", + "--id-only", + "proxy", + ]) + .output() + .expect("failed to get proxy canister id"); + let proxy_cid = String::from_utf8(output.stdout) + .expect("invalid utf8") + .trim() + .to_string(); + + // Test calling target canister through the proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "target", + "greet", + "(\"proxy\")", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(eq("(\"Hello, proxy!\")").trim()); + + // Test calling through proxy with cycles (should also work with 0 cycles) + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "target", + "greet", + "(\"world\")", + "--proxy", + &proxy_cid, + "--cycles", + "0", + ]) + .assert() + .success() + .stdout(eq("(\"Hello, world!\")").trim()); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2b6781d2..ad5ed5a6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -152,6 +152,14 @@ Make a canister call * `-n`, `--network ` — Name of the network to target, conflicts with environment argument * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the call through. + + When specified, instead of calling the target canister directly, the call will be sent to the proxy canister's `proxy` method, which forwards it to the target canister. +* `--cycles ` — Cycles to forward with the proxied call. + + Only used when --proxy is specified. Defaults to 0. + + Default value: `0` diff --git a/examples/icp-proxy-canister/icp.yaml b/examples/icp-proxy-canister/icp.yaml new file mode 100644 index 00000000..2a2484de --- /dev/null +++ b/examples/icp-proxy-canister/icp.yaml @@ -0,0 +1,39 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0-beta.2/docs/schemas/icp-yaml-schema.json +# +# This example demonstrates using a proxy canister to forward calls to other canisters. +# +# The proxy canister implements the interface defined at: +# https://github.com/dfinity/proxy-canister +# +# Usage: +# # Deploy both canisters +# icp deploy +# +# # Get the proxy canister ID +# icp canister status --id-only proxy +# +# # Call the target canister directly +# icp canister call target greet '("World")' +# +# # Call through the proxy canister (replace with actual proxy canister ID) +# icp canister call target greet '("World")' --proxy +# +# # Forward cycles with the proxied call +# icp canister call target greet '("World")' --proxy --cycles 1t + +canisters: + # A simple target canister to be called through the proxy + - name: target + build: + steps: + - type: pre-built + path: ../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + + # The proxy canister from dfinity/proxy-canister + - name: proxy + build: + steps: + - type: pre-built + url: https://github.com/dfinity/proxy-canister/releases/download/v0.1.0-beta.1/proxy.wasm + sha256: 9e5abf27930b06df7f50c4baa1ec71b7e53408cb81849054fc51a1825eb6c5a4