An In-Depth Guide to IC Programming and Best Practices to Follow

Written by nnsdao | Published 2021/12/05
Tech Story Tags: icp | ic | motoko | rust | programming | learning-to-code | ide | internet

TLDRThe integration of Internet Identity (II for short) needs to be distinguished between the development environment and the main network environment. The principals obtained through the II authentication of the main network environment cannot be used in the development environment, and the principals authenticated through the II of the development environment cannot be used in the main network environment.via the TL;DR App

Integrating Internet Identity

The integration of Internet Identity (II for short) needs to be distinguished between the development environment and the main network environment.

The principals obtained through the II authentication of the main network environment cannot be used in the development environment, and the principals authenticated through the II of the development environment cannot be used in the main network environment.

Development environment

Software Installation II of the IDE requires the following software to be downloaded and installed.

dfx: sh -ci "$(curl -fsSL https://smartcontracts.org/install.sh)"

Rust: install via the command "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" .

NodeJS: installed with the command "apt install nodejs; apt install npm; npm install -g n; n lts" .

CMake: install with the command "apt install cmake" .

Deploy Local II

Start the dfx development chain and deploy II canister on it by executing the following command:

$git clone https://github.com/dfinity/internet-identity.git
$cd internet-identity
$npm install
$dfx start --clean --background
$II_ENV=development dfx deploy --no-wallet --argument '(null)'
$dfx canister id internet_identity

Note: You need to write down the II_Canister_ID of the II canister obtained by "dfx canister id internet_identity" .

Performing II authentication

The following is an example of code to perform II authentication.

import { AuthClient } from "@dfinity/auth-client";

let identityProvider = 'http://lm5fh-ayaaa-aaaah-aafua-cai.localhost:8000';
let identity;
try {	
  const authClient = await AuthClient.create();
  if (await authClient.isAuthenticated()) {
    identity = authClient.getIdentity();
  } else {
    identity = await new Promise((resolve, reject) => {
      let timer = setTimeout(() => {
        timer = null;
        reject('do II auth timeout!');
      }, 30 * 1000);
      authClient.login({
        identityProvider,
        maxTimeToLive: BigInt(60_000_000_000),
        onSuccess: () => {
          if (timer != null) {
            clearTimeout(timer);
            timer = null;
            resolve(authClient.getIdentity());
          }
        },
        onError: (err) => {
          if (timer != null) {
            clearTimeout(timer);
            timer = null;
            reject(err);
          }
        },
      });
    });
  }
} catch (e) {
  console.log(e);
}
console.log(identity);

identityProvider specifies the url path to the II authentication service. If not specified, then the default is identity.ic0.app for the main network. localII path is provided here.

AuthClient.create() creates the auth client and will restore the identity from local storage if the Internet Identity has been done and has not expired.

authClient.getIdentity() is used to retrieve the identity, which may be II-authenticated or anonymously generated.

authClient.isAuthenticated() is used to check if the current identity is II-authenticated.

authClient.login(opt) is used to open a new window for II authentication. opt has the following options.

identityProvider Provides the url path to the authentication service, the default is identity.ic0.app.

maxTimeToLive provides the valid duration of the delegate proxy identity in ns.

onSuccess Specifies the callback for successful authentication. onError Specifies the callback for failed authentication.

Note: If the user closes the window without authentication, the onError callback is not generated.

When the II authentication is successfully completed, the II authenticated identity can be obtained through authClient.getIdentity().

Identity agent remaining time

The remaining valid hours of the II-authenticated identity can be obtained by:

const nextExpiration = identity.getDelegation().delegations
  .map(d => d.delegation.expiration)
  .reduce((current, next) => next < current ? next : current);
const expirationDuration  = nextExpiration - BigInt(Date.now()) * BigInt(1000_000);

Identity proxy requests

Requests can be sent to canister by:

import {Actor, HttpAgent} from "@dfinity/agent";

