DEV Community

Alexander
Alexander

Posted on • Updated on

Tutorial: How To Build A Token With Recurrent Payments On The Internet Computer Using ic-cron Library

This tutorial is dedicated to canister development on the Internet Computer (Dfinity) platform. Completing it you:

  1. Would know some of advanced canister (smart-contract) development techniques on the Internet Computer using Rust programming language.
  2. Would build your own token canister.
  3. 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:

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 named actor.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;
Enter fullscreen mode Exit fullscreen mode

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 Principals 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(())
    }

    ...

}
Enter fullscreen mode Exit fullscreen mode

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,
} 
Enter fullscreen mode Exit fullscreen mode

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(())
    }

    ...

}
Enter fullscreen mode Exit fullscreen mode

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(())
    }

    ...

}
Enter fullscreen mode Exit fullscreen mode

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,
        }
    }

    ...

} 
Enter fullscreen mode Exit fullscreen mode

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()
    }

    ...

}
Enter fullscreen mode Exit fullscreen mode

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()
    }

    ...

}
Enter fullscreen mode Exit fullscreen mode

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)
    }

    ...

}
Enter fullscreen mode Exit fullscreen mode

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();
    } 

    ...

}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice, that the update macro, annotating the function, also contains a guard function named controller_guard(), that will automatically check if the caller is a controller of the token, and will proceed to mint() 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 the src/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),
}
Enter fullscreen mode Exit fullscreen mode

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 the implement_cron!() macros. So make sure you put it somewhere in your actor.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,
}
Enter fullscreen mode Exit fullscreen mode

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

Here and in the previous function we pass the caller() as from 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()
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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,
} 
Enter fullscreen mode Exit fullscreen mode

Besides a task identifier this type also contains this information:

  • to and qty - 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()
} 
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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);
                    }
                };
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then in a new console window:

$ dfx deploy --argument '(principal "<your-principal>", record { name = "Test token"; symbol = "TST"; decimals = 2 : nat8 })'
...
Deployed canisters
Enter fullscreen mode Exit fullscreen mode

Instead of <your-principal> one needs to paste their own Principal which can be obtained with dfx 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 )'
()
Enter fullscreen mode Exit fullscreen mode

Checking the balance:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(100_000 : nat64)
Enter fullscreen mode Exit fullscreen mode

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 } } )'
()
Enter fullscreen mode Exit fullscreen mode

Balance check:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(102_000 : nat64)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 } } )'
()
Enter fullscreen mode Exit fullscreen mode

Warning! Notice that we transfer tokens to the aaaaa-aa account. This is the Principal 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)
Enter fullscreen mode Exit fullscreen mode

Checking again after some time:

$ dfx canister call ic-cron-recurrent-payments-example get_balance_of '(principal "<your-principal>")'
(104_600 : nat64)
Enter fullscreen mode Exit fullscreen mode

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;
      };
    };
  },
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Checking recurrent transfer tasks list again:

$ dfx canister call ic-cron-recurrent-payments-example get_my_recurrent_transfer_tasks '()'
(vec {})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

Complete source code of this tutorial is here:

https://github.com/seniorjoinu/ic-cron-recurrent-payments-example

Thanks for reading!

Top comments (0)