Skip to main content

5.3 Creating a decentralized token swap

Advanced
Tutorial

Overview

The Internet Computer enables decentralized finance (DeFi) applications through its design that includes complex, on-chain computation. One primary example of a DeFi application is a decentralized exchange (DEX). A DEX is an exchange that can be used to buy, sell, trade, and withdraw cryptocurrencies and other digital assets without a centralized authority that authorizes the trades, such as bank. Decentralized exchanges are extremely useful, as they allow users to buy and hold cryptocurrencies, trade it directly for another token or coin, then withdraw the tokens to be used elsewhere.

Decentralized token swap canister

In this tutorial, you'll deploy a simple token swap canister that demonstrates the core functionalities that a DEX provides:

  • Deposit tokens: Deposit tokens into the canister to be swapped for another token.

  • Swap tokens: Swap tokens for other tokens, using a simple 1:1 swap ratio to demonstrate basic functionality.

  • Withdraw tokens: After tokens have been swapped, they can be withdrawn from the canister.

Once a user has deposited funds, the tokens deposited can be swapped from one token to another. A trading pair is a set of two assets that can be traded with each other on an exchange. A token swap consists of two tuple values that represent the trading pair, such as [Token1, amount1] and [Token2, amount2].

In this example, the token swap canister simply swaps all deposits for two users. user_a's entire token balance of token_a is given to user_b, and user_b's entire token balance of token_b is given to user_a.

This example will use ICRC-2 tokens. To learn more about ICRC tokens, refer to the previous module 4.2: ICRC tokens

Prerequisites

Before you start, verify that you have set up your developer environment according to the instructions in 0.3 Developer environment setup.

Cloning the icrc2-swap example

To get started, open a new terminal window, navigate into your working directory (developer_journey), then use the following commands to clone the DFINITY examples repo and navigate into the icrc2-swap directory:

git clone https://github.com/dfinity/examples/
cd examples/motoko/icrc2-swap

Reviewing the project's files

First, let's review the files that are in this project:

.
├── Makefile
├── README.md
├── babel.config.js
├── dfx.json
├── jest.config.ts
├── package-lock.json
├── package.json
├── src
│   └── swap
│   ├── ICRC.mo
│   └── main.mo
├── test
│   ├── deploy.sh
│   └── swap
│   ├── agent.ts
│   ├── identity.ts
│   ├── setup.ts
│   ├── swap.test.ts
│   └── teardown.ts
└── tsconfig.json

In this project, there are two Motoko files in the src/swap subdirectory:

  • main.mo: The token swap canister's source code.

  • ICRC.mo: Defines an ICRC module containing types that are imported into the main.mo file.

This project also includes a test subdirectory containing TypeScript files that can be used to test the token swap canister's functionality.

Then, open the project's dfx.json file to review the project's canisters and configuration:

dfx.json
{
"canisters": {
"swap": {
"main": "src/swap/main.mo",
"type": "motoko"
},
"token_a": {
"type": "custom",
"candid": "https://raw.githubusercontent.com/dfinity/ic/2e3589427cd9648d4edaebc1b96b5daf8fdd94d8/rs/rosetta-api/icrc1/ledger/ledger.did",
"wasm": "https://download.dfinity.systems/ic/2e3589427cd9648d4edaebc1b96b5daf8fdd94d8/canisters/ic-icrc1-ledger.wasm.gz"
},
"token_b": {
"type": "custom",
"candid": "https://raw.githubusercontent.com/dfinity/ic/2e3589427cd9648d4edaebc1b96b5daf8fdd94d8/rs/rosetta-api/icrc1/ledger/ledger.did",
"wasm": "https://download.dfinity.systems/ic/2e3589427cd9648d4edaebc1b96b5daf8fdd94d8/canisters/ic-icrc1-ledger.wasm.gz"
}
},
"output_env_file": ".env",
"version": 1
}

