Skip to main content

ICP Rust agent

Intermediate
Agents

The ICP Rust agent by DFINITY is a simple library that enables you to build applications and interact with ICP, serving as a low-level Rust backend for the IC SDK.

The agent is designed to be compatible with multiple versions of the replica API, exposing both low-level APIs for communicating with components like the replica and higher-level APIs for communicating with software applications deployed as canisters.

One example of an implementation of the ICP Rust agent is dfx.

Adding the agent as a dependency

To add the ICP Rust agent crate as a dependency in your project, use the command:

cargo add ic-agent

Initializing the agent

Before using the agent in your project, it must be initialized using the Agent::builder() function. Here is an example of how to initialize the Rust agent:

use anyhow::Result;
use ic_agent::Agent;
use url::Url;

pub async fn create_agent(url: Url, use_mainnet: bool) -> Result<Agent> {
let agent = Agent::builder().with_url(url).build()?;
if !use_mainnet {
agent.fetch_root_key().await?;
}
Ok(agent)
}

#[tokio::main]
async fn main() -> Result<()> {
// URL of the IC HTTP Gateway
let url = Url::parse("https://ic0.app")?;
// alternatively, specify URL of some API boundary node
// NOTE: you need to discover correct domain names first
// let url = "https://<api-boundary-node-domain>";

let agent = create_agent(url, true).await?;

// Example of how you'd use the agent:
// let response = agent.query(canister_id, method_name).await?;

Ok(())
}

This instantiates the agent with static routing, which dispatches all requests to a single endpoint (in this case ic0.app).

Static vs dynamic routing

By default, the Rust agent uses static routing, sending all API calls to a single, fixed URL. This URL can either point to:

  • A specific API boundary node, which may be unreliable due to maintenance, etc.
  • A fleet of HTTP gateways that proxies the calls to the API boundary nodes, although HTTP gateways are not decentralized.

To improve reliability and decentralization, the agent also supports dynamically routing API calls directly to globally distributed API boundary nodes. In particular, the Rust agent is capable of:

  • Automatically discovering and monitoring the list of all API boundary nodes.
  • Filtering out unhealthy nodes.
  • Dynamically routing API calls across healthy nodes using different built-in strategies (e.g., round-robin and latency-based routing).

Dynamic routing improves decentralization by directly routing API calls to the decentralized API boundary nodes, and enhances resilience and performance by dynamically adapting to the current state of the network.

On top of the built-in strategies, the agent allows developers to implement their custom routing strategies tailored to their specific use cases.

Using dynamic routing

The following example demonstrates how to instantiate an agent with the default dynamic routing using ic0.app as the seed:

use anyhow::Result;
use ic_agent::Agent;
use url::Url;

#[tokio::main]
async fn main() -> Result<()> {
// Use the URL of an HTTP Gateway or API boundary node as the initial seed
let seed_url = Url::parse("https://ic0.app")?;

// The agent starts with the seed node and discovers healthy API nodes dynamically
// Until then, requests go through the seed, but only if it's healthy.
let agent = Agent::builder()
.with_url(seed_url)
.with_background_dynamic_routing()
.build()?;

// ... use the agent for API calls

Ok(())
}

In this configuration, the agent distributes requests across multiple healthy API boundary nodes using a default weighted round-robin algorithm that favors lower-latency nodes, prioritizing those closer to the client.

How dynamic routing works

At the core of dynamic routing in the agent is the RouteProvider trait, which dynamically selects a routing URL for each request by invoking its route() method:

pub trait RouteProvider {
/// Generates the next routing URL based on the internal routing logic
fn route(&self) -> Result<Url, AgentError>;
/// Generates up to `n` different routing URLs in order of priority.
fn n_ordered_routes(&self, n: usize) -> Result<Vec<Url>, AgentError>;
...
}

The example above instantiates DynamicRouteProvider, an implementation of RouteProvider, and injects it into the agent to handle dynamic request routing. The DynamicRouteProvider itself is generic and extensible, allowing you to define custom routing strategies.

Under the hood, DynamicRouteProvider builds and maintains a dynamic routing table, which is continuously updated by running two background services:

  1. A discovery service that periodically fetches the latest API boundary node topology.

  2. A health check service that periodically checks nodes health by pinging it.

This design enables the agent to adapt to changes in node availability and network topology, resulting in a resilient and performant routing.

Two built-in strategies are available:

  1. Round-robin, which distributes requests evenly across all healthy nodes.

  2. Latency-based weighted round-robin, which prioritizes healthy nodes with the lowest observed latency

The previous example uses the latency-based strategy to prioritize faster nodes, limiting the selection to the top three.

For more detailed information take a look at the documentation of the DynamicRouteProvider in the agent-rs.

Authentication

The Rust agent's Identity object provides signatures that can be used for HTTP requests or identity delegations. It represents the principal ID of the sender. Identity represents a single identity and cannot contain multiple identity values.

async fn create_a_canister() -> Result<Principal, Box<dyn std::error::Error>> {
  let agent = Agent::builder()
    .with_url(URL)
    .with_identity(create_identity())
    .build()?;

Identities can have different types, such as:

  • AnonymousIdentity: A unit type used through with_identity(AnonymousIdentity).

  • BasicIdentity, Secp256k1Identity, and `Prime256v1Identity: Created from pre-existing keys through either function:

    • BasicIdentity::from_pem_file("path/to/identity.pem")
    • BasicIdentity::from_key_pair(key_material)

There are minor variations in the function name.

  • ic-identity-hsm crate: Used for hardware security modules (HSM) like Yubikey or Nitrokey through HardwareIdentity::new(pkcs11_module_path, slot_index, key_id, || get_pin()).

Making calls

The Rust agent can be used to make calls to other canisters. To make a call to a canister, use the agent.update method for an update call or agent.query for a query call. Then, pass in the canister's ID and the canister's method you'd like to call.

The following example calls the ICP ledger and returns an account balance:

let icp_ledger = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap();
let response = agent.update(&icp_ledger, "account_balance")
    .with_arg(Encode!(&AccountBalanceArgs { account: some_account_id })?)
    .call_and_wait()
    .await?;
let tokens = Decode!(&response, Tokens)?;

ic-utils

The ic-utils crate provides a high-level interface that is designed to be canister-oriented and aware of the canister's Candid interface. Canister objects with ic-utils resemble the following:

let canister = Canister::builder()
    .with_agent(&agent)
    .with_canister_id(principal)
    .build()?;
canister.query("account_balance")
    .with_arg(&AccountBalanceArg { user_id })
    .build()
    .await

ic-utils provides several interfaces, including the management canister, cycles wallet canister, and Bitcoin integration canister. For example, the management canister can be used with a call such as ManagementCanister::create(&agent).canister_status(&canister_id).await.

Learn more in the ic-utils documentation.

Response verification

When using the certified queries feature, the agent must verify that the certificate returned with the query response is valid. A certificate consists of a tree, a signature on the tree's root hash that is valid under a public key, and an optional delegation linking the public key to the root public key. To validate the root hash, the agent uses the HashTree module.

Below is an example annotated with notes that explains how to verify responses using the HashTree module:

// Define a function that initializes the agent and builds a certified query call to get the XDR and ICP conversion rate from the cycles minter canister:
pub async fn xdr_permyriad_per_icp(agent: &Agent) -> DfxResult<u64> {
    let canister = Canister::builder()
        .with_agent(agent)
        .with_canister_id(MAINNET_CYCLE_MINTER_CANISTER_ID)
        .build()?;
    let (certified_rate,): (IcpXdrConversionRateCertifiedResponse,) = canister
        .query("get_icp_xdr_conversion_rate")
        .build()
        .call()
        .await?;
    // Check the certificate with a query call
    let cert = serde_cbor::from_slice(&certified_rate.certificate)?;
    agent
        .verify(&cert, MAINNET_CYCLE_MINTER_CANISTER_ID)
        .context(
            "The origin of the certificate for the XDR <> ICP exchange rate could not be verified",
        )?;
    // Verify that the certificate can be trusted:
    let witness = lookup_value(
        &cert,
        [
            b"canister",
            MAINNET_CYCLE_MINTER_CANISTER_ID.as_slice(),
            b"certified_data",
        ],
    )
    .context("The IC's certificate for the XDR <> ICP exchange rate could not be verified")?;
    // Call the HashTree for the certified_rate call:
    let tree = serde_cbor::from_slice::<HashTree<Vec<u8>>>(&certified_rate.hash_tree)?;
    ensure!(
        tree.digest() == witness,
        "The CMC's certificate for the XDR <> ICP exchange rate did not match the IC's certificate"
    );
    // Verify that the HashTree can be trusted:
    let lookup = tree.lookup_path([b"ICP_XDR_CONVERSION_RATE"]);
    let certified_data = if let LookupResult::Found(content) = lookup {
        content
    } else {
        bail!("The CMC's certificate did not contain the XDR <> ICP exchange rate");
    };
    let encoded_data = Encode!(&certified_rate.data)?;
    ensure!(
        certified_data == encoded_data,
        "The CMC's certificate for the XDR <> ICP exchange rate did not match the provided rate"
    );
    // If the above checks are successful, you can trust the exchange rate that has been returned:
    Ok(certified_rate.data.xdr_permyriad_per_icp)
}

Another application of HashTree can be found in the ic-certified-assets code.

Example

The following is an example of how to use the agent interface to make a call to a canister (in this example, the ICP ledger) deployed on the mainnet:

use ic_agent::{Agent, export::Principal};
use candid::{Encode, Decode, CandidType, Nat};
use serde::Deserialize;

#[derive(CandidType)]
struct AccountBalanceArgs {
    account: Vec<u8>,
}
#[derive(CandidType, Deserialize)]
struct Tokens {
    e8s: u64,
}
let icp_ledger = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap();
let response = agent.update(&icp_ledger, "account_balance")
    .with_arg(Encode!(&AccountBalanceArgs { account: some_account_id })?)
    .call_and_wait()
    .await?;
let tokens = Decode!(&response, Tokens)?;

Resources