const agent = new HttpAgent({identity}); //identity is an identity authenticated by II, if it is null, it defaults to an anonymous identity.
await agent.fetchRootKey();
const idlFactory = ({ IDL }) =>
    IDL.Service({
       whoami: IDL.Func([], [IDL.Principal], ['query']),
    });
const canisterId = "lm5fh-ayaaa-aaaah-aafua-cai";
const actor = Actor.createActor(idlFactory, {agent, canisterId});
const principal = await actor.whoami();

new HttpAgent({identity}) Generate agent for proxy requests. The agent uses the specified identity as the identity subject of the request, or an anonymous identity if not specified.

agent.fetchRootKey() is used to pull the rootkey, because the built-in rootkey is for the main network environment. The code can only be used in the development environment, and must not be used in the main network environment.

idlFactory defines the interface of canister. canisterId defines the ID of canister. Actor.createActor will create the actor.

actor.whoami is used to send requests to the canister.

Main Network Environment

The main difference between the main network environment and the development environment is the difference between II authentication and identity agent requests.

Performing II authentication

The following is a code example to perform II authentication.

import { AuthClient } from "@dfinity/auth-client";

let identityProvider = null;
let identity;
try {
  const authClient = await AuthClient.create();
  if (await authClient.isAuthenticated()) {
    identity = authClient.getIdentity();
  } else {
    identity = await new Promise((resolve, reject) => {
      let timer = setTimeout(() => {
        timer = null;
        reject('do II auth timeout!');
      }, 30 * 1000);
      authClient.login({
        identityProvider,
maxTimeToLive: BigInt(60_000_000_000),
        onSuccess: () => {
          if (timer != null) {
            clearTimeout(timer);
            timer = null;
            resolve(authClient.getIdentity());
          }
        },
        onError: (err) => {
          if (timer != null) {
            clearTimeout(timer);
            timer = null;
            reject(err);
          }
        },
      });
    });
  }
} catch (e) {
  console.log(e);
}
console.log(identity);

identityProvider specifies the url path to the II authentication service. If not specified, then the default is identity.ic0.app for the main network.

AuthClient.create() creates the auth client and will restore the identity from local storage if the Internet Identity has been done and has not expired.

authClient.getIdentity() is used to retrieve the identity, which may be II authenticated or anonymously generated.

authClient.isAuthenticated() is used to check if the current identity is II-authenticated.

authClient.login(opt) is used to open a new window for II authentication. opt has the following options.

identityProvider Provides the url path to the authentication service, the default is identity.ic0.app.

maxTimeToLive provides the valid duration of the delegate proxy identity in ns.

onSuccess Specifies the callback for successful authentication.

onError Specifies the callback for failed authentication.

Note: If the user closes the window without authentication, the onError callback is not generated.

When the II authentication is successfully completed, the II authenticated identity can be obtained through authClient.getIdentity().

Identity agent remaining time

The remaining valid duration of the II-authenticated identity can be obtained by:

const nextExpiration = identity.getDelegation().delegations
  .map(d => d.delegation.expiration)
  .reduce((current, next) => next < current ? next : current);
const expirationDuration  = nextExpiration - BigInt(Date.now()) * BigInt(1000_000);

Identity proxy requests

Requests can be sent to canister by:

import {Actor, HttpAgent} from "@dfinity/agent";

const agent = new HttpAgent({identity}); // identity is an identity authenticated by II, if it is null, it defaults to an anonymous identity.
// await agent.fetchRootKey(); // The main network environment cannot pull the rootkey, otherwise it may lead to man-in-the-middle attack.
const idlFactory = ({ IDL }) =>
    IDL.Service({
       whoami: IDL.Func([], [IDL.Principal], ['query']),
    });
const canisterId = "lm5fh-ayaaa-aaaah-aafua-cai";
const actor = Actor.createActor(idlFactory, {agent, canisterId});
const principal = await actor.whoami();