In this project's configuration, you can see definition of three canisters:

  • The swap canister: This canister provides the deposit, swap, and withdraw functionality for the project's ICRC-2 tokens. It uses the source code found at src/swap/main.mo.

  • The token_a canister: This canister is used to create token_a, an ICRC-2 token. This canister uses the most recent ICRC ledger Wasm and Candid files. To learn more about the ICRC Wasm and Candid files, refer to the previous module 4.2: ICRC tokens

  • The token_b canister: The canister is used to create token_b, an ICRC-2 token. This canister uses the most recent ICRC ledger Wasm and Candid files. To learn more about the ICRC Wasm and Candid files, refer to the previous module 4.2: ICRC tokens

Next, open the src/swap/main.mo file and review its contents. This code has been annotated with notes to explain the program's functionality:

src/swap/main.mo
import Blob "mo:base/Blob";
import Debug "mo:base/Debug";
import Iter "mo:base/Iter";
import Nat "mo:base/Nat";
import Option "mo:base/Option";
import P "mo:base/Prelude";
import Principal "mo:base/Principal";
import Result "mo:base/Result";
import TrieMap "mo:base/TrieMap";

// Import the ICRC type definitions that are stored in the ICRC.mo file:
import ICRC "./ICRC";

// This swap canister is the primary backend canister in this example.
// For simplicity, this canister is configured with two tokens that will be swapped:
shared (init_msg) actor class Swap(
init_args : {
token_a : Principal;
token_b : Principal;
}
) = this {

// Define private variables to track the deposited balances for token A and token B:
private var balancesA = TrieMap.TrieMap<Principal, Nat>(Principal.equal, Principal.hash);
private var balancesB = TrieMap.TrieMap<Principal, Nat>(Principal.equal, Principal.hash);

// Because TrieMaps are not directly storable in stable memory, define a location to store the data during canister upgrades:
private stable var stableBalancesA : ?[(Principal, Nat)] = null;
private stable var stableBalancesB : ?[(Principal, Nat)] = null;

// Define a function to check the balances of all users:
public query func balances() : async ([(Principal, Nat)], [(Principal, Nat)]) {
(Iter.toArray(balancesA.entries()), Iter.toArray(balancesB.entries()));
};

public type DepositArgs = {
spender_subaccount : ?Blob;
token : Principal;
from : ICRC.Account;
amount : Nat;
fee : ?Nat;
memo : ?Blob;
created_at_time : ?Nat64;
};

public type DepositError = {
#TransferFromError : ICRC.TransferFromError;
};

// Accept deposits:
// - Accept TokenA and TokenB
// - User approves transfer: `token_a.icrc2_approve({ spender=swap_canister; amount=amount; ... })`
// - User deposits their token: `swap_canister.deposit({ token=token_a; amount=amount; ... })`
// - These deposit handlers show how to safely accept and register deposits of an ICRC-2 token.
public shared (msg) func deposit(args : DepositArgs) : async Result.Result<Nat, DepositError> {
let token : ICRC.Actor = actor (Principal.toText(args.token));
let balances = which_balances(args.token);

// Load the fee from the token:
// The user can pass a null fee, which means uses the default. TO determine the default, it needs to be retrieved:
let fee = switch (args.fee) {
case (?f) { f };
case (null) { await token.icrc1_fee() };
};

// Define the logic for the token transfer:
let transfer_result = await token.icrc2_transfer_from({
spender_subaccount = args.spender_subaccount;
from = args.from;
to = { owner = Principal.fromActor(this); subaccount = null };
amount = args.amount;
fee = ?fee;
memo = args.memo;
created_at_time = args.created_at_time;
});

// Check that the transfer was successful:
let block_height = switch (transfer_result) {
case (#Ok(block_height)) { block_height };
case (#Err(err)) {
// Transfer failed. There's no cleanup since no state has changed.
return #err(#TransferFromError(err));
};
};

// Credit the sender's account:
let sender = args.from.owner;
let old_balance = Option.get(balances.get(sender), 0 : Nat);
balances.put(sender, old_balance + args.amount);

// Return the "block height" of the transfer:
#ok(block_height);
};

public type SwapArgs = {
user_a : Principal;
user_b : Principal;
};

public type SwapError = {
// This function is left as a placeholder for future implementors, in case their swap function could fail.
};

public shared (msg) func swap(args : SwapArgs) : async Result.Result<(), SwapError> {

// In this function, since both tokens will be deposited before the swap is executed, this function can run atomically, meaning there must not be an 'await` call within this function.
// Not using 'await' calls makes this function safer, since all state changes applied in the function will either be persisted or reverted.

// Give user_a's token_a to user_b:
// Add the the two user's token_a balances, and then give all of it to user_b:
balancesA.put(
args.user_b,
Option.get(balancesA.get(args.user_a), 0 : Nat) +
Option.get(balancesA.get(args.user_b), 0 : Nat),
);
balancesA.delete(args.user_a);

// Give user_b's token_b to user_a:
// Add the the two user's token_b balances, and then give all of it to user_a:
balancesB.put(
args.user_a,
Option.get(balancesB.get(args.user_a), 0 : Nat) +
Option.get(balancesB.get(args.user_b), 0 : Nat),
);
balancesB.delete(args.user_b);

#ok(());
};

public type WithdrawArgs = {
token : Principal;
to : ICRC.Account;
amount : Nat;
fee : ?Nat;
memo : ?Blob;
created_at_time : ?Nat64;
};

// Define a function that catches an error if the caller does not have sufficient funds deposited into the swap.
// This error is different from the TransferError(InsufficientFunds) error, which indicates that the canister does not have enough funds to fulfill a withdraw.
public type WithdrawError = {
#InsufficientFunds : { balance : ICRC.Tokens };
#TransferError : ICRC.TransferError;
};

// Define the functionality for withdrawing tokens:
// - Allow users to withdraw any tokens they hold.
// - These withdrawal handlers show how to safely send outbound transfers of an ICRC-1 token:
public shared (msg) func withdraw(args : WithdrawArgs) : async Result.Result<Nat, WithdrawError> {
let token : ICRC.Actor = actor (Principal.toText(args.token));
let balances = which_balances(args.token);

// Load the fee from the token:
let fee = switch (args.fee) {
case (?f) { f };
case (null) { await token.icrc1_fee() };
};

// Check that the user's balance is sufficient:
let old_balance = Option.get(balances.get(msg.caller), 0 : Nat);
if (old_balance < args.amount + fee) {
return #err(#InsufficientFunds { balance = old_balance });
};

// Debit the sender's account:
// Since ICP uses asynchronous execution, the sender's account is debited first to ensure that the user cannot withdraw more than they have.
// If the transfer was performed first, then debited to the user's account, there would be several malicious attacks that the transfer would be at risk for.

let new_balance = old_balance - args.amount - fee;
if (new_balance == 0) {
// Delete zero-balances to keep the balance table tidy:
balances.delete(msg.caller);
} else {
balances.put(msg.caller, new_balance);
};

// Perform the transfer and send the tokens:
let transfer_result = await token.icrc1_transfer({
from_subaccount = null;
to = args.to;
amount = args.amount;
fee = ?fee;
memo = args.memo;
created_at_time = args.created_at_time;
});

// Check that the transfer was successful:
let block_height = switch (transfer_result) {
case (#Ok(block_height)) { block_height };
case (#Err(err)) {
// If the transfer failed, refund the user's account.
let b = Option.get(balances.get(msg.caller), 0 : Nat);
balances.put(msg.caller, b + args.amount + fee);

return #err(#TransferError(err));
};
};

// Return the "block height" of the transfer:
#ok(block_height);
};

// Define a function that checks which token the user is withdrawing, and configures the rest of the transfer.
// This function will assert that the token specified must be either token_a, or token_b.
private func which_balances(t : Principal) : TrieMap.TrieMap<Principal, Nat> {
let balances = if (t == init_args.token_a) {
balancesA;
} else if (t == init_args.token_b) {
balancesB;
} else {
Debug.trap("invalid token canister");
};
};

// TrieMaps cannot be directly stored in stable memory, so define a preupgrade and postupgrade to store the balances into stable memory:
system func preupgrade() {
stableBalancesA := ?Iter.toArray(balancesA.entries());
stableBalancesB := ?Iter.toArray(balancesB.entries());
};

system func postupgrade() {
switch (stableBalancesA) {
case (null) {};
case (?entries) {
balancesA := TrieMap.fromEntries<Principal, Nat>(entries.vals(), Principal.equal, Principal.hash);
stableBalancesA := null;
};
};

switch (stableBalancesB) {
case (null) {};
case (?entries) {
balancesB := TrieMap.fromEntries<Principal, Nat>(entries.vals(), Principal.equal, Principal.hash);
stableBalancesB := null;
};
};
};

};

Next, open the ICRC.mo file and review its contents. This file defines several types that are imported in the main.mo file:

src/swap/ICRC.mo
module ICRC {
public type BlockIndex = Nat;
public type Subaccount = Blob;
// Number of nanoseconds since the UNIX epoch in UTC timezone.
public type Timestamp = Nat64;
// Number of nanoseconds between two [Timestamp]s.
public type Tokens = Nat;
public type TxIndex = Nat;

public type Account = {
owner : Principal;
subaccount : ?Subaccount;
};

public type TransferArg = {
from_subaccount : ?Subaccount;
to : Account;
amount : Tokens;
fee : ?Tokens;
memo : ?Blob;
created_at_time : ?Timestamp;
};

public type TransferError = {
#BadFee : { expected_fee : Tokens };
#BadBurn : { min_burn_amount : Tokens };
#InsufficientFunds : { balance : Tokens };
#TooOld;
#CreatedInFuture : { ledger_time : Nat64 };
#TemporarilyUnavailable;
#Duplicate : { duplicate_of : BlockIndex };
#GenericError : { error_code : Nat; message : Text };
};

public type TransferResult = {
#Ok : BlockIndex;
#Err : TransferError;
};

// The value returned from the [icrc1_metadata] endpoint.
public type MetadataValue = {
#Nat : Nat;
#Int : Int;
#Text : Text;
#Blob : Blob;
};

public type ApproveArgs = {
from_subaccount : ?Subaccount;
spender : Account;
amount : Tokens;
expected_allowance : ?Tokens;
expires_at : ?Timestamp;
fee : ?Tokens;
memo : ?Blob;
created_at_time : ?Timestamp;
};

public type ApproveError = {
#BadFee : { expected_fee : Tokens };
#InsufficientFunds : { balance : Tokens };
#AllowanceChanged : { current_allowance : Tokens };
#Expired : { ledger_time : Nat64 };
#TooOld;
#CreatedInFuture : { ledger_time : Nat64 };
#Duplicate : { duplicate_of : BlockIndex };
#TemporarilyUnavailable;
#GenericError : { error_code : Nat; message : Text };
};

public type ApproveResult = {
#Ok : BlockIndex;
#Err : ApproveError;
};

public type AllowanceArgs = {
account : Account;
spender : Account;
};

public type Allowance = {
allowance : Tokens;
expires_at : ?Timestamp;
};

public type TransferFromArgs = {
spender_subaccount : ?Subaccount;
from : Account;
to : Account;
amount : Tokens;
fee : ?Tokens;
memo : ?Blob;
created_at_time : ?Timestamp;
};

public type TransferFromResult = {
#Ok : BlockIndex;
#Err : TransferFromError;
};

public type TransferFromError = {
#BadFee : { expected_fee : Tokens };
#BadBurn : { min_burn_amount : Tokens };
#InsufficientFunds : { balance : Tokens };
#InsufficientAllowance : { allowance : Tokens };
#TooOld;
#CreatedInFuture : { ledger_time : Nat64 };
#Duplicate : { duplicate_of : BlockIndex };
#TemporarilyUnavailable;
#GenericError : { error_code : Nat; message : Text };
};

public type Actor = actor {
icrc1_name : shared query () -> async Text;
icrc1_symbol : shared query () -> async Text;
icrc1_decimals : shared query () -> async Nat8;
icrc1_metadata : shared query () -> async [(Text, MetadataValue)];
icrc1_total_supply : shared query () -> async Tokens;
icrc1_fee : shared query () -> async Tokens;
icrc1_minting_account : shared query () -> async ?Account;
icrc1_balance_of : shared query (Account) -> async Tokens;
icrc1_transfer : shared (TransferArg) -> async TransferResult;
icrc1_supported_standards : shared query () -> async [{
name : Text;
url : Text;
}];
icrc2_approve : shared (ApproveArgs) -> async ApproveResult;
icrc2_allowance : shared query (AllowanceArgs) -> async Allowance;
icrc2_transfer_from : shared (TransferFromArgs) -> async TransferFromResult;
};
};

Starting a local replica

Before you can deploy the project's canister, you'll need to assure that a local replica is running with the command:

dfx start --clean --background

Creating identities for user_a and user_b:

Create new local identities for user_a and user_b that will be used for the token swap:

dfx identity new user_a
dfx identity new user_b

Then, save the principals for these identities as environmental variables to reference later:

export USER_A=$(dfx identity get-principal --identity user_a)
export USER_B=$(dfx identity get-principal --identity user_b)

Next, switch to your DevJourney identity and save its principal as the environmental variable OWNER:

dfx identity use DevJourney
export OWNER=$(dfx identity get-principal)

Deploying token_a

Next, deploy token_a by deploying the token_a canister and passing a series of arguments to the canister to create the token:

dfx deploy token_a --argument '
(variant {
Init = record {
token_name = "Token A";
token_symbol = "A";
minting_account = record {
owner = principal "'${OWNER}'";
};
initial_balances = vec {
record {
record {
owner = principal "'${USER_A}'";
};
100_000_000_000;
};
};
metadata = vec {};
transfer_fee = 10_000;
archive_options = record {
trigger_threshold = 2000;
num_blocks_to_archive = 1000;
controller_id = principal "'${OWNER}'";
};
feature_flags = opt record {
icrc2 = true;
};
}
})
'

In this command, token_a is created with the following configuration:

  • token_name: Defines the token's name as 'Token A'.
  • token_symbol: Defines the token's symbol as 'A'.
  • minting_account: Defines the minting account as the OWNER environmental variable. This variable holds the principal of your DevJourney identity.
  • initial_balances: Defines the initial balance of tokens as '100_000_000_000'.
    • owner: Defines the owner of the initial token balance (100_000_000_000) as the USER_A environmental variable. This variable holds the principal of the user_a identity.
  • transfer_fee: Defines the transfer fee as '10_000'.
  • trigger_threshold: Defines the trigger threshold as '2000'.
  • num_blocks_to_archive: Defines the number of blocks to archive as '1000'.
  • controller_id: Define's the controller ID as the OWNER environmental variable.
  • feature_flags: Defines a feature flag.
    • icrc2: Set as 'true' to define the token using the ICRC-2 standard.

Deploying token_b

Next, deploy token_b by deploying the token_b canister and passing a series of arguments to the canister to create the token:

dfx deploy token_b --argument '
(variant {
Init = record {
token_name = "Token B";
token_symbol = "B";
minting_account = record {
owner = principal "'${OWNER}'";
};
initial_balances = vec {
record {
record {
owner = principal "'${USER_B}'";
};
100_000_000_000;
};
};
metadata = vec {};
transfer_fee = 10_000;
archive_options = record {
trigger_threshold = 2000;
num_blocks_to_archive = 1000;
controller_id = principal "'${OWNER}'";
};
feature_flags = opt record {
icrc2 = true;
};
}
})
'

In this command, token_b is created with the following configuration:

  • token_name: Defines the token's name as 'Token B'.
  • token_symbol: Defines the token's symbol as 'B'.
  • minting_account: Defines the minting account as the OWNER environmental variable. This variable holds the principal of your DevJourney identity.
  • initial_balances: Defines the initial balance of tokens as '100_000_000_000'.
    • owner: Defines the owner of the initial token balance (100_000_000_000) as the USER_B environmental variable. This variable holds the principal of the user_a identity.
  • transfer_fee: Defines the transfer fee as '10_000'.
  • trigger_threshold: Defines the trigger threshold as '2000'.
  • num_blocks_to_archive: Defines the number of blocks to archive as '1000'.
  • controller_id: Define's the controller ID as the OWNER environmental variable.
  • feature_flags: Defines a feature flag.
    • icrc2: Set as 'true' to define the token using the ICRC-2 standard.

Exporting the token canister IDs as environmental variables

Next, export the token canister IDs as environmental variables:

export TOKEN_A=$(dfx canister id token_a)
export TOKEN_B=$(dfx canister id token_b)

Deploying the swap canister

Then its time to deploy the swap canister. Use the following command, which passes the canister IDs for the token canisters as arguments to the swap canister:

dfx deploy swap --argument '
record {
token_a = (principal "'${TOKEN_A}'");
token_b = (principal "'${TOKEN_B}'");
}
'

Then, export the canister ID of the swap canister as an environmental variable:

export SWAP=$(dfx canister id swap)

Depositing tokens into the swap canister

Before tokens can be swapped, they must be transferred into the swap canister. Since the tokens in this example uses ICRC-2 tokens, this is a two-step process. First, the transfer must be approved. In this example, approve user_a to deposit 1.00000000 of token_a, and include 0.0001 extra for the transfer fee:

dfx canister call --identity user_a token_a icrc2_approve '
record {
amount = 100_010_000;
spender = record {
owner = principal "'${SWAP}'";
};
}
'

This command should return the following output:

(variant { Ok = 1 : nat })

Then, approve user_b to deposit 1.00000000 of token_b, and include 0.0001 extra for the transfer fee:

dfx canister call --identity user_b token_b icrc2_approve '
record {
amount = 100_010_000;
spender = record {
owner = principal "'${SWAP}'";
};
}
'

This command should return the following output:

(variant { Ok = 1 : nat })

Now, you can call the swap canister's deposit method. This method performs the actual token transfer, moving the tokens from the user's wallets into the swap canister. First, deposit user_a's tokens:

dfx canister call --identity user_a swap deposit 'record {
token = principal "'${TOKEN_A}'";
from = record {
owner = principal "'${USER_A}'";
};
amount = 100_000_000;
}'

Then, deposit user_b's tokens:

dfx canister call --identity user_b swap deposit 'record {
token = principal "'${TOKEN_B}'";
from = record {
owner = principal "'${USER_B}'";
};
amount = 100_000_000;
}'

