Skip to main content

2. Creating a decentralized token swap

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 2. ICRC tokens

Prerequisites

Before you start, verify that you have set up your developer environment according to the instructions in 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 example repo and navigate into the icp-token-swap-canister directory:

git clone https://github.com/Jonath-z/icp-token-swap-canister
cd icp-token-swap-canister

Reviewing the project's files

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

.
.
.
├── dfx.json
├── package-lock.json
├── package.json
├── src
│   └── backend
│   └── index.ts
└── index.did
└── token_a.did
└── token_b.did
├── test
│ ├── pretest.ts
│ ├── test.ts
│ ├── tests.ts

└── tsconfig.json

In this project, there are 3 did files in the src/backend subdirectory:

  • index.did: The backend candid interface

  • token_a.did and token_b.did: Define ICRC-2 token candid interfaces

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

{
"canisters": {
"backend": {
"type": "custom",
"main": "src/backend/index.ts",
"candid": "src/backend/index.did",
"build": "npx azle backend",
"wasm": ".azle/backend/backend.wasm",
"gzip": true,
"assets": [["src/frontend/dist", "dist"]],
"build_assets": "npm run build",
".env": [
"AZLE_TEST_FETCH",
"CANISTER_ID_TOKEN_B",
"CANISTER_ID_TOKEN_A",
"CANISTER_ID_BACKEND"
],
"declarations": {
"output": "test/dfx_generated/backend",
"node_compatibility": true
}
},
"token_a": {
"type": "custom",
"candid": "src/backend/token_a.did",
"wasm": "https://download.dfinity.systems/ic/2e3589427cd9648d4edaebc1b96b5daf8fdd94d8/canisters/ic-icrc1-ledger.wasm.gz",
".env": [
"AZLE_TEST_FETCH",
"CANISTER_ID_TOKEN_B",
"CANISTER_ID_TOKEN_A",
"CANISTER_ID_BACKEND"
]
},
"token_b": {
"type": "custom",
"candid": "src/backend/token_b.did",
"wasm": "https://download.dfinity.systems/ic/2e3589427cd9648d4edaebc1b96b5daf8fdd94d8/canisters/ic-icrc1-ledger.wasm.gz",
".env": [
"AZLE_TEST_FETCH",
"CANISTER_ID_TOKEN_B",
"CANISTER_ID_TOKEN_A",
"CANISTER_ID_BACKEND"
]
}
},
"output_env_file": ".env"
}

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

  • The backend 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/backend/index.ts.

  • 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 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 2. ICRC tokens

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

// Import necessary modules and types from the 'azle' library
import {
blob,
Canister,
ic,
nat,
nat64,
None,
Opt,
Principal,
query,
StableBTreeMap,
text,
update,
Record,
init,
} from "azle";
// Import specific types and functions from the 'azle/canisters/icrc' library
import {
Account,
ICRC,
TransferArgs,
TransferFromResult,
TransferResult,
} from "azle/canisters/icrc";

// Define the structure for deposit arguments
const DepositArgs = Record({
spender_subaccount: Opt(text), // Optional text field for spender subaccount
token: Principal, // Principal type for token
from: Account, // Account type for the source account
amount: nat, // Natural number type for the amount
fee: Opt(nat), // Optional natural number type for the fee
memo: Opt(blob), // Optional blob type for the memo
created_at_time: Opt(nat64), // Optional nat64 type for the creation time
});

// Define the structure for withdrawal arguments
const WithdrawArgs = Record({
token: Principal, // Principal type for token
to: Account, // Account type for the destination account
amount: nat, // Natural number type for the amount
fee: Opt(nat), // Optional natural number type for the fee
memo: Opt(blob), // Optional blob type for the memo
created_at_time: Opt(nat64), // Optional nat64 type for the creation time
});

// Define private variables to track the deposited balances for token A and token B
const balanceA = StableBTreeMap<string, number>(0); // StableBTreeMap for token A balances with id 0
const balanceB = StableBTreeMap<string, number>(1); // StableBTreeMap for token B balances with id 1

// Declare variables for storing Principal values of token A and token B
let tokenA: Principal;
let tokenB: Principal;

// Define the structure for initialization arguments
const InitArgs = Record({
token_a: Principal, // Principal type for token A
token_b: Principal, // Principal type for token B
});