new HttpAgent({identity}) Generates an agent for proxy requests that uses the specified identity as the identity subject of the request, or an anonymous identity if it is not specified.

The idlFactory defines the interface of the canister. canisterId defines the ID of the canister. actor.createActor will create the actor.

actor.whoami is used to send requests to canister.

Requesting Canister via Candid

There are two cases of sending requests to a canister: the canister of this project and the canister of other projects.

Canister of this project

For referencing this project's Canister, the steps are as follows.

  1. Add the dependency in dfx.json.
  2. Import the IDL definition of the canister and the canister ID.
  3. Create the Actor.
  4. Send the request.
  5. Adding dependencies
  6. Add dependencies in dfx.json, eg:

{
  "canisters": {
    "Nomos": {
      "main": "src/Nomos/main.mo",
      "type": "motoko"
    },
    "Nomos_assets": {
      "dependencies": [
        "Nomos"
      ],
      "frontend": {
        "entrypoint": "src/Nomos_assets/src/index.html"
      },
      "source": [
        "src/Nomos_assets/assets",
        "dist/Nomos_assets/"
      ],
      "type": "assets"
    }
  },
  ...
}

Import IDLs and IDs

Then import the IDL definition of canister as well as the canister ID. e.g. import {idlFactory as customNomosIDL, canisterId as customNomosID} from "dfx-generated/Nomos"; where Nomos is the name of the canister.

The specific IDL definition of canister and canister ID can be found in <project_root>/.dfx/local/canisters/<canister_name>/<canister_name>.js.

Creating an Actor

An Actor can be created once the IDL definition of the canister and the canister ID are available. e.g.

const nomosActor = Actor.createActor(customNomosIDL, {agent, canisterId: customNomosID});

Where agent is the request agent, see Identity Agent Request for details.

Sending requests

Once the actor is successfully created you can send a request like canister. For example. let userInfo = await nomosActor.userInfo()

Canister of other projects

The difference between sending a request to a canister of another project and this project is that there is no need to add dependencies, but you need to manually specify the IDL definition and ID of the canister.

Specifying IDL and ID

First specify the IDL definition of the canister and the canister ID. e.g.

const canisterId = Principal.fromText(canisterIdEl.value);
const idlFactory = ({ IDL }) =>
    IDL.Service({
      whoami: IDL.Func([], [IDL.Principal], ['query']),
    });

Creating an Actor

An Actor can be created when the IDL definition of canister and canister ID are available. e.g. const whoamiActor = Actor.createActor(idlFactory, {agent, canisterId}); Where agent is the request agent, see Identity Agent Request for details.

Sending requests

Once the actor is successfully created you can send a request like canister. For example. let principal = await actor.whoami()

IDL definition

IDL definition is a service declaration generation function. For example.

({ IDL }) =>
    IDL.Service({
      whoami: IDL.Func([], [IDL.Principal], ['query']),
register : IDL.Func([IDL.Text, IDL.Text],[IDL.Bool, IDL.Bool, IDL.Nat],[],),
    });
  1. The input of the function is IDL.
  2. The IDL.Service function is used to generate a service declaration. It accepts an object parameter, the field name of the object is the actor's function name, and the field value of the object is the actor's function signature.
  3. The IDL.Func function is used to generate the function signature.
  4. the first parameter is the set of input parameters, each element of the array indicates the type of the input parameters.
  5. the second parameter for the return value array, length 0 means no return value, each element of the array represents the type of each value of tuple. 6. the third parameter for the function type.
  6. The third parameter is an array of function types. [] denotes a shared type function, and ['query'] denotes a shared query type function.

