Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
317 changes: 317 additions & 0 deletions crates/icp-app/src/calls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
//! Reaching canisters over the IC's HTTP interface.
//!
//! The implementation of [`icp_project::calls::CanisterCalls`] for a machine
//! with an `ic-agent`: it owns the identity the calls are signed with, the
//! endpoint they go to, and the root key their answers are verified against.
//!
//! It also owns *proxy routing*. `--proxy` names a canister that forwards
//! management calls on the caller's behalf, and it applies to the whole
//! command, so it is a property of the caller rather than of any one call —
//! the proxy is the [`Authority::Mediated`] one, and a request that asks for
//! [`Authority::Direct`] skips it. A query made through a proxy necessarily
//! becomes an update, which is why the trait leaves how a query is answered to
//! the implementation.

use async_trait::async_trait;
use candid::{Encode, Nat, Principal};
use ic_agent::{
Agent, AgentError,
agent::{CallResponse, EffectiveId, SubnetType},
};
use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult};
use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo};

/// [`CanisterCalls`] over an `ic-agent`, optionally forwarding through a proxy
/// canister.
pub struct AgentCalls {
agent: Agent,
proxy: Option<Principal>,
caller: Principal,
}

impl AgentCalls {
/// Builds a caller from `agent`. `proxy`, when given, is the canister every
/// call is forwarded through.
///
/// The caller's principal is read once here: it cannot change for the life
/// of the agent, and every call would otherwise ask for it again.
pub fn new(agent: Agent, proxy: Option<Principal>) -> Result<Self, AgentError> {
let caller = agent
.get_principal()
.map_err(|message| AgentError::MessageError(message.clone()))?;
Ok(Self {
agent,
proxy,
caller,
})
}

/// The proxy `call` should go through, if any: none when no proxy was
/// configured, and none when the call asked to be made directly.
fn mediator(&self, call: &Call) -> Option<Principal> {
match call.authority {
Authority::Mediated => self.proxy,
Authority::Direct => None,
}
}

/// Turns an agent error into the shape the trait speaks in: a rejection is
/// a verdict and keeps its code, a call that ran out of time or never got
/// down the wire went unanswered, and anything else — a reply that would
/// not parse, a certificate that would not verify — failed for good.
fn wrap(canister: Principal, method: &str, err: AgentError) -> CallError {
match &err {
AgentError::CertifiedReject { reject, .. }
| AgentError::UncertifiedReject { reject, .. } => CallError::Rejected {
canister,
method: method.to_owned(),
code: reject.error_code.clone(),
message: reject.reject_message.clone(),
},
AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => {
CallError::unanswered(canister, method, err)
}
_ => CallError::failed(canister, method, err),
}
}

/// Forwards `call` through the proxy canister, unwrapping its reply.
async fn through_proxy(&self, proxy: Principal, call: &Call) -> Result<Vec<u8>, CallError> {
let args = ProxyArgs {
canister_id: call.canister,
method: call.method.clone(),
args: call.arg.clone(),
cycles: Nat::from(call.cycles),
};
let arg = Encode!(&args).map_err(|e| CallError::failed(proxy, "proxy", e))?;
let reply = self
.agent
.update(&proxy, "proxy")
.with_arg(arg)
.await
.map_err(|e| Self::wrap(proxy, "proxy", e))?;
let (result,): (ProxyResult,) =
candid::decode_args(&reply).map_err(|e| CallError::failed(proxy, "proxy", e))?;
match result {
ProxyResult::Ok(ok) => Ok(ok.result),
// The proxy reports the inner call's failure as text; it reached a
// verdict, so it is a rejection even though the code is lost.
ProxyResult::Err(err) => Err(CallError::Rejected {
canister: call.canister,
method: call.method.clone(),
code: None,
message: err.format_error(),
}),
}
}

/// Whether `canister` exists, which its controllers answer — see
/// [`CanisterCalls::controllers`].
///
/// A check that could not be made reads as "does not exist": every caller
/// asks this while deciding whether to report a read failure of its own,
/// and an existence check that failed too is no reason to suppress it.
async fn exists(&self, canister: Principal) -> bool {
self.controllers(canister)
.await
.is_ok_and(|controllers| controllers.is_some())
}

/// A subnet-scoped update: routed to a subnet rather than to any canister
/// on it, which the agent only exposes through a signed submission.
async fn to_subnet(&self, subnet: Principal, call: &Call) -> Result<Vec<u8>, CallError> {
let effective = EffectiveId::Subnet(subnet);
let fail = |e: AgentError| Self::wrap(call.canister, &call.method, e);

let signed = self
.agent
.update(&call.canister, &call.method)
.with_arg(call.arg.clone())
.sign()
.map_err(fail)?;
let response = self
.agent
.update_signed(effective, signed.signed_update)
.await
.map_err(fail)?;
match response {
CallResponse::Response(bytes) => Ok(bytes),
CallResponse::Poll(request_id) => {
let status = self
.agent
.sign_request_status(effective, request_id)
.map_err(fail)?;
Ok(self
.agent
.wait_signed(&request_id, effective, status.signed_request_status)
.await
.map_err(fail)?
.0)
}
}
}
}

/// Wraps a resolved agent as the caller this workspace's operations take,
/// forwarding through `proxy` when one was asked for.
pub fn calls(
agent: Agent,
proxy: Option<Principal>,
) -> Result<std::sync::Arc<dyn CanisterCalls>, AgentError> {
Ok(std::sync::Arc::new(AgentCalls::new(agent, proxy)?))
}

