Tutorial: Connecting A Token With Multiple Ledgers Using ic-event-hub
Alexander
Posted on March 28, 2022
This tutorial is dedicated to Rust smart-contract (canister) development on Internet Computer (Dfinity) platform. Completing it, you’ll know how to use ic-event-hub library APIs in order to perform efficient cross-canister integrations.
Before digging into this tutorial it is recommended to learn the basics of smart-contract development for the Internet Computer. Here are some good starting points: official website, dedicated to canister development on the IC; IC developer forum, where one could find an answer to almost any technical question.
Complete code of this tutorial is here:
Motivation
In the previous article about ic-event-hub
"Introduction To ic-event-hub Library", we imagined the following scenario:
- you've built a token canister that accepts transfer transactions from users and re-transmits the info about these transactions to any other canister that will ask for it;
- there are many ledger canisters (developed by third parties who want to integrate them with your token), each of which is specialized on specific transaction type:
- one of them only keeps track of transactions made by US citizens;
- another one only keeps track of transactions made to/from charity organizations;
- a bunch of personal ledgers, which keep track of transactions of a particular person or organization;
- and each month there is at least one more ledger appears with some new specific requirements;
- besides all of that you want to make your token as efficient as possible:
- if the token experiences high load (e.g. 100+ tx/block), you don't want to send 100+ messages to each ledger;
- since messages you send to ledgers are small (only contain some generic transfer data:
from
,to
,amount
,timestamp
), you want to pack these messages into a single batch and send them all at once - this way you could save a lot of cycles.
Let's now implement it! Let's build a simple token canister that allows multiple ledgers to listen for various events happening in this token. And also let's build a couple of such ledgers.
Implementation
Project layout
project/
token/
src/
actor.rs
common/
mod.rs
currency_token.rs
guards.rs
types.rs
build.sh
can.did
cargo.toml
ledger-1/
src/
actor.rs
common/
mod.rs
ledger.rs
types.rs
build.sh
can.did
cargo.toml
ledger-2/
src/
actor.rs
common/
mod.rs
ledger.rs
types.rs
build.sh
can.did
cargo.toml
DFX configuration
// project/dfx.json
{
"canisters": {
"token": {
"build": "./token/build.sh",
"candid": "./token/can.did",
"wasm": "./token/target/wasm32-unknown-unknown/release/token-opt.wasm",
"type": "custom"
},
"ledger-1": {
"build": "./ledger-1/build.sh",
"candid": "./ledger-1/can.did",
"wasm": "./ledger-1/target/wasm32-unknown-unknown/release/ledger-1-opt.wasm",
"type": "custom"
},
"ledger-2": {
"build": "./ledger-2/build.sh",
"candid": "./ledger-2/can.did",
"wasm": "./ledger-2/target/wasm32-unknown-unknown/release/ledger-2-opt.wasm",
"type": "custom"
}
},
"dfx": "0.9.2",
"networks": {
"local": {
"bind": "127.0.0.1:8000",
"type": "ephemeral"
}
},
"version": 1
}
Token canister
This canister provides basic token functionality: minting, transferring and burning. Each time such a functionality is used, a corresponding event is emitted.
Dependencies
# project/token/cargo.toml
[package]
name = "token"
version = "0.1.0"
edition = "2018"
[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"
[dependencies]
ic-cdk = "0.4.0"
ic-cdk-macros = "0.4.0"
candid = "0.7.12"
serde = "1.0"
ic-event-hub = "0.3.0"
ic-event-hub-macros = "0.3.0"
Buildscript
# project/token/build.sh
#!/usr/bin/env bash
SCRIPT=$(readlink -f "$0")
SCRIPTPATH=$(dirname "$SCRIPT")
cd "$SCRIPTPATH" || exit
cargo build --target wasm32-unknown-unknown --release --package token && \
ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/token.wasm -o ./target/wasm32-unknown-unknown/release/token-opt.wasm
Token internal logic
As it was said earlier, our token would have three separate functionalities: mint, transfer and burn. Also we want to provide a function to get current token balance of some account (we use user principals as account identifiers) as well as to get total token supply value.
We also want our token to have some associated information: token name, token symbol (ticker) and token decimals (how many cents there are in one token).
Also we want to control the access to the minting functionality, to allow to invoke it only for a selected set of principals (controllers).
These requirements left us with the following token state structure:
// project/token/src/common/currency_token.rs
#[derive(CandidType, Deserialize)]
pub struct CurrencyToken {
pub balances: HashMap<Principal, u64>,
pub total_supply: u64,
pub info: TokenInfo,
pub controllers: Controllers,
}
pub type Controllers = Vec<Principal>;
Let's define the implementation of this structure that will add all the functions we need:
// project/token/src/common/currency_token.rs
impl CurrencyToken {
pub fn mint(&mut self, to: Principal, qty: u64) -> Result<(), Error> {
if qty == 0 {
return Err(Error::ZeroQuantity);
}
let prev_balance = self.balance_of(&to);
let new_balance = prev_balance + qty;
self.total_supply += qty;
self.balances.insert(to, new_balance);
Ok(())
}
pub fn transfer(&mut self, from: Principal, to: Principal, qty: u64) -> Result<(), Error> {
if qty == 0 {
return Err(Error::ZeroQuantity);
}
let prev_from_balance = self.balance_of(&from);
let prev_to_balance = self.balance_of(&to);
if prev_from_balance < qty {
return Err(Error::InsufficientBalance);
}
let new_from_balance = prev_from_balance - qty;
let new_to_balance = prev_to_balance + qty;
if new_from_balance == 0 {
self.balances.remove(&from);
} else {
self.balances.insert(from, new_from_balance);
}
self.balances.insert(to, new_to_balance);
Ok(())
}
pub fn burn(&mut self, from: Principal, qty: u64) -> Result<(), Error> {
if qty == 0 {
return Err(Error::ZeroQuantity);
}
let prev_balance = self.balance_of(&from);
if prev_balance < qty {
return Err(Error::InsufficientBalance);
}
let new_balance = prev_balance - qty;
if new_balance == 0 {
self.balances.remove(&from);
} else {
self.balances.insert(from, new_balance);
}
self.total_supply -= qty;
Ok(())
}
pub fn balance_of(&self, account_owner: &Principal) -> u64 {
match self.balances.get(account_owner) {
None => 0,
Some(b) => *b,
}
}
}
There is nothing unusual in these functions. In fact they're pretty much copying the same functions from ERC-20 standard.
Each of these functions may return an Error, which type is defined as follows:
// project/token/src/common/types.rs
#[derive(Debug)]
pub enum Error {
InsufficientBalance,
ZeroQuantity,
AccessDenied,
ForbiddenOperation,
}
Actor definition
Now, when we have our basic token functionality ready, let's wrap it into a canister. The state definition and initialization is as simple as follows:
// project/token/src/actor.rs
static mut STATE: Option<CurrencyToken> = None;
pub fn get_state() -> &'static mut CurrencyToken {
unsafe { STATE.as_mut().unwrap() }
}
#[init]
fn init(controller: Principal, info: TokenInfo) {
let token = CurrencyToken {
balances: HashMap::new(),
total_supply: 0,
info,
controllers: vec![controller],
};
unsafe {
STATE = Some(token);
}
}
As you might notice, we pass a controller and token's info into an init()
function. This token info is defined like this:
// project/token/src/common/types.rs
#[derive(Clone, CandidType, Deserialize)]
pub struct TokenInfo {
pub name: String,
pub symbol: String,
pub decimals: u8,
}
The next thing we want to do is to set up the ic-event-hub
library. In order to do that, we need to invoke these three macros:
// project/token/src/actor.rs
implement_event_emitter!(10 * 1_000_000_000, 100 * 1024);
implement_subscribe!();
implement_unsubscribe!();
The first one initializes the event emitter's state. You can find more information about it in this tutorial. Other two macros are enabling event listeners to subscribe for events, by adding a couple of new update
functions (subscribe()
and unsubscribe()
).
In order to fully enable ic-event-hub
we also have to call send_events()
function inside the heartbeat
system function like this:
// project/token/src/actor.rs
#[heartbeat]
pub fn tick() {
send_events();
}
This expression will check the ic-event-hub
's state for ready event batches and then it will send these batches to their recipients.
Now, let's define all the update functions of our canister:
// project/token/src/actor.rs
#[update(guard = "controller_guard")]
fn mint(to: Principal, qty: u64) {
get_state().mint(to, qty).expect("Minting failed");
emit(MintEvent {
to,
amount: qty,
timestamp: time(),
});
}
#[update]
fn transfer(to: Principal, qty: u64) {
let from = caller();
get_state()
.transfer(from, to, qty)
.expect("Transfer failed");
emit(TransferEvent {
from,
to,
amount: qty,
timestamp: time(),
});
}
#[update]
fn burn(qty: u64) {
let from = caller();
get_state().burn(from, qty).expect("Burning failed");
emit(BurnEvent {
from,
amount: qty,
timestamp: time(),
});
}
In each of these methods, we're just calling an associated function defined in the state and emit an associated event. There are three types of events:
// project/token/src/common/types.rs
#[derive(Event)]
pub struct TransferEvent {
#[topic]
pub from: Principal,
#[topic]
pub to: Principal,
pub amount: u64,
pub timestamp: u64,
}
#[derive(Event)]
pub struct MintEvent {
#[topic]
pub to: Principal,
pub amount: u64,
pub timestamp: u64,
}
#[derive(Event)]
pub struct BurnEvent {
#[topic]
pub from: Principal,
pub amount: u64,
pub timestamp: u64,
}
By the way, the controllers_guard()
function is defined this way:
// project/token/src/common/guards.rs
pub fn controller_guard() -> Result<(), String> {
if get_state().controllers.contains(&caller()) {
Ok(())
} else {
Err(String::from("The caller is not a controller"))
}
}
The only thing our token misses are some basic query
methods:
// project/token/src/actor.rs
#[query]
fn get_balance_of(account_owner: Principal) -> u64 {
get_state().balance_of(&account_owner)
}
#[query]
fn get_total_supply() -> u64 {
get_state().total_supply
}
#[query]
fn get_info() -> TokenInfo {
get_state().info.clone()
}
This is it. The complete code for our token canister should look like this:
// project/common/src/actor.rs
// ----------------- MAIN LOGIC ------------------
#[update(guard = "controller_guard")]
fn mint(to: Principal, qty: u64) {
get_state().mint(to, qty).expect("Minting failed");
emit(MintEvent {
to,
amount: qty,
timestamp: time(),
});
}
#[update]
fn transfer(to: Principal, qty: u64) {
let from = caller();
get_state()
.transfer(from, to, qty)
.expect("Transfer failed");
emit(TransferEvent {
from,
to,
amount: qty,
timestamp: time(),
});
}
#[update]
fn burn(qty: u64) {
let from = caller();
get_state().burn(from, qty).expect("Burning failed");
emit(BurnEvent {
from,
amount: qty,
timestamp: time(),
});
}
#[query]
fn get_balance_of(account_owner: Principal) -> u64 {
get_state().balance_of(&account_owner)
}
#[query]
fn get_total_supply() -> u64 {
get_state().total_supply
}
#[query]
fn get_info() -> TokenInfo {
get_state().info.clone()
}
// ------------------- EVENT HUB ------------------
implement_event_emitter!(10 * 1_000_000_000, 100 * 1024);
implement_subscribe!();
implement_unsubscribe!();
#[heartbeat]
pub fn tick() {
send_events();
}
// ------------------ STATE ----------------------
static mut STATE: Option<CurrencyToken> = None;
pub fn get_state() -> &'static mut CurrencyToken {
unsafe { STATE.as_mut().unwrap() }
}
#[init]
fn init(controller: Principal, info: TokenInfo) {
let token = CurrencyToken {
balances: HashMap::new(),
total_supply: 0,
info,
controllers: vec![controller],
};
unsafe {
STATE = Some(token);
}
}
Candid interface
// project/token/can.did
type TokenInfo = record {
name : text;
symbol : text;
decimals : nat8;
};
service : (principal, TokenInfo) -> {
"mint" : (principal, nat64) -> ();
"transfer" : (principal, nat64) -> ();
"burn" : (nat64) -> ();
"get_balance_of" : (principal) -> (nat64) query;
"get_total_supply" : () -> (nat64) query;
"get_info" : () -> (TokenInfo) query;
}
Let's move to ledgers now.
Ledger canisters
We're going to implement two ledgers. Their implementation would be almost the same, but the first one will listen for all possible events emitted by the token canister, while the second one will listen only for events related to the particular principal we're gonna provide it at the canister initialization phase.
Dependencies
# project/ledger-1/cargo.toml
[package]
name = "ledger-1"
version = "0.1.0"
edition = "2018"
[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"
[dependencies]
ic-cdk = "0.4.0"
ic-cdk-macros = "0.4.0"
candid = "0.7.12"
serde = "1.0"
ic-cron = "0.6.1"
ic-event-hub = "0.3.0"
ic-event-hub-macros = "0.3.0"
Buildscript
# project/ledger-1/build.sh
#!/usr/bin/env bash
SCRIPT=$(readlink -f "$0")
SCRIPTPATH=$(dirname "$SCRIPT")
cd "$SCRIPTPATH" || exit
cargo build --target wasm32-unknown-unknown --release --package ledger-1 && \
ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/ledger_1.wasm -o ./target/wasm32-unknown-unknown/release/ledger-1-opt.wasm
Ledger internal logic
Ledger is a pretty straightforward thing - it is just a log of historical records in a chronological order. So its state and internals are trivial:
// project/ledger-1/src/common/ledger.rs
#[derive(CandidType, Deserialize)]
pub struct Ledger {
pub token: Principal,
pub entries: Vec<Entry>,
}
impl Ledger {
pub fn add_entry(&mut self, entry: Entry) {
self.entries.push(entry);
}
pub fn get_entries(&self) -> Vec<Entry> {
self.entries.clone()
}
}
We have a principal of the token we're going to listen to and the log of historical entries each of which is defined like this:
// project/ledger-1/src/common/types.rs
#[derive(Clone, CandidType, Deserialize)]
pub enum Entry {
Mint(MintEvent),
Transfer(TransferEvent),
Burn(BurnEvent),
}
#[derive(Clone, Event, CandidType, Deserialize)]
pub struct TransferEvent {
#[topic]
pub from: Principal,
#[topic]
pub to: Principal,
pub amount: u64,
pub timestamp: u64,
}
#[derive(Clone, Event, CandidType, Deserialize)]
pub struct MintEvent {
#[topic]
pub to: Principal,
pub amount: u64,
pub timestamp: u64,
}
#[derive(Clone, Event, CandidType, Deserialize)]
pub struct BurnEvent {
#[topic]
pub from: Principal,
pub amount: u64,
pub timestamp: u64,
}
Notice, events are defined the same way they are in the token's codebase.
Actor definition
Let's define canister's state and init()
function:
// project/ledger-1/src/actor.rs
static mut STATE: Option<Ledger> = None;
pub fn get_state() -> &'static mut Ledger {
unsafe { STATE.as_mut().unwrap() }
}
implement_cron!();
#[init]
fn init(token_principal: Principal) {
let token = Ledger {
token: token_principal,
entries: Vec::new(),
};
unsafe {
STATE = Some(token);
}
cron_enqueue(
token_principal,
SchedulingOptions {
delay_nano: 0,
interval_nano: 0,
iterations: Exact(1),
},
)
.expect("Enqueue failed");
}
#[heartbeat]
pub fn tick() {
for task in cron_ready_tasks() {
let token = task
.get_payload::<Principal>()
.expect("Payload deserialization failed");
spawn(async move {
token
.subscribe(SubscribeRequest {
callbacks: vec![CallbackInfo {
filter: EventFilter::empty(),
method_name: String::from("events_callback"),
}],
})
.await
.expect("Subscribe failed");
});
}
}
As you might know, it is impossible to make calls to external canisters inside init()
function (here is a thread about this), but we can workaround this restriction, by using ic-cron
library.
All we have to do is to enqueue a cron task and then process it in the first ever heartbeat
of this canister. This is exactly what we do here. Processing the enqueued task we're calling the token canister in order to subscribe to all events it emits.
Let's now define an events_callback()
function that will be used by ic-event-hub
as a callback for event processing:
// project/ledger-1/src/actor.rs
#[update(guard = "token_guard")]
pub fn events_callback(events: Vec<Event>) {
for event in events {
match event.get_name().as_str() {
"MintEvent" => {
let mint_event = MintEvent::from_event(event);
get_state().add_entry(Entry::Mint(mint_event));
}
"TransferEvent" => {
let transfer_event = TransferEvent::from_event(event);
get_state().add_entry(Entry::Transfer(transfer_event));
}
"BurnEvent" => {
let burn_event = BurnEvent::from_event(event);
get_state().add_entry(Entry::Burn(burn_event));
}
_ => trap("Unknown event"),
}
}
}
fn token_guard() -> Result<(), String> {
if caller() != get_state().token {
Err(String::from("Can only be called by the token canister"))
} else {
Ok(())
}
}
This function simply wraps any received event into an enum we defined earlier and then stores them into the state. We're guarding this function in order to prevent malicious canisters (or users) from messing with the history.
Let's also define a query
function that will let us get all events stored in this ledger:
// project/ledger-1/src/actor.rs
#[query]
fn get_events() -> Vec<Entry> {
get_state().get_entries()
}
This is it. Ledger canister #1 is ready. Don't forget to define the .did
file:
type Entry = variant {
Mint : MintEvent;
Transfer : TransferEvent;
Burn : BurnEvent;
};
type MintEvent = record {
to : principal;
amount : nat64;
timestamp : nat64;
};
type TransferEvent = record {
from : principal;
to : principal;
amount : nat64;
timestamp : nat64;
};
type BurnEvent = record {
from : principal;
amount : nat64;
timestamp : nat64;
};
service : (principal) -> {
"get_events" : () -> (vec Entry) query;
}
Ledger #2
The second ledger is defined almost the same way as the previous one. It differs in a way the subscribe()
call is made, because we want it to only listen for events related to a particular principal:
// project/ledger-2/src/actor.rs
#[heartbeat]
pub fn tick() {
for _ in cron_ready_tasks() {
spawn(async move {
let state = get_state();
state
.token
.subscribe(SubscribeRequest {
callbacks: vec![
CallbackInfo {
filter: MintEventFilter {
to: Some(state.track),
}
.to_event_filter(),
method_name: String::from("mint_callback"),
},
CallbackInfo {
filter: TransferEventFilter {
from: Some(state.track),
to: None,
}
.to_event_filter(),
method_name: String::from("transfer_callback"),
},
CallbackInfo {
filter: TransferEventFilter {
from: None,
to: Some(state.track),
}
.to_event_filter(),
method_name: String::from("transfer_callback"),
},
CallbackInfo {
filter: BurnEventFilter {
from: Some(state.track),
}
.to_event_filter(),
method_name: String::from("burn_callback"),
},
],
})
.await
.expect("Subscribe failed");
});
}
}
Subscribing for each event type we set an event filter that defines the only account identifier we're interested in. This account identifier (principal) should somehow appear in this ledger, so we just pass it as an argument into init()
function and save it to the state:
// project/ledger-2/src/actor.rs
#[init]
fn init(token_principal: Principal, track_principal: Principal) {
let token = Ledger {
token: token_principal,
track: track_principal,
entries: Vec::new(),
};
unsafe {
STATE = Some(token);
}
cron_enqueue(
(),
SchedulingOptions {
delay_nano: 0,
interval_nano: 0,
iterations: Exact(1),
},
)
.expect("Enqueue failed");
}
And since we modify the signature of the init()
function, we're also gonna need to update the candid interface:
// project/ledger-2/can.did
...
service : (principal, principal) -> {
"get_events" : () -> (vec Entry) query;
}
Everything else stays the same.
Battle testing
Let's now try to deploy and use our token and see what will happen.
Start the development network in a separate terminal window:
$ dfx start --clean
Check your principal:
$ dfx identity get-principal
<your principal>
Deploy canisters:
$ dfx deploy token --argument '(principal "<your principal>", record { name = "Test"; symbol = "TST"; decimals = 8 : nat8 })'
$ dfx deploy ledger-1 --argument '(principal "<token canister id>")'
$ dfx deploy ledger-2 --argument '(principal "<token canister id>", principal "aaaaa-aa")'
We're using "aaaaa-aa" principal as an account identifier for ledger-2 to listen to as an example, but you can use any other principal here.
Mint some tokens to yourself:
$ dfx canister call token mint '(principal "<your principal>", 1000)'
()
Transfer some tokens to "aaaaa-aa":
$ dfx canister call token transfer '(principal "aaaaa-aa", 200)'
()
Burn the rest:
$ dfx canister call token burn '(800)'
()
Let's now check the first ledger's state:
$ dfx canister call ledger-1 get_events '()'
(
vec {
variant {
Mint = record {
to = principal "<your principal>";
timestamp = 1_647_816_085_333_883_997 : nat64;
amount = 1_000 : nat64;
}
};
variant {
Transfer = record {
to = principal "aaaaa-aa";
from = principal "<your principal>";
timestamp = 1_647_816_164_528_431_504 : nat64;
amount = 200 : nat64;
}
};
variant {
Burn = record {
from = principal "<your principal>";
timestamp = 1_647_816_211_161_122_783 : nat64;
amount = 800 : nat64;
}
};
},
)
All three transactions are there, good. Now let's check the second ledger:
$ dfx canister call ledger-2 get_events '()'
(
vec {
variant {
Transfer = record {
to = principal "aaaaa-aa";
from = principal "<your principal>";
timestamp = 1_647_816_164_528_431_504 : nat64;
amount = 200 : nat64;
}
};
},
)
As was expected, it only contains a single transaction that is related to "aaaaa-aa" account id.
Looks like everything works fine. Woohoo!
The complete source code for this tutorial can be found here:
Thanks for reading!
Posted on March 28, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.