Tutorial: Extending Sonic With Limit Orders Using ic-cron Library

Written by seniorjoinu | Published 2022/02/04
Tech Story Tags: dfinity | internet-computer-protocol | sonic | crypto-exchange | crypto-trading-bot | ic-cron | smart-contracts | rust

TLDRThis tutorial attempts to give all the information in a beginner-friendly manner. It is intended for advanced blockchain-developers, who already understand all the basics about Rust canister development for the Internet Computer. This tutorial is dedicated to the [Internet Computer (Dfinity) platform. Finishing this tutorial you: Will know some of advanced canister (smart-contract) development techniques with Rust programming language. Will learn how to integrate canisters with each other using their public APIs. Will try using [ic-cron] library to execute any background canister code.via the TL;DR App

This tutorial is dedicated to Internet Computer (Dfinity) platform. Finishing this tutorial you:

  • Will know some of advanced canister (smart-contract) development techniques with Rust programming language.
  • Will learn how to integrate canisters with each other using their public APIs.
  • Will try using ic-cron library to execute any background canister code.

This tutorial attempts to give all the information in a beginner-friendly manner, however it is intended for advanced blockchain-developers, who already understand all the basics about Rust canister development for the Internet Computer.

Before we begin, it is recommended to go through these basics again. Here are some good starting points: official website dedicated to canister development on the IC; developer forum, where you can find an answer to almost any question.

Motivation

Sonic is an exchange (”swap”), deployed on the Internet Computer platform, based on “automated market-maker” (AMM) model. You might heard this term before alongside with words such as “Uniswap” and “DeFi”. In fact, any “swap” is a decentralized token exchange, where anyone could easily exchange cryptocurrencies. You just connect your token wallet, select a pair of tokens to exchange, put an amount and swap by the current exchange rate.

However, this UX simplicity is achieved with a big “cheat code”, which pretty much limits users of the exchange - they are no longer able to define an exchange rate they would like to sell/buy with. Instead users only have two interaction scenarios left:

  • To exchange one token for another with the current exchange rate.

  • To become a liquidity provider and make some money collecting fees.

Current exchange rate is decided automatically, with an algorithm. For example, Sonic (just like Uniswap), uses the constant product formula for that. This means that basically all the liquidity locked in any token pair defines an exchange rate for that token pair.

So, there is no order book inside these “swaps”, and no orders either. For the majority of traders this is a big problem, because orders (in particular, limit orders) are very important automation tools without which there is no way to build any complex trading strategy.

In this tutorial we’re attempting to resolve this issue by adding limit orders to Sonic. Of course we can’t just rewrite Sonic canister code any way we like and call it done (I mean, we could, but this won’t be Sonic anymore; this will be another service for which we would have to attract users and do all the other start-up work - this is not our way). But we could add this new functionality for Sonic from the outside!

Such an integration approach, when we extend a service from the outside and allow other developers to extend our extension permissionlessly, is called “open internet services paradigm”.

We could develop our own canister, which will contain all the logic it needs to manage our tokens for us, automagically. In other words, we will develop a personal trading bot that will execute limit orders for us - it will wait until an exchange rate reaches some condition and then the bot would execute the swap procedure. To accomplish this task we would use ic-cron library that would let us easily declare any background computations.

This is the complete source code for this tutorial for those who want to jump in right away:

https://github.com/seniorjoinu/ic-cron-sonic-bot-example

Project initialization

Here are the tools you would need in order to finish this tutorial:

You can use any file system layout you want, but here’s mine:

- src
  - actor.rs       // describes the main logic of the bot
  - clients        // contains API clients for easy integrations with external canisters
     - mod.rs      
     - dip20.rs    
     - sonic.rs
		 - xtc.rs
  - common         // utility codebase
     - mod.rs
     - guards.rs
     - types.rs
- build.sh         // canister buildscript
- can.did          // canister candid interface
- cargo.toml
- dfx.json      

First of all, lets set up the build procedure for our project. Inside cargo.toml we need to declare all of the dependencies we would use:

// cargo.toml

[package]
name = "ic-cron-sonic-bot-example"
version = "0.1.0"
edition = "2018"

[lib]
crate-type = ["cdylib"]
path = "src/actor.rs"

[dependencies]
ic-cdk = "0.3"
ic-cdk-macros = "0.3"
serde = "1.0"
async-trait = "0.1"
bigdecimal = "0.3"
ic-cron = "0.5"

Then inside build.sh file we have to put this script - it will build and optimize a wasm-module for us:

# build.sh

#!/usr/bin/env bash

cargo build --target wasm32-unknown-unknown --release --package ic-cron-sonic-bot-example && \
 ic-cdk-optimizer ./target/wasm32-unknown-unknown/release/ic_cron_sonic_bot_example.wasm -o ./target/wasm32-unknown-unknown/release/ic-cron-sonic-bot-example-opt.wasm

And then, of course, we need dfx.json file, in which we would declare our canisters (we only have one) and what are they made of:

# dfx.json

{
  "canisters": {
    "ic-cron-sonic-bot-example": {
      "build": "./build.sh",
      "candid": "./can.did",
      "wasm": "./target/wasm32-unknown-unknown/release/ic-cron-sonic-bot-example-opt.wasm",
      "type": "custom"
    }
  },
  "dfx": "0.9.0",
  "networks": {
    "local": {
      "bind": "127.0.0.1:8000",
      "type": "ephemeral"
    }
  },
  "version": 1
}

API clients

Currently Sonic only works with DIP20 tokens. At the moment of writing this tutorial, there are only two DIP20 tokens: XTC - a token that wraps cycles, WICP - a token wrapping ICP. This pair (XTC/WICP) is what our bot will trade.

It is very important to know how exactly we will initially transfer tokens to the bot beforehand. This is actually pretty easy. Bot is a canister and every canister has some cycles on its balance (follow this tutorial if you want to know how to top-up your canister’s cycles balance), so we could simply wrap these cycles into XTCs and use these XTCs for trading.

Let’s start API clients development with this remote canister then.

Integration with XTC

Any canister integration process on the IC platform start with the .did file of this canister. It is a special file describing all the public APIs of the canister. Here you would find such a file for XTC.

First of all, to be able to wrap and unwrap cycles to XTCs, we want to describe it interface (actually, we only need its mint() and burn() methods). As we can see from the .did file, these methods have the following signatures:

type TransactionId = nat64;

type MintError = variant {
    NotSufficientLiquidity;
};

type MintResult = variant {
    Ok : TransactionId;
    Err: MintError;
};

type BurnError = variant {
    InsufficientBalance;
    InvalidTokenContract;
    NotSufficientLiquidity;
};

type BurnResult = variant {
    Ok : TransactionId;
    Err: BurnError;
};

service : {
	mint: (principal, nat) -> (MintResult);
	burn: (record { canister_id: principal; amount: nat64 }) -> (BurnResult);
} 

To be specific, XTC token canister supports DIP20 standard, but its mint() and burn() functions are not obeying this standard. This is because of special logic encapsulated within these methods: mint() accepts incoming cycles through the system API and burn() returns all burnt XTCs back to the selected canister in form of native cycles. This means that we have to describe these two methods separately from other DIP20 functions.

All we need to do is to just convert these interfaces to Rust language:

// src/clients/xtc.rs

#[async_trait]
pub trait XTC {
    async fn mint(&self, to: Principal, cycles: u64) -> CallResult<(XTCMintResult,)>;
    async fn burn(&self, payload: XTCBurnPayload) -> CallResult<(XTCBurnResult,)>;
}

Let’s talk about this code snippet in more details.

First, this is a trait. We make it into a trait so we could implement it for Principal type after. By doing that we make for ourselves a very handy way to make calls to external canisters only knowing their canister id.

Second, this is an async-trait. Canisters talk to each other by sending requests and asynchronously waiting for a response.

It is recommended to read the official documentation, if the reader is not familiar with the async-trait module.

Third, each of these methods returns a CallResult - a special type defined in ic-cdk-rs (i.e. system type), that can tell if the remote call was successful at all. This type is defined this way:

/// The result of a Call.
///
/// Errors on the IC have two components; a Code and a message associated with it.
pub type CallResult<R> = Result<R, (RejectionCode, String)>;

/// Rejection code from calling another canister.
#[derive(Debug)]
pub enum RejectionCode {
    NoError = 0,

    SysFatal = 1,
    SysTransient = 2,
    DestinationInvalid = 3,
    CanisterReject = 4,
    CanisterError = 5,

    Unknown,
}

In other words, CallResult contains system information about cross-canister call status.

Forth, besides CallResult this code snippet contains other types with which we’re not familiar yet, but we’ve seen them in the .did file. Let’s also translate them to Rust:

Actually it is always better to start API client development from types declaration - this way you begin with understanding of the actual data that will be passed inside functions. Here I switched this order on purpose for better storytelling experience.

// src/clients/xtc.rs

#[derive(CandidType, Deserialize)]
pub struct XTCBurnPayload {
    pub canister_id: Principal,
    pub amount: u64,
}

#[derive(CandidType, Deserialize, Debug)]
pub enum XTCBurnError {
    InsufficientBalance,
    InvalidTokenContract,
    NotSufficientLiquidity,
}

pub type XTCBurnResult = Result<Nat, XTCBurnError>;

#[derive(CandidType, Deserialize)]
pub enum XTCMintError {
    NotSufficientLiquidity,
}

pub type XTCMintResult = Result<Nat, XTCMintError>;

All the types which will be passed around between canisters should be annotated with these two macros: CandidType and Deserialize - they are responsible for correct serialization on the wire.

Trait implementation

Now, when we have all the interfaces we need, it’s time to implement them for Principal type:

// src/clients/xtc.rs

#[async_trait]
impl XTC for Principal {
    async fn mint(&self, to: Principal, cycles: u64) -> CallResult<(XTCMintResult,)> {
        call_with_payment(*self, "mint", (to, Nat::from(0)), cycles).await
    }

    async fn burn(&self, payload: XTCBurnPayload) -> CallResult<(XTCBurnResult,)> {
        call(*self, "burn", (payload,)).await
    }
}

We should discuss this code snippet a little more.

  • For impl block we’re also using the same async-trait macro.
  • We use call() system function inside burn() method, that in fact performs a call to the remote canister. We pass *self inside this function, which is a copy (because of a star) of the remote canister Principal. At the end of the call() we have to .await it, because this function is asynchronous.
  • Inside the mint() method we use another type of remote call function - call_with_payment(). It differs from simple call() with only one last argument - amount of cycles we want to send to the remote canister when calling this method.

One can spot an interesting expression inside the mint() method - (to, Nat::from(0)) . Unfortunately, this is how XTC’s API works - you have to pass zero as a second argument (which in classic ERC-20 mint() is the amount of tokens to mint). This value does no affect the algorithm - you will get the same amount of XTC as the amount of cycles you send to this method. So, basically, this second argument is a fake.

Finishing XTC

This is it. Our XTC integration is over with that one little file called xtc.rs the full version of which you can find here: https://gist.github.com/seniorjoinu/d76cf839a4aa1e150368f19121b816e9

This file was tiny, but this allowed us to discuss some interesting development aspects in more detail. Hope this was useful.

DIP20

In order to not only be able to mint and burn XTCs, but to also be able to trade those XTCs on Sonic, we have to also implement an API client for DIP20 tokens standard. This is the same exact process - we just translate the .did файл into Rust programming language.

Starting with types:

// src/clients/dip20.rs

#[derive(CandidType, Deserialize)]
pub struct Dip20Metadata {
    pub fee: Nat,
    pub decimals: u8,
    pub owner: Principal,
    pub logo: String,
    pub name: String,
    pub totalSupply: Nat,
    pub symbol: String,
}

#[derive(CandidType, Deserialize)]
pub struct Dip20TokenInfo {
    pub holderNumber: u64,
    pub deployTime: u8,
    pub metadata: Dip20Metadata,
    pub historySize: u64,
    pub cycles: u64,
    pub feeTo: Principal,
}

#[derive(CandidType, Deserialize, Debug)]
pub enum Dip20TxError {
    InsufficientBalance,
    InsufficientAllowance,
    Unauthorized,
    LedgerTrap,
    AmountTooSmall,
    BlockUsed,
    ErrorOperationStyle,
    ErrorTo,
    Other,
}

pub type Dip20TxReceipt = Result<Nat, Dip20TxError>;

Then declaring the trait:

// src/clients/dip20.rs

#[async_trait]
pub trait Dip20 {
    async fn transfer(&self, to: Principal, value: Nat) -> CallResult<(Dip20TxReceipt,)>;
    async fn transfer_from(
        &self,
        from: Principal,
        to: Principal,
        value: Nat,
    ) -> CallResult<(Dip20TxReceipt,)>;
    async fn approve(&self, spender: Principal, value: Nat) -> CallResult<(Dip20TxReceipt,)>;
    async fn mint(&self, to: Principal, amount: Nat) -> CallResult<(Dip20TxReceipt,)>;
    async fn burn(&self, amount: Nat) -> CallResult<(Dip20TxReceipt,)>;

    async fn set_name(&self, name: String) -> CallResult<()>;
    async fn name(&self) -> CallResult<(String,)>;

    async fn set_logo(&self, logo: String) -> CallResult<()>;
    async fn get_logo(&self) -> CallResult<(String,)>;

    async fn set_fee(&self, fee: Nat) -> CallResult<()>;
    async fn set_fee_to(&self, fee_to: Nat) -> CallResult<()>;

    async fn set_owner(&self, owner: Principal) -> CallResult<()>;
    async fn owner(&self) -> CallResult<(Principal,)>;

    async fn symbol(&self) -> CallResult<(String,)>;
    async fn decimals(&self) -> CallResult<(u8,)>;
    async fn total_supply(&self) -> CallResult<(Nat,)>;
    async fn balance_of(&self, id: Principal) -> CallResult<(Nat,)>;
    async fn allowance(&self, owner: Principal, spender: Principal) -> CallResult<(Nat,)>;
    async fn get_metadata(&self) -> CallResult<(Dip20Metadata,)>;
    async fn history_size(&self) -> CallResult<(usize,)>;
    async fn get_token_info(&self) -> CallResult<(Dip20TokenInfo,)>;
    async fn get_holders(&self, start: usize, limit: usize)
        -> CallResult<(Vec<(Principal, Nat)>,)>;
    async fn get_allowance_size(&self) -> CallResult<(usize,)>;
    async fn get_user_approvals(&self, who: Principal) -> CallResult<(Vec<(Principal, Nat)>,)>;
}

And then implementing this trait for Principal type:

// src/clients/dip20.rs

#[async_trait]
impl Dip20 for Principal {
    async fn transfer(&self, to: Principal, value: Nat) -> CallResult<(Dip20TxReceipt,)> {
        call(*self, "transfer", (to, value)).await
    }

    async fn transfer_from(
        &self,
        from: Principal,
        to: Principal,
        value: Nat,
    ) -> CallResult<(Dip20TxReceipt,)> {
        call(*self, "transferFrom", (from, to, value)).await
    }

    async fn approve(&self, spender: Principal, value: Nat) -> CallResult<(Dip20TxReceipt,)> {
        call(*self, "approve", (spender, value)).await
    }

    async fn mint(&self, to: Principal, amount: Nat) -> CallResult<(Dip20TxReceipt,)> {
        call(*self, "mint", (to, amount)).await
    }

    async fn burn(&self, amount: Nat) -> CallResult<(Dip20TxReceipt,)> {
        call(*self, "burn", (amount,)).await
    }

    async fn set_name(&self, name: String) -> CallResult<()> {
        call(*self, "setName", (name,)).await
    }

    async fn name(&self) -> CallResult<(String,)> {
        call(*self, "name", ()).await
    }

    async fn set_logo(&self, logo: String) -> CallResult<()> {
        call(*self, "setLogo", (logo,)).await
    }

    async fn get_logo(&self) -> CallResult<(String,)> {
        call(*self, "getLogo", ()).await
    }

    async fn set_fee(&self, fee: Nat) -> CallResult<()> {
        call(*self, "setFee", (fee,)).await
    }

    async fn set_fee_to(&self, fee_to: Nat) -> CallResult<()> {
        call(*self, "setFeeTo", (fee_to,)).await
    }

    async fn set_owner(&self, owner: Principal) -> CallResult<()> {
        call(*self, "setOwner", (owner,)).await
    }

    async fn owner(&self) -> CallResult<(Principal,)> {
        call(*self, "owner", ()).await
    }

    async fn symbol(&self) -> CallResult<(String,)> {
        call(*self, "symbol", ()).await
    }

    async fn decimals(&self) -> CallResult<(u8,)> {
        call(*self, "decimals", ()).await
    }

    async fn total_supply(&self) -> CallResult<(Nat,)> {
        call(*self, "totalSupply", ()).await
    }

    async fn balance_of(&self, id: Principal) -> CallResult<(Nat,)> {
        call(*self, "balanceOf", (id,)).await
    }

    async fn allowance(&self, owner: Principal, spender: Principal) -> CallResult<(Nat,)> {
        call(*self, "allowance", (owner, spender)).await
    }

    async fn get_metadata(&self) -> CallResult<(Dip20Metadata,)> {
        call(*self, "getMetadata", ()).await
    }

    async fn history_size(&self) -> CallResult<(usize,)> {
        call(*self, "historySize", ()).await
    }

    async fn get_token_info(&self) -> CallResult<(Dip20TokenInfo,)> {
        call(*self, "getTokenInfo", ()).await
    }

    async fn get_holders(
        &self,
        start: usize,
        limit: usize,
    ) -> CallResult<(Vec<(Principal, Nat)>,)> {
        call(*self, "getHolders", (start, limit)).await
    }

    async fn get_allowance_size(&self) -> CallResult<(usize,)> {
        call(*self, "getAllowanceSize", ()).await
    }

    async fn get_user_approvals(&self, who: Principal) -> CallResult<(Vec<(Principal, Nat)>,)> {
        call(*self, "getUserApprovals", (who,)).await
    }
}

This is a pretty boring job, but you’ll see later how much use it will have later, when we implement the main logic. After all of this the file dip20.rs should look like this:

https://gist.github.com/seniorjoinu/e5004d8ae253781f4e7315e494e4fc27

Sonic

The last API client we need to implement is the Sonic client itself. Sonic’s source code is not open sourced yet (at the time this article is published), but we could use their pretty nice little API documentation with examples and stuff.

The flow is exactly the same as for previous API clients - we start with type definition:

// src/clients/sonic.rs

#[derive(CandidType, Deserialize)]
pub struct SonicTokenInfo {
    pub id: String,
    pub name: String,
    pub symbol: String,
    pub decimals: u8,
    pub fee: Nat,
    pub totalSupply: Nat,
}

#[derive(CandidType, Deserialize)]
pub struct SonicPairInfo {
    pub id: String,
    pub token0: String,
    pub token1: String,
    pub creator: Principal,
    pub reserve0: Nat,
    pub reserve1: Nat,
    pub price0CumulativeLast: Nat,
    pub price1CumulativeLast: Nat,
    pub kLast: Nat,
    pub blockTimestampLast: Int,
    pub totalSupply: Nat,
    pub lptoken: String,
}

#[derive(CandidType, Deserialize)]
pub struct SonicUserInfo {
    pub balances: Vec<(Principal, Nat)>,
    pub lpBalances: Vec<(String, Nat)>,
}

#[derive(CandidType, Deserialize)]
pub struct SonicSwapInfo {
    pub owner: Principal,
    pub cycles: Nat,
    pub tokens: Vec<SonicTokenInfo>,
    pub pairs: Vec<SonicPairInfo>,
}

#[derive(CandidType, Deserialize, Debug)]
pub enum MotokoResult<T, E> {
    ok(T),
    err(E),
}

pub type SonicTxReceipt = MotokoResult<Nat, String>;

impl<T, E> MotokoResult<T, E> {
    pub fn to_res(self) -> Result<T, E> {
        match self {
            MotokoResult::ok(t) => Ok(t),
            MotokoResult::err(e) => Err(e),
        }
    }
}

#[derive(CandidType, Deserialize, Debug)]
pub enum SonicDetailValue {
    I64(i64),
    U64(u64),
    Vec(Vec<SonicDetailValue>),
    Slice(Vec<u8>),
    Text(String),
    True,
    False,
    Float(f64),
    Principal(Principal),
}

#[derive(CandidType, Deserialize)]
pub struct SonicTxRecord {
    pub caller: Principal,
    pub operation: String,
    pub details: Vec<(String, SonicDetailValue)>,
    pub time: u64,

We have to stop here and take a closer look at the MotokoResult type. We need it because Sonic is written in Motoko language and Result type in Motoko is not compatible with the same Result type in Rust. This is why we need this type and also some kind of function to transform Motoko’s Result into Rust’s Result - to_res().

Let’s define the trait then:

// src/clients/sonic.rs

#[async_trait]
pub trait Sonic {
    // ------------ SWAP API --------------

    async fn swap_exact_tokens_for_tokens(
        &self,
        amount_in: Nat,
        amount_out_min: Nat,
        path: Vec<String>,
        to: Principal,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)>;

    async fn swap_tokens_for_exact_tokens(
        &self,
        amount_out: Nat,
        amount_in_max: Nat,
        path: Vec<String>,
        to: Principal,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)>;

    async fn get_pair(
        &self,
        token0: Principal,
        token1: Principal,
    ) -> CallResult<(Option<SonicPairInfo>,)>;

    async fn get_all_pairs(&self) -> CallResult<(Vec<SonicPairInfo>,)>;

    async fn get_num_pairs(&self) -> CallResult<(Nat,)>;

    // -------------- LIQUIDITY API ----------------

    async fn add_liquidity(
        &self,
        token0: Principal,
        token1: Principal,
        amount0_desired: Nat,
        amount1_desired: Nat,
        amount0_min: Nat,
        amount1_min: Nat,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)>;

    async fn remove_liquidity(
        &self,
        token0: Principal,
        token1: Principal,
        lp_amount: Nat,
        amount0_min: Nat,
        amount1_min: Nat,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)>;

    async fn get_user_LP_balances(&self, user: Principal) -> CallResult<(Vec<(String, Nat)>,)>;

    // ------------------ ASSETS API -------------------

    async fn add_token(&self, token_id: Principal) -> CallResult<(SonicTxReceipt,)>;

    async fn create_pair(
        &self,
        token0: Principal,
        token1: Principal,
    ) -> CallResult<(SonicTxReceipt,)>;

    async fn deposit(&self, token_id: Principal, value: Nat) -> CallResult<(SonicTxReceipt,)>;

    async fn withdraw(&self, token_id: Principal, value: Nat) -> CallResult<(SonicTxReceipt,)>;

    async fn transfer(&self, token_id: String, to: Principal, value: Nat) -> CallResult<(bool,)>;

    async fn approve(
        &self,
        token_id: String,
        spender: Principal,
        value: Nat,
    ) -> CallResult<(bool,)>;

    async fn transfer_from(
        &self,
        token_id: String,
        from: Principal,
        to: Principal,
        value: Nat,
    ) -> CallResult<(SonicTxReceipt,)>;

    async fn get_supported_token_list(&self) -> CallResult<(SonicTokenInfo,)>;

    async fn balance_of(&self, token_id: String, who: Principal) -> CallResult<(Nat,)>;

    async fn allowance(
        &self,
        token_id: String,
        owner: Principal,
        spender: Principal,
    ) -> CallResult<(Nat,)>;

    async fn total_supply(&self, token_id: String) -> CallResult<(Nat,)>;

    async fn name(&self, token_id: String) -> CallResult<(String,)>;

    async fn decimals(&self, token_id: String) -> CallResult<(Nat,)>;

    async fn symbol(&self, token_id: String) -> CallResult<(String,)>;

    // ------------------- OTHER API ----------------------

    async fn get_user_info(&self, user: Principal) -> CallResult<(SonicUserInfo,)>;

    async fn get_swap_info(&self) -> CallResult<(SonicSwapInfo,)>;
}

Sonic’s API definition allows us to take a sneak peek under the hood of the exchange. As we can tell by the way how its methods are defined, this exchange works with deposits - we have to deposit some tokens to Sonic canister beforehand in order to operate over these tokens later. This means that Sonic accounts an internal balance list, working like a multi-token (like ERC-1155). In other words, Sonic is a multi-token and exchange fusion.

This makes Sonic very different from Uniswap. In Uniswap there are many liquidity pools each of which is a separate smart-contract. So Uniswap is a composition of many liquidity pools. Sonic is an aggregation of many liquidity pools.

Then we need to implement this trait for type Principal:

// src/clients/sonic.rs

#[async_trait]
impl Sonic for Principal {
    async fn swap_exact_tokens_for_tokens(
        &self,
        amount_in: Nat,
        amount_out_min: Nat,
        path: Vec<String>,
        to: Principal,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)> {
        call(
            *self,
            "swapExactTokensForTokens",
            (amount_in, amount_out_min, path, to, deadline),
        )
        .await
    }

    async fn swap_tokens_for_exact_tokens(
        &self,
        amount_out: Nat,
        amount_in_max: Nat,
        path: Vec<String>,
        to: Principal,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)> {
        call(
            *self,
            "swapTokensForExactTokens",
            (amount_out, amount_in_max, path, to, deadline),
        )
        .await
    }

    async fn get_pair(
        &self,
        token0: Principal,
        token1: Principal,
    ) -> CallResult<(Option<SonicPairInfo>,)> {
        call(*self, "getPair", (token0, token1)).await
    }

    async fn get_all_pairs(&self) -> CallResult<(Vec<SonicPairInfo>,)> {
        call(*self, "getAllPairs", ()).await
    }

    async fn get_num_pairs(&self) -> CallResult<(Nat,)> {
        call(*self, "getNumPairs", ()).await
    }

    async fn add_liquidity(
        &self,
        token0: Principal,
        token1: Principal,
        amount0_desired: Nat,
        amount1_desired: Nat,
        amount0_min: Nat,
        amount1_min: Nat,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)> {
        call(
            *self,
            "addLiquidity",
            (
                token0,
                token1,
                amount0_desired,
                amount1_desired,
                amount0_min,
                amount1_min,
                deadline,
            ),
        )
        .await
    }

    async fn remove_liquidity(
        &self,
        token0: Principal,
        token1: Principal,
        lp_amount: Nat,
        amount0_min: Nat,
        amount1_min: Nat,
        deadline: Int,
    ) -> CallResult<(SonicTxReceipt,)> {
        call(
            *self,
            "removeLiquidity",
            (
                token0,
                token1,
                lp_amount,
                amount0_min,
                amount1_min,
                deadline,
            ),
        )
        .await
    }

    async fn get_user_LP_balances(&self, user: Principal) -> CallResult<(Vec<(String, Nat)>,)> {
        call(*self, "getUserLPBalances", (user,)).await
    }

    async fn add_token(&self, token_id: Principal) -> CallResult<(SonicTxReceipt,)> {
        call(*self, "addToken", (token_id,)).await
    }

    async fn create_pair(
        &self,
        token0: Principal,
        token1: Principal,
    ) -> CallResult<(SonicTxReceipt,)> {
        call(*self, "createPair", (token0, token1)).await
    }

    async fn deposit(&self, token_id: Principal, value: Nat) -> CallResult<(SonicTxReceipt,)> {
        call(*self, "deposit", (token_id, value)).await
    }

    async fn withdraw(&self, token_id: Principal, value: Nat) -> CallResult<(SonicTxReceipt,)> {
        call(*self, "withdraw", (token_id, value)).await
    }

    async fn transfer(&self, token_id: String, to: Principal, value: Nat) -> CallResult<(bool,)> {
        call(*self, "transfer", (token_id, to, value)).await
    }

    async fn approve(
        &self,
        token_id: String,
        spender: Principal,
        value: Nat,
    ) -> CallResult<(bool,)> {
        call(*self, "approve", (token_id, spender, value)).await
    }

    async fn transfer_from(
        &self,
        token_id: String,
        from: Principal,
        to: Principal,
        value: Nat,
    ) -> CallResult<(SonicTxReceipt,)> {
        call(*self, "transferFrom", (token_id, from, to, value)).await
    }

    async fn get_supported_token_list(&self) -> CallResult<(SonicTokenInfo,)> {
        call(*self, "getSupportedTokenList", ()).await
    }

    async fn balance_of(&self, token_id: String, who: Principal) -> CallResult<(Nat,)> {
        call(*self, "balanceOf", (token_id, who)).await
    }

    async fn allowance(
        &self,
        token_id: String,
        owner: Principal,
        spender: Principal,
    ) -> CallResult<(Nat,)> {
        call(*self, "allowance", (token_id, owner, spender)).await
    }

    async fn total_supply(&self, token_id: String) -> CallResult<(Nat,)> {
        call(*self, "totalSupply", (token_id,)).await
    }

    async fn name(&self, token_id: String) -> CallResult<(String,)> {
        call(*self, "name", (token_id,)).await
    }

    async fn decimals(&self, token_id: String) -> CallResult<(Nat,)> {
        call(*self, "decimals", (token_id,)).await
    }

    async fn symbol(&self, token_id: String) -> CallResult<(String,)> {
        call(*self, "symbol", (token_id,)).await
    }

    async fn get_user_info(&self, user: Principal) -> CallResult<(SonicUserInfo,)> {
        call(*self, "getUserInfo", (user,)).await
    }

    async fn get_swap_info(&self) -> CallResult<(SonicSwapInfo,)> {
        call(*self, "getSwapInfo", ()).await
    }
}

After all of that the complete file sonic.rs should look like that:

https://gist.github.com/seniorjoinu/941af3c0e093e7b1eef50890266ae645

This is it. We made it through all of the API clients. You can use them in any application you want, when you need to interact with these services.

Bot implementation

The state

Let’s start with the definition of the state of our bot. The bot should know Principal's of all of the canisters it will interact with (Sonic, XTC и WICP), as well as our own Principal, so it will only do something when we’re telling him to do that (otherwise someone else could use our bot and steal our tokens).

// src/actor.rs

#[derive(CandidType, Deserialize, Clone, Copy)]
pub struct State {
    pub xtc_canister: Principal,
    pub wicp_canister: Principal,
    pub sonic_swap_canister: Principal,
    pub controller: Principal,
}

pub static mut STATE: Option<State> = None;

pub fn get_state() -> &'static State {
    unsafe { STATE.as_ref().unwrap() }
}

get_state() function makes our life easier when we need to read the state (in fact, it encapsulates the unsafe block, making it safe).

Transactions in the IC are executed in a strict order according to the consensus (as in any other blockchain network) one by one. This guarantees us that our code will never be executed in a multi-threaded environment. This means that we may not bother about the unsafe nature of our state, on condition that in order to access it we will always use the get_state() function.

Let’s also define an init method of our canister:

// src/actor.rs

#[init]
pub fn init(controller: Principal) {
    unsafe {
        STATE = Some(State {
            xtc_canister: Principal::from_text("aanaa-xaaaa-aaaah-aaeiq-cai").unwrap(),
            wicp_canister: Principal::from_text("utozz-siaaa-aaaam-qaaxq-cai").unwrap(),
            sonic_swap_canister: Principal::from_text("3xwpq-ziaaa-aaaah-qcn4a-cai").unwrap(),
            controller,
        })
    }
}

This is pretty simple. In this method we just assign remote canister ids and let the deployer define the controller of the canister.

Don’t confuse this controller with the system’s canister controller - they are not the same thing.

Interacting with XTC

Next we need to make sure that the bot can wrap its cycles into XTC and then unwrap them back. Let’s define the following two methods for that:

// src/actor.rs

#[update(guard = controller_guard)]
pub async fn mint_xtc_with_own_cycles(amount: u64) {
    let state = get_state();

    XTC::mint(&state.xtc_canister, id(), amount)
        .await
        .expect("Unable to mint XTC with cycles: call failed")
        .0
        .expect("Unable to mint XTC with cycles: internal error");
}

#[update(guard = controller_guard)]
pub async fn burn_xtc_for_own_cycles(amount: u64) {
    let state = get_state();
    let payload = XTCBurnPayload {
        canister_id: id(),
        amount,
    };

    XTC::burn(&state.xtc_canister, payload)
        .await
        .expect("Unable to burn XTC for cycles: call failed")
        .0
        .expect("Unable to burn XTC for cycles: internal error");
}

This snippet has some interesting parts. Let’s stay here a little bit more.

As we can see, there is a macro #[update(guard = controller_guard)]. This is the same as usual update macro (it is recommended to read this tutorial, if you’re unfamiliar with it), but inside it we define a so called guard-function. This is a function that will be executed before the annotated function and its only purpose is to check whether the incoming message is valid. Mainly these guard-functions are used for access control - to check if the caller is authorized to call this canister method. In our case, a guard-function is defined like this:

// src/common/guards.rs

use crate::get_state;
use ic_cdk::caller;

pub fn controller_guard() -> Result<(), String> {
    if caller() != get_state().controller {
        return Err(String::from("Access denied"));
    }

    Ok(())
}

In other words, annotating a function with guard = controller_guard, we allow to call it only to the canister controller (i.e. only to ourselves).

Back to XTC’s functions. Inside them we use the XTC’s API client we defined earlier. We pass results of these call through the expect method twice: the first time - to make sure that the remote call was at all successful (the remote canister exists, it has enough cycles to perform our call etc.); and the second time - to make sure that canisters internal logic (in this case the logic of mint() and burn() methods) did work as intended.

Last, but not least, despite these methods not changing the state of our canister, we annotate it with an update (instead of query) macro. This is because they perform an inter-canister call and this action should definitely (at least for the time this tutorial is written) go through the consensus protocol.

Let’s move on. After we wrapped cycles into XTC, it would be nice to be able to get an information about how much XTC tokens the bot has in total. This time we use a different API client for that - Dip20 :

// src/actor.rs

#[update]
pub async fn my_token_balance(currency: Currency) -> Nat {
    let token = token_id_by_currency(currency);

    let (balance,) = Dip20::balance_of(&token, id())
        .await
        .expect("Unable to fetch my balance at token");

    balance
}

fn token_id_by_currency(currency: Currency) -> Principal {
    let state = get_state();

    match currency {
        Currency::XTC => state.xtc_canister,
        Currency::WICP => state.wicp_canister,
    }
}

#[derive(CandidType, Deserialize, Clone)]
pub enum Currency {
    XTC,
    WICP,
} 

To make it easier to work with we use this new Currency enum and a helper function token_id_by_currency(). Later you’ll see how much simpler it is to use it instead of raw Principal's.

Apart from XTC’s balance it would also be good to be able to see how much cycles does our canister have. We use system APIs to get this info:

// src/actor.rs

#[query]
pub fn my_cycles_balance() -> u64 {
    canister_balance()
}

Interacting with Sonic

We need a way to deposit some tokens onto exchange and to withdraw them later if we want. For that let’s define two more functions for the bot: deposit() and withdraw() in the following way:

// src/actor.rs

#[update(guard = controller_guard)]
pub async fn deposit(currency: Currency, amount: Nat) {
    let state = *get_state();
    let token = token_id_by_currency(currency);

    Dip20::approve(&token, state.sonic_swap_canister, amount.clone())
        .await
        .expect("Unable to approve tokens: call failed")
        .0
        .expect("Unable to approve tokens: internal error");

    Sonic::deposit(&state.sonic_swap_canister, token, amount)
        .await
        .expect("Unable to deposit tokens: call failed")
        .0
        .to_res()
        .expect("Unable to deposit tokens: internal error");
}

#[update(guard = controller_guard)]
pub async fn withdraw(currency: Currency, amount: Nat) {
    let state = get_state();
    let token = token_id_by_currency(currency);

    Sonic::withdraw(&state.sonic_swap_canister, token, amount)
        .await
        .expect("Unable to withdraw tokens: call failed")
        .0
        .to_res()
        .expect("Unable to withdraw tokens: internal error");
}

There is nothing special about these function - we just follow Sonic’s documentation to make it right. Inside the deposit() function we allow Sonic’s canister to spend our tokens (DIP20 token automation works the same way as ERC20 - with allowance/approval functionality), and then we ask Sonic’s canister to deposit these tokens on bot’s internal balance.

Inside deposit() function, in order to read the state correctly we use a star let state = *get_state(). This is important, because we use our state between asynchronous calls. Any time such a call happens, wasm runtime serializes the whole execution context, and when it deserializes it back (when we get an async response) there is no way for it to figure out what global object did this reference was pointing to. So we just copy this whole object (in our case it is tiny, so this is okay to do like that) so the wasm runtime could serialize it alongside other context data and then deserialize it back successfully.

If you want to evade copying (when you have a decent state) you could just repeat the expression let state = get_state() after each async call, each time creating a new correct reference to the global state object.

In order to be able to check whether functions deposit() and withdraw() worked as they should we would also need a function to check Sonic’s internal token balance of our bot:

// src/actor.rs

#[update]
pub async fn my_sonic_balance(currency: Currency) -> Nat {
    let state = get_state();
    let token = token_id_by_currency(currency);

    let (balance,) = Sonic::balance_of(&state.sonic_swap_canister, token.to_text(), id())
        .await
        .expect("Unable to fetch my balance at Sonic");

    balance
}

And, of course, we would need a function that calculates and shows us current exchange rate. We need this function for two reasons:

  • For our limit orders to work correctly.
  • To be able to check this rate from the console.

AMM-powered swaps define an exchange rate as a proportion of liquidity volume locked inside the pair. In other words if the pair contains two tokens A and B and there are 100 A tokens locked and 50 B tokens locked, the exchange rate would be 2:1.

We could define such a function as follows:

// src/actor.rs

async fn get_swap_price_internal(give_currency: Currency, take_currency: Currency) -> BigDecimal {
    let state = get_state();
    let give_token = token_id_by_currency(give_currency);
    let take_token = token_id_by_currency(take_currency);

    let (pair_opt,) = Sonic::get_pair(&state.sonic_swap_canister, give_token, take_token)
        .await
        .expect("Unable to fetch pair at Sonic");

    let pair = pair_opt.unwrap();

    let give_reserve = BigDecimal::from(pair.reserve0.0.to_bigint().unwrap());
    let take_reserve = BigDecimal::from(pair.reserve1.0.to_bigint().unwrap());

    give_reserve / take_reserve
}

#[update]
pub async fn get_swap_price(give_currency: Currency, take_currency: Currency) -> f64 {
    let give_token = token_id_by_currency(give_currency.clone());
    let take_token = token_id_by_currency(take_currency.clone());

    let price_bd = get_swap_price_internal(give_currency, take_currency).await;

    let (give_token_decimals,) = Dip20::decimals(&give_token)
        .await
        .expect("Unable to fetch give_token decimals");

    let (take_token_decimals,) = Dip20::decimals(&take_token)
        .await
        .expect("Unable to fetch take_token decimals");

    let decimals_dif =
        give_token_decimals.to_i32().unwrap() - take_token_decimals.to_i32().unwrap();

    let decimals_modifier = 10f64.pow(decimals_dif);

    price_bd.to_f64().unwrap() * decimals_modifier
}

As you can see from the code snippet - we have two different functions for that task. One for internal usage, and another for external means (for console). They differ by the way they represent the exchange rate. The first one returns absolute value of the rate (without accounting to how many decimals do tokens have). The second one returns normalized value of the rate (with decimals number taken into an account).

Usually, money are never stored inside float type variables, because float is non-deterministic. Instead they are stored and processed inside integer type variables with a note about how many precision numbers are there. But for our tutorial it is okay to use float to represent money to the user.

For internal computations we use BigDecimal library, which makes it a lot easier to convert money from Nat .

Adding orders

We want our bot to support two types of orders: market orders (which should be executed immediately) and limit orders (which should only be executed when the exchange rate reaches some condition pre-defined condition).

Let’s start with defining order types:

// src/common/types.rs

#[derive(CandidType, Deserialize)]
pub enum Order {
    Market(MarketOrder),
    Limit(LimitOrder),
}

#[derive(CandidType, Deserialize, Clone)]
pub struct MarketOrder {
    pub give_currency: Currency,
    pub take_currency: Currency,
    pub directive: OrderDirective,
}

#[derive(CandidType, Deserialize, Clone)]
pub struct LimitOrder {
    pub target_price_condition: TargetPrice,
    pub market_order: MarketOrder,
}

#[derive(CandidType, Deserialize, Clone)]
pub enum TargetPrice {
    MoreThan(f64),
    LessThan(f64),
}

#[derive(CandidType, Deserialize, Clone)]
pub enum OrderDirective {
    GiveExact(Nat),
    TakeExact(Nat),
}

MarketOrder is pretty straightforward: it defines a pair of tokens (give is a token, which we want to exchange; take is a token which we want to receive back), and also OrderDirective - a parameter, that lets us specify how many tokens do we want to exchange (or receive back) so the other amount could be calculated automatically according to current exchange rate and slippage value.

For example, there are two tokens we have A and we want B. The exchange rate is 2:1, slippage - 10%. If we set OrderDirective to GiveExact(100), we will certainly lose 100 A tokens, and we receive back some amount of B tokens between 45 (max slippage) and 50 (min slippage). If we set OrderDirective to TakeExact(50), we will certainly receive 50 B tokens, and we lose some amount of A tokens between 100 (min slippage) and 110 (max slippage).

LimitOrder is defined as a MarketOrder, which can only be executed when the exchange rate reaches some TargetPrice condition.

For our bot to be able to periodically check whether or not the exchange rate reached the condition, we will empower it with ic-cron library. To initialize it you have to use this macro:

// src/actor.rs

implement_cron!(); 

After that we’ll get access to cron_enqueue() function, which we can use to plan a background activity for the bot. Let’s define the last public function of the bot’s canister that we will use to add a new trade order:

// src/actor.rs

#[update(guard = controller_guard)]
pub async fn add_order(order: Order) -> Option<TaskId> {
    match order {
        Order::Market(market_order) => {
            execute_market_order(market_order).await;

            None
        }
        Order::Limit(limit_order) => {
            // TODO: we need to somehow freeze tokens spent for limit orders

            let task_id = cron_enqueue(
                limit_order,
                SchedulingInterval {
                    delay_nano: 0,
                    interval_nano: 1_000_000_000 * 10, // check each 10 seconds,
                    iterations: Iterations::Exact(1),
                },
            )
            .expect("Unable to schedule a task");

            Some(task_id)
        }
    }
}

Inside this function we detect what order type was passed to the bot and react differently according to this information.

You can see the TODO block that says “we need to somehow freeze tokens spent for limit orders”. It means that when the bot receives a limit order, it has no way to determine if it has enough tokens to execute it. We leave that problem outside this tutorial.

If the bot receives a market order it executes it immediately. If the bot receives a limit order it plans a background task to check each 10 seconds if the condition of that order was satisfied. If it is, bot executes the limit order. Any background canister work we define in a special system function - the one annotated with heartbeat macro:

// src/actor.rs

#[heartbeat]
pub fn tick() {
    for task in cron_ready_tasks() {
        // we only have limit orders here
        let limit_order = task
            .get_payload::<LimitOrder>()
            .expect("Unable to parse limit order");

        ic_cdk::block_on(async {
            let res = execute_limit_order(limit_order.clone()).await;

            if !res {
                cron_enqueue(
                    limit_order,
                    SchedulingInterval {
                        delay_nano: 0,
                        interval_nano: 1_000_000_000 * 10,
                        iterations: Iterations::Exact(1),
                    },
                )
                .expect("Unable to reschedule a task");
            }
        });
    }
}

To iterate over every background task that should be executed right now, we use ic-cron library function called cron_ready_tasks(). Then we take a limit order out of this background task and try to execute it. If it wasn’t executed successfully (exchange rate condition wasn’t satisfied) we plan another background task to be executed after the next 10 seconds.

As you can see, our tasks are only executed once (Iterations::Exact(1)). This way we’re getting rid of a race condition, when the bot executes current limit order for too long, such that a task gets rescheduled.

This way we manually manage background tasks: if we need to continue executing current task - we put it back into the queue; if we’re done - we do nothing.

It is also important to notice that we’re using system API here - ic_cdk::block_on(). This function has a bad name - it should be called spawn - because this is what it actually does. It receives a Future and passes it to the runtime to finish it in background. Using this function you can return the result immediately without waiting for asynchronous work to finish. You should always use this function inside heartbeat function if you have something async running there.

Now let’s define the function execute_limit_order(), to understand how it works:

// src/actor.rs

async fn execute_limit_order(order: LimitOrder) -> bool {
    let price = get_swap_price(
        order.market_order.give_currency.clone(),
        order.market_order.take_currency.clone(),
    )
    .await;

    match order.target_price_condition {
        TargetPrice::MoreThan(target_price) => {
            if price >= target_price {
                execute_market_order(order.market_order).await;
                true
            } else {
                false
            }
        }
        TargetPrice::LessThan(target_price) => {
            if price <= target_price {
                execute_market_order(order.market_order).await;
                true
            } else {
                false
            }
        }
    }
}

The process is pretty simple: the bot requests current exchange rate and then checks if it satisfies the condition from the order. If it does, the bot executes execute_market_order() function, which uses Sonic API’s to perform a swap with the current exchange rate:

// src/actor.rs

async fn execute_market_order(order: MarketOrder) -> Nat {
    let state = *get_state();

    let give_token = token_id_by_currency(order.give_currency.clone());
    let take_token = token_id_by_currency(order.take_currency.clone());

    let slippage_bd = BigDecimal::from_f64(0.99f64).unwrap(); // can tolerate 1% slippage
    let deadline = Int(BigInt::from(time() + 1_000_000_000 * 20)); // 20 seconds til now
    let this = id();

    let price_bd = get_swap_price_internal(order.give_currency, order.take_currency).await;

    match order.directive {
        OrderDirective::GiveExact(give_amount) => {
            let give_amount_bd = BigDecimal::from(give_amount.0.to_bigint().unwrap());

            let take_amount_min_bd = give_amount_bd / price_bd * slippage_bd;
            let take_amount_min = Nat(take_amount_min_bd
                .to_bigint()
                .unwrap()
                .to_biguint()
                .unwrap());

            Sonic::swap_exact_tokens_for_tokens(
                &state.sonic_swap_canister,
                give_amount,
                take_amount_min,
                vec![give_token.to_text(), take_token.to_text()],
                this,
                deadline,
            )
            .await
            .expect("Unable to swap exact tokens: call failed")
            .0
            .to_res()
            .expect("Unable to swap exact tokens: internal error")
        }
        OrderDirective::TakeExact(take_amount) => {
            let take_amount_bd = BigDecimal::from(take_amount.0.to_bigint().unwrap());

            let give_amount_max_bd = take_amount_bd * price_bd * slippage_bd;
            let give_amount_max = Nat(give_amount_max_bd
                .to_bigint()
                .unwrap()
                .to_biguint()
                .unwrap());

            Sonic::swap_tokens_for_exact_tokens(
                &state.sonic_swap_canister,
                take_amount,
                give_amount_max,
                vec![give_token.to_text(), take_token.to_text()],
                this,
                deadline,
            )
            .await
            .expect("Unable to swap to exact tokens: call failed")
            .0
            .to_res()
            .expect("Unable to swap exact tokens: internal error")
        }
    }
}

This function calculates current exchange rate and slippage value. Then according to OrderDirective value, it uses Sonic’s API to swap tokens.

Congratulations! We’ve managed to finish our long coding session. The complete actor.rs file can be found here:

https://gist.github.com/seniorjoinu/a9d1faad6891c28072ac0a57e8639788

Candid-interface definition

All we left to do is to define some candid-interfaces for our canister so we could interact with it using the console. This is the same exact process we did at the beginning of this tutorial, but in reverse - now we need to compose a .did file from our Rust source code:

// can.did

type Currency = variant {
    XTC;
    WICP;
};

type OrderDirective = variant {
    GiveExact : nat;
    TakeExact : nat;
};

type TargetPrice = variant {
    MoreThan : float64;
    LessThan : float64;
};

type MarketOrder = record {
    give_currency : Currency;
    take_currency : Currency;
    directive : OrderDirective;
};

type LimitOrder = record {
    target_price_condition : TargetPrice;
    market_order : MarketOrder;
};

type Order = variant {
    Market : MarketOrder;
    Limit : LimitOrder;
};

service : {
    "deposit" : (Currency, nat) -> ();
    "withdraw" : (Currency, nat) -> ();
    "mint_xtc_with_own_cycles" : (nat64) -> ();
    "burn_xtc_for_own_cycles" : (nat64) -> ();
    "my_token_balance" : (Currency) -> (nat);
    "my_sonic_balance" : (Currency) -> (nat);
    "my_cycles_balance" : () -> (nat64) query;
    "get_swap_price" : (Currency, Currency) -> (float64);
    "add_order" : (Order) -> (opt nat64);
}

Complete git repository is here. Come take a look:

https://github.com/seniorjoinu/ic-cron-sonic-bot-example

Deployment and usage

Let’s see what the bot we made is capable of.

Deployment

First of all we need to remember our own Principal, to make it into the bots controller:

$ dfx identity get-principal
6xqad-ivesr-pbpu5-3g5ka-3piah-uvuk2-buwfp-enqaa-p64lr-y7sdi-sqe

This command will return different result for you. Make sure you use the one it shows you.

To deploy a canister on the IC we need a so called wallet-canister. Follow this tutorial in order to get one. Then we just need to call this simple command:

$ dfx deploy --network ic --wallet <your-wallet-canister-id> --argument '(principal "<your principal>")'
...
Deployed canisters

We use --wallet parameter, because starting from dfx version 0.9.0 the --no-wallet behavior is the default one.

Deployment complete! Let’s see how much cycles does the bot have:

 $ dfx canister --network ic call ic-cron-sonic-bot-example my_cycles_balance '()'
(3_594_723_022_948 : nat64)

This command may return a different result for you. Make sure your canister has at least 1T cycles.

Depositing tokens

To cut our risks we’ll try to swap a small amount money. Let’s take 100B cycles (~ $0.13) and mint them into XTCs:

$ dfx canister --network ic call ic-cron-sonic-bot-example mint_xtc_with_own_cycles '(100_000_000_000 : nat64)'
()

Let’s check if our XTCs balance has changed:

$ dfx canister --network ic call ic-cron-sonic-bot-example my_token_balance '(variant { XTC })'
(98_000_000_000 : nat)

Looks good!

We received a little bit less XTCs than we sent cycles. This is because each operation XTC’s canister keeps a little fee (2B XTCs) to be able to operate autonomously. We will take this into account.

After that we need to deposit XTCs into Sonic. As you remember, we wrote a special deposit() function for that:

$ dfx canister --network ic call ic-cron-sonic-bot-example deposit '(variant { XTC }, 92_000_000_000 : nat)'
()

Let’s make sure our internal Sonic balance is indeed now equals to 92B XTCs:

$ dfx canister --network ic call ic-cron-sonic-bot-example my_sonic_balance '(variant { XTC })'
(92_000_000_000 : nat)

Trading

We start with a market order. Let’s swap all XTCs we have with WICPs:

$ dfx canister --network ic call ic-cron-sonic-bot-example add_order '(variant { Market = record { give_currency = variant { XTC }; take_currency = variant { WICP }; directive = variant { GiveExact = 92_000_000_000 : nat } } })'
(null)

null response is good. For a market order this means that everything went as expected. Let’s check our WICP Sonic balance:

$ dfx canister --network ic call ic-cron-sonic-bot-example my_sonic_balance '(variant { WICP })'
(579_267 : nat)

Yes, we made it! We managed to swap XTCs for WICPs with the current exchange rate. By the way, let’s check its current value:

$ dfx canister --network ic call ic-cron-sonic-bot-example get_swap_price '(variant { WICP }, variant { XTC })'
(15.834037910203596 : float64)

This means that at the moment of writing this article for each WICP we should pay with 15.83... XTC. Okay, let’s make some money then! Let’s set a limit order that should work when the exchange rate reaches at least 15.84:

$ dfx canister --network ic call ic-cron-sonic-bot-example add_order '(variant { Limit = record { target_price_condition = variant { MoreThan = 15.84 }; market_order = record { give_currency = variant { WICP }; take_currency = variant { XTC }; directive = variant { GiveExact = 579_267 : nat } } } } )'
(opt (0 : nat64))

This response is also good. It means that the limit order is now in the background task queue and its id is 0. Now we need to wait till the price will actually go all the way up to 15.84. This can take a while, but we can periodically check it with get_swap_price() function.

I got lucky and after a few hours the exchange rate indeed went to 15.84.

$ dfx canister --network ic call ic-cron-sonic-bot-example my_sonic_balance '(variant { XTC })'
(91_757_051_334 : nat)

Obviously, we couldn’t make any profit with such a small amount of money on stake (was 92_000_000_000, now 91_757_051_334). But at least we didn’t risk anything.

Withdrawing tokens

So, let’s withdraw our XTCs back from Sonic and then transform them back into cycles:

$ dfx canister --network ic call ic-cron-sonic-bot-example withdraw '(variant { XTC }, 91_757_051_334 : nat)'
()

Checking the XTC balance (and seeing that we again lost some fee):

$ dfx canister --network ic call ic-cron-sonic-bot-example my_token_balance '(variant { XTC })'
(89_757_051_334 : nat)

Now we need to burn these XTCs to get cycles back (here you need to take fees into account yourself):

$ dfx canister --network ic call ic-cron-sonic-bot-example burn_xtc_for_own_cycles '(87_757_051_334 : nat64)'
()

All we left to do is to check if the bot has any XTC left:

$ dfx canister --network ic call ic-cron-sonic-bot-example my_token_balance '(variant { XTC })'
(0 : nat)

This is it! We successfully checked our trading bot sanity and almost made some money from it!

Afterword

Despite that this bot needs some polishing (balance tracking, order cancellation, new order types etc.), it does its job pretty well. Moreover, you can extend it to a fully-fledged multi-user service with real order book, web interface and some arbitrage instruments.

We did a great job completing this tutorial. Just think about it: we extended someone else’s service for our own need without asking anyone for a permission. This is the power of the open internet services paradigm. This is the power of web3!


Written by seniorjoinu | Blockchain and stuff
Published by HackerNoon on 2022/02/04