Type mapping

  • Text: String. For example, "123".
  • Import import { Principal } from "@dfinity/agent";
  • Blob: Array of values. For example, [1,2,3].
  • Nat: BigNumber. import import { BigNumber } from "bignumber.js";
  • Int: BigNumber. import {BigNumber} from "bignumber.js";
  • NatN: Nat8, Nat16, Nat32 correspond to numeric values, Nat64 corresponds to BigNumber.
  • IntN: Int8, Int16, Int32 correspond to numeric values, Int64 corresponds to BigNumber.
  • Float: numeric value. For example, 1.235.
  • Bool: Boolean value. For example true or false.
  • Null: null.
  • [T]: Array. For example ["1", "2"].
  • ?T: array. null corresponds to the empty array [], otherwise it is a single-element array.
  • Tuple: array. For example ["1", 1].
  • Record: object. For example, {"a":1, "b": "2"}.
  • Variant: Object. An object that has only a single key-value pair. For example, {"tag": "value"}.
  • Func: [Princiapl, string]. func type represents a function reference of a Service. The first element of the array is the identity of the service, and the second element is the name of the function.
  • Service: Princiapl. is the identity of the service.
  • Any: Any type.
  • None: No value, will not be returned.

Handling HTTP requests

Raw HTTP requests can be processed in canister. Note that the /api and /_ routes are not processed by the HTTP middleware processor.

If a canister needs to handle HTTP requests, it needs to provide a service with the following candid definition:

type HttpResponse = 
 record {
   body: blob;
   headers: vec HeaderField;
   status_code: nat16;
};
type HttpRequest = 
 record {
   body: blob;
   headers: vec HeaderField;
   method: text;
   url: text;
 };
type HeaderField = 
 record {
   text;
   text;
 };
service : {
   “http_request”: (HttpRequest) -> (HttpResponse) query;
}

Motoko

import Text "mo:base/Text";

actor {
    type HeaderField = (Text, Text);
    type HttpRequest = {
        method: Text;
        url: Text;
        headers: [HeaderField];
        body: Blob;
    };
    type HttpResponse = {
        status_code: Nat16;
        headers: [HeaderField];
        body: Blob;
    };
    public query func http_request(req: HttpRequest) : async HttpResponse {
        return {
            status_code=200;
            headers= [("content-type","text/plain")];
            body=Text.encodeUtf8("hello boy!");
        };
    };
};

Rust

use ic_cdk::export::{candid::{CandidType, Deserialize}};
use ic_cdk_macros::*;
use serde_bytes::{ByteBuf};

type HeaderField = (String, String);

#[derive(Clone, Debug, CandidType, Deserialize)]
struct HttpRequest {
    method: String,
    url: String,
    headers: Vec<(String, String)>,
    body: ByteBuf,
}

#[derive(Clone, Debug, CandidType, Deserialize)]
struct HttpResponse {
    status_code: u16,
    headers: Vec<HeaderField>,
    body: Vec<u8>,
}

#[query]
async fn http_request(_req: HttpRequest) -> HttpResponse {
    let mut headers: Vec<HeaderField> = Vec::new();
    headers.push(("content-type".to_string(), "text/plain".to_string()));
    return HttpResponse {
        status_code: 200,
        headers,
        body: "hello boy!".as_bytes().to_vec(),
    }
}

Canister management

IC provides a virtual canister for all canister management, call it canister manager, the canister ID is "aaaaaa-aa". IC canister manager does not actually exist as a container (with isolated state, Wasm code, etc.).

The canister manager's did file is described as follows:

type canister_id = principal;
type user_id = principal;
type wasm_module = blob;

type canister_settings = record {
  controllers : opt vec principal;
  compute_allocation : opt nat;
  memory_allocation : opt nat;
  freezing_threshold : opt nat;
};

type definite_canister_settings = record {
  controllers : vec principal;
  compute_allocation : nat;
  memory_allocation : nat;
  freezing_threshold : nat;
};

service ic : {
  create_canister : (record {
    settings : opt canister_settings
  }) -> (record {canister_id : canister_id});
  update_settings : (record {
    canister_id : principal;
    settings : canister_settings
  }) -> ();
  install_code : (record {
    mode : variant {install; reinstall; upgrade};
    canister_id : canister_id;
    wasm_module : wasm_module;
    arg : blob;
  }) -> ();
  uninstall_code : (record {canister_id : canister_id}) -> ();
  start_canister : (record {canister_id : canister_id}) -> ();
  stop_canister : (record {canister_id : canister_id}) -> ();
  canister_status : (record {canister_id : canister_id}) -> (record {
      status : variant { running; stopping; stopped };
      settings: definite_canister_settings;
      module_hash: opt blob;
      memory_size: nat;
      cycles: nat;
  });
  delete_canister : (record {canister_id : canister_id}) -> ();
  deposit_cycles : (record {canister_id : canister_id}) -> ();
  raw_rand : () -> (blob);

  // provisional interfaces for the pre-ledger world
  provisional_create_canister_with_cycles : (record {
    amount: opt nat;
    settings : opt canister_settings
  }) -> (record {canister_id : canister_id});
  provisional_top_up_canister :
    (record { canister_id: canister_id; amount: nat }) -> ();
}

create_canister

create_canister : (record {settings : opt canister_settings}) -> (record {canister_id : canister_id});

Before deploying a container, the administrator of the container first has to register it in the system, get a container ID (being an empty container) and then install the code separately.

The optional settings parameter can be used to make the following settings.

controllers (vec principal): list of principals. Size must be between 0 and 10. Default value: contains only the caller of create_canister. This value is assigned to the controller attribute of the container.

compute_allocation (nat): must be a number between 0 and 100, including 0 and 100, with a default value of 0. It indicates how much compute power should be guaranteed for this container, representing the percentage of the maximum compute power that can be allocated for a single container. If the system is unable to provide the requested allocation, for example because it is overbooked, the call will be rejected.

memory_allocation (nat): must be a number between 0 and 2^48 (i.e. 256TB), inclusive, with a default value of 0. It indicates how much memory the container is allowed to use in total. Any attempt to increase memory usage beyond this allocation will fail. If the system is unable to provide the requested allocation, for example because it is overbooked, the call will be rejected. If set to 0, the container's memory will grow as best it can and be limited by the memory available on the network.

freezing_threshold (nat): must be a number between 0 and 2^64-1 inclusive, and indicates the length of time (in seconds), default value: 2592000 (approximately 30 days). Considering the current size of the container and the current storage cost of the system, the container is considered frozen when the system estimates that the container will run out of cycles after freeze_threshold seconds.

Note: Additional cycles need to be added for injection into the new canister when creating_canister is executed.

update_settings

update_settings : (record {canister_id : principal; settings : canister_settings}) -> ();

Only controllers of canister can update the settings.

canister_id specifies the id of the canister whose settings need to be updated.

settings is the same as settings in create_canister, if a field is not included in settings, it means that the field is not changed.

install_code

install_code : (record {mode : variant {install; reinstall; upgrade}; canister_id : canister_id; wasm_module : wasm_module; arg : blob;}) -> ();

This method installs the code into the container. Only the controllers of the container can install the code.

For different modes, the situation is different.

If mode = install, the container must previously be empty. This instantiates the container module and calls its canister_init system method (if present) and passes arg to that method.

If mode = reinstall, if the container is not empty, its existing code and state are removed before doing mode = install. Note that this is different from the uninstall_code followed by install_code, as this forces rejection of all unresponsive calls.

If mode = upgrade, this will perform an upgrade of the non-empty container, passing arg to the canister_post_upgrade system method of the new instance.

Note: This call is invalid if the response to this request is reject.

uninstall_code

uninstall_code : (record {canister_id : canister_id}) -> ();

This method removes the container's code and state, emptying the container again. Only the container's controllers can unload the code.

Uninstall will reject all calls to the container that have not yet been responded to, and remove the container's code and state. Outstanding responses to the container are not processed, even if they arrive after the code has been installed again.

canister is now empty. In particular, any incoming or queued calls will be rejected.

