This tutorial is dedicated to platform. Finishing this tutorial you: Internet Computer (Dfinity) 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 library to execute any background canister code. ic-cron 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: dedicated to canister development on the IC; , where you can find an answer to almost any question. official website developer forum Motivation is an exchange (”swap”), deployed on the Internet Computer platform, based on “ ” (AMM) model. You might heard this term before alongside with words such as “ ” and “ ”. 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. Sonic automated market-maker Uniswap DeFi 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 and make some money collecting fees. liquidity provider Current exchange rate is decided automatically, with an algorithm. For example, Sonic (just like Uniswap), uses the formula for that. This means that basically all the liquidity locked in any token pair defines an exchange rate for that token pair. constant product So, there is no inside these “swaps”, and no orders either. For the majority of traders this is a big problem, because orders (in particular, ) are very important automation tools without which there is no way to build any complex trading strategy. order book limit orders 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. To accomplish this task we would use library that would let us easily declare any background computations. 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. ic-cron 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: dfx 0.9.0 and toolchain rust 1.54+ wasm32-unknown-unknown ic-cdk-optimizer 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 we need to declare all of the dependencies we would use: cargo.toml // 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 file we have to put this script - it will build and optimize a wasm-module for us: build.sh # 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 file, in which we would declare our canisters (we only have one) and what are they made of: dfx.json # 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 tokens. At the moment of writing this tutorial, there are only two DIP20 tokens: - a token that wraps cycles, - a token wrapping ICP. This pair (XTC/WICP) is what our bot will trade. DIP20 XTC WICP 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 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. this tutorial 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 file of this canister. It is a special file describing all the public APIs of the canister. you would find such a file for XTC. .did Here First of all, to be able to wrap and unwrap cycles to XTCs, we want to describe it interface (actually, we only need its and methods). As we can see from the file, these methods have the following signatures: mint() burn() .did 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 and functions are not obeying this standard. This is because of special logic encapsulated within these methods: accepts incoming cycles through the system API and 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. mint() burn() mint() burn() 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. , this is a trait. We make it into a trait so we could implement it for type after. By doing that we make for ourselves a very handy way to make calls to external canisters only knowing their canister id. First Principal , this is an . Canisters talk to each other by sending requests and asynchronously waiting for a response. Second async-trait It is recommended to read the , if the reader is not familiar with the module. official documentation async-trait , each of these methods returns a - a special type defined in (i.e. system type), that can tell if the remote call was successful at all. This type is defined this way: Third CallResult ic-cdk-rs /// 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, contains system information about cross-canister call status. CallResult , besides this code snippet contains other types with which we’re not familiar yet, but we’ve seen them in the file. Let’s also translate them to Rust: Forth CallResult .did 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: and - they are responsible for correct serialization on the wire. CandidType Deserialize Trait implementation Now, when we have all the interfaces we need, it’s time to implement them for type: Principal // 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 block we’re also using the same macro. impl async-trait We use system function inside method, that in fact performs a call to the remote canister. We pass inside this function, which is a copy (because of a star) of the remote canister . At the end of the we have to it, because this function is asynchronous. call() burn() *self Principal call() .await Inside the method we use another type of remote call function - . It differs from simple with only one last argument - amount of cycles we want to send to the remote canister when calling this method. mint() call_with_payment() call() One can spot an interesting expression inside the method - . Unfortunately, this is how XTC’s API works - you have to pass zero as a second argument (which in classic 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. mint() (to, Nat::from(0)) ERC-20 mint() Finishing XTC This is it. Our XTC integration is over with that one little file called the full version of which you can find here: xtc.rs 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 into Rust programming language. .did файл 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 type: Principal // 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 should look like this: dip20.rs 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 with examples and stuff. pretty nice little API documentation 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 type. We need it because Sonic is written in language and type in Motoko is not compatible with the same type in Rust. This is why we need this type and also some kind of function to transform Motoko’s into Rust’s - . MotokoResult Motoko Result Result Result 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 ). In other words, Sonic is a multi-token and exchange fusion. ERC-1155 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 should look like that: sonic.rs 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 's of all of the canisters it will interact with (Sonic, XTC и WICP), as well as our own , 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). Principal Principal // 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() } } function makes our life easier when we need to read the state (in fact, it encapsulates the block, making it ). get_state() unsafe 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 nature of our state, on condition that in order to access it we will always use the function. unsafe get_state() Let’s also define an method of our canister: init // 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 . This is the same as usual macro (it is recommended to read , 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: #[update(guard = controller_guard)] update this tutorial // 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 , we allow to call it only to the canister controller (i.e. only to ourselves). guard = controller_guard 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 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 and methods) did work as intended. expect mint() burn() Last, but not least, despite these methods not changing the state of our canister, we annotate it with an (instead of ) 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. update query 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 enum and a helper function . Later you’ll see how much simpler it is to use it instead of raw 's. Currency token_id_by_currency() Principal 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: and in the following way: deposit() withdraw() // 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 function we allow Sonic’s canister to spend our tokens (DIP20 token automation works the same way as ERC20 - with functionality), and then we ask Sonic’s canister to deposit these tokens on bot’s internal balance. deposit() allowance/approval Inside function, in order to read the state correctly we use a star . 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. deposit() let state = *get_state() If you want to evade copying (when you have a decent state) you could just repeat the expression after each async call, each time creating a new correct reference to the global state object. let state = get_state() In order to be able to check whether functions and worked as they should we would also need a function to check Sonic’s internal token balance of our bot: deposit() withdraw() // 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 and and there are 100 tokens locked and 50 tokens locked, the exchange rate would be . A B A B 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 inside type variables, because float is non-deterministic. Instead they are stored and processed inside type variables with a note about how many precision numbers are there. But for our tutorial it is okay to use to represent money to the user. are never stored float integer float For internal computations we use library, which makes it a lot easier to convert money from . BigDecimal 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), } is pretty straightforward: it defines a pair of tokens ( is a token, which we want to exchange; is a token which we want to receive back), and also - 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 value. MarketOrder give take OrderDirective slippage For example, there are two tokens we have and we want . The exchange rate is , slippage - . If we set to , we will certainly lose 100 tokens, and we receive back some amount of tokens between 45 (max slippage) and 50 (min slippage). If we set to , we will certainly receive 50 tokens, and we lose some amount of tokens between 100 (min slippage) and 110 (max slippage). A B 2:1 10% OrderDirective GiveExact(100) A B OrderDirective TakeExact(50) B A is defined as a , which can only be executed when the exchange rate reaches some condition. LimitOrder MarketOrder TargetPrice 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 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: cron_enqueue() // 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 macro: heartbeat // 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 . 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. cron_ready_tasks() As you can see, our tasks are only executed once ( ). 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. Iterations::Exact(1) 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 - . This function has a bad name - it should be called - because this is what it actually does. It receives a 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 function if you have something async running there. ic_cdk::block_on() spawn Future heartbeat Now let’s define the function , to understand how it works: execute_limit_order() // 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 function, which uses Sonic API’s to perform a swap with the current exchange rate: execute_market_order() // 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 value, it uses Sonic’s API to swap tokens. OrderDirective Congratulations! We’ve managed to finish our long coding session. The complete file can be found here: actor.rs 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 file from our Rust source code: .did // 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 , to make it into the bots controller: Principal $ 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 in order to get one. Then we just need to call this simple command: this tutorial $ dfx deploy --network ic --wallet <your-wallet-canister-id> --argument '(principal "<your principal>")' ... Deployed canisters We use parameter, because starting from version the behavior is the default one. --wallet dfx 0.9.0 --no-wallet 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 function for that: deposit() $ 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) response is good. For a market order this means that everything went as expected. Let’s check our WICP Sonic balance: null $ 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 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.83... 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 . Now we need to wait till the price will actually go all the way up to . This can take a while, but we can periodically check it with function. 0 15.84 get_swap_price() 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 , now ). But at least we didn’t risk anything. 92_000_000_000 91_757_051_334 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!