The ledger canister
This document is a specification of the public interface of the Ledger Canister. It provides an overview of the functionality, details some internal aspects, and documents publicly available methods. We also provide an abstract mathematical model which makes precise the expected behavior of the methods implemented by the canister, albeit at a somewhat high level of abstraction.
Parts of the canister interface are for internal consumption only, and therefore not part of this specification. However, whenever relevant, we do provide some insights into those aspects as well. |
Overview & terminology
In brief, the Ledger canister maintains a set of accounts owned by IC principals; each account has associated a Tokens balance. Account owners can initiate the transfer of tokens from the accounts they control to any other ledger account. All transfer operations are recorded on an append-only transaction ledger. The interface of the Ledger canister also allows minting and burning of tokens, which are additional transactions which are recorded on the transaction ledger.
Tokens
There can be multiple utility Tokens in the IC at once. The utility Tokens used by the IC Governance is the Internet Computer Protocol tokens (ICP). The smallest indivisible unit of Tokens are "e8"s: one e8 is 10-8 Tokens.
Accounts
The Ledger canister keeps track of accounts:
-
Every account belongs to (and is controlled by) an IC principal
-
Each account has precisely one owner (i.e. no “joint accounts”)
-
A principal may control more than one account. We distinguish between the different accounts of the same principal via a (32-byte string) subaccount_identifier. So, logically, each ledger account corresponds to a pair
(account_owner, subaccount_identifier)
. -
The account identifier corresponding to such a pair is a 32-byte string calculated as follows:
account_identifier(principal,subaccount_identifier) = CRC32(h) || h where h = sha224(“\x0Aaccount-id” || principal || subaccount_identifier)
That is, the account identifier (or sometimes simply, the account) corresponding to a principal and a subaccount identifier is obtained by hashing using sha224 the concatenation of domain separator “\x0Aaccount-id”, the principal and the subaccount identifier, and prepended with the (big endian representation of the) CRC32 of the resulting hash value. The domain separator consists of a string (here "account-id") prepended by a single byte equal to the length of the string (here, \x0A).
-
For any principle, we refer to the account which corresponds to the all-0 subaccount_identifier as the default account of a principle.
-
The default account of the Governance canister plays an important role in minting/burning tokens (see below), and we refer to it as the
minting_account_id
.
Operations, transactions, blocks , transaction ledger
Account balances change as the result of one of three operations: sending tokens from one account to another, minting tokens to an account and burning tokens from an account. Each operation is triggered by a call to the Ledger canister and is recorded as a transaction: in addition to the details of the operation a transaction includes a Memo field (a 64 bit number), and a timestamp indicating the time at which the transaction was created.
Each transaction is included in a block (there is only one transaction per block) and blocks are "chained" by including in each block the hash of the previous block (details of how blocks are serialized are below). The position of a block in the ledger is called the block index (or block height); counting starting from 0.
The types used to represent these concepts are specified below in Candid syntax.
- Basic datatypes
type Tokens = record { e8s : nat64; }; // Account identifier is a 32-byte array. // The first 4 bytes is big-endian encoding of a CRC32 checksum of the last 28 bytes type AccountIdentifier = blob; //There are three types of operations: minting tokens, burning tokens & transferring tokens type Transfer = variant { Mint: record { to: AccountIdentifier; amount: Tokens; }; Burn: record { from: AccountIdentifier; amount: Tokens; }; Send: record { from: AccountIdentifier; to: AccountIdentifier; amount: Tokens; }; }; type Memo = u64; // Timestamps are represented as nanoseconds from the UNIX epoch in UTC timezone type TimeStamp = record { timestamp_nanos: nat64; }; Transaction = record { transfer: Transfer; memo: Memo; created_at_time: Timestamp; }; Block = record { parent_hash: Hash; transaction: Transaction; timestamp: Timestamp; }; type BlockIndex = nat64; //The ledger is a list of blocks type Ledger = vec Block
Methods
The Ledger canister implements methods to:
-
transfer ICP from one account to another
-
get the balance of a ledger account
Transferring tokens
The owner of an account can transfer Tokens from that account to any other account using the transfer
method.
The inputs to the method are as follows:
-
amount
: the amount of tokens to be transferred -
fee
: the fee to be paid for the transfer -
from_subaccount
: a subaccount identifier which specifies from which account of the caller the ICP should take place. This parameter is optional — if it is not specified by the caller, then it is set to the all 0 vector. -
to
: the account identifier to which the tokens should be transferred -
memo
: this is a 64-bit number chosen by the sender; it can be used in various ways, e.g. to identify specific transfers. -
created_at_time
: a timestamp indicating when the transaction was created by the caller — if it is not specified by the caller then this is set to the current IC time.
The Ledger canister executes a transfer
call as follows:
-
checks that the destination is a well-formed account identifier
-
checks that the transaction is recent enough (has been created within the last 24 hours) and is not "in the future" (that is, it checks that
created_at_time
is not in the future by more than an allowed time drift, specified by a parameter in the IC, currently set at 60 seconds) -
calculates the source account (using the calling principal and
from_subaccount
) and checks that it holds more than amount+fee ICP -
checks that
fee
matches thestandard_fee
(currently, the standard fee is a fixed constant set to be 10-4 ICP, see below for an exception) -
checks that an identical transaction has not been submitted in the last 24 hours
-
if any of the checks fails, it returns an appropriate error
-
otherwise it
-
substracts amount+fee from the source account
-
adds amount to the destination account
-
adds transaction
(Transfer(from, to, amount, fee), memo, created_at_time)
to the ledger:-
it creates a block, containing the transaction, sets the
parent_hash
in the block to belast_hash
(essentially, the hash of the last block in the ledger), andtimestamp
in the block to be the system timestamp; -
it calculates
last_hash
as the hash of the encoding of the block newly created (see below for how the encoding is calculated); -
it appends the block to the ledger and returns its height.
-
-
Chaining ledger blocks
As explained above, the blocks contained in the ledger are chained (by including in a block the hash of the previous block). This enables authenticating the entire ledger by only signing its last block.
In this section we describe the details of the chaining, by specifying how a block is serialized before it is hashed.
At a high level, the block is serialized using protobuf. However, since protobuf encodings are not necessarily deterministic (and are also not guaranteed to stay fixed) here we provide the specific encoding used, which is guaranteed not to change.
The definition below is recursive. It uses .
to denote concatenation of byte strings, and two functions that are not defined here, but are well established: we write len(x)
for the length of bytestring x
.
We also write varint(s)
, for the variable length encoding of integer s
. The precise definition of this function can be found in the protobuf documentation.
encoded_block(Block{parent_hash, timestamp, transaction}) := let encoded_transaction = encode_transaction(transaction) in encode_hash(parent_hash) . 12 0a 08 . varint(timestamp) . 1a . len(encoded_transaction) . encoded_transaction encode_hash(Nil) := Nil encode_hash(hash) := 0a 22 0a 20 . hash encode_transaction(Transaction{operation, memo, created_at_time}) := let encoded_operation = encode_operation(operation) encoded_memo = encode_memo(memo) encoded_timestamp = encode_timestamp(created_at_time) in encoded_operation . 22 . len(encoded_memo) . encoded_memo . 32 . len(encoded_timestamp) . encoded_timestamp encode_memo(Nil) := Nil encode_memo(Memo{memo}) := 08 . varint(memo) encode_timestamp(Timestamp{timestamp_nanos}) := 08. varint(timestamp_nanos) encode_operation(Burn{AccountIdentifier{from}, Tokens{amount}}) := // identifiers can be 28 or 32 bytes (4 bytes checksum + 28 bytes hash) let encoded_account_identifier = 0a . len(from) . varint(from) encoded_amount = 08 . varint(amount) encoded_burn = 0a . len(encoded_account_identifier) . encoded_account_identifier . 1a . len(encoded_amount) . encoded_amount in 0a . len(encoded_burn) . encoded_burn encode_operation(Mint{AccountIdentifier{to}, Tokens{amount}}) := // identifiers can be 28 or 32 bytes (4 bytes checksum + 28 bytes hash) let encoded_account_identifier = 0a . len(to) . to encoded_amount = 08 . varint(amount) encoded_mint = 12 . len(encoded_account_identifier) . encoded_account_identifier . 1a . len(encoded_amount) . encoded_amount in 12 . len(encoded_mint) . encoded_mint encode_operation(Transfer{AccountIdentifier{from}, AccountIdentifier{to}, Tokens{amount}, Tokens{fee}}) := let encoded_from = 0a . len(from) . from encoded_to = 0a . len(to) . to encoded_amount = 08 . varint(amount) encoded_fee = 08 . varint(fee) encoded_transfer = 0a . len(encoded_from) . encoded_from . 12 . len(encoded_to) . encoded_to . 1a . len(encoded_amount) . encoded_amount . 22 . len(encoded_fee) . encoded_fee in 1a . len(encoded_transfer) . encoded_transfer
Burning and minting Tokens
Typical transfers move ICP from one account to another.
An important exception is when either the source or the destination of a transfer is the special minting_account_id
.
The effect of a transfer to the minting account is that the tokens are simply removed from the source account and not deposited anywhere; the tokens are burned.
Burn transactions are recorded on the ledger as (Burn(from,amount))
.
Importantly, the fee for a burn transfer is 0, but the amount of tokens to be burned must exceed the standard_fee
.
The effect of a transfer from the minting_account_id
account is that tokens are simply added to the destination account; the tokens are minted.
When invoked, the transaction (Mint(to,amount))
is added to the transaction ledger.
Notice that the minting_account_id
is controlled by the Governance canister which makes minting tokens a privileged operation only available to this canister.
The candid signature of the transfer
method, together with some additional required datatypes is below.
- Additional datatypes & canister methods
// Arguments for the `transfer` call. type TransferArgs = record { memo: Memo; amount: Tokens; fee: Tokens; from_subaccount: opt SubAccount; to: AccountIdentifier; created_at_time: opt TimeStamp; }; type TransferError = variant { // The fee specified in the send request was not the one the ledger expects. BadFee : record { expected_fee : Tokens; }; // The sender's (sub)account doesn't have enough funds for completing the transaction. Return an error with the debit account balance. InsufficientFunds : record { balance: Tokens; }; // The transaction is too old, the ledger only accepts transactions created within 24 hours window. Return an error with the allowed time-window size in nanoseconds. TxTooOld : record { allowed_window_nanos: nat64 }; // `created_at_time` is in future. TxCreatedInFuture : null; // The transaction was already submitted before. TxDuplicate : record { duplicate_of: BlockIndex; } }; type TransferResult = variant { Ok : BlockIndex; Err : TransferError; }; service : { transfer : (TransferArgs) -> (TransferResult); }
Balance
A transaction ledger tracks the balances of all accounts in the natural way (see the Semantics section below for a more formal definition).
Any principal can obtain the balance of an arbitrary account via the method account_balance
: the input parameter is the account identifier; the result is the balance associated to the account.
The balance of the account with account identifier minting_account_id
is always 0; the balance of any other account is calculated in the obvious way.
type AccountBalanceArgs = record { account: AccountIdentifier; }; service : { // Get the amount of ICP on the specified account. account_balance : (AccountBalanceArgs) -> (Tokens) query; }
Semantics
In this section we provide a semantics of the public methods exposed by the ledger. We use somewhat ad-hoc mathematical notation which we keep close to the notation introduced above.
We use " · " to denote list concatenation. We write default_subaccount
for the all-0 vector. If L is a list then we write |L| for the length of a list L and L[i] for the i’th element of L. The first element of L is L[0].
Basic types
Operation = Transfer = { from: AccountIdentifier; to: AccountIdentifier; amount: Tokens; fee: Tokens; } | Mint = { to: AccountIdentifier; amount: Tokens; } | Burn = { from: AccountIdentifier; amount: Tokens; } } Block = { operation: Operation; memo: Memo; created_at_time: Timestamp; hash: Hash; } Ledger = List(Block)
Ledger State
The state of the Ledger canister comprises:
-
the transaction ledger (a chained list of blocks containing transactions);
-
global variables:
-
last_hash
: an optional variable which records the hash of the last block in the ledger; it is set to None if no block is present in the ledger.
-
State = { ledger: Ledger; last_hash: Hash | None; };
Initially, the ledger is set to the empty list and last_hash
is set to None:
{ ledger = []; last_hash = None; }
Balances
Given a transaction ledger, we define the balance
function which associates to a ledger account its ICP balance.
balance: Ledger x AccountIdentifier -> Nat
The function is defined, recursively, as follows:
balance([],account_id) = 0 if (B = Block{Transfer{from,to,amount, fee}, memo, time, hash}) and (to = account_id)) | (B = Block{Mint{to, amount}, memo, time}) and (to = account_id)) then then balance(OlderBlocks · [B] , account_id) = balance(OlderBlocks, account_id) + amount, if (B = Block{Transfer{from,to,amount,fee},memo,time}} and (from = account_id) then balance(OlderBlocks · [B], account_id) = balance(OlderBlocks,account_id) - (amount+fee) if (B = Block{Burn{from,amount}) and (from = account_id) then balance(OlderBlocks · [B], account_id) = balance(OlderBlocks,account_id) - amount otherwise balance(OlderBlocks · [B], account_id) = balance(OlderBlocks, account_id)
We describe the semantics of ledger methods as a function which takes as input a ledger state, the call arguments and returns a (potentially) new state and a reply.
In the description of the function we use some additional functions which reflect system provided information. These include caller()
which returns the principal who invoked the method, now()
which return the IC time and drift
a constant indicating permissible time drift between IC and external time.
We also write well_formed(.)
for a boolean valued function which checks that its input is a well-formed account identifier (i.e. the first four bytes are equal to CRC32 of the remaining 28 bytes).
Ledger Method: transfer
- State & arguments
S A = { memo: Memo; amount: Tokens; fee: Tokens; from_subaccount: opt SubAccount; to: AccountIdentifier; created_at_time: opt TimeStamp; }
- Resulting state & reply
output (S',R) calculated as follows: if created_at_time = None then created_at_time = now(); if timestamp > now() + drift then (S',R) = (S, Err); if now() - timestamp > 24h then (S',R) = (S, Err); if not(well_formed(to)) then (S',R) = (S, Err); if to = `minting_account_id` and (fee ≠ 0 or amount < standard_fee) then (S',R) = (S, Err); if from_subaccount = None then from_subaccount = default_subaccount; from = account_identifier(caller(),from_subaccount) if from = `minting_account_id' then B = Block{Mint{to, amount}, memo, timestamp, S.last_hash} else if to = `minting_account_id` then B = Block{Burn{from, amount}, memo, timestamp, S.last_hash} else B = Block{Transfer{from, to, amount, fee}, memo, timestamp, S.last_hash}; if exists i (ledger[i].operation, ledger[i].memo, ledger[i].timestamp) = (B.operation,B.memo,B.timestamp) then (S',R)=(S,Err) else (S'.ledger = [B] · S.ledger); (S'.lasthash = hash(B)); R = |S'.ledger|-1;