Skip to main content

5.3 Creating a decentralized token swap

Advanced
Tutorial

ICP 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 can be used to buy, sell, trade, and withdraw cryptocurrencies and other digital assets without a centralized authority that authorizes the trades.

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.

This example is currently not available in ICP Ninja and must be run locally with dfx.

Before you start, verify that you have set up your developer environment according to the instructions in 1.2 Developer environment setup.
### Cloning the `icrc2-swap` example

To get started, open a new terminal window, navigate into your working directory (developer_liftoff), 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

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 the 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 our ICRC type definitions
import ICRC "./ICRC";

// The swap canister is the main backend canister for this example. To simplify
// this example we configure the swap canister with the two tokens it will be
// swapping.
shared (init_msg) actor class Swap(
init_args : {
token_a : Principal;
token_b : Principal;
}
) = this {

// Track the deposited per-user 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 we need a
// location to store the data during canister upgrades.
private stable var stableBalancesA : ?[(Principal, Nat)] = null;
private stable var stableBalancesB : ?[(Principal, Nat)] = null;

// balances is a simple getter to check the balances of all users, to make debugging easier.
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 here. The user can pass a null fee, which
// means "use the default". So we need to look up the default in order to
// correctly deduct it from their balance.
let fee = switch (args.fee) {
case (?f) { f };
case (null) { await token.icrc1_fee() };
};

// Perform the transfer, to capture the tokens.
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 for us to do since no state has
// changed, so we can just wrap and return the error to the frontend.
return #err(#TransferFromError(err));
};
};

// From here on out, we need to make sure that this function does *not*
// fail. If it failed, the token transfer would be complete (meaning we
// would have the user's tokens), but we would not have credited their
// account yet, so this canister would not *know* that it had received the
// user's tokens.
//
// If the function *can* fail here after this point, we should either:
// - Move that code to a separate action later
// - Have failure-handling code which refunds the user's tokens

// 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 = {
// Left as a placeholder for future implementors, in case their swap
// function could fail.
};

// Swap TokenA for TokenB
// - Exchange tokens between the two given users.
// - For this example, there will be no clever swap mechanism, it simply swaps all
// deposits for the two users, even allowing anybody to perform the swap.
// Designing a useful and safer swap mechanism is left as an exercise for
// the reader.
// - UserA's full balance of TokenA is given to UserB, and UserB's full
// balance of TokenB is given to UserA.
public shared (msg) func swap(args : SwapArgs) : async Result.Result<(), SwapError> {
// Because both tokens were deposited before calling swap, we can execute
// this function atomically. To do that there must be no `await` calls in
// this function. If we *did* have `await` calls in this function, we would
// need to be careful with the order that we update any internal state
// variables. If this function were to update some state variables, call
// `await`, then fail, before updating others, it could leave this canister
// with inconsistent internal state.
//
// Making this function atomic (by not using `await`) makes it safer,
// because either all of the state changes applied in this function will be
// persisted, or all of the state changes will be reverted.

// Give user_a's token_a to user_b
// Add the the two user's token_a balances, and 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 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;
};

public type WithdrawError = {
// The caller doesn't not have sufficient funds deposited in this swap
// contract to fulfil this withdrawal. Note, this is different from the
// TransferError(InsufficientFunds), which would indicate that this
// canister doesn't have enough funds to fulfil the withdrawal (a much more
// serious error).
#InsufficientFunds : { balance : ICRC.Tokens };
// For other transfer errors, we can just wrap and return them.
#TransferError : ICRC.TransferError;
};

// Allow withdrawals
// - 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 here. The user can pass a null fee, which
// means "use the default". So we need to look up the default in order to
// correctly deduct it from their balance.
let fee = switch (args.fee) {
case (?f) { f };
case (null) { await token.icrc1_fee() };
};

// Check 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
//
// We do this first, due to the asynchronous nature of the IC. By debitting
// the account first, we ensure that the user cannot withdraw more than
// they have.
//
// If we were to perform the transfer, then debit the user's account, there
// are several ways which that attack could lead to loss of funds. For
// example:
// - The user could call `withdraw` repeatedly, in a DOS attack to trigger
// a race condition. This would queue multiple outbound transfers in
// parallel, resulting in the user withdrawing more funds than available.
// - The token could perform a "reentrancy" attack, where the token's
// implementation of `icrc1_transfer` calls back into this canister, and
// triggers another recursive withdrawal, resulting in draining of this
// canister's token balance. However, because the token canister directly
// controls user's balances anyway, it could simplify this attack, and just
// change the canister's balance. Generally, this is why you should only
// use token canisters which you trust and can review.
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, to 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)) {
// The transfer failed, we need to refund the user's account (less
// fees), so that they do not completely lose their tokens, and can
// retry the withdrawal.
//
// Refund the user's account. Note, we can't just put the old_balance
// back, because their balance may have changed simultaneously while we
// were waiting for the transaction.
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);
};

// which_balances checks which token we are withdrawing, and configure 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 we need 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;
};
};

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 DevLiftoff identity and save its principal as the environmental variable OWNER:

dfx identity use DevLiftoff
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 start --clean --background
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 DevLiftoff 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 '1_000'
  • controller_id: Defines 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 DevLiftoff 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 '1_000.'
  • controller_id: Defines 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 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 use 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}'";
}'
ICP AstronautNeed help?

Did you get stuck somewhere in this tutorial, or do you 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: