Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6935680
new crate canjsonrpc
gregorydemay Feb 3, 2025
932ecb0
setup auxiliary files
gregorydemay Feb 3, 2025
a58d7f3
JSON RPC types
gregorydemay Feb 4, 2025
af1454d
request builder
gregorydemay Feb 4, 2025
e7dc79d
skeleton to execute request
gregorydemay Feb 4, 2025
0ce804e
cycles charging
gregorydemay Feb 4, 2025
7d9763b
retry strategy
gregorydemay Feb 4, 2025
c5d0dec
added request builder methods
gregorydemay Feb 5, 2025
5863011
constructor JsonRpcRequest
gregorydemay Feb 5, 2025
09daf7f
PoC with Tower
gregorydemay Feb 5, 2025
84a00a9
copied over cycles cost calculation
gregorydemay Feb 5, 2025
c27f7a7
charge user as filter
gregorydemay Feb 5, 2025
f7929d5
retry policy if response too large
gregorydemay Feb 6, 2025
aedffaf
map request/response to JSON RPC
gregorydemay Feb 6, 2025
cf78f22
layered service HTTP -> ICP
gregorydemay Feb 7, 2025
dbd89d5
dump
gregorydemay Feb 10, 2025
14627b7
Revert
gregorydemay Feb 10, 2025
066016c
rename canhttp
gregorydemay Feb 10, 2025
2236d82
basic cycles handling
gregorydemay Feb 10, 2025
d2c172f
IC client
gregorydemay Feb 10, 2025
996e5c4
build client in evm_rpc_canister
gregorydemay Feb 10, 2025
336d970
Merge branch 'main' into gdemay/XC-287-canjsonrpc
gregorydemay Feb 10, 2025
1a8f6de
use client from canhttp
gregorydemay Feb 10, 2025
66fb8ae
clippy
gregorydemay Feb 10, 2025
431a1bb
move cycle accounting to canhttp
gregorydemay Feb 10, 2025
54cde76
default implementation RequestCyclesCostWithCollateralEstimator
gregorydemay Feb 10, 2025
8f1df2d
Put cycles accounting on a separate layer
gregorydemay Feb 10, 2025
5492d68
docs
gregorydemay Feb 11, 2025
9c4b59c
fix: use cycles to charge for request_cost
gregorydemay Feb 11, 2025
8f244bb
formatting
gregorydemay Feb 11, 2025
cf88542
improve Rust docs of EstimateRequestCyclesCost
gregorydemay Feb 12, 2025
4d994ce
improve panic message
gregorydemay Feb 12, 2025
eaba9b1
Apply suggestions from code review
gregorydemay Feb 12, 2025
54d2789
Improve doc
gregorydemay Feb 12, 2025
f709fb5
Merge branch 'main' into gdemay/XC-287-canjsonrpc
gregorydemay Feb 13, 2025
360ac72
remove trait cycles to attach
gregorydemay Feb 13, 2025
f75eef7
rename
gregorydemay Feb 13, 2025
394ea3a
linting
gregorydemay Feb 13, 2025
f5c3dc6
fix CI
gregorydemay Feb 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
- uses: Swatinem/rust-cache@v2

- name: Cargo test
run: cargo test -- --test-threads=2 --nocapture
run: cargo test --workspace -- --test-threads=2 --nocapture

e2e:
runs-on: ubuntu-latest
Expand Down
32 changes: 32 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ inherits = "release"

[dependencies]
candid = { workspace = true }
canhttp = { path = "canhttp" }
ethnum = { workspace = true }
evm_rpc_types = { path = "evm_rpc_types" }
futures = { workspace = true }
Expand All @@ -37,6 +38,7 @@ serde = { workspace = true }
serde_bytes = { workspace = true }
serde_json = { workspace = true }
thousands = "0.2"
tower = {workspace = true}
url = { workspace = true }
hex = "0.4"
ethers-core = "2.0"
Expand Down Expand Up @@ -75,8 +77,9 @@ serde = "1.0"
serde_json = "1.0"
serde_bytes = "0.11.15"
strum = { version = "0.26", features = ["derive"] }
tower = "0.5.2"
thiserror = "2.0.11"
url = "2.5"

[workspace]
members = ["e2e/rust", "evm_rpc_types"]
members = [ "canhttp", "e2e/rust", "evm_rpc_types"]
8 changes: 8 additions & 0 deletions canhttp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 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).