// Define and export the main canister
export default Canister({
// Define an initialization method
initialize: update([InitArgs], text, (args) => {
tokenA = args.token_a; // Assign token A Principal
tokenB = args.token_b; // Assign token B Principal
return "initialized"; // Return initialization message
}),

// Define a method to list balances
listBalances: query([], text, () => {
const balancesA = balanceA.keys().map((value) => {
return {
[value]: balanceA.get(value).Some, // Get and format balances for token A
};
});

const balancesB = balanceB.keys().map((value) => {
return {
[value]: balanceB.get(value).Some, // Get and format balances for token B
};
});

return JSON.stringify({ balancesA, balancesB }); // Return balances as JSON string
}),

// Define a method to handle deposits
deposit: update([DepositArgs], TransferFromResult, async (transferArgs) => {
console.log({ env: process.env }); // Log environment variables
const tokenId = transferArgs.token.toString(); // Convert token Principal to string
console.log({
icrc: transferArgs.token.toString(),
tokena: tokenA.toString(),
tokenb: tokenB.toString(),
});
const icrc = ICRC(Principal.fromText(transferArgs.token.toString())); // Initialize ICRC with token Principal

let balanceStore;
if (tokenId === tokenA.toString()) balanceStore = balanceA; // Select balance store for token A
if (tokenId === tokenB.toString()) balanceStore = balanceB; // Select balance store for token B

const oldBalance = balanceStore?.get(transferArgs.from.owner.toString()).Some; // Get the old balance

if (!oldBalance) // If no old balance exists
balanceStore?.insert(transferArgs.from.owner.toString(), Number(transferArgs.amount.toString())); // Insert new balance
else
balanceStore?.insert(transferArgs.from.owner.toString(), Number(transferArgs.amount.toString()) + oldBalance); // Update existing balance

// 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
const fee = transferArgs.fee ? transferArgs.fee : await ic.call(icrc.icrc1_fee, { args: [] }); // Get fee

const transferData: TransferArgs = {
...transferArgs,
to: {
owner: Principal.fromText(ic.id().toString()), // Set destination to canister id
subaccount: None, // No subaccount
},
from_subaccount: None, // No subaccount
memo: None, // No memo
fee: fee, // Set fee
};

let transfer_result = await ic.call(icrc.icrc2_transfer_from, { args: [transferData] }); // Perform transfer
return transfer_result; // Return transfer result
}),

// Define a method to handle withdrawals
withdraw: update([WithdrawArgs], TransferResult, async (transferArgs) => {
const tokenId = transferArgs.token.toString(); // Convert token Principal to string

const icrc = ICRC(Principal.fromText(tokenId)); // Initialize ICRC with token Principal

const transferData: TransferArgs = {
to: transferArgs.to, // Set destination account
from_subaccount: None, // No subaccount
memo: transferArgs.memo, // Set memo
fee: transferArgs.fee, // Set fee
amount: transferArgs.amount, // Set amount
created_at_time: transferArgs.created_at_time, // Set creation time
};

let transfer_result = await ic.call(icrc.icrc1_transfer, { args: [transferData] }); // Perform transfer
return transfer_result; // Return transfer result
}),

// Define a method to swap balances between users
swap: update(
[Record({ user_a: Principal, user_b: Principal })], // Define arguments structure
text, // Return type
(swapArgs) => {
const user_a = swapArgs.user_a.toString(); // Convert user A Principal to string
const user_b = swapArgs.user_b.toString(); // Convert user B Principal to string
// Give user_a's token_a to user_b
// Add the two users' token_a balances, and then give all of it to user_b
const userABalanceForTokenA = balanceA.get(user_a).Some; // Get user A's balance for token A
const userBBalanceForTokenA = balanceA.get(user_b).Some; // Get user B's balance for token A

balanceA.insert(user_b, (userABalanceForTokenA ?? 0) + (userBBalanceForTokenA ?? 0)); // Update user B's balance
balanceA.remove(user_a); // Remove user A's balance

// Give user_b's token_b to user_a
// Add the two users' token_b balances, and then give all of it to user_a
const userABalanceForTokenB = balanceB.get(user_a).Some; // Get user A's balance for token B
const userBBalanceForTokenB = balanceB.get(user_b).Some; // Get user B's balance for token B

balanceB.insert(user_a, (userBBalanceForTokenB ?? 0) + (userABalanceForTokenB ?? 0)); // Update user A's balance
balanceB.remove(user_b); // Remove user B's balance

return "Success"; // Return success message
}
),
});

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

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 it's time to deploy the backend canister. Use the following command, which deploys and calls the initialize function of the backend canister:

dfx deploy backend

dfx canister call backend initialize '
record {
token_a = (principal "'${TOKEN_A}'");
token_b = (principal "'${TOKEN_B}'");
}
'

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

export SWAP=$(dfx canister id backend)

Depositing tokens into the backend canister

Before tokens can be swapped, they must be transferred into the backend 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 backend 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 backend 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 backend 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 backend canister, a token swap can be performed by calling the backend canister's swap method:

dfx canister call backend 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 backend balances

The output of this command will resemble the following:

{
"balancesA": [
{
"kmp6t-h6ejb-tekcb-i3fcl-ftmq5-vy7xh-aqgeo-vmj7q-eyelp-3qrzy-cqe": 100000000
}
],
"balancesB": [
{
"wf2zm-xaady-dvxcj-oqfh5-7pman-ijt2j-x2ikl-hzdvy-jf5qe-e4qda-fae": 100000000
}
]
}

Withdrawing tokens

Now that the swap has been completed, the users can withdraw their newly received tokens out of the backend 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 backend 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 backend 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: