diff --git a/motoko/icrc2-swap/README.md b/motoko/icrc2-swap/README.md index ff65f2c2c3..b42686bb82 100644 --- a/motoko/icrc2-swap/README.md +++ b/motoko/icrc2-swap/README.md @@ -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() : 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 @@ -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. diff --git a/motoko/icrc2-swap/backend/app.mo b/motoko/icrc2-swap/backend/app.mo index 94eaaeef8b..2befcf53b8 100644 --- a/motoko/icrc2-swap/backend/app.mo +++ b/motoko/icrc2-swap/backend/app.mo @@ -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. @@ -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 { - 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 +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); @@ -131,7 +131,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 +139,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 +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(args.token); let fee = switch (args.fee) { @@ -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. @@ -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())); }; @@ -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)); }; }; diff --git a/motoko/icrc2-swap/backend/ICRC.mo b/motoko/icrc2-swap/bindings/ICRC.mo similarity index 59% rename from motoko/icrc2-swap/backend/ICRC.mo rename to motoko/icrc2-swap/bindings/ICRC.mo index b0dd706db5..9ff6431e35 100644 --- a/motoko/icrc2-swap/backend/ICRC.mo +++ b/motoko/icrc2-swap/bindings/ICRC.mo @@ -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; + } +} diff --git a/motoko/icrc2-swap/candid/icrc.did b/motoko/icrc2-swap/candid/icrc.did new file mode 100644 index 0000000000..7e80b7df58 --- /dev/null +++ b/motoko/icrc2-swap/candid/icrc.did @@ -0,0 +1,125 @@ +// 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 types its `actor()` +// references against it, so the same generated bindings work 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). Generate the Motoko bindings with: +// didc bind -t mo candid/icrc.did > bindings/ICRC.mo + +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..62f1f004b6 100644 --- a/motoko/icrc2-swap/mops.toml +++ b/motoko/icrc2-swap/mops.toml @@ -2,7 +2,7 @@ moc = "1.9.0" [dependencies] -core = "2.5.0" +core = "2.6.0" [moc] # M0236: use context dot notation