The unloaded container retains its cycle count, controllers,, status and allocations.

canister_status

canister_status : (record {canister_id : canister_id}) -> (record {
      status : variant { running; stopping; stopped }; settings: definite_canister_settings;
      module_hash: opt blob; memory_size: nat; cycles: nat;});

Indicates various information about the canister. Only the controller of the container can request its status. It contains status.

status: It can be one of running, stopped or stopped.

SHA256 hash: The SHA256 hash of the module installed on the container.

If the container is empty, it is null.

controller: list of controllers

allocations: size of occupied memory, number of cycles.

stop_canister

stop_canister : (record {canister_id : canister_id}) -> ();

The controller of a container can stop the container (for example, to prepare for a container upgrade).

Stopping the canister is not an atomic operation. The immediate effect is that the state of the container changes to being stopped (unless the container is already stopped). The system will reject all calls to the container that is being stopped, indicating that the container is being stopped. Responses to the stopped canister are processed as usual. After all outstanding responses are processed (so there is no open call context), the container state is changed to stopped and the caller who managed the container response to the stop_canister request.

start_canister

start_canister : (record {canister_id : canister_id}) -> ();

A container can be started by its controller.

If the container state has been stopped or is being stopped, the container state is set to running only. In the latter case, all stop_canister calls that are being processed fail (and are rejected).

If the container is already running, the state remains unchanged.

delete_canister

delete_canister : (record {canister_id : canister_id}) -> ();

This method deletes a canister from the IC. only the controllers of the container can delete it, and the container must have been stopped.

Deleting a container cannot be undone, any state stored on the container is permanently deleted and its cycle is discarded. once a container is deleted, its ID cannot be used again.

deposit_cycles

deposit_cycles : (record {canister_id : canister_id}) -> ();

This method stores the cycle contained in this call in the specified container.

There is no controller restriction on who can call this method.

raw_rand
raw_rand : () -> (blob);

This method accepts no input and returns 32 pseudo-random bytes to the caller. The return value is not known to any part of the IC at the time this call is submitted. A new return value is generated each time this method is called.

Inter-canister calls

Currently motoko cannot generate canisters directly from within a canister, so canisters need to be deployed externally and then the ID of the canister being called is passed to the caller.

Currently inter-canister calls cannot be made using query.

Note: It may be possible to create a canister using the IC_MANAGER line canister.

Example:

actor Counterer {
    type  CounterIn = actor {
        get : shared query () -> async Nat;
        set : (n: Nat) -> async ();
        inc : () -> async ();
    };
    var counter : ?CounterIn = null;

    // set counter canister ID.
    public func init(c : Text) {
        counter := ?actor(c);
    };

    // Get the value of the counter.
    public func get() : async Nat {
        switch counter {
            case (?c) { await c.get();};
            case null {0};
        };
    };

    // Set the value of the counter.
    public func set(n: Nat) {
        switch counter {
            case (?c) {await c.set(n);};
            case null {()};
        };
    };
    
    // Increment the value of the counter.
    public func inc() {
        switch counter {
            case (?c) {await c.inc();};
            case null {()};
        };
    };
};

Canister receives ICP

By implementing the following did interface, canister is able to be notified when it receives an ICP:

type TransactionNotification = 
 record {
   amount: ICPTs;
   block_height: BlockHeight;
   from: principal;
   from_subaccount: opt SubAccount;
   memo: Memo;
   to: principal;
   to_subaccount: opt SubAccount;
 };
type SubAccount = vec nat8;
type Result = 
 variant {
   Err: text;
   Ok;
 };
type Memo = nat64;
type ICPTs = record {e8s: nat64;};
type BlockHeight = nat64;
service : {
  transaction_notification: (TransactionNotification) -> (Result);
}

Also published on: https://nnsdao.medium.com/ic-programming-best-practices-cf7228b39074


Written by nnsdao | The boundaryless autonomous organization.
Published by HackerNoon on 2021/12/05