Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion motoko/icrc2-swap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Three canisters:
- **`token_a` / `token_b`**: Standard ICRC-1/ICRC-2 ledger canisters, pre-built from the DFINITY IC release.
- **`backend`**: The swap canister (`backend/app.mo`). Accepts deposits, performs 1:1 swaps, and processes withdrawals. It discovers the token canister principals automatically at runtime via `PUBLIC_CANISTER_ID:token_a` / `PUBLIC_CANISTER_ID:token_b` environment variables injected by icp-cli.

`backend/ICRC.mo` defines the ICRC-1/2 types and actor interface used by the backend. These are defined inline (rather than from a mops package) so the full interface is visible in the example.
The backend talks to the token ledgers through a single generated Motoko binding, `bindings/ICRC.mo`, produced from the ICRC-1/ICRC-2 interface in `candid/icrc.did`. Every token reference is typed against that one shared type — `actor(<token-principal>) : ICRC.Self` — so the same binding serves both `token_a` and `token_b` (and would serve any ICRC-1/2 ledger the backend is pointed at, e.g. ckBTC or an SNS token). This is the "one interface, many ledgers, principal chosen at runtime" pattern: because the target is dynamic, the types come from the standard Candid interface rather than being bound to a single canister id. The binding is committed, so building the example needs no extra tooling.

## Build and deploy from the command line

Expand Down Expand Up @@ -62,6 +62,16 @@ icp network stop

`bash test.sh` runs the full swap flow with `icrc2-alice` and `icrc2-bob` as the two parties. Test 2 verifies that swapping with no deposits returns `InsufficientBalance`. Tests 6 and 7 verify the actual token balance delta in the ledger after withdrawal, confirming the full round-trip. Tests are idempotent — they can be run multiple times without redeploying.

## Updating the token interface bindings

