Skip to content
Merged
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,13 @@ 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 imports the ICRC-1/ICRC-2 interface **directly from the committed Candid file** `candid/icrc.did`, using Motoko's `idl:` import (moc 1.13.0+):

```motoko
import ICRC "idl:../candid/icrc.did";
```

This yields the interface's named types and its service type `ICRC.Self` — no bindings are generated or committed, and no extra tooling is needed. The `idl:` import provides *types only*, so the backend supplies its own actor reference per token: `actor(<token-principal>) : ICRC.Self`. Every token reference is typed against that one shared type, so the same interface 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.

## Build and deploy from the command line

Expand Down Expand Up @@ -62,6 +68,10 @@ 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

`candid/icrc.did` is the ICRC-1/ICRC-2 interface the backend calls. It is imported directly via the `idl:` import (see [Architecture](#architecture)), so there is nothing to regenerate and no extra tooling to install — just edit `candid/icrc.did` and rebuild. moc reads it at build time and derives the Motoko types (snake_case Candid names become PascalCase automatically).

## Fee handling

ICRC-1 tokens charge a `transfer_fee` (10,000 e8s in this example) on every transfer through the ledger.
Expand Down
131 changes: 0 additions & 131 deletions motoko/icrc2-swap/backend/ICRC.mo

This file was deleted.

23 changes: 14 additions & 9 deletions motoko/icrc2-swap/backend/app.mo
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import Principal "mo:core/Principal";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";

import ICRC "ICRC";
// Import the ICRC-1/ICRC-2 interface types directly from the committed Candid
// file (moc 1.13.0+ `idl:` import). This yields the named types and the service
// type `ICRC.Self`; no bindings are generated or committed. The backend brings
// its own actor reference per token — `actor(<principal>) : ICRC.Self` — so the
// same types serve any ICRC-1/2 ledger the backend is pointed at.
import ICRC "idl:../candid/icrc.did";

// 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 +63,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 +104,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 +136,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 +184,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 +207,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 +227,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 +236,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
126 changes: 126 additions & 0 deletions motoko/icrc2-swap/candid/icrc.did
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// ICRC-1 + ICRC-2 token-ledger interface.
//
// This is the *standard* interface every ICRC-1/ICRC-2 ledger exposes — not a
// specific ledger. The swap backend imports it directly (moc `idl:` import,
// see backend/app.mo) and types its `actor(<token-principal>)` references
// against it, so the same interface works for any ICRC-1/2 ledger the backend
// is pointed at (token_a, token_b, ckBTC, an SNS token, ...).
//
// The type definitions match the ICRC-1 ledger reference implementation
// (dfinity/ic, ledger-suite-icrc). To update, edit this file directly — moc
// reads it at build time; there are no generated bindings to regenerate.

type Subaccount = blob;
type Timestamp = nat64;
type Tokens = nat;
type BlockIndex = nat;

type Account = record {
owner : principal;
subaccount : opt Subaccount;
};

type TransferArg = record {
from_subaccount : opt Subaccount;
to : Account;
amount : Tokens;
fee : opt Tokens;
memo : opt blob;
created_at_time : opt Timestamp;
};

type TransferError = variant {
BadFee : record { expected_fee : Tokens };
BadBurn : record { min_burn_amount : Tokens };
InsufficientFunds : record { balance : Tokens };
TooOld;
CreatedInFuture : record { ledger_time : Timestamp };
TemporarilyUnavailable;
Duplicate : record { duplicate_of : BlockIndex };
GenericError : record { error_code : nat; message : text };
};

type TransferResult = variant {
Ok : BlockIndex;
Err : TransferError;
};

type MetadataValue = variant {
Nat : nat;
Int : int;
Text : text;
Blob : blob;
};

type StandardRecord = record { url : text; name : text };

type Allowance = record { allowance : Tokens; expires_at : opt Timestamp };
type AllowanceArgs = record { account : Account; spender : Account };

type ApproveArgs = record {
from_subaccount : opt Subaccount;
spender : Account;
amount : Tokens;
expected_allowance : opt Tokens;
expires_at : opt Timestamp;
fee : opt Tokens;
memo : opt blob;
created_at_time : opt Timestamp;
};

type ApproveError = variant {
BadFee : record { expected_fee : Tokens };
InsufficientFunds : record { balance : Tokens };
AllowanceChanged : record { current_allowance : Tokens };
Expired : record { ledger_time : Timestamp };
TooOld;
CreatedInFuture : record { ledger_time : Timestamp };
Duplicate : record { duplicate_of : BlockIndex };
TemporarilyUnavailable;
GenericError : record { error_code : nat; message : text };
};

type ApproveResult = variant { Ok : BlockIndex; Err : ApproveError };

type TransferFromArgs = record {
spender_subaccount : opt Subaccount;
from : Account;
to : Account;
amount : Tokens;
fee : opt Tokens;
memo : opt blob;
created_at_time : opt Timestamp;
};

type TransferFromError = variant {
BadFee : record { expected_fee : Tokens };
BadBurn : record { min_burn_amount : Tokens };
InsufficientFunds : record { balance : Tokens };
InsufficientAllowance : record { allowance : Tokens };
TooOld;
CreatedInFuture : record { ledger_time : Timestamp };
Duplicate : record { duplicate_of : BlockIndex };
TemporarilyUnavailable;
GenericError : record { error_code : nat; message : text };
};

type TransferFromResult = variant {
Ok : BlockIndex;
Err : TransferFromError;
};

service : {
icrc1_name : () -> (text) query;
icrc1_symbol : () -> (text) query;
icrc1_decimals : () -> (nat8) query;
icrc1_metadata : () -> (vec record { text; MetadataValue }) query;
icrc1_total_supply : () -> (Tokens) query;
icrc1_fee : () -> (Tokens) query;
icrc1_minting_account : () -> (opt Account) query;
icrc1_balance_of : (Account) -> (Tokens) query;
icrc1_transfer : (TransferArg) -> (TransferResult);
icrc1_supported_standards : () -> (vec StandardRecord) query;
icrc2_approve : (ApproveArgs) -> (ApproveResult);
icrc2_allowance : (AllowanceArgs) -> (Allowance) query;
icrc2_transfer_from : (TransferFromArgs) -> (TransferFromResult);
}
4 changes: 2 additions & 2 deletions motoko/icrc2-swap/mops.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[toolchain]
moc = "1.9.0"
moc = "1.13.0"

[dependencies]
core = "2.5.0"
core = "2.6.0"

[moc]
# M0236: use context dot notation
Expand Down
Loading