5.3 Creating a decentralized token swap
Overview
The Internet Computer enables decentralized finance (DeFi) applications through its design that includes complex, onchain 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 anICRC
module containing types that are imported into themain.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:
{
"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 atsrc/swap/main.mo
.The
token_a
canister: This canister is used to createtoken_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 tokensThe
token_b
canister: The canister is used to createtoken_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:
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:
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 theOWNER
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 theUSER_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 theOWNER
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 theOWNER
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 theUSER_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 theOWNER
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:
Developer Discord community, which is a large chatroom for ICP developers to ask questions, get help, or chat with other developers asynchronously via text chat.
Motoko Bootcamp - The DAO Adventure - Discover the Motoko language in this 7 day adventure and learn to build a DAO on the Internet Computer.
Motoko Bootcamp - Discord community - A community for and by Motoko developers to ask for advice, showcase projects and participate in collaborative events.
Weekly developer office hours to ask questions, get clarification, and chat with other developers live via voice chat. This is hosted on our developer Discord group.
Submit your feedback to the ICP Developer feedback board.