Tutorial: How To Build A Token With Recurrent Payments On The Internet Computer Using ic-cron Library
Alexander
Posted on March 1, 2022
This tutorial is dedicated to canister development on the Internet Computer (Dfinity) platform. Completing it you:
- Would know some of advanced canister (smart-contract) development techniques on the Internet Computer using Rust programming language.
- Would build your own token canister.
- Would use ic-cron library in order to add recurrent payment mechanics to that token canister.
This tutorial is intended for advanced developers who already understand the basics of developing Rust canisters on the IC.
Before digging into it, it is recommended to recover the basics once again. Some good starting points are: the official website, dedicated to canister development on the IC; IC developer forum, where you can find an answer to almost any technical question.
Motivation
There are a lot of token standards out there. One of the most popular token standard on Ethereum is the famous ERC20. It was so successful, that many other people base their token standards on top of ERC20 (including other networks, like DIP20). It would be fair to say, that ERC20-like tokens are the most common in the wild on any network.
Despite this standard being so good and easy-to-use, it is pretty trivial in terms of functionality. Tokens are supposed to replace money, but there are some things you could do with money, but can’t do with tokens. For example, using web2 banking (classic money) you can easily “subscribe” to some service, making automatic periodical payments to it. This pattern is called “recurrent payments” and there is no such thing in web3. Yet.
In this tutorial, I’ll show you how to extend your token in order to add recurrent payment functionality to it using ic-cron library.
The complete source code for this tutorial can be found here:
https://github.com/seniorjoinu/ic-cron-recurrent-payments-example
Let’s go.
Project initialization
In order to proceed, make sure you have these tools installed on your machine:
- dfx 0.9.0
-
rust 1.54+ and
wasm32-unknown-unknown
toolchain - ic-cdk-optimizer
You can use any file system layout for this project, but I will use this one:
- src
- actor.rs // canister API description
- common
- mod.rs
- currency_token.rs // token internals
- guards.rs // guard canister functions
- types.rs // related types
- cargo.toml
- dfx.json
- build.sh // canister buildscript
- can.did // canister candid interface
First of all, we have to set up dependencies for the project, using the cargo.toml
file:
// cargo.toml
[package]
name = "ic-cron-recurrent-payments-example"
version = "0.1.0"
edition = "2018"
[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"
[dependencies]
ic-cdk = "0.3.3"
ic-cdk-macros = "0.3.3"
serde = "1.0"
ic-cron = "0.5.1"
Then we need to put this script inside the build.sh
file that will build and optimize the wasm-module for us:
# build.sh
#!/usr/bin/env bash
cargo build --target wasm32-unknown-unknown --release --package ic-cron-recurrent-payments-example && \
ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/ic_cron_recurrent_payments_example.wasm -o ./target/wasm32-unknown-unknown/release/ic-cron-recurrent-payments-example-opt.wasm
And after that, we would fill the dfx.json
file in order to describe the canister we are going to build:
// dfx.json
{
"canisters": {
"ic-cron-recurrent-payments-example": {
"build": "./build.sh",
"candid": "./can.did",
"wasm": "./target/wasm32-unknown-unknown/release/ic-cron-recurrent-payments-example-opt.wasm",
"type": "custom"
}
},
"defaults": {
"build": {
"packtool": ""
}
},
"dfx": "0.9.0",
"networks": {
"local": {
"bind": "127.0.0.1:8000",
"type": "ephemeral"
}
},
"version": 1
}
Token internals
To better understand recurrent payments logic let’s make other parts of the token as simple as possible. Using that token canister users should be able to:
- mint new tokens;
- transfer tokens to a different account;
- burn tokens;
- check the balance of any account;
- check the total supply of tokens;
- check the token’s info (name, ticker/symbol, decimals).
Also we would add these new functionalities:
- recurrent token minting;
- recurrent token transferring;
- an ability to check current user’s recurrent tasks and to cancel them.
Basic token functions
It is important to say, that we’re going to define these basic functions (token state and some functions to modify this state) without using any of Internet Computer APIs. This would allow us to test this code using only the Rust’s default testing framework.
We will add Internet Computer API calls (e.g.
caller()
function) later in the file namedactor.rs
.
Token state
First of all, let’s describe a data type for all the data that would be stored in the canister:
// src/common/currency_token.rs
pub struct CurrencyToken {
pub balances: HashMap<Principal, u64>,
pub total_supply: u64,
pub info: TokenInfo,
pub controllers: Controllers,
pub recurrent_mint_tasks: HashSet<TaskId>,
pub recurrent_transfer_tasks: HashMap<Principal, HashSet<TaskId>>,
}
// src/common/types.rs
#[derive(Clone, CandidType, Deserialize)]
pub struct TokenInfo {
pub name: String,
pub symbol: String,
pub decimals: u8,
}
pub type Controllers = Vec<Principal>;
pub type TaskId = u64;
For token balances storage for each account we will use the balances
property. Notice that it is a hashmap, the key of which is a Principal
of the user.
Inside the total_supply
property we’ll store total amount of tokens in circulation (total minted tokens minus total burned tokens).
Inside the info
property we will store some basic information about our token - TokenInfo
, which is a name, a ticker/symbol and an amount of decimals.
Inside the controllers
property we’ll store a list of Principal
s of token administrators, which are users who can mint new tokens.
Also, in order to index ic-cron’s background tasks for efficient access, we would need a couple of additional properties: recurrent_mint_tasks
and recurrent_transfer_tasks
. We need these new indexes because ic-cron’s task scheduler stores all of its background tasks in a single collection that is optimized for quick scheduling. There are no other indexes there, so we would need to add these new ones.
Token minting
Let’s define a method to mint new tokens:
// 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(())
}
...
}
This method takes as arguments an account identifier to mint tokens to and an amount of tokens that should be minted. Inside this method we just increment users current balance by the given amount and then update the total supply value.
The method returns ()
, if the operation was successful, or returns Error
, if the value of qty
argument equals to 0
. Error
type is defined the following way:
// src/common/types.rs
#[derive(Debug)]
pub enum Error {
InsufficientBalance,
ZeroQuantity,
AccessDenied,
ForbiddenOperation,
}
Token burning
The method to burn tokens works in exactly the opposite way, decrementing an amount of tokens from the account’s balance and updating the total supply counter:
// src/common/currency_token.rs
impl CurrencyToken {
...
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(())
}
...
}
This method takes an account identifier and an amount of tokens to burn as arguments. It returns ()
, if the operation went good, or an Error
, if the value of qty
argument is 0
or bigger than the account balance.
Notice that we’re using a specific pattern to write code: 1) check for arguments validity; 2) return any possible error; 3) change the state of the token.
It is always useful to follow this pattern in order to prevent future errors, including bugs which introduce a re-entrancy attack vulnerability.
Token transferring
Now let’s define a function to transfer tokens between accounts. This function will reduce an amount of tokens from one account and add them to another:
// src/common/currency_token.rs
impl CurrencyToken {
...
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(())
}
...
}
This method takes account identifiers and an amount of tokens to transfer as arguments. In case of success it returns ()
, in case of the amount of tokens to transfer is 0
or exceeds current sender’s balance, this method returns an Error
.
Getting balance
The balance getting function is pretty simple so we won’t discuss it in details:
// src/common/currency_token.rs
impl CurrencyToken {
...
pub fn balance_of(&self, account_owner: &Principal) -> u64 {
match self.balances.get(account_owner) {
None => 0,
Some(b) => *b,
}
}
...
}
Recurrent tasks management
As was previously mentioned, we have to maintain our own background task indexes in order to access them efficiently. Later you’ll see why, but now it is okay to have troubles with understanding of this move. It would be easier to start with recurrent mint tasks first:
// src/common/currency_token.rs
impl CurrencyToken {
...
pub fn register_recurrent_mint_task(&mut self, task_id: TaskId) {
self.recurrent_mint_tasks.insert(task_id);
}
pub fn unregister_recurrent_mint_task(&mut self, task_id: TaskId) -> bool {
self.recurrent_mint_tasks.remove(&task_id)
}
pub fn get_recurrent_mint_tasks(&self) -> Vec<TaskId> {
self.recurrent_mint_tasks.iter().cloned().collect()
}
...
}
So, all we want is to maintain a list of all recurrent minting tasks by their ids and to be able to return that list any time we need it.
It is a little bit harder with recurrent transfer tasks, because we need to maintain such a list of tasks for each account separately. To achieve that, we’ll use a mapping where to each account we map its own list of recurrent transfer tasks:
// src/common/currency_token.rs
impl CurrencyToken {
...
pub fn register_recurrent_transfer_task(&mut self, from: Principal, task_id: TaskId) {
match self.recurrent_transfer_tasks.entry(from) {
Entry::Occupied(mut entry) => {
entry.get_mut().insert(task_id);
}
Entry::Vacant(entry) => {
let mut s = HashSet::new();
s.insert(task_id);
entry.insert(s);
}
};
}
pub fn unregister_recurrent_transfer_task(&mut self, from: Principal, task_id: TaskId) -> bool {
match self.recurrent_transfer_tasks.get_mut(&from) {
Some(tasks) => tasks.remove(&task_id),
None => false,
}
}
pub fn get_recurrent_transfer_tasks(&self, from: Principal) -> Vec<TaskId> {
self.recurrent_transfer_tasks
.get(&from)
.map(|t| t.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default()
}
...
}
Basic functionality testing
This is all we had to do to implement internal token functions. Let’s now use Rust’s standard testing framework to check if we did everything right.
First of all, for easy testing we would need a couple of utility functions:
// src/common/currency_token.rs
#[cfg(test)]
mod tests {
...
pub fn random_principal_test() -> Principal {
Principal::from_slice(
&SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
.to_be_bytes(),
)
}
fn create_currency_token() -> (CurrencyToken, Principal) {
let controller = random_principal_test();
let token = CurrencyToken {
balances: HashMap::new(),
total_supply: 0,
info: TokenInfo {
name: String::from("test"),
symbol: String::from("TST"),
decimals: 8,
},
controllers: vec![controller],
recurrent_mint_tasks: HashSet::new(),
recurrent_transfer_tasks: HashMap::new(),
};
(token, controller)
}
...
}
random_principal_test()
function creates a unique account Principal
identifier using current system time as a seed. create_currency_token()
function creates a default token object filled with test data.
// src/common/currency_token.rs
#[cfg(test)]
mod tests {
...
#[test]
fn minting_works_right() {
let (mut token, controller) = create_currency_token();
let user_1 = random_principal_test();
token.mint(user_1, 100).ok().unwrap();
assert_eq!(token.total_supply, 100);
assert_eq!(token.balances.len(), 1);
assert_eq!(token.balances.get(&user_1).unwrap().clone(), 100);
token.mint(controller, 200).ok().unwrap();
assert_eq!(token.total_supply, 300);
assert_eq!(token.balances.len(), 2);
assert_eq!(token.balances.get(&user_1).unwrap().clone(), 100);
assert_eq!(token.balances.get(&controller).unwrap().clone(), 200);
}
#[test]
fn burning_works_fine() {
let (mut token, _) = create_currency_token();
let user_1 = random_principal_test();
token.mint(user_1, 100).ok().unwrap();
token.burn(user_1, 90).ok().unwrap();
assert_eq!(token.balances.len(), 1);
assert_eq!(token.balances.get(&user_1).unwrap().clone(), 10);
assert_eq!(token.total_supply, 10);
token.burn(user_1, 20).err().unwrap();
token.burn(user_1, 10).ok().unwrap();
assert!(token.balances.is_empty());
assert!(token.balances.get(&user_1).is_none());
assert_eq!(token.total_supply, 0);
token.burn(user_1, 20).err().unwrap();
}
#[test]
fn transfer_works_fine() {
let (mut token, controller) = create_currency_token();
let user_1 = random_principal_test();
let user_2 = random_principal_test();
token.mint(user_1, 1000).ok().unwrap();
token.transfer(user_1, user_2, 100).ok().unwrap();
assert_eq!(token.balances.len(), 2);
assert_eq!(token.balances.get(&user_1).unwrap().clone(), 900);
assert_eq!(token.balances.get(&user_2).unwrap().clone(), 100);
assert_eq!(token.total_supply, 1000);
token.transfer(user_1, user_2, 1000).err().unwrap();
token.transfer(controller, user_2, 100).err().unwrap();
token.transfer(user_2, user_1, 100).ok().unwrap();
assert_eq!(token.balances.len(), 1);
assert_eq!(token.balances.get(&user_1).unwrap().clone(), 1000);
assert!(token.balances.get(&user_2).is_none());
assert_eq!(token.total_supply, 1000);
token.transfer(user_2, user_1, 1).err().unwrap();
token.transfer(user_2, user_1, 0).err().unwrap();
}
...
}
We won’t stop on these test cases for too long. In each of them there is a series of checks which make sure that the state stays in a way it should stay no matter what function do we call. But tests are very important, because they give us confidence in our code. Never skip tests.
Token canister API
We’re almost at the finish line. All that’s left to do is:
- to add a canister state initializing function;
- to add token management functions using internal functions we wrote earlier;
- to add recurrent mechanics into these token management functions.
State initialization
So, we need a function that will fill the canister state with some default values at the moment of canister creation:
// src/actor.rs
static mut STATE: Option<CurrencyToken> = None;
pub fn get_token() -> &'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],
recurrent_mint_tasks: HashSet::new(),
recurrent_transfer_tasks: HashMap::new(),
};
unsafe {
STATE = Some(token);
}
}
init()
function takes as an argument a Principal
of the controller - admin user that will be able to mint new tokens. And it takes as an argument an information about the token TokenInfo
. Also here we have an utility function named get_token()
, that returns a safe reference to the token’s state.
Token management
Let’s start with token minting:
// src/actor.rs
#[update(guard = "controller_guard")]
fn mint(to: Principal, qty: u64, scheduling_interval: Option<SchedulingInterval>) {
match scheduling_interval {
Some(interval) => {
let task_id = cron_enqueue(
CronTaskKind::RecurrentMint(RecurrentMintTask { to, qty }),
interval,
)
.expect("Mint scheduling failed");
get_token().register_recurrent_mint_task(task_id);
}
None => {
get_token().mint(to, qty).expect("Minting failed");
}
}
}
Notice, that the
update
macro, annotating the function, also contains aguard
function namedcontroller_guard()
, that will automatically check if the caller is a controller of the token, and will proceed tomint()
function only if it’s so. Otherwise the user will be responded with an error.This
guard
function is pretty simple and you can check it out in the Github repo in thesrc/common/guards.rs
file, if you want to.
This function is able to perform recurrent mints as well as common ones. To make this happen we need to add an additional argument to this function to let a user pass some background task parameters to the function - scheduling_interval
. SchedulingInterval
type is a part of the ic-cron library and is defined the following way:
#[derive(Clone, Copy, CandidType, Deserialize)]
pub struct SchedulingInterval {
pub delay_nano: u64,
pub interval_nano: u64,
pub iterations: Iterations,
}
#[derive(Clone, Copy, CandidType, Deserialize)]
pub enum Iterations {
Infinite,
Exact(u64),
}
This argument lets a user to define recurrent minting time settings and repetitions count.
If the value of the scheduling_interval
argument is None
, then the function will perform the common minting procedure calling CurrencyToken::mint()
method of the state, otherwise it will schedule a new background task for a task scheduler using cron_enqueue()
function, after which it will register this new background task in our recurrent minting tasks index.
cron_enqueue()
function, as well as any other functionality from the ic-cron library, is only available after you use theimplement_cron!()
macros. So make sure you put it somewhere in youractor.rs
file.
Recurrent minting tasks which we pass into the cron_enqueue()
function, are defined the following way:
// src/common/types.rs
#[derive(CandidType, Deserialize, Debug)]
pub struct RecurrentMintTask {
pub to: Principal,
pub qty: u64,
}
Token transfer function is defined in a similar fashion:
// src/actor.rs
#[update]
fn transfer(to: Principal, qty: u64, scheduling_interval: Option<SchedulingInterval>) {
let from = caller();
match scheduling_interval {
Some(interval) => {
let task_id = cron_enqueue(
CronTaskKind::RecurrentTransfer(RecurrentTransferTask { from, to, qty }),
interval,
)
.expect("Transfer scheduling failed");
get_token().register_recurrent_transfer_task(from, task_id);
}
None => {
get_token()
.transfer(from, to, qty)
.expect("Transfer failed");
}
}
}
If the scheduling_interval
argument is None
, then this function performs a common token transfer calling CurrencyToken::transfer()
method of the state. But if this argument has any payload inside, than the function schedules a recurrent transfer task to the task scheduler and saves this task into the index.
Recurrent transfer tasks are defined this way:
#[derive(CandidType, Deserialize, Debug)]
pub struct RecurrentTransferTask {
pub from: Principal,
pub to: Principal,
pub qty: u64,
}
This data is everything our canister needs in order to call CurrencyToken::transfer()
method later.
In order to differentiate between background task types we’ll use this enum
:
#[derive(CandidType, Deserialize, Debug)]
pub enum CronTaskKind {
RecurrentTransfer(RecurrentTransferTask),
RecurrentMint(RecurrentMintTask),
}
Don’t worry if you still feel confused by now - we’re almost at the point where it all will make sense.
Token burning function is much more simpler, since there is no recurrent tasks in there:
// src/actor.rs
#[update]
fn burn(qty: u64) {
get_token().burn(caller(), qty).expect("Burning failed");
}
Here and in the previous function we pass the
caller()
asfrom
argument, because these actions can only be performed by the account owner.Data getters are also pretty simple:
// src/actor.rs
#[query]
fn get_balance_of(account_owner: Principal) -> u64 {
get_token().balance_of(&account_owner)
}
#[query]
fn get_total_supply() -> u64 {
get_token().total_supply
}
#[query]
fn get_info() -> TokenInfo {
get_token().info.clone()
}
Recurrent tasks
Now to the most interesting part of this tutorial. Inside mint()
and transfer()
functions we scheduled some tasks for the task scheduler. We want users of the token to be able to list their active tasks and to be able to cancel them.
Recurrent mint tasks getter and cancel functions are defined this way:
// src/actor.rs
#[update]
pub fn cancel_recurrent_mint_task(task_id: TaskId) -> bool {
cron_dequeue(task_id).expect("Task id not found");
get_token().unregister_recurrent_mint_task(task_id)
}
#[query(guard = "controller_guard")]
pub fn get_recurrent_mint_tasks() -> Vec<RecurrentMintTaskExt> {
get_token()
.get_recurrent_mint_tasks()
.into_iter()
.map(|task_id| {
let task = get_cron_state().get_task_by_id(&task_id).unwrap();
let kind: CronTaskKind = task
.get_payload()
.expect("Unable to decode a recurrent mint task");
match kind {
CronTaskKind::RecurrentTransfer(_) => trap("Invalid task kind"),
CronTaskKind::RecurrentMint(mint_task) => RecurrentMintTaskExt {
task_id: task.id,
to: mint_task.to,
qty: mint_task.qty,
scheduled_at: task.scheduled_at,
rescheduled_at: task.rescheduled_at,
scheduling_interval: task.scheduling_interval,
},
}
})
.collect()
}
Task canceling is simple: we just remove the task from the task scheduler using cron_dequeue()
function and then remove this same task from the index. If everything went good the caller will see a true
response.
It is a little bit harder with recurrent tasks listing though. The CurrencyToken::get_recurrent_mint_tasks()
function only returns task identifiers instead of task data (which is much more useful for the end user). To change that we need to define a new task type that will contain all the task data we want to show to the user:
// src/common/types.rs
#[derive(CandidType, Deserialize)]
pub struct RecurrentMintTaskExt {
pub task_id: TaskId,
pub to: Principal,
pub qty: u64,
pub scheduled_at: u64,
pub rescheduled_at: Option<u64>,
pub scheduling_interval: SchedulingInterval,
}
Besides a task identifier this type also contains this information:
-
to
andqty
- target account and an amount of tokens to mint; -
scheduled_at
- the task’s creation timestamp; -
rescheduled_at
- last minting timestamp; -
scheduling_interval
- background task time parameters provided by the user at the moment of the task creation.
So, we just map the list of TaskId
into this new type using Iterator::map()
.
The same exact things we need to do in order to make recurrent transfers to work:
// src/actor.rs
#[update]
pub fn cancel_my_recurrent_transfer_task(task_id: TaskId) -> bool {
cron_dequeue(task_id).expect("Task id not found");
get_token().unregister_recurrent_transfer_task(caller(), task_id)
}
#[query]
pub fn get_my_recurrent_transfer_tasks() -> Vec<RecurrentTransferTaskExt> {
get_token()
.get_recurrent_transfer_tasks(caller())
.into_iter()
.map(|task_id| {
let task = get_cron_state().get_task_by_id(&task_id).unwrap();
let kind: CronTaskKind = task
.get_payload()
.expect("Unable to decode a recurrent transfer task");
match kind {
CronTaskKind::RecurrentMint(_) => trap("Invalid task kind"),
CronTaskKind::RecurrentTransfer(transfer_task) => RecurrentTransferTaskExt {
task_id: task.id,
from: transfer_task.from,
to: transfer_task.to,
qty: transfer_task.qty,
scheduled_at: task.scheduled_at,
rescheduled_at: task.rescheduled_at,
scheduling_interval: task.scheduling_interval,
},
}
})
.collect()
}
For these background tasks we use the following type:
// src/common/types.rs
#[derive(CandidType, Deserialize)]
pub struct RecurrentTransferTaskExt {
pub task_id: TaskId,
pub from: Principal,
pub to: Principal,
pub qty: u64,
pub scheduled_at: u64,
pub rescheduled_at: Option<u64>,
pub scheduling_interval: SchedulingInterval,
}
It differs from the RecurrentMintTaskExt
only with one extra field - from
, that defines the sender’s account.
Finally. This is the moment where everything should come together and start to make sense.
Now we need to describe how exactly we’ll execute these recurrent tasks. This should be done inside the special system function annotated with the heartbeat
function:
// src/actor.rs
#[heartbeat]
pub fn tick() {
let token = get_token();
for task in cron_ready_tasks() {
let kind: CronTaskKind = task.get_payload().expect("Unable to decode task payload");
match kind {
CronTaskKind::RecurrentMint(mint_task) => {
token
.mint(mint_task.to, mint_task.qty)
.expect("Unable to perform scheduled mint");
if let Iterations::Exact(n) = task.scheduling_interval.iterations {
if n == 1 {
token.unregister_recurrent_mint_task(task.id);
}
};
}
CronTaskKind::RecurrentTransfer(transfer_task) => {
token
.transfer(transfer_task.from, transfer_task.to, transfer_task.qty)
.expect("Unable to perform scheduled transfer");
if let Iterations::Exact(n) = task.scheduling_interval.iterations {
if n == 1 {
token.unregister_recurrent_transfer_task(transfer_task.from, task.id);
}
};
}
}
}
}
In this function we iterate through the list of all tasks ready to be executed right now (supplied by the cron_ready_tasks()
function). For each of these tasks we figure out its kind denoted by CronTaskKind
enum, and then depending on that task kind we execute one of two following methods: CurrencyToken::mint()
or CurrencyToken::transfer()
.
Notice, that inside this function we also need to check how many executions is left for the task. If there is only one execution left, we have to manually remove it from our recurrent task indexes.
Unfortunately, ic-cron library doesn’t support callback execution on certain events like the end of a task execution. This is why we have to manually watch for this event to happen and react to it in an imperative manner.
Candid interface description
This is it! We finished the coding part. All we left to do is to describe the interface of our canister inside the .did
file, in order to be able to communicate with it from the console:
// can.did
type TaskId = nat64;
type Iterations = variant {
Infinite;
Exact : nat64;
};
type SchedulingInterval = record {
delay_nano : nat64;
interval_nano : nat64;
iterations : Iterations;
};
type RecurrentTransferTaskExt = record {
task_id : TaskId;
from : principal;
to : principal;
qty : nat64;
scheduled_at : nat64;
rescheduled_at : opt nat64;
scheduling_interval : SchedulingInterval;
};
type RecurrentMintTaskExt = record {
task_id : TaskId;
to : principal;
qty : nat64;
scheduled_at : nat64;
rescheduled_at : opt nat64;
scheduling_interval : SchedulingInterval;
};
type TokenInfo = record {
name : text;
symbol : text;
decimals : nat8;
};
service : (principal, TokenInfo) -> {
"mint" : (principal, nat64, opt SchedulingInterval) -> ();
"transfer" : (principal, nat64, opt SchedulingInterval) -> ();
"burn" : (nat64) -> ();
"get_balance_of" : (principal) -> (nat64) query;
"get_total_supply" : () -> (nat64) query;
"get_info" : () -> (TokenInfo) query;
"cancel_recurrent_mint_task" : (TaskId) -> (bool);
"get_recurrent_mint_tasks" : () -> (vec RecurrentMintTaskExt) query;
"cancel_my_recurrent_transfer_task" : (TaskId) -> (bool);
"get_my_recurrent_transfer_tasks" : () -> (vec RecurrentTransferTaskExt) query;
}
Interacting with the token
Now let’s finally use the console to check whether it works or not. Let’s start a development environment in order to deploy our canister to it:
$ dfx start --clean
Then in a new console window:
$ dfx deploy --argument '(principal "<your-principal>", record { name = "Test token"; symbol = "TST"; decimals = 2 : nat8 })'
...
Deployed canisters
Instead of
<your-principal>
one needs to paste their ownPrincipal
which can be obtained withdfx identity get-principal
command.
Okay, let’s mint us some tokens:
$ dfx canister call ic-cron-recurrent-payments-example mint '(principal "<your-principal>", 100000 : nat64, null )'
()
Checking the balance:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(100_000 : nat64)
Nice, common token minting procedure works as expected. Let’s try the recurrent one then:
$ dfx canister call ic-cron-recurrent-payments-example mint '(principal "<your-principal>", 10_00 : nat64, opt record { delay_nano = 0 : nat64; interval_nano = 10_000_000_000 : nat64; iterations = variant { Exact = 5 : nat64 } } )'
()
Balance check:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(102_000 : nat64)
Another balance check after a minute or so:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(105_000 : nat64)
The balance won’t grow anymore, because we set the iterations count to 5
, which means that we received 5000
tokens total in 5 portions of 1000 tokens each in intervals of ~10 seconds. It took us only ~40 seconds, because the first portion was received immediately after we created the task (delay_nano
was 0
).
So, it looks like recurrent token minting works fine. Let’s check recurrent token transferring now:
$ dfx canister call ic-cron-recurrent-payments-example transfer '(principal "aaaaa-aa", 1_00 : nat64, opt record { delay_nano = 0 : nat64; interval_nano = 10_000_000_000 : nat64; iterations = vari
ant { Infinite } } )'
()
Warning! Notice that we transfer tokens to the
aaaaa-aa
account. This is thePrincipal
of so called management canister. Don’t ever transfer real money to that account - you’ll never get them back.
Let’s check our balance:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_900 : nat64)
Checking again after some time:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_600 : nat64)
Looks like it worked. We’re sure being charged by 100
tokens each 10 seconds, as we wanted. Let’s see our recurrent transfer tasks list:
$ dfx canister call ic-cron-recurrent-payments-example get_my_recurrent_transfer_tasks '()'
(
vec {
record {
to = principal "aaaaa-aa";
qty = 100 : nat64;
task_id = 1 : nat64;
from = principal "<your-principal>";
scheduled_at = 1_645_660_528_445_056_278 : nat64;
rescheduled_at = opt (1_645_660_528_445_056_278 : nat64);
scheduling_interval = record {
interval_nano = 10_000_000_000 : nat64;
iterations = variant { Infinite };
delay_nano = 0 : nat64;
};
};
},
)
As we can see from the response currently we only have a single recurrent transfer task doing its job. Let’s cancel it:
$ dfx canister call ic-cron-recurrent-payments-example cancel_my_recurrent_transfer_task '(1 : nat64)'
(true)
Checking recurrent transfer tasks list again:
$ dfx canister call ic-cron-recurrent-payments-example get_my_recurrent_transfer_tasks '()'
(vec {})
Good, the task was removed from the list. Let’s check we’re no longer charged:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_100 : nat64)
And after some time it stills the same:
$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_100 : nat64)
Woohoo! It works!
Afterword
As you could see, it is possible to create some really cool things with the Internet Computer. Some things that don’t exist anywhere else yet, like we’re some kind of artists or so. Recurrent payments were not possible before the IC and ic-cron. There is no other web3 platform with such a feature so easy to use and to build on top of.
The Internet Computer is the new Internet. And this tutorial is just another proof of that statement.
My other tutorials on ic-cron:
- https://hackernoon.com/how-to-execute-background-tasks-on-particular-weekdays-with-ic-cron-and-chrono
- https://hackernoon.com/tutorial-extending-sonic-with-limit-orders-using-ic-cron-library
Complete source code of this tutorial is here:
https://github.com/seniorjoinu/ic-cron-recurrent-payments-example
Thanks for reading!
Posted on March 1, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
March 1, 2022