diff --git a/motoko/icrc2-swap/README.md b/motoko/icrc2-swap/README.md index ff65f2c2c3..25aa07ab3f 100644 --- a/motoko/icrc2-swap/README.md +++ b/motoko/icrc2-swap/README.md @@ -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() : 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 @@ -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. diff --git a/motoko/icrc2-swap/backend/ICRC.mo b/motoko/icrc2-swap/backend/ICRC.mo deleted file mode 100644 index b0dd706db5..0000000000 --- a/motoko/icrc2-swap/backend/ICRC.mo +++ /dev/null @@ -1,131 +0,0 @@ -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; - }; -}; diff --git a/motoko/icrc2-swap/backend/app.mo b/motoko/icrc2-swap/backend/app.mo index 94eaaeef8b..f6b2e17bb5 100644 --- a/motoko/icrc2-swap/backend/app.mo +++ b/motoko/icrc2-swap/backend/app.mo @@ -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() : 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. @@ -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 { - let token : ICRC.Actor = actor (args.token.toText()); + let token : ICRC.Self = actor (args.token.toText()); let balances = which_balances(args.token); // Load the fee from the token here. The user can pass a null fee, which @@ -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); @@ -131,7 +136,7 @@ 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), @@ -139,7 +144,7 @@ actor Swap { 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), @@ -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(args.token); let fee = switch (args.fee) { @@ -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. @@ -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())); }; @@ -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)); }; }; diff --git a/motoko/icrc2-swap/candid/icrc.did b/motoko/icrc2-swap/candid/icrc.did new file mode 100644 index 0000000000..728d11eeff --- /dev/null +++ b/motoko/icrc2-swap/candid/icrc.did @@ -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()` 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); +} diff --git a/motoko/icrc2-swap/mops.toml b/motoko/icrc2-swap/mops.toml index d3f244542c..00581ef401 100644 --- a/motoko/icrc2-swap/mops.toml +++ b/motoko/icrc2-swap/mops.toml @@ -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