From 4c59fd2c6608b78e17cf49de2e0e6bfd77c8355f Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Tue, 23 May 2023 15:24:29 -0600 Subject: [PATCH 1/9] Fix example add mops deps, amend default args txt --- example/dfx.json | 12 ++---------- example/icrc1/main.mo | 8 ++++---- example/mops.toml | 3 +++ icrc1-default-args.txt | 30 +++++++++++++++--------------- src/ICRC1/Canisters/Token.mo | 1 - src/ICRC1/Utils.mo | 2 +- tests/ICRC1/ICRC1.ActorTest.mo | 2 +- 7 files changed, 26 insertions(+), 32 deletions(-) create mode 100644 example/mops.toml diff --git a/example/dfx.json b/example/dfx.json index f767170..61f0f2e 100644 --- a/example/dfx.json +++ b/example/dfx.json @@ -1,23 +1,15 @@ { "version": 1, - "dfx": "0.11.2", "canisters": { "icrc1_example": { "type": "motoko", - "main": "src/icrc1/main.mo" + "main": "icrc1/main.mo" } }, "defaults": { "build": { - "packtool": "", + "packtool": "mops sources", "args": "" } - }, - - "networks": { - "local": { - "bind": "127.0.0.1:8000", - "type": "ephemeral" - } } } diff --git a/example/icrc1/main.mo b/example/icrc1/main.mo index 54238ac..8f0b4a7 100644 --- a/example/icrc1/main.mo +++ b/example/icrc1/main.mo @@ -59,15 +59,15 @@ shared ({ caller = _owner }) actor class Token( }; public shared ({ caller }) func icrc1_transfer(args : ICRC1.TransferArgs) : async ICRC1.TransferResult { - await ICRC1.transfer(token, args, caller); + await* ICRC1.transfer(token, args, caller); }; public shared ({ caller }) func mint(args : ICRC1.Mint) : async ICRC1.TransferResult { - await ICRC1.mint(token, args, caller); + await* ICRC1.mint(token, args, caller); }; public shared ({ caller }) func burn(args : ICRC1.BurnArgs) : async ICRC1.TransferResult { - await ICRC1.burn(token, args, caller); + await* ICRC1.burn(token, args, caller); }; // Functions from the rosetta icrc1 ledger @@ -77,7 +77,7 @@ shared ({ caller = _owner }) actor class Token( // Additional functions not included in the ICRC1 standard public shared func get_transaction(i : ICRC1.TxIndex) : async ?ICRC1.Transaction { - await ICRC1.get_transaction(token, i); + await* ICRC1.get_transaction(token, i); }; // Deposit cycles into this archive canister. diff --git a/example/mops.toml b/example/mops.toml new file mode 100644 index 0000000..617edc9 --- /dev/null +++ b/example/mops.toml @@ -0,0 +1,3 @@ +[dependencies] +base = "0.8.7" +icrc1 = "0.0.1" \ No newline at end of file diff --git a/icrc1-default-args.txt b/icrc1-default-args.txt index ad39e59..0a92714 100644 --- a/icrc1-default-args.txt +++ b/icrc1-default-args.txt @@ -1,18 +1,18 @@ -( record { - name = ""; - symbol = ""; - decimals = 6; - fee = 1_000_000; - max_supply = 1_000_000_000_000; - initial_balances = vec { - record { - record { - owner = principal "r7inp-6aaaa-aaaaa-aaabq-cai"; - subaccount = null; - }; - 100_000_000 - } - }; +( record { + name = ""; + symbol = ""; + decimals = 6; + fee = 1_000_000; + max_supply = 1_000_000_000_000; + initial_balances = vec { + record { + record { + owner = principal ""; + subaccount = null; + }; + 100_000_000 + } + }; min_burn_amount = 10_000; minting_account = null; advanced_settings = null; diff --git a/src/ICRC1/Canisters/Token.mo b/src/ICRC1/Canisters/Token.mo index bef65a9..7925f96 100644 --- a/src/ICRC1/Canisters/Token.mo +++ b/src/ICRC1/Canisters/Token.mo @@ -8,7 +8,6 @@ import ExperimentalCycles "mo:base/ExperimentalCycles"; import SB "mo:StableBuffer/StableBuffer"; import ICRC1 ".."; -import Archive "Archive"; shared ({ caller = _owner }) actor class Token( init_args : ICRC1.TokenInitArgs, diff --git a/src/ICRC1/Utils.mo b/src/ICRC1/Utils.mo index 786f335..ee02c99 100644 --- a/src/ICRC1/Utils.mo +++ b/src/ICRC1/Utils.mo @@ -35,7 +35,7 @@ module { public let default_standard : T.SupportedStandard = { name = "ICRC-1"; - url = "https://github.com/dfinity/ICRC-1"; + url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1"; }; // Creates a Stable Buffer with the default supported standards and returns it. diff --git a/tests/ICRC1/ICRC1.ActorTest.mo b/tests/ICRC1/ICRC1.ActorTest.mo index a26cd9c..bce3860 100644 --- a/tests/ICRC1/ICRC1.ActorTest.mo +++ b/tests/ICRC1/ICRC1.ActorTest.mo @@ -369,7 +369,7 @@ module { assertTrue( ICRC1.supported_standards(token) == [{ name = "ICRC-1"; - url = "https://github.com/dfinity/ICRC-1"; + url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1"; }], ); }, From c6839ccd8e9209b5d03a106385e8ae6b7dbe42aa Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Wed, 24 May 2023 08:22:24 -0600 Subject: [PATCH 2/9] Initial ICRC-2 commit, methods based on ICRC-1 --- dfx.json | 4 + src/ICRC2/Canisters/Token.mo | 93 ++++ src/ICRC2/Types.mo | 94 ++++ src/ICRC2/Utils.mo | 112 +++++ src/ICRC2/lib.mo | 250 +++++++++++ tests/ActorTest.mo | 8 +- tests/ICRC2/Archive.ActorTest.mo | 135 ++++++ tests/ICRC2/ICRC2.ActorTest.mo | 730 +++++++++++++++++++++++++++++++ 8 files changed, 1424 insertions(+), 2 deletions(-) create mode 100644 src/ICRC2/Canisters/Token.mo create mode 100644 src/ICRC2/Types.mo create mode 100644 src/ICRC2/Utils.mo create mode 100644 src/ICRC2/lib.mo create mode 100644 tests/ICRC2/Archive.ActorTest.mo create mode 100644 tests/ICRC2/ICRC2.ActorTest.mo diff --git a/dfx.json b/dfx.json index 7404066..38db407 100644 --- a/dfx.json +++ b/dfx.json @@ -6,6 +6,10 @@ "type": "motoko", "main": "src/ICRC1/Canisters/Token.mo" }, + "icrc2": { + "type": "motoko", + "main": "src/ICRC2/Canisters/Token.mo" + }, "test": { "type": "motoko", "main": "tests/ActorTest.mo", diff --git a/src/ICRC2/Canisters/Token.mo b/src/ICRC2/Canisters/Token.mo new file mode 100644 index 0000000..2efbeb4 --- /dev/null +++ b/src/ICRC2/Canisters/Token.mo @@ -0,0 +1,93 @@ +import Array "mo:base/Array"; +import Iter "mo:base/Iter"; +import Option "mo:base/Option"; +import Time "mo:base/Time"; + +import ExperimentalCycles "mo:base/ExperimentalCycles"; + +import SB "mo:StableBuffer/StableBuffer"; + +import ICRC2 ".."; + +shared ({ caller = _owner }) actor class Token( + init_args : ICRC2.TokenInitArgs +) : async ICRC2.FullInterface { + + let icrc2_args : ICRC2.InitArgs = { + init_args with minting_account = Option.get( + init_args.minting_account, + { + owner = _owner; + subaccount = null; + }, + ); + }; + + stable let token = ICRC2.init(icrc2_args); + + /// Functions for the ICRC2 token standard + public shared query func icrc1_name() : async Text { + ICRC2.name(token); + }; + + public shared query func icrc1_symbol() : async Text { + ICRC2.symbol(token); + }; + + public shared query func icrc1_decimals() : async Nat8 { + ICRC2.decimals(token); + }; + + public shared query func icrc1_fee() : async ICRC2.Balance { + ICRC2.fee(token); + }; + + public shared query func icrc1_metadata() : async [ICRC2.MetaDatum] { + ICRC2.metadata(token); + }; + + public shared query func icrc1_total_supply() : async ICRC2.Balance { + ICRC2.total_supply(token); + }; + + public shared query func icrc1_minting_account() : async ?ICRC2.Account { + ?ICRC2.minting_account(token); + }; + + public shared query func icrc1_balance_of(args : ICRC2.Account) : async ICRC2.Balance { + ICRC2.balance_of(token, args); + }; + + public shared query func icrc1_supported_standards() : async [ICRC2.SupportedStandard] { + ICRC2.supported_standards(token); + }; + + public shared ({ caller }) func icrc1_transfer(args : ICRC2.TransferArgs) : async ICRC2.TransferResult { + await* ICRC2.transfer(token, args, caller); + }; + + public shared ({ caller }) func mint(args : ICRC2.Mint) : async ICRC2.TransferResult { + await* ICRC2.mint(token, args, caller); + }; + + public shared ({ caller }) func burn(args : ICRC2.BurnArgs) : async ICRC2.TransferResult { + await* ICRC2.burn(token, args, caller); + }; + + // Functions for integration with the rosetta standard + public shared query func get_transactions(req : ICRC2.GetTransactionsRequest) : async ICRC2.GetTransactionsResponse { + ICRC2.get_transactions(token, req); + }; + + // Additional functions not included in the ICRC2 standard + public shared func get_transaction(i : ICRC2.TxIndex) : async ?ICRC2.Transaction { + await* ICRC2.get_transaction(token, i); + }; + + // Deposit cycles into this canister. + public shared func deposit_cycles() : async () { + let amount = ExperimentalCycles.available(); + let accepted = ExperimentalCycles.accept(amount); + assert (accepted == amount); + }; +}; diff --git a/src/ICRC2/Types.mo b/src/ICRC2/Types.mo new file mode 100644 index 0000000..43e0956 --- /dev/null +++ b/src/ICRC2/Types.mo @@ -0,0 +1,94 @@ +import Types1 "../ICRC1/Types"; + +module { + + public type Value = Types1.Value; + + public type BlockIndex = Types1.BlockIndex; + public type Subaccount = Types1.Subaccount; + public type Balance = Types1.Balance; + public type StableBuffer = Types1.StableBuffer; + public type StableTrieMap = Types1.StableTrieMap; + + public type Account = Types1.Account; + + public type EncodedAccount = Types1.EncodedAccount; + + public type SupportedStandard = Types1.SupportedStandard; + + public type Memo = Types1.Memo; + public type Timestamp = Types1.Timestamp; + public type Duration = Types1.Duration; + public type TxIndex = Types1.TxIndex; + public type TxLog = Types1.TxLog; + + public type MetaDatum = Types1.MetaDatum; + public type MetaData = [MetaDatum]; + + public type TxKind = Types1.TxKind; + + public type Mint = Types1.Mint; + + public type BurnArgs = Types1.BurnArgs; + + public type Burn = Types1.Burn; + + public type TransferArgs = Types1.TransferArgs; + + public type Transfer = Types1.Transfer; + + /// Internal representation of a transaction request + public type TransactionRequest = Types1.TransactionRequest; + + public type Transaction = Types1.Transaction; + + public type TimeError = Types1.TimeError; + + public type TransferError = Types1.TransferError; + + public type TransferResult = Types1.TransferResult; + + /// Interface for the ICRC token canister + public type TokenInterface = Types1.TokenInterface; + + public type TxCandidBlob = Types1.TxCandidBlob; + + /// The Interface for the Archive canister + public type ArchiveInterface = Types1.ArchiveInterface; + + /// Initial arguments for the setting up the icrc2 token canister + public type InitArgs = Types1.InitArgs; + + /// [InitArgs](#type.InitArgs) with optional fields for initializing a token canister + public type TokenInitArgs = Types1.TokenInitArgs; + + /// Additional settings for the [InitArgs](#type.InitArgs) type during initialization of an icrc2 token canister + public type AdvancedSettings = Types1.AdvancedSettings; + + public type AccountBalances = Types1.AccountBalances; + + /// The details of the archive canister + public type ArchiveData = Types1.ArchiveData; + + /// The state of the token canister + public type TokenData = Types1.TokenData; + + // Rosetta API + /// The type to request a range of transactions from the ledger canister + public type GetTransactionsRequest = Types1.GetTransactionsRequest; + + public type TransactionRange = Types1.TransactionRange; + + public type QueryArchiveFn = Types1.QueryArchiveFn; + + public type ArchivedTransaction = Types1.ArchivedTransaction; + + public type GetTransactionsResponse = Types1.GetTransactionsResponse; + + /// Functions supported by the rosetta + public type RosettaInterface = Types1.RosettaInterface; + + /// Interface of the ICRC token and Rosetta canister + public type FullInterface = TokenInterface and RosettaInterface; + +}; diff --git a/src/ICRC2/Utils.mo b/src/ICRC2/Utils.mo new file mode 100644 index 0000000..1f25f08 --- /dev/null +++ b/src/ICRC2/Utils.mo @@ -0,0 +1,112 @@ +import Hash "mo:base/Hash"; +import Nat "mo:base/Nat"; +import Nat8 "mo:base/Nat8"; +import Principal "mo:base/Principal"; + +import StableBuffer "mo:StableBuffer/StableBuffer"; + +import T "Types"; +import U1 "../ICRC1/Utils"; + +module { + // Creates a Stable Buffer with the default metadata and returns it. + public func init_metadata(args : T.InitArgs) : StableBuffer.StableBuffer { + let metadata = SB.initPresized(4); + SB.add(metadata, ("icrc2:fee", #Nat(args.fee))); + SB.add(metadata, ("icrc2:name", #Text(args.name))); + SB.add(metadata, ("icrc2:symbol", #Text(args.symbol))); + SB.add(metadata, ("icrc2:decimals", #Nat(Nat8.toNat(args.decimals)))); + + metadata; + }; + + public let default_standard : T.SupportedStandard = { + name = "ICRC-2"; + url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-2"; + }; + + // Creates a Stable Buffer with the default supported standards and returns it. + public func init_standards() : StableBuffer.StableBuffer { + let standards = SB.initPresized(4); + SB.add(standards, default_standard); + + standards; + }; + + // Returns the default subaccount for cases where a user does + // not specify it. + public func default_subaccount() : T.Subaccount { + U1.default_subaccount(); + }; + + // Computes a hash from the least significant 32-bits of `n`, ignoring other bits. + public func hash(n : Nat) : Hash.Hash { + U1.hash(n); + }; + + // Formats the different operation arguements into + // a `TransactionRequest`, an internal type to access fields easier. + public func create_transfer_req( + args : T.TransferArgs, + owner : Principal, + tx_kind : T.TxKind, + ) : T.TransactionRequest { + U1.create_transfer_req(args, owner, tx_kind); + }; + + // Transforms the transaction kind from `variant` to `Text` + public func kind_to_text(kind : T.TxKind) : Text { + U1.kind_to_text(kind); + }; + + // Formats the tx request into a finalised transaction + public func req_to_tx(tx_req : T.TransactionRequest, index : Nat) : T.Transaction { + U1.req_to_tx(tx_req, index); + }; + + public func div_ceil(n : Nat, d : Nat) : Nat { + U1.div_ceil(n, d); + }; + + /// Retrieves the balance of an account + public func get_balance(accounts : T.AccountBalances, encoded_account : T.EncodedAccount) : T.Balance { + U1.get_balance(accounts, encoded_account); + }; + + /// Updates the balance of an account + public func update_balance( + accounts : T.AccountBalances, + encoded_account : T.EncodedAccount, + update : (T.Balance) -> T.Balance, + ) { + U1.update_balance(accounts, encoded_account, update); + }; + + // Transfers tokens from the sender to the + // recipient in the tx request + public func transfer_balance( + token : T.TokenData, + tx_req : T.TransactionRequest, + ) { + U1.transfer_balance(token, tx_req); + }; + + public func mint_balance( + token : T.TokenData, + encoded_account : T.EncodedAccount, + amount : T.Balance, + ) { + U1.mint_balance(token, encoded_account, amount); + }; + + public func burn_balance( + token : T.TokenData, + encoded_account : T.EncodedAccount, + amount : T.Balance, + ) { + U1.burn_balance(token, encoded_account, amount); + }; + + // Stable Buffer Module with some additional functions + public let SB = U1.SB; +}; diff --git a/src/ICRC2/lib.mo b/src/ICRC2/lib.mo new file mode 100644 index 0000000..f590047 --- /dev/null +++ b/src/ICRC2/lib.mo @@ -0,0 +1,250 @@ +import Blob "mo:base/Blob"; +import Debug "mo:base/Debug"; +import Float "mo:base/Float"; +import Nat "mo:base/Nat"; +import Nat64 "mo:base/Nat64"; +import Nat8 "mo:base/Nat8"; +import Principal "mo:base/Principal"; + +import Itertools "mo:itertools/Iter"; +import StableTrieMap "mo:StableTrieMap"; + +import T "Types"; +import Utils "Utils"; +import ICRC1 "../ICRC1"; +import Account "../ICRC1/Account"; + +/// The ICRC2 class with all the functions for creating an +/// ICRC2 token on the Internet Computer +module { + let { SB } = Utils; + + public type Account = T.Account; + public type Subaccount = T.Subaccount; + public type AccountBalances = T.AccountBalances; + + public type Transaction = T.Transaction; + public type Balance = T.Balance; + public type TransferArgs = T.TransferArgs; + public type Mint = T.Mint; + public type BurnArgs = T.BurnArgs; + public type TransactionRequest = T.TransactionRequest; + public type TransferError = T.TransferError; + + public type SupportedStandard = T.SupportedStandard; + + public type InitArgs = T.InitArgs; + public type TokenInitArgs = T.TokenInitArgs; + public type TokenData = T.TokenData; + public type MetaDatum = T.MetaDatum; + public type TxLog = T.TxLog; + public type TxIndex = T.TxIndex; + + public type TokenInterface = T.TokenInterface; + public type RosettaInterface = T.RosettaInterface; + public type FullInterface = T.FullInterface; + + public type ArchiveInterface = T.ArchiveInterface; + + public type GetTransactionsRequest = T.GetTransactionsRequest; + public type GetTransactionsResponse = T.GetTransactionsResponse; + public type QueryArchiveFn = T.QueryArchiveFn; + public type TransactionRange = T.TransactionRange; + public type ArchivedTransaction = T.ArchivedTransaction; + + public type TransferResult = T.TransferResult; + + public let MAX_TRANSACTIONS_IN_LEDGER = ICRC1.MAX_TRANSACTIONS_IN_LEDGER; + public let MAX_TRANSACTION_BYTES : Nat64 = ICRC1.MAX_TRANSACTION_BYTES; + public let MAX_TRANSACTIONS_PER_REQUEST = ICRC1.MAX_TRANSACTIONS_PER_REQUEST; + + /// Initialize a new ICRC-2 token + public func init(args : T.InitArgs) : T.TokenData { + let { + name; + symbol; + decimals; + fee; + minting_account; + max_supply; + initial_balances; + min_burn_amount; + advanced_settings; + } = args; + + var _burned_tokens = 0; + var permitted_drift = 60_000_000_000; + var transaction_window = 86_400_000_000_000; + + switch (advanced_settings) { + case (?options) { + _burned_tokens := options.burned_tokens; + permitted_drift := Nat64.toNat(options.permitted_drift); + transaction_window := Nat64.toNat(options.transaction_window); + }; + case (null) {}; + }; + + if (not Account.validate(minting_account)) { + Debug.trap("minting_account is invalid"); + }; + + let accounts : T.AccountBalances = StableTrieMap.new(); + + var _minted_tokens = _burned_tokens; + + for ((i, (account, balance)) in Itertools.enumerate(initial_balances.vals())) { + + if (not Account.validate(account)) { + Debug.trap( + "Invalid Account: Account at index " # debug_show i # " is invalid in 'initial_balances'" + ); + }; + + let encoded_account = Account.encode(account); + + StableTrieMap.put( + accounts, + Blob.equal, + Blob.hash, + encoded_account, + balance, + ); + + _minted_tokens += balance; + }; + + { + name = name; + symbol = symbol; + decimals; + var _fee = fee; + max_supply; + var _minted_tokens = _minted_tokens; + var _burned_tokens = _burned_tokens; + min_burn_amount; + minting_account; + accounts; + metadata = Utils.init_metadata(args); + supported_standards = Utils.init_standards(); + transactions = SB.initPresized(MAX_TRANSACTIONS_IN_LEDGER); + permitted_drift; + transaction_window; + archive = { + var canister = actor ("aaaaa-aa"); + var stored_txs = 0; + }; + }; + }; + + /// Retrieve the name of the token + public func name(token : T.TokenData) : Text { + token.name; + }; + + /// Retrieve the symbol of the token + public func symbol(token : T.TokenData) : Text { + token.symbol; + }; + + /// Retrieve the number of decimals specified for the token + public func decimals({ decimals } : T.TokenData) : Nat8 { + decimals; + }; + + /// Retrieve the fee for each transfer + public func fee(token : T.TokenData) : T.Balance { + token._fee; + }; + + /// Set the fee for each transfer + public func set_fee(token : T.TokenData, fee : Nat) { + token._fee := fee; + }; + + /// Retrieve all the metadata of the token + public func metadata(token : T.TokenData) : [T.MetaDatum] { + SB.toArray(token.metadata); + }; + + /// Returns the total supply of circulating tokens + public func total_supply(token : T.TokenData) : T.Balance { + token._minted_tokens - token._burned_tokens; + }; + + /// Returns the total supply of minted tokens + public func minted_supply(token : T.TokenData) : T.Balance { + token._minted_tokens; + }; + + /// Returns the total supply of burned tokens + public func burned_supply(token : T.TokenData) : T.Balance { + token._burned_tokens; + }; + + /// Returns the maximum supply of tokens + public func max_supply(token : T.TokenData) : T.Balance { + token.max_supply; + }; + + /// Returns the account with the permission to mint tokens + /// + /// Note: **The minting account can only participate in minting + /// and burning transactions, so any tokens sent to it will be + /// considered burned.** + + public func minting_account(token : T.TokenData) : T.Account { + token.minting_account; + }; + + /// Retrieve the balance of a given account + public func balance_of({ accounts } : T.TokenData, account : T.Account) : T.Balance { + let encoded_account = Account.encode(account); + Utils.get_balance(accounts, encoded_account); + }; + + /// Returns an array of standards supported by this token + public func supported_standards(token : T.TokenData) : [T.SupportedStandard] { + SB.toArray(token.supported_standards); + }; + + /// Formats a float to a nat balance and applies the correct number of decimal places + public func balance_from_float(token : T.TokenData, float : Float) : T.Balance { + ICRC1.balance_from_float(token, float); + }; + + /// Transfers tokens from one account to another account (minting and burning included) + public func transfer( + token : T.TokenData, + args : T.TransferArgs, + caller : Principal, + ) : async* T.TransferResult { + await* ICRC1.transfer(token, args, caller); + }; + + /// Helper function to mint tokens with minimum args + public func mint(token : T.TokenData, args : T.Mint, caller : Principal) : async* T.TransferResult { + await* ICRC1.mint(token, args, caller); + }; + + /// Helper function to burn tokens with minimum args + public func burn(token : T.TokenData, args : T.BurnArgs, caller : Principal) : async* T.TransferResult { + await* ICRC1.burn(token, args, caller); + }; + + /// Returns the total number of transactions that have been processed by the given token. + public func total_transactions(token : T.TokenData) : Nat { + ICRC1.total_transactions(token); + }; + + /// Retrieves the transaction specified by the given `tx_index` + public func get_transaction(token : T.TokenData, tx_index : T.TxIndex) : async* ?T.Transaction { + await* ICRC1.get_transaction(token, tx_index); + }; + + /// Retrieves the transactions specified by the given range + public func get_transactions(token : T.TokenData, req : T.GetTransactionsRequest) : T.GetTransactionsResponse { + ICRC1.get_transactions(token, req); + }; + +}; diff --git a/tests/ActorTest.mo b/tests/ActorTest.mo index 29489eb..e545964 100644 --- a/tests/ActorTest.mo +++ b/tests/ActorTest.mo @@ -1,7 +1,9 @@ import Debug "mo:base/Debug"; -import Archive "ICRC1/Archive.ActorTest"; +import Archive1 "ICRC1/Archive.ActorTest"; import ICRC1 "ICRC1/ICRC1.ActorTest"; +import Archive2 "ICRC2/Archive.ActorTest"; +import ICRC2 "ICRC2/ICRC2.ActorTest"; import ActorSpec "./utils/ActorSpec"; @@ -9,8 +11,10 @@ actor { let { run } = ActorSpec; let test_modules = [ - Archive.test, + Archive1.test, ICRC1.test, + Archive2.test, + ICRC2.test, ]; public func run_tests() : async () { diff --git a/tests/ICRC2/Archive.ActorTest.mo b/tests/ICRC2/Archive.ActorTest.mo new file mode 100644 index 0000000..214da22 --- /dev/null +++ b/tests/ICRC2/Archive.ActorTest.mo @@ -0,0 +1,135 @@ +import Debug "mo:base/Debug"; +import Array "mo:base/Array"; +import Iter "mo:base/Iter"; +import Nat "mo:base/Nat"; +import Int "mo:base/Int"; +import Float "mo:base/Float"; +import Nat64 "mo:base/Nat64"; +import Principal "mo:base/Principal"; +import EC "mo:base/ExperimentalCycles"; + +import Archive "../../src/ICRC1/Canisters/Archive"; +import T "../../src/ICRC2/Types"; + +import ActorSpec "../utils/ActorSpec"; + +module { + let { + assertTrue; + assertFalse; + assertAllTrue; + describe; + it; + skip; + pending; + run; + } = ActorSpec; + + func new_tx(i : Nat) : T.Transaction { + { + kind = ""; + mint = null; + burn = null; + transfer = null; + index = i; + timestamp = Nat64.fromNat(i); + }; + }; + + // [start, end) + func txs_range(start : Nat, end : Nat) : [T.Transaction] { + Array.tabulate( + (end - start) : Nat, + func(i : Nat) : T.Transaction { + new_tx(start + i); + }, + ); + }; + + func new_txs(length : Nat) : [T.Transaction] { + txs_range(0, length); + }; + + let TC = 1_000_000_000_000; + let CREATE_CANISTER = 100_000_000_000; + + func create_canister_and_add_cycles(n : Float) { + EC.add( + CREATE_CANISTER + Int.abs(Float.toInt(n * 1_000_000_000_000)), + ); + }; + + public func test() : async ActorSpec.Group { + describe( + "Archive Canister", + [ + it( + "append_transactions()", + do { + create_canister_and_add_cycles(0.1); + let archive = await Archive.Archive(); + + let txs = new_txs(500); + + assertAllTrue([ + (await archive.total_transactions()) == 0, + (await archive.append_transactions(txs)) == #ok(), + (await archive.total_transactions()) == 500, + ]); + }, + ), + it( + "get_transaction()", + do { + create_canister_and_add_cycles(0.1); + let archive = await Archive.Archive(); + + let txs = new_txs(3555); + + let res = await archive.append_transactions(txs); + + assertAllTrue([ + res == #ok(), + (await archive.total_transactions()) == 3555, + (await archive.get_transaction(0)) == ?new_tx(0), + (await archive.get_transaction(999)) == ?new_tx(999), + (await archive.get_transaction(1000)) == ?new_tx(1000), + (await archive.get_transaction(1234)) == ?new_tx(1234), + (await archive.get_transaction(2829)) == ?new_tx(2829), + (await archive.get_transaction(3554)) == ?new_tx(3554), + (await archive.get_transaction(3555)) == null, + (await archive.get_transaction(999999)) == null, + ]); + }, + ), + it( + "get_transactions()", + do { + + create_canister_and_add_cycles(0.1); + let archive = await Archive.Archive(); + + let txs = new_txs(5000); + + let res = await archive.append_transactions(txs); + + let tx_range = await archive.get_transactions({ + start = 3251; + length = 2000; + }); + + assertAllTrue([ + res == #ok(), + (await archive.total_transactions()) == 5000, + (await archive.get_transactions({ start = 0; length = 100 })).transactions == txs_range(0, 100), + (await archive.get_transactions({ start = 225; length = 100 })).transactions == txs_range(225, 325), + (await archive.get_transactions({ start = 225; length = 1200 })).transactions == txs_range(225, 1425), + (await archive.get_transactions({ start = 980; length = 100 })).transactions == txs_range(980, 1080), + (await archive.get_transactions({ start = 3251; length = 2000 })).transactions == txs_range(3251, 5000), + ]); + }, + ), + ], + ); + }; +}; diff --git a/tests/ICRC2/ICRC2.ActorTest.mo b/tests/ICRC2/ICRC2.ActorTest.mo new file mode 100644 index 0000000..b3d1f24 --- /dev/null +++ b/tests/ICRC2/ICRC2.ActorTest.mo @@ -0,0 +1,730 @@ +import Array "mo:base/Array"; +import Debug "mo:base/Debug"; +import Iter "mo:base/Iter"; +import Nat "mo:base/Nat"; +import Nat8 "mo:base/Nat8"; +import Principal "mo:base/Principal"; + +import Itertools "mo:itertools/Iter"; +import StableBuffer "mo:StableBuffer/StableBuffer"; + +import ActorSpec "../utils/ActorSpec"; + +import ICRC2 "../../src/ICRC2"; +import T "../../src/ICRC2/Types"; + +import U "../../src/ICRC2/Utils"; + +module { + public func test() : async ActorSpec.Group { + + let { + assertTrue; + assertFalse; + assertAllTrue; + describe; + it; + skip; + pending; + run; + } = ActorSpec; + + let { SB } = U; + + func add_decimals(n : Nat, decimals : Nat) : Nat { + n * (10 ** decimals); + }; + + func mock_tx(to : T.Account, index : Nat) : T.Transaction { + { + burn = null; + transfer = null; + kind = "MINT"; + timestamp = 0; + index; + mint = ?{ + to; + amount = index + 1; + memo = null; + created_at_time = null; + }; + }; + }; + + let canister : T.Account = { + owner = Principal.fromText("x4ocp-k7ot7-oiqws-rg7if-j4q2v-ewcel-2x6we-l2eqz-rfz3e-6di6e-jae"); + subaccount = null; + }; + + let user1 : T.Account = { + owner = Principal.fromText("prb4z-5pc7u-zdfqi-cgv7o-fdyqf-n6afm-xh6hz-v4bk4-kpg3y-rvgxf-iae"); + subaccount = null; + }; + + let user2 : T.Account = { + owner = Principal.fromText("ygyq4-mf2rf-qmcou-h24oc-qwqvv-gt6lp-ifvxd-zaw3i-celt7-blnoc-5ae"); + subaccount = null; + }; + + func txs_range(start : Nat, end : Nat) : [T.Transaction] { + Array.tabulate( + (end - start) : Nat, + func(i : Nat) : T.Transaction { + mock_tx(user1, start + i); + }, + ); + }; + + func is_tx_equal(t1 : T.Transaction, t2 : T.Transaction) : Bool { + { t1 with timestamp = 0 } == { t2 with timestamp = 0 }; + }; + + func is_opt_tx_equal(t1 : ?T.Transaction, t2 : ?T.Transaction) : Bool { + switch (t1, t2) { + case (?t1, ?t2) { + is_tx_equal(t1, t2); + }; + case (_, ?t2) { false }; + case (?t1, _) { false }; + case (_, _) { true }; + }; + }; + + func validate_get_transactions( + token : T.TokenData, + tx_req : T.GetTransactionsRequest, + tx_res : T.GetTransactionsResponse + ) : Bool { + let { archive } = token; + + let token_start = 0; + let token_end = ICRC2.total_transactions(token); + + let req_start = tx_req.start; + let req_end = tx_req.start + tx_req.length; + + var log_length = 0; + + if (req_start < token_end) { + log_length := (Nat.min(token_end, req_end) - Nat.max(token_start, req_start)) : Nat; + }; + + if (log_length != tx_res.log_length) { + Debug.print("Failed at log_length: " # Nat.toText(log_length) # " != " # Nat.toText(tx_res.log_length)); + return false; + }; + + var txs_size = 0; + if (req_end > archive.stored_txs and req_start <= token_end) { + txs_size := Nat.min(req_end, token_end) - archive.stored_txs; + }; + + if (txs_size != tx_res.transactions.size()) { + Debug.print("Failed at txs_size: " # Nat.toText(txs_size) # " != " # Nat.toText(tx_res.transactions.size())); + return false; + }; + + if (txs_size > 0) { + let index = tx_res.transactions[0].index; + + if (tx_res.first_index != index) { + Debug.print("Failed at first_index: " # Nat.toText(tx_res.first_index) # " != " # Nat.toText(index)); + return false; + }; + + for (i in Iter.range(0, txs_size - 1)) { + let tx = tx_res.transactions[i]; + let mocked_tx = mock_tx(user1, archive.stored_txs + i); + + if (not is_tx_equal(tx, mocked_tx)) { + + Debug.print("Failed at tx: " # debug_show (tx) # " != " # debug_show (mocked_tx)); + return false; + }; + }; + } else { + if (tx_res.first_index != 0xFFFF_FFFF_FFFF_FFFF) { + Debug.print("Failed at first_index: " # Nat.toText(tx_res.first_index) # " != " # Nat.toText(0xFFFF_FFFF_FFFF_FFFF)); + return false; + }; + }; + + true; + }; + + func validate_archived_range(request : [T.GetTransactionsRequest], response : [T.ArchivedTransaction]) : async Bool { + + if (request.size() != response.size()) { + return false; + }; + + for ((req, res) in Itertools.zip(request.vals(), response.vals())) { + if (res.start != req.start) { + Debug.print("Failed at start: " # Nat.toText(res.start) # " != " # Nat.toText(req.start)); + return false; + }; + if (res.length != req.length) { + Debug.print("Failed at length: " # Nat.toText(res.length) # " != " # Nat.toText(req.length)); + return false; + }; + + let archived_txs = (await res.callback(req)).transactions; + let expected_txs = txs_range(res.start, res.start + res.length); + + if (archived_txs.size() != expected_txs.size()) { + return false; + }; + + for ((tx1, tx2) in Itertools.zip(archived_txs.vals(), expected_txs.vals())) { + if (not is_tx_equal(tx1, tx2)) { + Debug.print("Failed at archived_txs: " # debug_show (tx1, tx2)); + return false; + }; + }; + + }; + + true; + }; + + func are_txs_equal(t1 : [T.Transaction], t2 : [T.Transaction]) : Bool { + Itertools.equal(t1.vals(), t2.vals(), is_tx_equal); + }; + + func create_mints(token : T.TokenData, minting_principal : Principal, n : Nat) : async () { + for (i in Itertools.range(0, n)) { + ignore await* ICRC2.mint( + token, + { + to = user1; + amount = i + 1; + memo = null; + created_at_time = null; + }, + minting_principal, + ); + }; + }; + + let default_token_args : T.InitArgs = { + name = "Under-Collaterised Lending Tokens"; + symbol = "UCLTs"; + decimals = 8; + fee = 5 * (10 ** 8); + max_supply = 1_000_000_000 * (10 ** 8); + minting_account = canister; + initial_balances = []; + min_burn_amount = (10 * (10 ** 8)); + advanced_settings = null; + }; + + return describe( + "ICRC2 Token Implementation Tessts", + [ + it( + "init()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + // returns without trapping + assertAllTrue([ + token.name == args.name, + token.symbol == args.symbol, + token.decimals == args.decimals, + token._fee == args.fee, + token.max_supply == args.max_supply, + + token.minting_account == args.minting_account, + SB.toArray(token.supported_standards) == [U.default_standard], + SB.size(token.transactions) == 0, + ]); + }, + ), + + it( + "name()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.name(token) == args.name, + ); + }, + ), + + it( + "symbol()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.symbol(token) == args.symbol, + ); + }, + ), + + it( + "decimals()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.decimals(token) == args.decimals, + ); + }, + ), + it( + "fee()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.fee(token) == args.fee, + ); + }, + ), + it( + "minting_account()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.minting_account(token) == args.minting_account, + ); + }, + ), + it( + "balance_of()", + do { + let args = default_token_args; + + let token = ICRC2.init({ args + with initial_balances = [ + (user1, 100), + (user2, 200), + ]; + }); + + assertAllTrue([ + ICRC2.balance_of(token, user1) == 100, + ICRC2.balance_of(token, user2) == 200, + ]); + }, + ), + it( + "total_supply()", + do { + let args = default_token_args; + + let token = ICRC2.init({ args + with initial_balances = [ + (user1, 100), + (user2, 200), + ]; + }); + + assertTrue( + ICRC2.total_supply(token) == 300, + ); + }, + ), + + it( + "metadata()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.metadata(token) == [ + ("icrc2:fee", #Nat(args.fee)), + ("icrc2:name", #Text(args.name)), + ("icrc2:symbol", #Text(args.symbol)), + ("icrc2:decimals", #Nat(Nat8.toNat(args.decimals))), + ], + ); + }, + ), + + it( + "supported_standards()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + assertTrue( + ICRC2.supported_standards(token) == [{ + name = "ICRC-2"; + url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-2"; + }], + ); + }, + ), + + it( + "mint()", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + let mint_args : T.Mint = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(args.decimals)); + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + assertAllTrue([ + res == #Ok(0), + ICRC2.balance_of(token, user1) == mint_args.amount, + ICRC2.balance_of(token, args.minting_account) == 0, + ICRC2.total_supply(token) == mint_args.amount, + ]); + }, + ), + + describe( + "burn()", + [ + it( + "from funded account", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + let mint_args : T.Mint = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(args.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let burn_args : T.BurnArgs = { + from_subaccount = user1.subaccount; + amount = 50 * (10 ** Nat8.toNat(args.decimals)); + memo = null; + created_at_time = null; + }; + + let prev_balance = ICRC2.balance_of(token, user1); + let prev_total_supply = ICRC2.total_supply(token); + + let res = await* ICRC2.burn(token, burn_args, user1.owner); + + assertAllTrue([ + res == #Ok(1), + ICRC2.balance_of(token, user1) == ((prev_balance - burn_args.amount) : Nat), + ICRC2.total_supply(token) == ((prev_total_supply - burn_args.amount) : Nat), + ]); + }, + ), + it( + "from an empty account", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + let burn_args : T.BurnArgs = { + from_subaccount = user1.subaccount; + amount = 200 * (10 ** Nat8.toNat(args.decimals)); + memo = null; + created_at_time = null; + }; + + let prev_balance = ICRC2.balance_of(token, user1); + let prev_total_supply = ICRC2.total_supply(token); + let res = await* ICRC2.burn(token, burn_args, user1.owner); + + assertAllTrue([ + res == #Err( + #InsufficientFunds { + balance = 0; + }, + ), + ]); + }, + ), + it( + "burn amount less than min_burn_amount", + do { + let args = default_token_args; + + let token = ICRC2.init(args); + + let mint_args : T.Mint = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(args.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let burn_args : T.BurnArgs = { + from_subaccount = user1.subaccount; + amount = 5 * (10 ** Nat8.toNat(args.decimals)); + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.burn(token, burn_args, user1.owner); + + assertAllTrue([ + res == #Err( + #BadBurn { + min_burn_amount = 10 * (10 ** 8); + }, + ), + ]); + }, + ), + ], + ), + describe( + "transfer()", + [ + it( + "Transfer from funded account", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let transfer_args : T.TransferArgs = { + from_subaccount = user1.subaccount; + to = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.transfer( + token, + transfer_args, + user1.owner, + ); + + + assertAllTrue([ + res == #Ok(1), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 145), + token._burned_tokens == ICRC2.balance_from_float(token, 5), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 50), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 195), + ]); + }, + ), + ], + ), + + describe( + "Internal Archive Testing", + [ + describe( + "A token canister with 4123 total txs", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + await create_mints(token, canister.owner, 4123); + [ + it( + "Archive has 4000 stored txs", + do { + + assertAllTrue([ + token.archive.stored_txs == 4000, + SB.size(token.transactions) == 123, + SB.capacity(token.transactions) == ICRC2.MAX_TRANSACTIONS_IN_LEDGER, + ]); + }, + ), + it( + "get_transaction() works for txs in the archive and ledger canister", + do { + assertAllTrue([ + is_opt_tx_equal( + (await* ICRC2.get_transaction(token, 0)), + ?mock_tx(user1, 0), + ), + is_opt_tx_equal( + (await* ICRC2.get_transaction(token, 1234)), + ?mock_tx(user1, 1234), + ), + is_opt_tx_equal( + (await* ICRC2.get_transaction(token, 2000)), + ?mock_tx(user1, 2000), + ), + is_opt_tx_equal( + (await* ICRC2.get_transaction(token, 4100)), + ?mock_tx(user1, 4100), + ), + is_opt_tx_equal( + (await* ICRC2.get_transaction(token, 4122)), + ?mock_tx(user1, 4122), + ), + ]); + }, + ), + it( + "get_transactions from 0 to 2000", + do { + let req = { + start = 0; + length = 2000; + }; + + let res = ICRC2.get_transactions( + token, + req, + ); + + let archived_txs = res.archived_transactions; + + assertAllTrue([ + validate_get_transactions(token, req, res), + (await validate_archived_range([{ start = 0; length = 2000 }], archived_txs)), + ]); + }, + ), + it( + "get_transactions from 3000 to 4123", + do { + let req = { + start = 3000; + length = 1123; + }; + + let res = ICRC2.get_transactions( + token, + req, + ); + + let archived_txs = res.archived_transactions; + + assertAllTrue([ + validate_get_transactions(token, req, res), + (await validate_archived_range([{ start = 3000; length = 1000 }], archived_txs)), + ]); + }, + ), + it( + "get_transactions from 4000 to 4123", + do { + let req = { + start = 4000; + length = 123; + }; + + let res = ICRC2.get_transactions( + token, + req, + ); + + let archived_txs = res.archived_transactions; + + assertAllTrue([ + validate_get_transactions(token, req, res), + (await validate_archived_range([], archived_txs)), + ]); + }, + ), + it( + "get_transactions exceeding the txs in the ledger (0 to 5000)", + do { + let req = { + start = 0; + length = 5000; + }; + + let res = ICRC2.get_transactions( + token, + req, + ); + + let archived_txs = res.archived_transactions; + + assertAllTrue([ + validate_get_transactions(token, req, res), + (await validate_archived_range([{ start = 0; length = 4000 }], archived_txs)), + + ]); + }, + ), + it( + "get_transactions outside the txs range (5000 to 6000)", + do { + let req = { + start = 5000; + length = 1000; + }; + + let res = ICRC2.get_transactions( + token, + req, + ); + + let archived_txs = res.archived_transactions; + + assertAllTrue([ + validate_get_transactions(token, req, res), + (await validate_archived_range([], archived_txs)), + + ]); + }, + ), + ]; + }, + ), + ], + ), + ], + ); + }; +}; From 57c49892151c1576629d82c441cb09a17ae331b0 Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Thu, 25 May 2023 12:52:26 -0600 Subject: [PATCH 3/9] Adds icrc2_approve method, utils, types, helpers --- src/ICRC1/Types.mo | 7 +- src/ICRC2/Approve.mo | 176 +++++++++++++++++++++++++++++++++++ src/ICRC2/Canisters/Token.mo | 4 + src/ICRC2/Types.mo | 95 ++++++++++++++++++- src/ICRC2/Utils.mo | 60 ++++++++++++ src/ICRC2/lib.mo | 26 ++++++ 6 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 src/ICRC2/Approve.mo diff --git a/src/ICRC1/Types.mo b/src/ICRC1/Types.mo index b85e096..b1e7fa4 100644 --- a/src/ICRC1/Types.mo +++ b/src/ICRC1/Types.mo @@ -115,14 +115,17 @@ module { #CreatedInFuture : { ledger_time : Timestamp }; }; - public type TransferError = TimeError or { + public type OperationError = TimeError or { #BadFee : { expected_fee : Balance }; - #BadBurn : { min_burn_amount : Balance }; #InsufficientFunds : { balance : Balance }; #Duplicate : { duplicate_of : TxIndex }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; }; + + public type TransferError = OperationError or { + #BadBurn : { min_burn_amount : Balance }; + }; public type TransferResult = { #Ok : TxIndex; diff --git a/src/ICRC2/Approve.mo b/src/ICRC2/Approve.mo new file mode 100644 index 0000000..d8714f9 --- /dev/null +++ b/src/ICRC2/Approve.mo @@ -0,0 +1,176 @@ +import Blob "mo:base/Blob"; +import Debug "mo:base/Debug"; +import Int "mo:base/Int"; +import Nat "mo:base/Nat"; +import Nat64 "mo:base/Nat64"; +import Option "mo:base/Option"; +import Result "mo:base/Result"; +import Time "mo:base/Time"; + +import STMap "mo:StableTrieMap"; + +import T "Types"; +import Utils "Utils"; +import Account "../ICRC1/Account"; +import Transfer "../ICRC1/Transfer"; + +module { + + // Checks if an approval expiration is greater than the current ledger time + public func validate_expiration(token : T.TokenData, expires_at : ?Nat64) : Bool { + switch (expires_at) { + case null { return true }; + case (?expiration) { + return Transfer.is_in_future(token, expiration); + }; + }; + }; + + /// Checks if an approve request is valid + public func validate_request( + token : T.TokenData, + app_req : T.ApproveRequest, + ) : Result.Result<(), T.ApproveError> { + + if (app_req.from.owner == app_req.spender.owner) { + return #err( + #GenericError({ + error_code = 0; + message = "The spender account owner cannot be equal to the source account owner."; + }) + ); + }; + + if (not Account.validate(app_req.from)) { + return #err( + #GenericError({ + error_code = 0; + message = "Invalid account entered for approval source. " # debug_show (app_req.from); + }) + ); + }; + + if (not Account.validate(app_req.spender)) { + return #err( + #GenericError({ + error_code = 0; + message = "Invalid account entered for approval spender. " # debug_show (app_req.spender); + }) + ); + }; + + // TODO: Verify if approval memo should be validated for approvals. + if (not Transfer.validate_memo(app_req.memo)) { + return #err( + #GenericError({ + error_code = 0; + message = "Memo must not be more than 32 bytes"; + }) + ); + }; + + if (app_req.amount == 0) { + return #err( + #GenericError({ + error_code = 0; + message = "Amount must be greater than 0"; + }) + ); + }; + + // TODO: Verify if approval fee should be validated as a transfer fee. + if (not Transfer.validate_fee(token, app_req.fee)) { + return #err( + #BadFee { + expected_fee = token._fee; + } + ); + }; + + let balance : T.Balance = Utils.get_balance( + token.accounts, + app_req.encoded.from, + ); + + // If no approval fee provided, validates against transaction fee. + switch (app_req.fee) { + case (?fee) { + if (fee > balance) return #err(#InsufficientFunds { balance }); + }; + case null { + if (token._fee > balance) return #err(#InsufficientFunds { balance }); + }; + }; + + // Validates that the approval contains the expected allowance + switch (app_req.expected_allowance) { + case null {}; + case (?expected) { + let allowance_record = Utils.get_allowance(token.approvals, app_req); + if (expected != allowance_record.allowance) { + return #err( + #AllowanceChanged { + current_allowance = allowance_record.allowance; + } + ); + }; + }; + }; + + if (not validate_expiration(token, app_req.expires_at)) { + return #err( + #Expired { + ledger_time = Nat64.fromNat(Int.abs(Time.now())); + } + ); + }; + + switch (app_req.created_at_time) { + case (null) {}; + case (?created_at_time) { + + if (Transfer.is_too_old(token, created_at_time)) { + return #err(#TooOld); + }; + + if (Transfer.is_in_future(token, created_at_time)) { + return #err( + #CreatedInFuture { + ledger_time = Nat64.fromNat(Int.abs(Time.now())); + } + ); + }; + }; + }; + + #ok(); + }; + + /// Writes/overwrites the allowance of an approval + public func write_approval( + token : T.TokenData, + app_req : T.ApproveRequest, + ) : async* T.ApproveResult { + + let { amount = allowance; expires_at; encoded } = app_req; + let prev_allowance = Utils.get_allowance(token.approvals, app_req); + let new_allowance : T.Allowance = { allowance; expires_at }; + + if (new_allowance != prev_allowance) { + let accont_approvals = Utils.get_account_approvals(token.approvals, encoded.from); + switch (accont_approvals) { + case (?approvals) { + let spenders = STMap.get(approvals, Blob.equal, Blob.hash, encoded.spender); + STMap.put(approvals, Blob.equal, Blob.hash, encoded.spender, new_allowance); + }; + case null { + let approvals : T.Approvals = STMap.new(); + STMap.put(approvals, Blob.equal, Blob.hash, encoded.spender, new_allowance); + STMap.put(token.approvals, Blob.equal, Blob.hash, encoded.from, approvals); + }; + }; + }; + + #Ok(allowance); + }; +}; diff --git a/src/ICRC2/Canisters/Token.mo b/src/ICRC2/Canisters/Token.mo index 2efbeb4..2e19b9b 100644 --- a/src/ICRC2/Canisters/Token.mo +++ b/src/ICRC2/Canisters/Token.mo @@ -74,6 +74,10 @@ shared ({ caller = _owner }) actor class Token( await* ICRC2.burn(token, args, caller); }; + public shared ({ caller }) func icrc2_approve(args : ICRC2.ApproveArgs) : async ICRC2.ApproveResult { + await* ICRC2.approve(token, args, caller); + }; + // Functions for integration with the rosetta standard public shared query func get_transactions(req : ICRC2.GetTransactionsRequest) : async ICRC2.GetTransactionsResponse { ICRC2.get_transactions(token, req); diff --git a/src/ICRC2/Types.mo b/src/ICRC2/Types.mo index 43e0956..24b0cc1 100644 --- a/src/ICRC2/Types.mo +++ b/src/ICRC2/Types.mo @@ -1,3 +1,6 @@ +import Nat "mo:base/Nat"; +import Nat64 "mo:base/Nat64"; + import Types1 "../ICRC1/Types"; module { @@ -37,6 +40,56 @@ module { public type Transfer = Types1.Transfer; + /// Arguments for an allowance operation + public type AllowanceArgs = { + account : Account; + spender : Account; + }; + + public type Allowance = { + allowance : Nat; + expires_at : ?Nat64; + }; + + /// Arguments for an approve operation + public type ApproveArgs = { + from_subaccount : ?Subaccount; + spender : Account; + amount : Balance; + expected_allowance : ?Nat; + expires_at : ?Nat64; + fee : ?Balance; + memo : ?Memo; + created_at_time : ?Nat64; + }; + + /// Arguments for a transfer from operation + public type TransferFromArgs = { + spender_subaccount : ?Subaccount; + from : Account; + to : Account; + amount : Balance; + fee : ?Balance; + memo : ?Memo; + created_at_time : ?Nat64; + }; + + /// Internal representation of an Approve request + public type ApproveRequest = { + from : Account; + spender : Account; + amount : Balance; + expected_allowance : ?Nat; + expires_at : ?Nat64; + fee : ?Balance; + memo : ?Memo; + created_at_time : ?Nat64; + encoded : { + from : EncodedAccount; + spender : EncodedAccount; + }; + }; + /// Internal representation of a transaction request public type TransactionRequest = Types1.TransactionRequest; @@ -44,10 +97,35 @@ module { public type TimeError = Types1.TimeError; + public type OperationError = Types1.OperationError; + public type TransferError = Types1.TransferError; + public type ApproveError = OperationError or { + // The caller specified the [expected_allowance] field, and the current + // allowance did not match the given value. + #AllowanceChanged : { current_allowance : Nat }; + // The approval request expired before the ledger had a chance to apply it. + #Expired : { ledger_time : Nat64 }; + }; + + public type TransferFromError = TransferError or { + // The caller exceeded its allowance. + #InsufficientAllowance : { allowance : Nat }; + }; + public type TransferResult = Types1.TransferResult; + public type ApproveResult = { + #Ok : Nat; + #Err : ApproveError; + }; + + public type TransferFromResult = { + #Ok : TxIndex; + #Err : TransferFromError; + }; + /// Interface for the ICRC token canister public type TokenInterface = Types1.TokenInterface; @@ -67,11 +145,17 @@ module { public type AccountBalances = Types1.AccountBalances; + public type Approvals = StableTrieMap; + + public type ApprovalAllowances = StableTrieMap; + /// The details of the archive canister public type ArchiveData = Types1.ArchiveData; /// The state of the token canister - public type TokenData = Types1.TokenData; + public type TokenData = Types1.TokenData and { + approvals : ApprovalAllowances; + }; // Rosetta API /// The type to request a range of transactions from the ledger canister @@ -88,7 +172,14 @@ module { /// Functions supported by the rosetta public type RosettaInterface = Types1.RosettaInterface; + /// Functions supported by the ICRC-2 standard + public type ICRC2Interface = actor { + icrc2_approve : shared (ApproveArgs) -> async ApproveResult; + // icrc2_transfer_from : shared (TransferFromArgs) -> async TransferFromResult; + // icrc2_allowance : shared query (AllowanceArgs) -> async Allowance; + }; + /// Interface of the ICRC token and Rosetta canister - public type FullInterface = TokenInterface and RosettaInterface; + public type FullInterface = TokenInterface and RosettaInterface and ICRC2Interface; }; diff --git a/src/ICRC2/Utils.mo b/src/ICRC2/Utils.mo index 1f25f08..6773f90 100644 --- a/src/ICRC2/Utils.mo +++ b/src/ICRC2/Utils.mo @@ -1,12 +1,15 @@ +import Blob "mo:base/Blob"; import Hash "mo:base/Hash"; import Nat "mo:base/Nat"; import Nat8 "mo:base/Nat8"; import Principal "mo:base/Principal"; +import STMap "mo:StableTrieMap"; import StableBuffer "mo:StableBuffer/StableBuffer"; import T "Types"; import U1 "../ICRC1/Utils"; +import Account "../ICRC1/Account"; module { // Creates a Stable Buffer with the default metadata and returns it. @@ -54,6 +57,29 @@ module { U1.create_transfer_req(args, owner, tx_kind); }; + // Formats the different operation arguments into + // an `ApproveRequest`, an internal type to access fields easier. + public func create_approve_req( + args : T.ApproveArgs, + owner : Principal, + ) : T.ApproveRequest { + + let from = { + owner; + subaccount = args.from_subaccount; + }; + + let encoded = { + from = Account.encode(from); + spender = Account.encode(args.spender); + }; + + { + args with from = from; + encoded; + }; + }; + // Transforms the transaction kind from `variant` to `Text` public func kind_to_text(kind : T.TxKind) : Text { U1.kind_to_text(kind); @@ -73,6 +99,40 @@ module { U1.get_balance(accounts, encoded_account); }; + public func get_account_approvals( + approval_allowances : T.ApprovalAllowances, + encoded_account : T.EncodedAccount, + ) : ?T.Approvals { + return STMap.get(approval_allowances, Blob.equal, Blob.hash, encoded_account); + }; + + /// Retrieves an approval's allowance + public func get_allowance( + approval_allowances : T.ApprovalAllowances, + { encoded } : T.ApproveRequest, + ) : T.Allowance { + let default_allowance = { allowance = 0; expires_at = null }; + let account_approvals = get_account_approvals(approval_allowances, encoded.from); + + switch (account_approvals) { + case (?approvals) { + let spender_allowance = STMap.get( + approvals, + Blob.equal, + Blob.hash, + encoded.spender, + ); + switch (spender_allowance) { + case (?allowance) { allowance }; + case (_) { + default_allowance; + }; + }; + }; + case (_) { default_allowance }; + }; + }; + /// Updates the balance of an account public func update_balance( accounts : T.AccountBalances, diff --git a/src/ICRC2/lib.mo b/src/ICRC2/lib.mo index f590047..2e4a85d 100644 --- a/src/ICRC2/lib.mo +++ b/src/ICRC2/lib.mo @@ -11,6 +11,7 @@ import StableTrieMap "mo:StableTrieMap"; import T "Types"; import Utils "Utils"; +import Approve "Approve"; import ICRC1 "../ICRC1"; import Account "../ICRC1/Account"; @@ -26,10 +27,15 @@ module { public type Transaction = T.Transaction; public type Balance = T.Balance; public type TransferArgs = T.TransferArgs; + public type AllowanceArgs = T.AllowanceArgs; + public type ApproveArgs = T.ApproveArgs; + public type TransferFromArgs = T.TransferFromArgs; public type Mint = T.Mint; public type BurnArgs = T.BurnArgs; public type TransactionRequest = T.TransactionRequest; public type TransferError = T.TransferError; + public type ApproveError = T.ApproveError; + public type TransferFromError = T.TransferFromError; public type SupportedStandard = T.SupportedStandard; @@ -53,6 +59,8 @@ module { public type ArchivedTransaction = T.ArchivedTransaction; public type TransferResult = T.TransferResult; + public type ApproveResult = T.ApproveResult; + public type TransferFromResult = T.TransferFromResult; public let MAX_TRANSACTIONS_IN_LEDGER = ICRC1.MAX_TRANSACTIONS_IN_LEDGER; public let MAX_TRANSACTION_BYTES : Nat64 = ICRC1.MAX_TRANSACTION_BYTES; @@ -90,6 +98,7 @@ module { }; let accounts : T.AccountBalances = StableTrieMap.new(); + let approvals : T.ApprovalAllowances = StableTrieMap.new(); var _minted_tokens = _burned_tokens; @@ -125,6 +134,7 @@ module { min_burn_amount; minting_account; accounts; + approvals; metadata = Utils.init_metadata(args); supported_standards = Utils.init_standards(); transactions = SB.initPresized(MAX_TRANSACTIONS_IN_LEDGER); @@ -232,6 +242,22 @@ module { await* ICRC1.burn(token, args, caller); }; + /// Creates or updates an approval allowance + public func approve(token : T.TokenData, args : T.ApproveArgs, caller : Principal) : async* T.ApproveResult { + let app_req = Utils.create_approve_req(args, caller); + + switch (Approve.validate_request(token, app_req)) { + case (#err(errorType)) { + return #Err(errorType); + }; + case (#ok(_)) {}; + }; + + let { encoded; amount } = app_req; + + await* Approve.write_approval(token, app_req); + }; + /// Returns the total number of transactions that have been processed by the given token. public func total_transactions(token : T.TokenData) : Nat { ICRC1.total_transactions(token); From b4a1e609e1f144c41bdffa80c2199bac55e52cb1 Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Thu, 25 May 2023 15:08:13 -0600 Subject: [PATCH 4/9] Adds icrc2_allowance, updates utils, helpers --- src/ICRC2/Approve.mo | 4 ++-- src/ICRC2/Canisters/Token.mo | 4 ++++ src/ICRC2/Types.mo | 2 +- src/ICRC2/Utils.mo | 2 +- src/ICRC2/lib.mo | 10 ++++++++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/ICRC2/Approve.mo b/src/ICRC2/Approve.mo index d8714f9..a5a0093 100644 --- a/src/ICRC2/Approve.mo +++ b/src/ICRC2/Approve.mo @@ -106,7 +106,7 @@ module { switch (app_req.expected_allowance) { case null {}; case (?expected) { - let allowance_record = Utils.get_allowance(token.approvals, app_req); + let allowance_record = Utils.get_allowance(token.approvals, app_req.encoded); if (expected != allowance_record.allowance) { return #err( #AllowanceChanged { @@ -153,7 +153,7 @@ module { ) : async* T.ApproveResult { let { amount = allowance; expires_at; encoded } = app_req; - let prev_allowance = Utils.get_allowance(token.approvals, app_req); + let prev_allowance = Utils.get_allowance(token.approvals, encoded); let new_allowance : T.Allowance = { allowance; expires_at }; if (new_allowance != prev_allowance) { diff --git a/src/ICRC2/Canisters/Token.mo b/src/ICRC2/Canisters/Token.mo index 2e19b9b..88e0d65 100644 --- a/src/ICRC2/Canisters/Token.mo +++ b/src/ICRC2/Canisters/Token.mo @@ -78,6 +78,10 @@ shared ({ caller = _owner }) actor class Token( await* ICRC2.approve(token, args, caller); }; + public shared query func icrc2_allowance(args : ICRC2.AllowanceArgs) : async ICRC2.Allowance { + ICRC2.allowance(token, args); + }; + // Functions for integration with the rosetta standard public shared query func get_transactions(req : ICRC2.GetTransactionsRequest) : async ICRC2.GetTransactionsResponse { ICRC2.get_transactions(token, req); diff --git a/src/ICRC2/Types.mo b/src/ICRC2/Types.mo index 24b0cc1..54f7a0e 100644 --- a/src/ICRC2/Types.mo +++ b/src/ICRC2/Types.mo @@ -176,7 +176,7 @@ module { public type ICRC2Interface = actor { icrc2_approve : shared (ApproveArgs) -> async ApproveResult; // icrc2_transfer_from : shared (TransferFromArgs) -> async TransferFromResult; - // icrc2_allowance : shared query (AllowanceArgs) -> async Allowance; + icrc2_allowance : shared query (AllowanceArgs) -> async Allowance; }; /// Interface of the ICRC token and Rosetta canister diff --git a/src/ICRC2/Utils.mo b/src/ICRC2/Utils.mo index 6773f90..9d9677d 100644 --- a/src/ICRC2/Utils.mo +++ b/src/ICRC2/Utils.mo @@ -109,7 +109,7 @@ module { /// Retrieves an approval's allowance public func get_allowance( approval_allowances : T.ApprovalAllowances, - { encoded } : T.ApproveRequest, + encoded : {from : T.EncodedAccount; spender : T.EncodedAccount}, ) : T.Allowance { let default_allowance = { allowance = 0; expires_at = null }; let account_approvals = get_account_approvals(approval_allowances, encoded.from); diff --git a/src/ICRC2/lib.mo b/src/ICRC2/lib.mo index 2e4a85d..11fa360 100644 --- a/src/ICRC2/lib.mo +++ b/src/ICRC2/lib.mo @@ -26,6 +26,7 @@ module { public type Transaction = T.Transaction; public type Balance = T.Balance; + public type Allowance = T.Allowance; public type TransferArgs = T.TransferArgs; public type AllowanceArgs = T.AllowanceArgs; public type ApproveArgs = T.ApproveArgs; @@ -258,6 +259,15 @@ module { await* Approve.write_approval(token, app_req); }; + /// Retrieve the allowance of a given approval + public func allowance({ approvals } : T.TokenData, args : T.AllowanceArgs) : T.Allowance { + let encoded_args = { + from = Account.encode(args.account); + spender = Account.encode(args.spender); + }; + Utils.get_allowance(approvals, encoded_args); + }; + /// Returns the total number of transactions that have been processed by the given token. public func total_transactions(token : T.TokenData) : Nat { ICRC1.total_transactions(token); From c9a8d31f4f5530244674328f3b414c9efbc5d10c Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Thu, 25 May 2023 17:38:53 -0600 Subject: [PATCH 5/9] Adds icrc2_transfer_from, updates utils, helpers --- src/ICRC2/Approve.mo | 11 +--- src/ICRC2/Canisters/Token.mo | 4 ++ src/ICRC2/TransferFrom.mo | 42 ++++++++++++++ src/ICRC2/Types.mo | 28 +++++++++- src/ICRC2/Utils.mo | 22 ++++++++ src/ICRC2/lib.mo | 105 +++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 src/ICRC2/TransferFrom.mo diff --git a/src/ICRC2/Approve.mo b/src/ICRC2/Approve.mo index a5a0093..119ab67 100644 --- a/src/ICRC2/Approve.mo +++ b/src/ICRC2/Approve.mo @@ -93,13 +93,8 @@ module { ); // If no approval fee provided, validates against transaction fee. - switch (app_req.fee) { - case (?fee) { - if (fee > balance) return #err(#InsufficientFunds { balance }); - }; - case null { - if (token._fee > balance) return #err(#InsufficientFunds { balance }); - }; + if(Option.get(app_req.fee, token._fee) > balance){ + return #err(#InsufficientFunds { balance }); }; // Validates that the approval contains the expected allowance @@ -149,7 +144,7 @@ module { /// Writes/overwrites the allowance of an approval public func write_approval( token : T.TokenData, - app_req : T.ApproveRequest, + app_req : T.WriteApproveRequest, ) : async* T.ApproveResult { let { amount = allowance; expires_at; encoded } = app_req; diff --git a/src/ICRC2/Canisters/Token.mo b/src/ICRC2/Canisters/Token.mo index 88e0d65..46f0d43 100644 --- a/src/ICRC2/Canisters/Token.mo +++ b/src/ICRC2/Canisters/Token.mo @@ -78,6 +78,10 @@ shared ({ caller = _owner }) actor class Token( await* ICRC2.approve(token, args, caller); }; + public shared ({ caller }) func icrc2_transfer_from(args : ICRC2.TransferFromArgs) : async ICRC2.TransferFromResult { + await* ICRC2.transfer_from(token, args, caller); + }; + public shared query func icrc2_allowance(args : ICRC2.AllowanceArgs) : async ICRC2.Allowance { ICRC2.allowance(token, args); }; diff --git a/src/ICRC2/TransferFrom.mo b/src/ICRC2/TransferFrom.mo new file mode 100644 index 0000000..f3a0ea7 --- /dev/null +++ b/src/ICRC2/TransferFrom.mo @@ -0,0 +1,42 @@ +import Option "mo:base/Option"; +import Result "mo:base/Result"; + +import T "Types"; +import Utils "Utils"; +import Approve "Approve"; +import Transfer "../ICRC1/Transfer"; + +module { + /// Checks if a Transfer From request is valid + public func validate_request( + token : T.TokenData, + txf_req : T.TransactionFromRequest, + ) : Result.Result<(), T.TransferFromError> { + + let { allowance; expires_at } = Utils.get_allowance(token.approvals, txf_req.encoded); + if (allowance < txf_req.amount + Option.get(txf_req.fee, token._fee)) { + return #err( + #InsufficientAllowance({ + allowance = allowance; + }) + ); + }; + if (not Approve.validate_expiration(token, expires_at)) { + return #err( + #GenericError({ + error_code = 0; + message = "Allowance has already expired"; + }) + ); + }; + + switch (Transfer.validate_request(token, txf_req)) { + case (#err(errorType)) { + return #err(errorType); + }; + case (#ok(_)) {}; + }; + + return #ok(); + }; +}; diff --git a/src/ICRC2/Types.mo b/src/ICRC2/Types.mo index 54f7a0e..e2f0ecb 100644 --- a/src/ICRC2/Types.mo +++ b/src/ICRC2/Types.mo @@ -74,6 +74,15 @@ module { created_at_time : ?Nat64; }; + public type WriteApproveRequest = { + amount : Balance; + expires_at : ?Nat64; + encoded : { + from : EncodedAccount; + spender : EncodedAccount; + }; + }; + /// Internal representation of an Approve request public type ApproveRequest = { from : Account; @@ -90,6 +99,23 @@ module { }; }; + /// Internal representation of a Transaction From request + public type TransactionFromRequest = { + kind : TxKind; + from : Account; + to : Account; + spender : Account; + amount : Balance; + fee : ?Balance; + memo : ?Memo; + created_at_time : ?Nat64; + encoded : { + from : EncodedAccount; + to : EncodedAccount; + spender : EncodedAccount; + }; + }; + /// Internal representation of a transaction request public type TransactionRequest = Types1.TransactionRequest; @@ -175,7 +201,7 @@ module { /// Functions supported by the ICRC-2 standard public type ICRC2Interface = actor { icrc2_approve : shared (ApproveArgs) -> async ApproveResult; - // icrc2_transfer_from : shared (TransferFromArgs) -> async TransferFromResult; + icrc2_transfer_from : shared (TransferFromArgs) -> async TransferFromResult; icrc2_allowance : shared query (AllowanceArgs) -> async Allowance; }; diff --git a/src/ICRC2/Utils.mo b/src/ICRC2/Utils.mo index 9d9677d..c0ac725 100644 --- a/src/ICRC2/Utils.mo +++ b/src/ICRC2/Utils.mo @@ -57,6 +57,28 @@ module { U1.create_transfer_req(args, owner, tx_kind); }; + // Formats the different operation arguements into + // a `TransactionFromRequest`, an internal type to access fields easier. + public func create_transfer_from_req( + args : T.TransferFromArgs, + owner : Principal, + tx_kind : T.TxKind, + ) : T.TransactionFromRequest { + let spender = { owner; subaccount = args.spender_subaccount }; + var transfer_args = { args with from_subaccount = null }; + + var transfer_from_args = U1.create_transfer_req(transfer_args, owner, tx_kind); + { + transfer_from_args with encoded = { + from = Account.encode(args.from); + to = Account.encode(args.to); + spender = Account.encode(spender); + }; + from = args.from; + spender; + }; + }; + // Formats the different operation arguments into // an `ApproveRequest`, an internal type to access fields easier. public func create_approve_req( diff --git a/src/ICRC2/lib.mo b/src/ICRC2/lib.mo index 11fa360..83c2704 100644 --- a/src/ICRC2/lib.mo +++ b/src/ICRC2/lib.mo @@ -4,7 +4,9 @@ import Float "mo:base/Float"; import Nat "mo:base/Nat"; import Nat64 "mo:base/Nat64"; import Nat8 "mo:base/Nat8"; +import Option "mo:base/Option"; import Principal "mo:base/Principal"; +import EC "mo:base/ExperimentalCycles"; import Itertools "mo:itertools/Iter"; import StableTrieMap "mo:StableTrieMap"; @@ -12,8 +14,10 @@ import StableTrieMap "mo:StableTrieMap"; import T "Types"; import Utils "Utils"; import Approve "Approve"; +import TransferFrom "TransferFrom"; import ICRC1 "../ICRC1"; import Account "../ICRC1/Account"; +import Archive "../ICRC1/Canisters/Archive"; /// The ICRC2 class with all the functions for creating an /// ICRC2 token on the Internet Computer @@ -268,6 +272,73 @@ module { Utils.get_allowance(approvals, encoded_args); }; + /// Transfers tokens by spender from one account to another account (minting and burning included) + public func transfer_from( + token : T.TokenData, + args : T.TransferFromArgs, + caller : Principal, + ) : async* T.TransferFromResult { + + let spender = { + owner = caller; + subaccount = args.spender_subaccount; + }; + + let txf_kind = if (args.from == token.minting_account) { + #mint; + } else if (args.to == token.minting_account) { + #burn; + } else { + #transfer; + }; + + let txf_req = Utils.create_transfer_from_req(args, caller, txf_kind); + + switch (TransferFrom.validate_request(token, txf_req)) { + case (#err(errorType)) { + return #Err(errorType); + }; + case (#ok(_)) {}; + }; + + let { encoded; amount; fee } = txf_req; + let { allowance; expires_at } = Utils.get_allowance(token.approvals, encoded); + ignore Approve.write_approval( + token, + { + amount = allowance - amount - Option.get(fee, token._fee); + expires_at; + encoded; + }, + ); + + // process transaction + switch (txf_req.kind) { + case (#mint) { + Utils.mint_balance(token, encoded.to, amount); + }; + case (#burn) { + Utils.burn_balance(token, encoded.from, amount); + }; + case (#transfer) { + Utils.transfer_balance(token, txf_req); + + // burn fee + Utils.burn_balance(token, encoded.from, token._fee); + }; + }; + + // store transaction + let index = SB.size(token.transactions) + token.archive.stored_txs; + let tx = Utils.req_to_tx(txf_req, index); + SB.add(token.transactions, tx); + + // transfer transaction to archive if necessary + await* update_canister(token); + + #Ok(tx.index); + }; + /// Returns the total number of transactions that have been processed by the given token. public func total_transactions(token : T.TokenData) : Nat { ICRC1.total_transactions(token); @@ -283,4 +354,38 @@ module { ICRC1.get_transactions(token, req); }; + // Updates the token's data and manages the transactions + // + // **added at the end of any function that creates a new transaction** + func update_canister(token : T.TokenData) : async* () { + let txs_size = SB.size(token.transactions); + + if (txs_size >= MAX_TRANSACTIONS_IN_LEDGER) { + await* append_transactions(token); + }; + }; + + // Moves the transactions from the ICRC1 canister to the archive canister + // and returns a boolean that indicates the success of the data transfer + func append_transactions(token : T.TokenData) : async* () { + let { archive; transactions } = token; + + if (archive.stored_txs == 0) { + EC.add(200_000_000_000); + archive.canister := await Archive.Archive(); + }; + + let res = await archive.canister.append_transactions( + SB.toArray(transactions) + ); + + switch (res) { + case (#ok(_)) { + archive.stored_txs += SB.size(transactions); + SB.clear(transactions); + }; + case (#err(_)) {}; + }; + }; + }; From b7738a7adb633a5d784dd377e540f856d8f37b39 Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Thu, 25 May 2023 19:42:14 -0600 Subject: [PATCH 6/9] No changes (file formatting) --- icrc1-default-args.txt | 2 +- tests/ICRC2/ICRC2.ActorTest.mo | 35 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/icrc1-default-args.txt b/icrc1-default-args.txt index 0a92714..0aca026 100644 --- a/icrc1-default-args.txt +++ b/icrc1-default-args.txt @@ -7,7 +7,7 @@ initial_balances = vec { record { record { - owner = principal ""; + owner = principal "r7inp-6aaaa-aaaaa-aaabq-cai"; subaccount = null; }; 100_000_000 diff --git a/tests/ICRC2/ICRC2.ActorTest.mo b/tests/ICRC2/ICRC2.ActorTest.mo index b3d1f24..36298dd 100644 --- a/tests/ICRC2/ICRC2.ActorTest.mo +++ b/tests/ICRC2/ICRC2.ActorTest.mo @@ -91,9 +91,9 @@ module { }; func validate_get_transactions( - token : T.TokenData, - tx_req : T.GetTransactionsRequest, - tx_res : T.GetTransactionsResponse + token : T.TokenData, + tx_req : T.GetTransactionsRequest, + tx_res : T.GetTransactionsResponse, ) : Bool { let { archive } = token; @@ -251,7 +251,7 @@ module { let token = ICRC2.init(args); assertTrue( - ICRC2.name(token) == args.name, + ICRC2.name(token) == args.name ); }, ), @@ -264,7 +264,7 @@ module { let token = ICRC2.init(args); assertTrue( - ICRC2.symbol(token) == args.symbol, + ICRC2.symbol(token) == args.symbol ); }, ), @@ -277,7 +277,7 @@ module { let token = ICRC2.init(args); assertTrue( - ICRC2.decimals(token) == args.decimals, + ICRC2.decimals(token) == args.decimals ); }, ), @@ -289,7 +289,7 @@ module { let token = ICRC2.init(args); assertTrue( - ICRC2.fee(token) == args.fee, + ICRC2.fee(token) == args.fee ); }, ), @@ -301,7 +301,7 @@ module { let token = ICRC2.init(args); assertTrue( - ICRC2.minting_account(token) == args.minting_account, + ICRC2.minting_account(token) == args.minting_account ); }, ), @@ -310,8 +310,8 @@ module { do { let args = default_token_args; - let token = ICRC2.init({ args - with initial_balances = [ + let token = ICRC2.init({ + args with initial_balances = [ (user1, 100), (user2, 200), ]; @@ -328,15 +328,15 @@ module { do { let args = default_token_args; - let token = ICRC2.init({ args - with initial_balances = [ + let token = ICRC2.init({ + args with initial_balances = [ (user1, 100), (user2, 200), ]; }); assertTrue( - ICRC2.total_supply(token) == 300, + ICRC2.total_supply(token) == 300 ); }, ), @@ -354,7 +354,7 @@ module { ("icrc2:name", #Text(args.name)), ("icrc2:symbol", #Text(args.symbol)), ("icrc2:decimals", #Nat(Nat8.toNat(args.decimals))), - ], + ] ); }, ), @@ -370,7 +370,7 @@ module { ICRC2.supported_standards(token) == [{ name = "ICRC-2"; url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-2"; - }], + }] ); }, ), @@ -468,7 +468,7 @@ module { res == #Err( #InsufficientFunds { balance = 0; - }, + } ), ]); }, @@ -506,7 +506,7 @@ module { res == #Err( #BadBurn { min_burn_amount = 10 * (10 ** 8); - }, + } ), ]); }, @@ -550,7 +550,6 @@ module { user1.owner, ); - assertAllTrue([ res == #Ok(1), ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 145), From 2c8fda015ef9411122e1fc012ba84631d4e6cb5a Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Thu, 25 May 2023 22:02:23 -0600 Subject: [PATCH 7/9] Adds icrc2_approve and allowance tests --- src/ICRC2/lib.mo | 3 + tests/ICRC2/ICRC2.ActorTest.mo | 299 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) diff --git a/src/ICRC2/lib.mo b/src/ICRC2/lib.mo index 83c2704..dbed7f9 100644 --- a/src/ICRC2/lib.mo +++ b/src/ICRC2/lib.mo @@ -260,6 +260,9 @@ module { let { encoded; amount } = app_req; + // burn fee + Utils.burn_balance(token, encoded.from, Option.get(args.fee, token._fee)); + await* Approve.write_approval(token, app_req); }; diff --git a/tests/ICRC2/ICRC2.ActorTest.mo b/tests/ICRC2/ICRC2.ActorTest.mo index 36298dd..54c5259 100644 --- a/tests/ICRC2/ICRC2.ActorTest.mo +++ b/tests/ICRC2/ICRC2.ActorTest.mo @@ -1,9 +1,12 @@ import Array "mo:base/Array"; import Debug "mo:base/Debug"; +import Int "mo:base/Int"; import Iter "mo:base/Iter"; import Nat "mo:base/Nat"; import Nat8 "mo:base/Nat8"; +import Nat64 "mo:base/Nat64"; import Principal "mo:base/Principal"; +import Time "mo:base/Time"; import Itertools "mo:itertools/Iter"; import StableBuffer "mo:StableBuffer/StableBuffer"; @@ -66,6 +69,11 @@ module { subaccount = null; }; + let user3 : T.Account = { + owner = Principal.fromText("qnr6q-xmlmu-t6jhl-fyjlg-lf3fv-6mnao-oyrpr-76s4k-3lngw-jrrsd-yae"); + subaccount = null; + }; + func txs_range(start : Nat, end : Nat) : [T.Transaction] { Array.tabulate( (end - start) : Nat, @@ -561,6 +569,297 @@ module { ), ], ), + describe( + "approve() & allowance()", + [ + it( + "Approval from funded account", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res == #Ok(approve_args.amount), + allowance == ICRC2.balance_from_float(token, 50), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 195), + token._burned_tokens == ICRC2.balance_from_float(token, 5), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 195), + ]); + }, + ), + it( + "Approval from account with no funds", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res == #Err( + #InsufficientFunds { balance = 0 } + ), + allowance == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ]); + }, + ), + it( + "Approval from account with exact funds to pay fee", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 5 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res == #Ok(approve_args.amount), + allowance == ICRC2.balance_from_float(token, 50), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 5), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 0), + ]); + }, + ), + it( + "Approval with correct expected allowance", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 5 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = ?0; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res == #Ok(approve_args.amount), + allowance == ICRC2.balance_from_float(token, 50), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 5), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 0), + ]); + }, + ), + it( + "Approval with incorrect expected allowance", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 5 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = ?50; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res == #Err( + #AllowanceChanged { + current_allowance = 0; + } + ), + allowance == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 5), + token._burned_tokens == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 5), + ]); + }, + ), + it( + "Approval with expired allowance", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 5 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let now = Nat64.fromNat(Int.abs(Time.now())); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = ?(now + 100); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res == #Err( + #Expired { + ledger_time = now; + } + ), + allowance == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 5), + token._burned_tokens == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 5), + ]); + }, + ), + ], + ), describe( "Internal Archive Testing", From 6f064ade675eda3429f42004e6ce2c0219cff4e2 Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Fri, 26 May 2023 01:26:51 -0600 Subject: [PATCH 8/9] Adds icrc2_transfer_from tests --- src/ICRC2/lib.mo | 2 +- tests/ICRC2/ICRC2.ActorTest.mo | 213 ++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 2 deletions(-) diff --git a/src/ICRC2/lib.mo b/src/ICRC2/lib.mo index dbed7f9..0ec2144 100644 --- a/src/ICRC2/lib.mo +++ b/src/ICRC2/lib.mo @@ -306,7 +306,7 @@ module { let { encoded; amount; fee } = txf_req; let { allowance; expires_at } = Utils.get_allowance(token.approvals, encoded); - ignore Approve.write_approval( + ignore await* Approve.write_approval( token, { amount = allowance - amount - Option.get(fee, token._fee); diff --git a/tests/ICRC2/ICRC2.ActorTest.mo b/tests/ICRC2/ICRC2.ActorTest.mo index 54c5259..1ec1ca6 100644 --- a/tests/ICRC2/ICRC2.ActorTest.mo +++ b/tests/ICRC2/ICRC2.ActorTest.mo @@ -227,7 +227,7 @@ module { }; return describe( - "ICRC2 Token Implementation Tessts", + "ICRC2 Token Implementation Tests", [ it( "init()", @@ -860,7 +860,218 @@ module { ), ], ), + describe( + "transfer_from()", + [ + it( + "Spender Transfer From funded Allowance and Account", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res1 = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 20 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res1 == #Ok(approve_args.amount), + res2 == #Ok(1), + allowance1 == ICRC2.balance_from_float(token, 50), + allowance2 == ICRC2.balance_from_float(token, 25), + token._burned_tokens == ICRC2.balance_from_float(token, 10), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 170), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user3) == ICRC2.balance_from_float(token, 20), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 190), + ]); + }, + ), + it( + "Spender Transfer From funded Allowance but Account with insufficient funds", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 20 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res1 = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 20 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res1 == #Ok(approve_args.amount), + res2 == #Err(#InsufficientFunds({ balance = ICRC2.balance_from_float(token, 15) })), + allowance1 == ICRC2.balance_from_float(token, 50), + allowance2 == ICRC2.balance_from_float(token, 50), + token._burned_tokens == ICRC2.balance_from_float(token, 5), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 15), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user3) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 15), + ]); + }, + ), + it( + "Spender Transfer From Allowance with insufficient funds", + do { + let args = default_token_args; + let token = ICRC2.init(args); + let mint_args = { + to = user1; + amount = 50 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 20 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res1 = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 20 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res1 == #Ok(approve_args.amount), + res2 == #Err(#InsufficientAllowance({ allowance = ICRC2.balance_from_float(token, 20) })), + allowance1 == ICRC2.balance_from_float(token, 20), + allowance2 == ICRC2.balance_from_float(token, 20), + token._burned_tokens == ICRC2.balance_from_float(token, 5), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 45), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user3) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 45), + ]); + }, + ), + ], + ), describe( "Internal Archive Testing", [ From fe98141c995f97af42d68ca910800fbf12bcabc2 Mon Sep 17 00:00:00 2001 From: Raul Ceron Date: Fri, 26 May 2023 10:02:33 -0600 Subject: [PATCH 9/9] Adds ICRC-2 Example Tests, update allowance checks --- src/ICRC2/Approve.mo | 9 - tests/ICRC2/ICRC2.ActorTest.mo | 457 ++++++++++++++++++++++++++++++++- 2 files changed, 454 insertions(+), 12 deletions(-) diff --git a/src/ICRC2/Approve.mo b/src/ICRC2/Approve.mo index 119ab67..25ea486 100644 --- a/src/ICRC2/Approve.mo +++ b/src/ICRC2/Approve.mo @@ -69,15 +69,6 @@ module { ); }; - if (app_req.amount == 0) { - return #err( - #GenericError({ - error_code = 0; - message = "Amount must be greater than 0"; - }) - ); - }; - // TODO: Verify if approval fee should be validated as a transfer fee. if (not Transfer.validate_fee(token, app_req.fee)) { return #err( diff --git a/tests/ICRC2/ICRC2.ActorTest.mo b/tests/ICRC2/ICRC2.ActorTest.mo index 1ec1ca6..01b5288 100644 --- a/tests/ICRC2/ICRC2.ActorTest.mo +++ b/tests/ICRC2/ICRC2.ActorTest.mo @@ -920,7 +920,7 @@ module { let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); assertAllTrue([ - res1 == #Ok(approve_args.amount), + res1 == #Ok(ICRC2.balance_from_float(token, 50)), res2 == #Ok(1), allowance1 == ICRC2.balance_from_float(token, 50), allowance2 == ICRC2.balance_from_float(token, 25), @@ -989,7 +989,7 @@ module { let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); assertAllTrue([ - res1 == #Ok(approve_args.amount), + res1 == #Ok(ICRC2.balance_from_float(token, 50)), res2 == #Err(#InsufficientFunds({ balance = ICRC2.balance_from_float(token, 15) })), allowance1 == ICRC2.balance_from_float(token, 50), allowance2 == ICRC2.balance_from_float(token, 50), @@ -1058,7 +1058,7 @@ module { let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); assertAllTrue([ - res1 == #Ok(approve_args.amount), + res1 == #Ok(ICRC2.balance_from_float(token, 20)), res2 == #Err(#InsufficientAllowance({ allowance = ICRC2.balance_from_float(token, 20) })), allowance1 == ICRC2.balance_from_float(token, 20), allowance2 == ICRC2.balance_from_float(token, 20), @@ -1072,6 +1072,457 @@ module { ), ], ), + describe( + "ICRC-2 Examples", + [ + it( + "Alice deposits tokens to canister C", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + // 1. Alice wants to deposit 100 tokens on an ICRC-2 ledger to canister C. + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 105 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + // 2. Alice calls icrc2_approve with spender set to the canister's default + // account ({ owner = C; subaccount = null}) and amount set to the token amount + // she wants to deposit (100) plus the transfer fee. + let res1 = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 3. Alice can then call some deposit method on the canister, which calls + // icrc2_transfer_from with from set to Alice's (the caller) account, to set to + // the canister's account, and amount set to the token amount she wants to deposit (100). + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user2; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + // 4. The canister can now determine from the result of the call whether the transfer + // was successful. If it was successful, the canister can now safely commit the + // deposit to state and know that the tokens are in its account. + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res1 == #Ok(ICRC2.balance_from_float(token, 105)), + res2 == #Ok(1), + allowance1 == ICRC2.balance_from_float(token, 105), + allowance2 == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 10), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 90), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 100), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 190), + ]); + }, + ), + it( + "Canister C transfers tokens from Alice's account to Bob's account, on Alice's behalf", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + // 1. Canister C wants to transfer 100 tokens on an ICRC-2 ledger from Alice's account to Bob's account. + let approve_args : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 105 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + // 2. Alice previously approved canister C to transfer tokens on her behalf by calling + // icrc2_approve with spender set to the canister's default account ({ owner = C; subaccount = null }) + // and amount set to the token amount she wants to allow (100) plus the transfer fee. + let res1 = await* ICRC2.approve( + token, + approve_args, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 3. During some update call, the canister can now call icrc2_transfer_from with from set to + // Alice's account, to set to Bob's account, and amount set to the token amount she wants to transfer (100). + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + // 4. Once the call completes successfully, Bob has 100 extra tokens on his account, + // and Alice has 100 (plus the fee) tokens less in her account. + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res1 == #Ok(ICRC2.balance_from_float(token, 105)), + res2 == #Ok(1), + allowance1 == ICRC2.balance_from_float(token, 105), + allowance2 == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 10), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 90), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user3) == ICRC2.balance_from_float(token, 100), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 190), + ]); + }, + ), + it( + "Alice removes her allowance for canister C", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args1 : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res1 = await* ICRC2.approve( + token, + approve_args1, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 1. Alice wants to remove her allowance of 100 tokens on an ICRC-2 ledger for canister C. + + // 2. Alice calls icrc2_approve on the ledger with spender set to the canister's + // default account ({ owner = C; subaccount = null }) and amount set to 0. + let approve_args2 : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 0; + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.approve( + token, + approve_args2, + user1.owner, + ); + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 3. The canister can no longer transfer tokens on Alice's behalf. + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res3 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + assertAllTrue([ + res1 == #Ok(ICRC2.balance_from_float(token, 100)), + res2 == #Ok(0), + res3 == #Err(#InsufficientAllowance({ allowance = 0 })), + allowance1 == ICRC2.balance_from_float(token, 100), + allowance2 == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 10), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 190), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 190), + ]); + }, + ), + it( + "Alice atomically removes her allowance for canister C", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args1 : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res1 = await* ICRC2.approve( + token, + approve_args1, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 1. Alice wants to remove her allowance of 100 tokens on an ICRC-2 ledger for canister C. + + // 2. Alice calls icrc2_approve on the ledger with spender set to the canister's default + // account ({ owner = C; subaccount = null }), amount set to 0, and expected_allowance set to 100 tokens. + let approve_args2 : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 0; + expected_allowance = ?(100 * (10 ** Nat8.toNat(token.decimals))); + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.approve( + token, + approve_args2, + user1.owner, + ); + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 3. If the call succeeds, the allowance got removed successfully. An AllowanceChanged error + // would indicate that canister C used some of the allowance before Alice's call completed. + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res3 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + assertAllTrue([ + res1 == #Ok(ICRC2.balance_from_float(token, 100)), + res2 == #Ok(0), + res3 == #Err(#InsufficientAllowance({ allowance = 0 })), + allowance1 == ICRC2.balance_from_float(token, 100), + allowance2 == ICRC2.balance_from_float(token, 0), + token._burned_tokens == ICRC2.balance_from_float(token, 10), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 190), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 190), + ]); + }, + ), + it( + "Alice atomically removes her allowance for canister C - AllowanceChanged", + do { + let args = default_token_args; + let token = ICRC2.init(args); + + let mint_args = { + to = user1; + amount = 200 * (10 ** Nat8.toNat(token.decimals)); + memo = null; + created_at_time = null; + }; + + ignore await* ICRC2.mint( + token, + mint_args, + args.minting_account.owner, + ); + + let approve_args1 : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 100 * (10 ** Nat8.toNat(token.decimals)); + expected_allowance = null; + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res1 = await* ICRC2.approve( + token, + approve_args1, + user1.owner, + ); + + let { allowance = allowance1 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // Adds transfer to check for AllowanceChanged + let transfer_from_args : T.TransferFromArgs = { + spender_subaccount = user2.subaccount; + from = user1; + to = user3; + amount = 10 * (10 ** Nat8.toNat(token.decimals)); + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res2 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + let { allowance = allowance2 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 1. Alice wants to remove her allowance of 100 tokens on an ICRC-2 ledger for canister C. + + // 2. Alice calls icrc2_approve on the ledger with spender set to the canister's default + // account ({ owner = C; subaccount = null }), amount set to 0, and expected_allowance set to 100 tokens. + let approve_args2 : T.ApproveArgs = { + from_subaccount = user1.subaccount; + spender = user2; + amount = 0; + expected_allowance = ?(100 * (10 ** Nat8.toNat(token.decimals))); + expires_at = null; + fee = ?token._fee; + memo = null; + created_at_time = null; + }; + + let res3 = await* ICRC2.approve( + token, + approve_args2, + user1.owner, + ); + + let { allowance = allowance3 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + // 3. If the call succeeds, the allowance got removed successfully. An AllowanceChanged error + // would indicate that canister C used some of the allowance before Alice's call completed. + let res4 = await* ICRC2.transfer_from( + token, + transfer_from_args, + user2.owner, + ); + + let { allowance = allowance4 } = ICRC2.allowance(token, { account = user1; spender = user2 }); + + assertAllTrue([ + res1 == #Ok(ICRC2.balance_from_float(token, 100)), + res2 == #Ok(1), + res3 == #Err(#AllowanceChanged({ current_allowance = ICRC2.balance_from_float(token, 85) })), + res4 == #Ok(2), + allowance1 == ICRC2.balance_from_float(token, 100), + allowance2 == ICRC2.balance_from_float(token, 85), + allowance3 == ICRC2.balance_from_float(token, 85), + allowance4 == ICRC2.balance_from_float(token, 70), + token._burned_tokens == ICRC2.balance_from_float(token, 15), + ICRC2.balance_of(token, user1) == ICRC2.balance_from_float(token, 165), + ICRC2.balance_of(token, user2) == ICRC2.balance_from_float(token, 0), + ICRC2.balance_of(token, user3) == ICRC2.balance_from_float(token, 20), + ICRC2.total_supply(token) == ICRC2.balance_from_float(token, 185), + ]); + }, + ), + ], + ), describe( "Internal Archive Testing", [