diff --git a/dfx.json b/dfx.json index 82ce98f..a4a3782 100644 --- a/dfx.json +++ b/dfx.json @@ -5,6 +5,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/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..0aca026 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 "r7inp-6aaaa-aaaaa-aaabq-cai"; + 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/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/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/src/ICRC2/Approve.mo b/src/ICRC2/Approve.mo new file mode 100644 index 0000000..25ea486 --- /dev/null +++ b/src/ICRC2/Approve.mo @@ -0,0 +1,162 @@ +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"; + }) + ); + }; + + // 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. + if(Option.get(app_req.fee, 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.encoded); + 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.WriteApproveRequest, + ) : async* T.ApproveResult { + + let { amount = allowance; expires_at; encoded } = app_req; + let prev_allowance = Utils.get_allowance(token.approvals, encoded); + 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 new file mode 100644 index 0000000..46f0d43 --- /dev/null +++ b/src/ICRC2/Canisters/Token.mo @@ -0,0 +1,105 @@ +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); + }; + + public shared ({ caller }) func icrc2_approve(args : ICRC2.ApproveArgs) : async ICRC2.ApproveResult { + 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); + }; + + // 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/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 new file mode 100644 index 0000000..e2f0ecb --- /dev/null +++ b/src/ICRC2/Types.mo @@ -0,0 +1,211 @@ +import Nat "mo:base/Nat"; +import Nat64 "mo:base/Nat64"; + +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; + + /// 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; + }; + + public type WriteApproveRequest = { + amount : Balance; + expires_at : ?Nat64; + encoded : { + from : EncodedAccount; + spender : EncodedAccount; + }; + }; + + /// 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 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; + + public type Transaction = Types1.Transaction; + + 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; + + 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; + + 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 and { + approvals : ApprovalAllowances; + }; + + // 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; + + /// 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 and ICRC2Interface; + +}; diff --git a/src/ICRC2/Utils.mo b/src/ICRC2/Utils.mo new file mode 100644 index 0000000..c0ac725 --- /dev/null +++ b/src/ICRC2/Utils.mo @@ -0,0 +1,194 @@ +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. + 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); + }; + + // 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( + 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); + }; + + // 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); + }; + + 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 : {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); + + 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, + 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..0ec2144 --- /dev/null +++ b/src/ICRC2/lib.mo @@ -0,0 +1,394 @@ +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 Option "mo:base/Option"; +import Principal "mo:base/Principal"; +import EC "mo:base/ExperimentalCycles"; + +import Itertools "mo:itertools/Iter"; +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 +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 Allowance = T.Allowance; + 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; + + 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 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; + 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(); + let approvals : T.ApprovalAllowances = 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; + approvals; + 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); + }; + + /// 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; + + // burn fee + Utils.burn_balance(token, encoded.from, Option.get(args.fee, token._fee)); + + 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); + }; + + /// 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 await* 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); + }; + + /// 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); + }; + + // 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(_)) {}; + }; + }; + +}; 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/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"; }], ); }, 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..01b5288 --- /dev/null +++ b/tests/ICRC2/ICRC2.ActorTest.mo @@ -0,0 +1,1690 @@ +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"; + +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; + }; + + 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, + 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 Tests", + [ + 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( + "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( + "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(ICRC2.balance_from_float(token, 50)), + 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(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), + 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(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), + 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( + "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", + [ + 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)), + + ]); + }, + ), + ]; + }, + ), + ], + ), + ], + ); + }; +};