#[async_trait]
impl CanisterCalls for AgentCalls {
fn caller(&self) -> Principal {
self.caller
}

async fn update(&self, call: Call) -> Result<Vec<u8>, CallError> {
// A subnet-scoped call names the subnet it acts on, and a proxy could
// only act on the subnet it lives on itself — so this comes first,
// rather than dropping the one thing the call is about. The commands
// that can name a subnet refuse to name a proxy as well.
if let RouteTo::Subnet(subnet) = call.route {
return self.to_subnet(subnet, &call).await;
}
if let Some(proxy) = self.mediator(&call) {
return self.through_proxy(proxy, &call).await;
}
let mut builder = self
.agent
.update(&call.canister, &call.method)
.with_arg(call.arg.clone());
if let RouteTo::Canister(effective) = call.route {
builder = builder.with_effective_canister_id(effective);
}
builder
.await
.map_err(|e| Self::wrap(call.canister, &call.method, e))
}

async fn query(&self, call: Call) -> Result<Vec<u8>, CallError> {
// A proxy only accepts updates, so a query through one becomes an
// update. The reply is the same either way.
if let Some(proxy) = self.mediator(&call) {
return self.through_proxy(proxy, &call).await;
}
let mut builder = self
.agent
.query(&call.canister, &call.method)
.with_arg(call.arg.clone());
if let RouteTo::Canister(effective) = call.route {
builder = builder.with_effective_canister_id(effective);
}
builder
.call()
.await
.map_err(|e| Self::wrap(call.canister, &call.method, e))
}

async fn metadata_section(
&self,
canister: Principal,
path: &str,
) -> Result<Option<Vec<u8>>, CallError> {
match self
.agent
.read_state_canister_metadata(canister, path)
.await
{
Ok(bytes) => Ok(Some(bytes)),
Err(err) => {
// A path the certificate will not certify looks the same as a
// canister that was never created, which is the error this
// reports rather than "no such section".
if self.exists(canister).await {
Ok(None)
} else {
Err(Self::wrap(canister, "read_state(metadata)", err))
}
}
Comment thread
adamspofford-dfinity marked this conversation as resolved.
}
}

async fn controllers(&self, canister: Principal) -> Result<Option<Vec<Principal>>, CallError> {
match self.agent.read_state_canister_controllers(canister).await {
Ok(controllers) => Ok(Some(controllers)),
Err(AgentError::LookupPathAbsent(_)) => Ok(None),
Err(err) => Err(Self::wrap(canister, "read_state(controllers)", err)),
}
}

async fn module_hash(&self, canister: Principal) -> Result<Option<Vec<u8>>, CallError> {
let err = match self.agent.read_state_canister_module_hash(canister).await {
Ok(hash) => return Ok(Some(hash)),
Err(err) => err,
};
// No module-hash path means nothing is installed — or that there is no
// canister to install into, which is not an answer this can give.
if matches!(err, AgentError::LookupPathAbsent(_)) && self.exists(canister).await {
return Ok(None);
}
Err(Self::wrap(canister, "read_state(module_hash)", err))
}

async fn subnet_of(&self, canister: Principal) -> Result<Principal, CallError> {
Ok(self
.agent
.get_subnet_by_canister(&canister)
.await
.map_err(|e| Self::wrap(canister, "subnet_of", e))?
.id())
}

async fn subnet_uses_engine_operator(&self, subnet: Principal) -> Result<bool, CallError> {
let info = self
.agent
.get_subnet_by_id(&subnet)
.await
.map_err(|e| Self::wrap(subnet, "subnet_type", e))?;
Ok(matches!(info.subnet_type(), Some(SubnetType::CloudEngine)))
}
}

#[cfg(test)]
mod tests {
use super::*;
use ic_agent::agent::{RejectCode, RejectResponse};

fn wrapped(err: AgentError) -> CallError {
AgentCalls::wrap(Principal::anonymous(), "read_canister_snapshot_data", err)
}

/// The retry loops in the snapshot transfers branch on this: only a call
/// the network never answered is worth repeating, so a deterministic
/// failure must not be classified as one and stall the command for the
/// whole retry window.
///
/// `AgentError::TransportError` is transient for the same reason, but the
/// error it carries is private to `ic-agent` and cannot be built here.
#[test]
fn only_an_unanswered_call_is_transient() {
assert!(wrapped(AgentError::TimeoutWaitingForResponse()).is_transient());

// Deterministic: the reply was received and could not be understood.
assert!(!wrapped(AgentError::CertificateVerificationFailed()).is_transient());
assert!(!wrapped(AgentError::MessageError("malformed reply".to_owned())).is_transient());
}

/// A rejection is the network's verdict: it keeps its code for callers to
/// branch on, and repeating the call would only earn it again.
#[test]
fn a_rejection_keeps_its_code_and_is_not_transient() {
let err = wrapped(AgentError::CertifiedReject {
reject: RejectResponse {
reject_code: RejectCode::CanisterError,
reject_message: "canister is stopped".to_owned(),
error_code: Some("IC0508".to_owned()),
},
operation: None,
});
assert_eq!(err.code(), Some("IC0508"));
assert!(err.is_rejection());
assert!(!err.is_transient());
}
}
1 change: 1 addition & 0 deletions crates/icp-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
//! there and implemented here.

pub mod agent;
pub mod calls;
pub mod context;
pub mod directories;
pub mod identity;
Expand Down
Loading
Loading