From 022551cbe2a6c404074e9900f02812b8bf9e11cb Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 4 Nov 2025 08:10:13 +0100 Subject: [PATCH 1/4] Create `canruntime` crate --- Cargo.lock | 23 +++++ Cargo.toml | 3 +- ic-canister-runtime/CHANGELOG.md | 58 ++++++++++++ ic-canister-runtime/Cargo.toml | 22 +++++ ic-canister-runtime/LICENSE | 1 + ic-canister-runtime/NOTICE | 1 + ic-canister-runtime/src/lib.rs | 153 +++++++++++++++++++++++++++++++ release-plz.toml | 5 + 8 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 ic-canister-runtime/CHANGELOG.md create mode 100644 ic-canister-runtime/Cargo.toml create mode 120000 ic-canister-runtime/LICENSE create mode 120000 ic-canister-runtime/NOTICE create mode 100644 ic-canister-runtime/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 19236ab..b363872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -812,6 +823,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "ic-canister-runtime" +version = "0.1.0" +dependencies = [ + "async-trait", + "candid", + "ic-cdk", + "ic-error-types", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "ic-cdk" version = "0.18.7" diff --git a/Cargo.toml b/Cargo.toml index 8169b21..1cb5ca2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["canhttp", "examples/http_canister"] +members = ["canhttp", "examples/http_canister", "ic-canister-runtime"] resolver = "2" [workspace.package] @@ -12,6 +12,7 @@ readme = "README.md" [workspace.dependencies] assert_matches = "1.5.0" +async-trait = "0.1.88" candid = { version = "0.10.13" } ciborium = "0.2.2" futures-channel = "0.3.31" diff --git a/ic-canister-runtime/CHANGELOG.md b/ic-canister-runtime/CHANGELOG.md new file mode 100644 index 0000000..4df96a5 --- /dev/null +++ b/ic-canister-runtime/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2025-10-08 + +### Added +- **Breaking:** A new method `charge_cycles` that does the actual charging was added to `CyclesChargingPolicy` ([#7](https://github.com/dfinity/canhttp/pull/7)) +- Example of canister using the library to make HTTP requests ([#6](https://github.com/dfinity/canhttp/pull/6)) + +### Changed +- **Breaking:** Update `ic-cdk` to `v0.18.7` including several changes to align with the new HTTP outcall API ([#21](https://github.com/dfinity/canhttp/pull/21)). Notably: + - `IcError` is refactored into an enum + - Use of the new `HttpRequestArgs` and `HttpRequestResult` types in `CyclesChargingPolicy` and `Client` trait impls + - Removal of `IcHttpRequestWithCycles`, `CyclesCostEstimator`, `CyclesAccountingError` and `CyclesAccounting` due to the `ic-cdk` method for making HTTP outcalls now taking care of charging cycles + +[0.3.0]: https://github.com/dfinity/canhttp/compare/canhttp-v0.2.1..canhttp-v0.3.0 + +## [0.2.1] - 2025-07-11 + +### Added + +- An `iter` method to `canhttp::multi::MultiResults` returning a borrowing iterator. + +### Changed +- The `canhttp` crate has been moved from the [`evm-rpc-canister`](https://github.com/dfinity/evm-rpc-canister) repository to the new [`canhttp`](https://github.com/dfinity/canhttp) repository. + +[0.2.1]: https://github.com/dfinity/canhttp/compare/canhttp-v0.2.0..canhttp-v0.2.1 + +## [0.2.0] - 2025-07-08 + +### Added +- Data structures `TimedSizedVec` and `TimedSizedMap` to store a limited number of expiring elements ([#434](https://github.com/dfinity/evm-rpc-canister/pull/434)) +- Method to list `Ok` results in a `MultiResults` ([#435](https://github.com/dfinity/evm-rpc-canister/pull/435)) + +### Changed + +- **Breaking:** change the `code` field in the `IcError` type to use `ic_error_types::RejectCode` instead of `ic_cdk::api::call::RejectionCode` ([#428](https://github.com/dfinity/evm-rpc-canister/pull/428)) + +[0.2.0]: https://github.com/dfinity/canhttp/compare/canhttp-v0.1.0..canhttp-v0.2.0 + +## [0.1.0] - 2025-06-04 + +### Added + +- JSON-RPC request ID with constant binary size ([#397](https://github.com/dfinity/evm-rpc-canister/pull/397)) +- Use `canhttp` to make parallel calls ([#391](https://github.com/dfinity/evm-rpc-canister/pull/391)) +- Improve validation of JSON-RPC requests and responses to adhere to the JSON-RPC specification ([#386](https://github.com/dfinity/evm-rpc-canister/pull/386) and [#387](https://github.com/dfinity/evm-rpc-canister/pull/387)) +- Retry layer ([#378](https://github.com/dfinity/evm-rpc-canister/pull/378)) +- JSON RPC conversion layer ([#375](https://github.com/dfinity/evm-rpc-canister/pull/375)) +- HTTP conversion layer ([#374](https://github.com/dfinity/evm-rpc-canister/pull/374)) +- Observability layer ([#370](https://github.com/dfinity/evm-rpc-canister/pull/370)) +- Library `canhttp` ([#364](https://github.com/dfinity/evm-rpc-canister/pull/364)) + +[0.1.0]: https://github.com/dfinity/canhttp/releases/tag/canhttp-v0.1.0 diff --git a/ic-canister-runtime/Cargo.toml b/ic-canister-runtime/Cargo.toml new file mode 100644 index 0000000..4ce58b3 --- /dev/null +++ b/ic-canister-runtime/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ic-canister-runtime" +version = "0.1.0" +description = "Rust library that abstracts the canister runtime on the Internet Computer" +license.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true +edition.workspace = true +include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] +repository.workspace = true +documentation = "https://docs.rs/canruntime" + +[dependencies] +async-trait = { workspace = true } +candid = { workspace = true } +ic-cdk = { workspace = true } +ic-error-types = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] diff --git a/ic-canister-runtime/LICENSE b/ic-canister-runtime/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/ic-canister-runtime/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/ic-canister-runtime/NOTICE b/ic-canister-runtime/NOTICE new file mode 120000 index 0000000..7e1b82f --- /dev/null +++ b/ic-canister-runtime/NOTICE @@ -0,0 +1 @@ +../NOTICE \ No newline at end of file diff --git a/ic-canister-runtime/src/lib.rs b/ic-canister-runtime/src/lib.rs new file mode 100644 index 0000000..611fc92 --- /dev/null +++ b/ic-canister-runtime/src/lib.rs @@ -0,0 +1,153 @@ +//! Library to abstract the canister runtime so that code making requests to canisters can be reused: +//! * in production using [`ic_cdk`], +//! * in unit tests by mocking this trait, +//! * in integration tests by implementing this trait for `PocketIc`. + +#![forbid(unsafe_code)] +#![forbid(missing_docs)] + +use async_trait::async_trait; +use candid::{utils::ArgumentEncoder, CandidType, Principal}; +use ic_cdk::call::{Call, CallFailed, CandidDecodeFailed}; +use ic_error_types::RejectCode; +use serde::de::DeserializeOwned; +use thiserror::Error; + +/// Abstract the canister runtime so that code making requests to canisters can be reused: +/// * in production using [`ic_cdk`], +/// * in unit tests by mocking this trait, +/// * in integration tests by implementing this trait for `PocketIc`. +#[async_trait] +pub trait Runtime { + /// Defines how asynchronous inter-canister update calls are made. + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned; + + /// Defines how asynchronous inter-canister query calls are made. + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned; +} + +/// Error returned by the Internet Computer when making an inter-canister call. +#[derive(Error, Clone, Debug, PartialEq, Eq)] +pub enum IcError { + /// The liquid cycle balance is insufficient to perform the call. + #[error("Insufficient liquid cycles balance, available: {available}, required: {required}")] + InsufficientLiquidCycleBalance { + /// The liquid cycle balance available in the canister. + available: u128, + /// The required cycles to perform the call. + required: u128, + }, + + /// The `ic0.call_perform` operation failed when performing the inter-canister call. + #[error("Inter-canister call perform failed")] + CallPerformFailed, + + /// The inter-canister call is rejected. + #[error("Inter-canister call rejected: {code:?} - {message})")] + CallRejected { + /// Rejection code as specified [here](https://internetcomputer.org/docs/current/references/ic-interface-spec#reject-codes) + code: RejectCode, + /// Associated helper message. + message: String, + }, + + /// The response from the inter-canister call could not be decoded as Candid. + #[error("The inter-canister call response could not be decoded: {message}")] + CandidDecodeFailed { + /// The specific Candid error that occurred. + message: String, + }, +} + +impl From for IcError { + fn from(err: CallFailed) -> Self { + match err { + CallFailed::CallPerformFailed(_) => IcError::CallPerformFailed, + CallFailed::CallRejected(e) => { + IcError::CallRejected { + // `CallRejected::reject_code()` can only return an error result if there is a + // new error code on ICP that the CDK is not aware of. We map it to `SysFatal` + // since none of the other error codes apply. + // In particular, note that `RejectCode::SysUnknown` is only applicable to + // inter-canister calls that used `ic0.call_with_best_effort_response`. + code: e.reject_code().unwrap_or(RejectCode::SysFatal), + message: e.reject_message().to_string(), + } + } + CallFailed::InsufficientLiquidCycleBalance(e) => { + IcError::InsufficientLiquidCycleBalance { + available: e.available, + required: e.required, + } + } + } + } +} + +impl From for IcError { + fn from(err: CandidDecodeFailed) -> Self { + IcError::CandidDecodeFailed { + message: err.to_string(), + } + } +} + +/// Runtime when interacting with a canister running on the Internet Computer. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub struct IcRuntime; + +#[async_trait] +impl Runtime for IcRuntime { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + Call::unbounded_wait(id, method) + .with_args(&args) + .with_cycles(cycles) + .await + .map_err(IcError::from) + .and_then(|response| response.candid::().map_err(IcError::from)) + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + Call::unbounded_wait(id, method) + .with_args(&args) + .await + .map_err(IcError::from) + .and_then(|response| response.candid::().map_err(IcError::from)) + } +} diff --git a/release-plz.toml b/release-plz.toml index 8670a4d..2aa344d 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -34,6 +34,11 @@ name = "canhttp" #git_release_enable = false # enable GitHub releases publish = true # enable `cargo publish` +[[package]] +name = "ic-canister-runtime" +#git_release_enable = false # enable GitHub releases +publish = true # enable `cargo publish` + [[package]] name = "http_canister" release = false # don't process this package \ No newline at end of file From 5a0fde5d79d405886a985748638b35d77b7d8d4f Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 6 Nov 2025 09:06:24 +0100 Subject: [PATCH 2/4] Make sure `IcRuntime` can only be instantiated with `new` or `default` --- ic-canister-runtime/src/lib.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ic-canister-runtime/src/lib.rs b/ic-canister-runtime/src/lib.rs index 611fc92..bceddac 100644 --- a/ic-canister-runtime/src/lib.rs +++ b/ic-canister-runtime/src/lib.rs @@ -110,8 +110,17 @@ impl From for IcError { } /// Runtime when interacting with a canister running on the Internet Computer. -#[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub struct IcRuntime; +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub struct IcRuntime { + _private: (), +} + +impl IcRuntime { + /// Create a new instance of [`IcRuntime`]. + pub fn new() -> Self { + Self::default() + } +} #[async_trait] impl Runtime for IcRuntime { From baa3213a44af24ff21d8efddc2619df535f7870f Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 6 Nov 2025 09:06:58 +0100 Subject: [PATCH 3/4] Fix `ic-canister-runtime` CHANGELOG --- ic-canister-runtime/CHANGELOG.md | 52 +------------------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/ic-canister-runtime/CHANGELOG.md b/ic-canister-runtime/CHANGELOG.md index 4df96a5..b7e3f52 100644 --- a/ic-canister-runtime/CHANGELOG.md +++ b/ic-canister-runtime/CHANGELOG.md @@ -5,54 +5,4 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.3.0] - 2025-10-08 - -### Added -- **Breaking:** A new method `charge_cycles` that does the actual charging was added to `CyclesChargingPolicy` ([#7](https://github.com/dfinity/canhttp/pull/7)) -- Example of canister using the library to make HTTP requests ([#6](https://github.com/dfinity/canhttp/pull/6)) - -### Changed -- **Breaking:** Update `ic-cdk` to `v0.18.7` including several changes to align with the new HTTP outcall API ([#21](https://github.com/dfinity/canhttp/pull/21)). Notably: - - `IcError` is refactored into an enum - - Use of the new `HttpRequestArgs` and `HttpRequestResult` types in `CyclesChargingPolicy` and `Client` trait impls - - Removal of `IcHttpRequestWithCycles`, `CyclesCostEstimator`, `CyclesAccountingError` and `CyclesAccounting` due to the `ic-cdk` method for making HTTP outcalls now taking care of charging cycles - -[0.3.0]: https://github.com/dfinity/canhttp/compare/canhttp-v0.2.1..canhttp-v0.3.0 - -## [0.2.1] - 2025-07-11 - -### Added - -- An `iter` method to `canhttp::multi::MultiResults` returning a borrowing iterator. - -### Changed -- The `canhttp` crate has been moved from the [`evm-rpc-canister`](https://github.com/dfinity/evm-rpc-canister) repository to the new [`canhttp`](https://github.com/dfinity/canhttp) repository. - -[0.2.1]: https://github.com/dfinity/canhttp/compare/canhttp-v0.2.0..canhttp-v0.2.1 - -## [0.2.0] - 2025-07-08 - -### Added -- Data structures `TimedSizedVec` and `TimedSizedMap` to store a limited number of expiring elements ([#434](https://github.com/dfinity/evm-rpc-canister/pull/434)) -- Method to list `Ok` results in a `MultiResults` ([#435](https://github.com/dfinity/evm-rpc-canister/pull/435)) - -### Changed - -- **Breaking:** change the `code` field in the `IcError` type to use `ic_error_types::RejectCode` instead of `ic_cdk::api::call::RejectionCode` ([#428](https://github.com/dfinity/evm-rpc-canister/pull/428)) - -[0.2.0]: https://github.com/dfinity/canhttp/compare/canhttp-v0.1.0..canhttp-v0.2.0 - -## [0.1.0] - 2025-06-04 - -### Added - -- JSON-RPC request ID with constant binary size ([#397](https://github.com/dfinity/evm-rpc-canister/pull/397)) -- Use `canhttp` to make parallel calls ([#391](https://github.com/dfinity/evm-rpc-canister/pull/391)) -- Improve validation of JSON-RPC requests and responses to adhere to the JSON-RPC specification ([#386](https://github.com/dfinity/evm-rpc-canister/pull/386) and [#387](https://github.com/dfinity/evm-rpc-canister/pull/387)) -- Retry layer ([#378](https://github.com/dfinity/evm-rpc-canister/pull/378)) -- JSON RPC conversion layer ([#375](https://github.com/dfinity/evm-rpc-canister/pull/375)) -- HTTP conversion layer ([#374](https://github.com/dfinity/evm-rpc-canister/pull/374)) -- Observability layer ([#370](https://github.com/dfinity/evm-rpc-canister/pull/370)) -- Library `canhttp` ([#364](https://github.com/dfinity/evm-rpc-canister/pull/364)) - -[0.1.0]: https://github.com/dfinity/canhttp/releases/tag/canhttp-v0.1.0 +## Unreleased \ No newline at end of file From ab39b44e2f0d7ac27473fbd5518c240c6a51ac0e Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 6 Nov 2025 09:07:30 +0100 Subject: [PATCH 4/4] Fix documentation link in `Cargo.toml` --- ic-canister-runtime/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ic-canister-runtime/Cargo.toml b/ic-canister-runtime/Cargo.toml index 4ce58b3..02f2b92 100644 --- a/ic-canister-runtime/Cargo.toml +++ b/ic-canister-runtime/Cargo.toml @@ -9,7 +9,7 @@ authors.workspace = true edition.workspace = true include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] repository.workspace = true -documentation = "https://docs.rs/canruntime" +documentation = "https://docs.rs/ic-canister-runtime" [dependencies] async-trait = { workspace = true }