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
andtoken_b.did
: DefineICRC-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 atsrc/backend/index.ts
. -
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 2. ICRC tokens -
The
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 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 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 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:
-
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.
-
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.