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
- have a local instance of the Ledger Canister
- deploy the
icp-ledger-public-functions.mo
actor locally and name the canistericp-ledger
in yourdfx.json
. - transfer some ICP to the canister
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.