## [Unreleased]
15 changes: 15 additions & 0 deletions canhttp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "canhttp"
version = "0.1.0"
description = "Rust library to issue HTTPs outcalls from a canister on the Internet Computer"
license = "Apache-2.0"
readme = "README.md"
authors = ["DFINITY Foundation"]
edition = "2021"
include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"]
documentation = "https://docs.rs/canhttp"

[dependencies]
ic-cdk = { workspace = true }
tower = { workspace = true, features = ["filter", "retry"] }
thiserror = { workspace = true }
1 change: 1 addition & 0 deletions canhttp/LICENSE
6 changes: 6 additions & 0 deletions canhttp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[![Internet Computer portal](https://img.shields.io/badge/InternetComputer-grey?logo=internet%20computer&style=for-the-badge)](https://internetcomputer.org)
[![DFinity Forum](https://img.shields.io/badge/help-post%20on%20forum.dfinity.org-blue?style=for-the-badge)](https://forum.dfinity.org/)
[![GitHub license](https://img.shields.io/badge/license-Apache%202.0-blue.svg?logo=apache&style=for-the-badge)](LICENSE)


# canhttp
69 changes: 69 additions & 0 deletions canhttp/src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use ic_cdk::api::call::RejectionCode;
use ic_cdk::api::management_canister::http_request::{
CanisterHttpRequestArgument as IcHttpRequest, HttpResponse as IcHttpResponse,
};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use thiserror::Error;
use tower::{BoxError, Service};

/// Thin wrapper around [`ic_cdk::api::management_canister::http_request::http_request`]
/// that implements the [`tower::Service`] trait. Its functionality can be extended by composing so-called
/// [tower middlewares](https://docs.rs/tower/latest/tower/#usage).
///
/// Middlewares from this crate:
/// * [`crate::cycles::CyclesAccounting`]: handles cycles accounting.
#[derive(Clone, Debug)]
pub struct Client;

/// Error returned by the Internet Computer when making an HTTPs outcall.
#[derive(Error, Debug, PartialEq, Eq)]
#[error("Error from ICP: (code {code:?}, message {message})")]
pub struct IcError {
/// Rejection code as specified [here](https://internetcomputer.org/docs/current/references/ic-interface-spec#reject-codes)
pub code: RejectionCode,
/// Associated helper message.
pub message: String,
}

impl IcError {
/// Determines whether the error indicates that the response was larger than the specified
/// [`max_response_bytes`](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request) specified in the request.
///
/// If true, retrying with a larger value for `max_response_bytes` may help.
pub fn is_response_too_large(&self) -> bool {
self.code == RejectionCode::SysFatal
&& (self.message.contains("size limit") || self.message.contains("length limit"))
}
}

impl Service<IcHttpRequestWithCycles> for Client {
type Response = IcHttpResponse;
type Error = BoxError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
Comment thread
lpahlavi marked this conversation as resolved.

fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}

fn call(
&mut self,
IcHttpRequestWithCycles { request, cycles }: IcHttpRequestWithCycles,
) -> Self::Future {
Box::pin(async move {
match ic_cdk::api::management_canister::http_request::http_request(request, cycles)
.await
{
Ok((response,)) => Ok(response),
Err((code, message)) => Err(BoxError::from(IcError { code, message })),
}
})
}
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IcHttpRequestWithCycles {
pub request: IcHttpRequest,
pub cycles: u128,
}
160 changes: 160 additions & 0 deletions canhttp/src/cycles/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#[cfg(test)]
mod tests;

use crate::client::IcHttpRequestWithCycles;
use ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument;
use thiserror::Error;
use tower::filter::Predicate;
use tower::BoxError;

/// Estimate the amount of cycles to charge for a single HTTPs outcall.
pub trait CyclesChargingPolicy {
/// Determine the amount of cycles to charge the caller.
///
/// If the value is `0`, no cycles will be charged, meaning that the canister using that library will
/// pay for HTTPs outcalls with its own cycles. Otherwise, the returned amount of cycles will be transferred
/// from the caller to the canister's cycles balance to pay (in part or fully) for the HTTPs outcall.
fn cycles_to_charge(
Comment thread
lpahlavi marked this conversation as resolved.
&self,
_request: &CanisterHttpRequestArgument,
_attached_cycles: u128,
) -> u128 {
0
}
}

/// Estimate the exact minimum cycles amount required to send an HTTPs outcall as specified
/// [here](https://internetcomputer.org/docs/current/developer-docs/gas-cost#https-outcalls).
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct CyclesCostEstimator {
num_nodes_in_subnet: u32,
Comment thread
lpahlavi marked this conversation as resolved.
}

impl CyclesCostEstimator {
/// Maximum value for `max_response_bytes` which is 2MB,
/// see the [IC specification](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request).
pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000;

/// Create a new estimator for a subnet having the given number of nodes.
pub const fn new(num_nodes_in_subnet: u32) -> Self {
CyclesCostEstimator {
num_nodes_in_subnet,
}
}

/// Compute the number of cycles required to send the given request via HTTPs outcall.
///
/// An HTTP outcall entails calling the `http_request` method on the management canister interface,
/// which requires that cycles to pay for the call must be explicitly attached with the call
/// ([IC specification](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-http_request)).
/// The required amount of cycles to attach is specified
/// [here](https://internetcomputer.org/docs/current/developer-docs/gas-cost#https-outcalls).
pub fn cost_of_http_request(&self, request: &CanisterHttpRequestArgument) -> u128 {
let payload_body_bytes = request
.body
.as_ref()
.map(|body| body.len())
.unwrap_or_default();
let extra_payload_bytes = request.url.len()
+ request
.headers
.iter()
.map(|header| header.name.len() + header.value.len())
.sum::<usize>()
+ request.transform.as_ref().map_or(0, |transform| {
transform.function.0.method.len() + transform.context.len()
});
let max_response_bytes = request
.max_response_bytes
.unwrap_or(Self::DEFAULT_MAX_RESPONSE_BYTES);
let request_bytes = (payload_body_bytes + extra_payload_bytes) as u128;
self.base_fee()
+ self.request_fee(request_bytes)
+ self.response_fee(max_response_bytes as u128)
}

fn base_fee(&self) -> u128 {
3_000_000_u128
.saturating_add(60_000_u128.saturating_mul(self.num_nodes_as_u128()))
.saturating_mul(self.num_nodes_as_u128())
}

fn request_fee(&self, bytes: u128) -> u128 {
400_u128
.saturating_mul(self.num_nodes_as_u128())
.saturating_mul(bytes)
}

fn response_fee(&self, bytes: u128) -> u128 {
800_u128
.saturating_mul(self.num_nodes_as_u128())
.saturating_mul(bytes)
}

fn num_nodes_as_u128(&self) -> u128 {
self.num_nodes_in_subnet as u128
}
}

/// Error return by the [`CyclesAccounting`] middleware.
#[derive(Error, Debug)]
pub enum CyclesAccountingError {
/// Error returned when the caller should be charged but did not attach sufficiently many cycles.
#[error("insufficient cycles (expected {expected:?}, received {received:?})")]
InsufficientCyclesError {
/// Expected amount of cycles. Minimum value that should have been sent.
expected: u128,
/// Received amount of cycles
received: u128,
},
}

/// A middleware to handle cycles accounting, i.e. verify if sufficiently many cycles are available in a request.
/// How cycles are estimated is given by `CyclesEstimator`
#[derive(Clone, Debug)]
pub struct CyclesAccounting<Charging> {
cycles_cost_estimator: CyclesCostEstimator,
charging_policy: Charging,
}

impl<Charging> CyclesAccounting<Charging> {
/// Create a new middleware given the cycles estimator.
pub fn new(num_nodes_in_subnet: u32, charging_policy: Charging) -> Self {
Self {
cycles_cost_estimator: CyclesCostEstimator::new(num_nodes_in_subnet),
charging_policy,
}
}
}

impl<CyclesEstimator> Predicate<CanisterHttpRequestArgument> for CyclesAccounting<CyclesEstimator>
where
CyclesEstimator: CyclesChargingPolicy,
{
type Request = IcHttpRequestWithCycles;

fn check(&mut self, request: CanisterHttpRequestArgument) -> Result<Self::Request, BoxError> {
let cycles_to_attach = self.cycles_cost_estimator.cost_of_http_request(&request);
let cycles_to_charge = self
.charging_policy
.cycles_to_charge(&request, cycles_to_attach);
if cycles_to_charge > 0 {
let cycles_available = ic_cdk::api::call::msg_cycles_available128();
if cycles_available < cycles_to_charge {
return Err(Box::new(CyclesAccountingError::InsufficientCyclesError {
expected: cycles_to_charge,
received: cycles_available,
}));
}
let cycles_received = ic_cdk::api::call::msg_cycles_accept128(cycles_to_charge);
assert_eq!(
cycles_received, cycles_to_charge,
"Expected to receive {cycles_to_charge}, but got {cycles_received}"
);
Comment thread
lpahlavi marked this conversation as resolved.
}
Ok(IcHttpRequestWithCycles {
request,
cycles: cycles_to_attach,
Comment thread
lpahlavi marked this conversation as resolved.
})
}
}
Loading