`candid/icrc.did` is the ICRC-1/ICRC-2 interface the backend calls; `bindings/ICRC.mo` is the Motoko binding generated from it. The binding is committed, so no extra tooling is needed to build. If you change the interface, regenerate the binding with [`didc`](https://github.com/dfinity/candid/releases):

```bash
didc bind -t mo candid/icrc.did > bindings/ICRC.mo
```

(The ICRC-1/2 type names are already PascalCase, so `didc` output is idiomatic as-is — no post-processing needed.)

## Fee handling

ICRC-1 tokens charge a `transfer_fee` (10,000 e8s in this example) on every transfer through the ledger.
Expand Down
18 changes: 9 additions & 9 deletions motoko/icrc2-swap/backend/app.mo
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Principal "mo:core/Principal";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";

import ICRC "ICRC";
import ICRC "../bindings/ICRC";

// The swap canister accepts deposits of two ICRC-2 tokens, swaps balances
// 1:1 between users, and allows withdrawals.
Expand Down Expand Up @@ -58,7 +58,7 @@ actor Swap {
// - 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 func deposit(args : DepositArgs) : async Result.Result<Nat, DepositError> {
let token : ICRC.Actor = actor (args.token.toText());
let token : ICRC.Self = actor (args.token.toText());
let balances = which_balances<system>(args.token);

// Load the fee from the token here. The user can pass a null fee, which
Expand Down Expand Up @@ -99,7 +99,7 @@ actor Swap {
// Credit the sender's account
let sender = args.from.owner;
let old_balance = balances.get(sender).get(0 : Nat);
let _ = balances.swap(sender, old_balance + args.amount);
balances.add(sender, old_balance + args.amount);

// Return the "block height" of the transfer
#ok(block_height);
Expand Down Expand Up @@ -131,15 +131,15 @@ actor Swap {
};

// Give user_a's token_a to user_b
let _ = balancesA.swap(
balancesA.add(
args.user_b,
balancesA.get(args.user_a).get(0 : Nat) +
balancesA.get(args.user_b).get(0 : Nat),
);
balancesA.remove(args.user_a);

// Give user_b's token_b to user_a
let _ = balancesB.swap(
balancesB.add(
args.user_a,
balancesB.get(args.user_a).get(0 : Nat) +
balancesB.get(args.user_b).get(0 : Nat),
Expand Down Expand Up @@ -179,7 +179,7 @@ actor Swap {
Runtime.trap("anonymous caller not allowed");
};

let token : ICRC.Actor = actor (args.token.toText());
let token : ICRC.Self = actor (args.token.toText());
let balances = which_balances<system>(args.token);

let fee = switch (args.fee) {
Expand All @@ -202,7 +202,7 @@ actor Swap {
if (new_balance == 0) {
balances.remove(msg.caller);
} else {
let _ = balances.swap(msg.caller, new_balance);
balances.add(msg.caller, new_balance);
};

// Perform the transfer, to send the tokens.
Expand All @@ -222,7 +222,7 @@ actor Swap {
} catch (e) {
// Token ledger trapped — refund and surface the error.
let b = balances.get(msg.caller).get(0 : Nat);
let _ = balances.swap(msg.caller, b + args.amount + fee);
balances.add(msg.caller, b + args.amount + fee);
return #err(#CallFailed(e.message()));
};

Expand All @@ -231,7 +231,7 @@ actor Swap {
case (#Err(err)) {
// Transfer failed — refund the user's account.
let b = balances.get(msg.caller).get(0 : Nat);
let _ = balances.swap(msg.caller, b + args.amount + fee);
balances.add(msg.caller, b + args.amount + fee);
return #err(#TransferError(err));
};
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,131 +1,99 @@
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;
// This is a generated Motoko binding.
// Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible.

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

public type TransferArg = {
from_subaccount : ?Subaccount;
to : Account;
amount : Tokens;
module {
public type Account = { owner : Principal; subaccount : ?Subaccount };
public type Allowance = { allowance : Tokens; expires_at : ?Timestamp };
public type AllowanceArgs = { account : Account; spender : Account };
public type ApproveArgs = {
fee : ?Tokens;
memo : ?Blob;
from_subaccount : ?Subaccount;
created_at_time : ?Timestamp;
amount : Tokens;
expected_allowance : ?Tokens;
expires_at : ?Timestamp;
spender : Account;
};

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

public type TransferResult = {
#Ok : BlockIndex;
#Err : TransferError;
#BadFee : { expected_fee : Tokens };
#AllowanceChanged : { current_allowance : Tokens };
#CreatedInFuture : { ledger_time : Timestamp };
#TooOld;
#Expired : { ledger_time : Timestamp };
#InsufficientFunds : { balance : Tokens };
};

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

public type ApproveArgs = {
from_subaccount : ?Subaccount;
spender : Account;
amount : Tokens;
expected_allowance : ?Tokens;
expires_at : ?Timestamp;
public type StandardRecord = { url : Text; name : Text };
public type Subaccount = Blob;
public type Timestamp = Nat64;
public type Tokens = Nat;
public type TransferArg = {
to : Account;
fee : ?Tokens;
memo : ?Blob;
from_subaccount : ?Subaccount;
created_at_time : ?Timestamp;
amount : Tokens;
};

public type ApproveError = {
public type TransferError = {
#GenericError : { message : Text; error_code : Nat };
#TemporarilyUnavailable;
#BadBurn : { min_burn_amount : Tokens };
#Duplicate : { duplicate_of : BlockIndex };
#BadFee : { expected_fee : Tokens };
#InsufficientFunds : { balance : Tokens };
#AllowanceChanged : { current_allowance : Tokens };
#Expired : { ledger_time : Nat64 };
#CreatedInFuture : { ledger_time : Timestamp };
#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;
#InsufficientFunds : { balance : Tokens };
};

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

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

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

public type Actor = actor {
icrc1_name : shared query () -> async Text;
icrc1_symbol : shared query () -> async Text;
public type TransferFromResult = {
#Ok : BlockIndex;
#Err : TransferFromError;
};
public type TransferResult = { #Ok : BlockIndex; #Err : TransferError };
public type Self = actor {
icrc1_balance_of : shared query Account -> async Tokens;
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_metadata : shared query () -> async [(Text, MetadataValue)];
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;
};
};
icrc1_name : shared query () -> async Text;
icrc1_supported_standards : shared query () -> async [StandardRecord];
icrc1_symbol : shared query () -> async Text;
icrc1_total_supply : shared query () -> async Tokens;
icrc1_transfer : shared TransferArg -> async TransferResult;
icrc2_allowance : shared query AllowanceArgs -> async Allowance;
icrc2_approve : shared ApproveArgs -> async ApproveResult;
icrc2_transfer_from : shared TransferFromArgs -> async TransferFromResult;
}
}
Loading
Loading