Performing a token swap

Now that both users have deposited their tokens into the swap canister, a token swap can be performed by calling the swap canister's swap method:

dfx canister call swap swap 'record {
user_a = principal "'${USER_A}'";
user_b = principal "'${USER_B}'";
}'

Then, check the balances for each user to confirm that user_b holds token_a, and user_a holds token_b:

dfx canister call swap balances

The output of this command will resemble the following:

(
vec {
record {
principal "3lk6x-5zgc5-jpteg-rvtcd-tukj2-agopr-y5nkn-5ovbc-ru3jm-6zrnr-qqe";
100_000_000 : nat;
};
},
vec {
record {
principal "7nxst-xuaqc-zaq33-5gohe-npp7h-zvrn5-od3zi-b277f-s65tc-jegzq-eqe";
100_000_000 : nat;
};
},
)

Withdrawing tokens

Now that the swap has been completed, the users can withdraw their newly received tokens out of the swap canister. First, withdraw user_a's balance of 1.00000000 token_b tokens, minus the 0.0001 transfer fee:

dfx canister call --identity user_a swap withdraw 'record {
token = principal "'${TOKEN_B}'";
to = record {
owner = principal "'${USER_A}'";
};
amount = 99_990_000;
}'

Then, withdraw user_b's balance of 1.00000000 token_a tokens, minus the 0.0001 transfer fee:

dfx canister call --identity user_b swap withdraw 'record {
token = principal "'${TOKEN_A}'";
to = record {
owner = principal "'${USER_B}'";
};
amount = 99_990_000;
}'

Checking token balances

To confirm everything worked as expected, check the token balance for each user. First, check user_a's balance of token_a. This user should have 998.99980000 A:

dfx canister call token_a icrc1_balance_of 'record {
owner = principal "'${USER_A}'";
}'

Then, check user_b's balance of token_b. This user should have 998.99980000 B:

dfx canister call token_b icrc1_balance_of 'record {
owner = principal "'${USER_B}'";
}'

Need help?

Did you get stuck somewhere in this tutorial, or feel like you need additional help understanding some of the concepts? The ICP community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out:

Next steps