ICP Ledger

ICP tokens are held on a canister that implements a token ledger. We refer to it as the Ledger Canister. This ICP Ledger Canister exposes a Candid Interface with ledger functionality, like sending tokens and querying balances.

We will only focus on the icrc1 part of the interface. The full interface is available as a file here and online in a 'ledger explorer'.

For detailed information about the icrc1 standard, you may review chapter 9.

Motoko Interface

This is a subset of the interface as a Motoko module. It only includes icrc1 related types and functions. It is available as icp-ledger-interface.mo

Note, that the types are slightly different from the icrc1 standard, but the functions are the same.

Types

  • Account
  • MetadataValue
  • Result
  • StandardRecord
  • TransferArg
  • TransferError
  • Self

Public functions

module {
  public type Account = { owner : Principal; subaccount : ?[Nat8] };

  public type MetadataValue = {
    #Int : Int;
    #Nat : Nat;
    #Blob : [Nat8];
    #Text : Text;
  };

  public type Result = { #Ok : Nat; #Err : TransferError };

  public type StandardRecord = { url : Text; name : Text };

  public type TransferArg = {
    to : Account;
    fee : ?Nat;
    memo : ?[Nat8];
    from_subaccount : ?[Nat8];
    created_at_time : ?Nat64;
    amount : Nat;
  };

  public type TransferError = {
    #GenericError : { message : Text; error_code : Nat };
    #TemporarilyUnavailable;
    #BadBurn : { min_burn_amount : Nat };
    #Duplicate : { duplicate_of : Nat };
    #BadFee : { expected_fee : Nat };
    #CreatedInFuture : { ledger_time : Nat64 };
    #TooOld;
    #InsufficientFunds : { balance : Nat };
  };

  public type Self = actor {
    icrc1_balance_of : shared query Account -> async Nat;

    icrc1_decimals : shared query () -> async Nat8;

    icrc1_fee : shared query () -> async Nat;

    icrc1_metadata : shared query () -> async [(Text, MetadataValue)];

    icrc1_minting_account : shared query () -> async ?Account;

    icrc1_name : shared query () -> async Text;

    icrc1_supported_standards : shared query () -> async [StandardRecord];

    icrc1_symbol : shared query () -> async Text;

    icrc1_total_supply : shared query () -> async Nat;
    
    icrc1_transfer : shared TransferArg -> async Result;
  }
}

Import

We import the ICP ledger canister by importing the interface file and declaring an actor by principle ryjl3-tyaaa-aaaaa-aaaba-cai and type it as the Self type (which is declared in the interface).

NOTE
If you are testing locally, you should have the Ledger Canister installed locally.

import Interface "icp-ledger-interface";

actor {
    // The Ledger Canister ID
    let ICP = "ryjl3-tyaaa-aaaaa-aaaba-cai";
    let icp = actor(ICP) : Interface.Self;
}

We can now reference the icp ledger canister as icp.

Public functions

These functions are available in icp-ledger-public-functions.mo together with a test function. To test these functions, please read the test section.

icrc1_name

icrc1_name : shared query () -> async Text;

Example

    func name() : async* Text {
        await icp.icrc1_name();
    };

icrc1_symbol

icrc1_symbol : shared query () -> async Text;

Example

    func symbol() : async* Text {
        await icp.icrc1_symbol();
    };

icrc1_decimals

icrc1_decimals : shared query () -> async Nat8;

Example

    func decimals() : async* Nat8 {
        await icp.icrc1_decimals();
    };

icrc1_fee

icrc1_fee : shared query () -> async Nat;

Example

    func fee() : async* Nat {
        await icp.icrc1_fee();
    };

icrc1_metadata

icrc1_metadata : shared query () -> async [(Text, MetadataValue)];

Example

    func metadata() : async* [(Text, Interface.MetadataValue)] {
        await icp.icrc1_metadata();
    };

icrc1_minting_account

icrc1_minting_account : shared query () -> async ?Account;

Example

    func minting_account() : async* ?Interface.Account {
        await icp.icrc1_minting_account();
    };

icrc1_supported_standards

icrc1_supported_standards : shared query () -> async [StandardRecord];

Example

    func supported_standards() : async* [Interface.StandardRecord] {
        await icp.icrc1_supported_standards();
    };

icrc1_total_supply

icrc1_total_supply : shared query () -> async Nat;

Example

    func total_supply() : async* Nat {
        await icp.icrc1_total_supply();
    };

icrc1_balance_of

icrc1_balance_of : shared query Account -> async Nat;

Example

    func balance(acc : Interface.Account) : async* Nat {
        await icp.icrc1_balance_of(acc);
    };

icrc1_transfer

icrc1_transfer : shared TransferArg -> async Result;

Example


    func transfer(arg : Interface.TransferArg) : async* Interface.Result {
        await icp.icrc1_transfer(arg);
    };

Test

Before we can run the test, we have to

If you followed the steps for local ledger canister deployment, you should have ICP on the default account of your identity. Then you can send ICP to your canister with these steps.

Step 1

First export your canister id to an environment variable. Assuming your canister name in dfx.json is icp-ledger run:

export CANISTER_ID=$(dfx canister id icp-ledger)

Step 2

Then export your the default account id belonging to your canister principal by using the dfx ledger account-id command with the --of-principal flag:

export ACCOUNT_ID=$(dfx ledger account-id --of-principal $CANISTER_ID)

Step 3

Check the ICP balance on your local Ledger of the default accounts of your identity and canister id with these commands.

Identity balance:

dfx ledger balance

Canister balance:

dfx ledger balance $ACCOUNT_ID

Step 4

Finally, send ICP to your canister with the dfx ledger transfer command:

dfx ledger transfer --amount 1 $ACCOUNT_ID --memo 0

Now check your balances again to check the transfer.

Step 5

To test all the Ledger public functions, we run this test. (Available in icp-ledger-public-functions.mo)

For testing the icrc1_balance_of function, you need to replace the principal with your own principal.

For the icrc1_transfer function, we use a specific subaccount by making a 32 byte [Nat8] array with the first byte set to 1.

    public func test() : async { #OK : Interface.Result; #ERR : Text } {
        try {

            // Query tests
            ignore await* name();
            ignore await* symbol();
            ignore await* decimals();
            ignore await* fee();
            ignore await* metadata();
            ignore await* minting_account();
            ignore await* supported_standards();
            ignore await* total_supply();

            // Balance_of test
            // Replace with your principal
            let principal : Principal = Principal.fromText("gfpvm-mqv27-7sz2a-nmav4-isngk-exxnl-g73x3-memx7-u5xbq-3alvq-dqe");

            let account : Interface.Account = {
                owner = principal;
                subaccount = null;
            };

            ignore await* balance(account);

            // Transfer test
            var sub = Array.init<Nat8>(32, 0);
            sub[0] := 1;
            let subaccount = Array.freeze(sub);

            let account2 : Interface.Account = {
                owner = principal;
                subaccount = ?subaccount;
            };

            let arg : Interface.TransferArg = {
                from_subaccount = null;
                to = account2;
                amount = 100_000_000; // 1 ICP;
                fee = null;
                memo = null;
                created_at_time = null;
            };

            let result = await* transfer(arg);

            #OK result

        } catch (e) {
            #ERR(Error.message(e));
        };
    };

After running this test locally, you should check your canister balance again to verify that the icrc1_transfer function was successful and thus the whole test was executed.