├── .github └── workflows │ ├── build-test.yml │ ├── docs.yml │ └── version.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUDIT_IGNORE ├── Cargo.lock ├── Cargo.toml ├── README.md ├── codecov.yml ├── dfx.json ├── scripts ├── build.sh └── local-run-tests.sh ├── spec └── IS20.md └── src ├── candid └── .gitkeep ├── factory ├── Cargo.toml └── src │ ├── api.rs │ ├── api │ └── inspect_message.rs │ ├── error.rs │ ├── lib.rs │ ├── main.rs │ └── state.rs └── token ├── api ├── Cargo.toml └── src │ ├── account.rs │ ├── canister.rs │ ├── canister │ ├── icrc1_transfer.rs │ ├── inspect.rs │ ├── is20_auction.rs │ └── is20_transactions.rs │ ├── error.rs │ ├── lib.rs │ ├── mock.rs │ ├── principal.rs │ ├── state.rs │ ├── state │ ├── balances.rs │ ├── config.rs │ └── ledger.rs │ └── tx_record.rs └── impl ├── Cargo.toml ├── src ├── canister.rs ├── lib.rs └── main.rs └── tests └── icrc1.rs /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Validate & Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | branches: 8 | - 'main' 9 | paths-ignore: 10 | - "**/README.md" 11 | 12 | pull_request: 13 | branches: [main] 14 | paths-ignore: 15 | - "**/README.md" 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | cancel-in-progress: true 20 | 21 | env: 22 | CARGO_TERM_COLOR: always 23 | 24 | jobs: 25 | build-test: 26 | uses: infinity-swap/ci-wf/.github/workflows/build-n-test.yml@main 27 | with: 28 | runs-on: ubuntu-latest 29 | container-image: ghcr.io/infinity-swap/ic-dev-full:rust1.68-dfx0.13-rc-2022-09-30 30 | enable-target-cache: true 31 | audit-allow-warnings: true 32 | skip-test: ${{ github.ref_type == 'tag' }} 33 | test-script: | 34 | ./scripts/build.sh 35 | cargo llvm-cov --all-features --workspace --lcov --output-path .artifact/lcov.info 36 | 37 | output-artifact: artifact-is20 38 | artifact-script: | 39 | cargo build --target wasm32-unknown-unknown -p is20-token-canister --features export-api --release 40 | ic-wasm target/wasm32-unknown-unknown/release/is20-token-canister.wasm -o .artifact/is20-token.wasm shrink 41 | 42 | cargo build --target wasm32-unknown-unknown -p token-factory --features export-api --release 43 | ic-wasm target/wasm32-unknown-unknown/release/token-factory.wasm -o .artifact/is20-factory.wasm shrink 44 | 45 | cargo run -p is20-token-canister --features export-api > .artifact/is20-token.did 46 | cargo run -p token-factory --features export-api > .artifact/is20-factory.did 47 | 48 | secrets: 49 | gh_token: ${{ secrets.GH_PKG_TOKEN }} 50 | gh_login: ${{ secrets.GH_PKG_LOGIN }} 51 | 52 | 53 | codecov: 54 | if: ${{ github.ref_type != 'tag' }} 55 | needs: [build-test] 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - uses: actions/checkout@v3 60 | - name: "Getting artifact" 61 | uses: actions/download-artifact@v3 62 | with: 63 | name: artifact-is20 64 | path: ./.artifact 65 | - uses: codecov/codecov-action@v3 66 | with: 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | files: .artifact/lcov.info 69 | verbose: true 70 | 71 | 72 | release: 73 | if: ${{github.ref_type == 'tag'}} 74 | needs: [build-test] 75 | runs-on: ubuntu-latest 76 | 77 | steps: 78 | - name: "Getting artifact" 79 | uses: actions/download-artifact@v3 80 | with: 81 | name: artifact-is20 82 | path: ./.artifact 83 | - name: "Compress" 84 | run: | 85 | rm -f .artifact/lcov.info 86 | cd .artifact 87 | tar -czf ../is20-${{ github.ref_name }}.tar.gz --owner=0 --group=0 --no-same-owner --no-same-permissions . 88 | - name: Release 89 | uses: softprops/action-gh-release@v1 90 | with: 91 | files: | 92 | ./*.tar.gz 93 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: "Publish cargo docs" 2 | 3 | on: 4 | workflow_dispatch: 5 | # Just run it 6 | push: 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | cargo-docs: 12 | uses: infinity-swap/ci-wf/.github/workflows/publish-cargo-docs.yml@main 13 | with: 14 | gcs_bucket: 'infinity-rust-docs' 15 | 16 | secrets: 17 | gh_token: ${{ secrets.GH_PKG_TOKEN }} 18 | gcp_token: ${{ secrets.GCP_JSON_DOCS_TOKEN }} 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | name: "Bump cargo version & git tag" 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | version: 10 | uses: infinity-swap/ci-wf/.github/workflows/bump-version-tag.yml@main 11 | secrets: 12 | gh_token: ${{ secrets.GH_PKG_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.dfx 3 | /.idea 4 | /.vscode 5 | 6 | token.did 7 | token-factory.did 8 | token.wasm 9 | /proptest-regressions 10 | 11 | /.DS_Store 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | fail_fast: true 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.0.1 7 | hooks: 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | - id: check-toml 11 | - id: check-json 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - id: detect-private-key 15 | 16 | - repo: local 17 | hooks: 18 | - id: run-cargo-formatter 19 | name: Run Cargo formatter 20 | entry: /bin/bash -c "cargo fmt" 21 | language: script 22 | files: \.x$ 23 | always_run: true 24 | - id: run-cargo-clippy 25 | name: Run Cargo clippy 26 | entry: /bin/bash -c "cargo clippy --no-deps" 27 | language: script 28 | files: \.x$ 29 | always_run: true 30 | -------------------------------------------------------------------------------- /AUDIT_IGNORE: -------------------------------------------------------------------------------- 1 | # time crate 2 | # Potential segfault in the time crate 3 | # Available in several transitive IC dependencies 4 | RUSTSEC-2020-0071 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["src/token/api", "src/token/impl", "src/factory"] 3 | 4 | [workspace.package] 5 | version = "1.10.45" 6 | edition = "2021" 7 | 8 | [workspace.dependencies] 9 | canister-sdk = { git = "https://github.com/infinity-swap/canister-sdk", package = "canister-sdk", tag = "v0.3.x" } 10 | ic-exports = { git = "https://github.com/infinity-swap/canister-sdk", package = "ic-exports", tag = "v0.3.x" } 11 | ic-stable-structures = { git = "https://github.com/infinity-swap/canister-sdk", tag = "v0.3.x" } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![IS20 banner](https://user-images.githubusercontent.com/6412426/146728389-42384977-0ed3-43a6-83d3-ce16db609c09.png) 2 | 3 | [![codecov](https://codecov.io/github/infinity-swap/IS20/branch/main/graph/badge.svg?token=SL2P26VSL7)](https://codecov.io/github/infinity-swap/IS20) 4 | 5 | #### NOTICE - THIS REPOSITORY IS DEPRECATED IN FAVOUR OF ICRC STANDARD 6 | 7 | # IS20 - Introduction 8 | 9 | IS20 is an Internet Computer token standard proposed by Infinity Swap. 10 | 11 | You can find the standard spec at [spec/IS20.md](spec/IS20.md) and the default implementation in the `src` directory. 12 | 13 | This repository contains two canisters: 14 | 15 | - `factory` is responsible for creating and deploying new token canisters 16 | - `token` is the default implementation of the IS20 token 17 | 18 | # Usage 19 | 20 | You can try using the factory and tokens using `dfx` tool. To do so, install and start `dfx`: 21 | 22 | ```shell 23 | sh -ci "$(curl -fsSL https://sdk.dfinity.org/install.sh)" 24 | 25 | dfx start --background 26 | ``` 27 | 28 | To build the canister you will also need the `ic_cdk_optimizer` tool: 29 | 30 | ``` 31 | cargo install ic-cdk-optimizer 32 | ``` 33 | 34 | Then deploy the factory: 35 | 36 | ```shell 37 | dfx identity get-principal 38 | >> y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae 39 | 40 | // Use the user principal above to set the owner 41 | dfx deploy token_factory --argument '(principal "y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae", null)' 42 | 43 | >> Creating a wallet canister on the local network. 44 | >> The wallet canister on the "local" network for user "max" is "yjeau-xiaaa-aaaaa-aabsa-cai" 45 | >> Deploying: token_factory 46 | >> Creating canisters... 47 | >> Creating canister "token_factory"... 48 | >> "token_factory" canister created with canister id: "yofga-2qaaa-aaaaa-aabsq-cai" 49 | 50 | ``` 51 | 52 | Note the wallet ID for the current user (in the example above it's `yjeau-xiaaa-aaaaa-aabsa-cai`). The factory requires 53 | the caller to provide cycles or ICP to create a token canister. As we don't have an ICP ledger locally, we use cycles. 54 | The minimum amount of cycles required by the factory to create a canister is `10^12`. 55 | 56 | ```shell 57 | // Use the user principal above to set the owner 58 | dfx canister --wallet yjeau-xiaaa-aaaaa-aabsa-cai call --with-cycles 1000000000000 token_factory create_token \ 59 | '(record { 60 | name = "y"; 61 | symbol = "y"; 62 | decimals = 8; 63 | owner = principal "y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae"; 64 | fee = 0; 65 | fee_to = principal "y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae"; }, null)' 66 | 67 | >> (variant { principal "r7inp-6aaaa-aaaaa-aaabq-cai" }) 68 | ``` 69 | 70 | The returned principal id is the token canister principal. You can use this id to make token calls: 71 | 72 | ```shell 73 | // Tokens transfer 74 | dfx canister call r7inp-6aaaa-aaaaa-aaabq-cai transfer '(principal "aaaaa-aa", 1000: nat)' 75 | >> (variant { 17_724 = 2 : nat }) 76 | 77 | // Get transaction information 78 | dfx canister call r7inp-6aaaa-aaaaa-aaabq-cai get_transaction '(1:nat)' 79 | >> ( 80 | >> record { 81 | >> 25_979 = principal "aaaaa-aa"; 82 | >> 5_094_982 = 0 : nat; 83 | >> 100_394_802 = variant { 2_633_774_657 }; 84 | >> 1_136_829_802 = principal "y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae"; 85 | >> 2_688_582_695 = variant { 3_021_957_963 }; 86 | >> 2_781_795_542 = 1_640_332_539_774_695_111 : int; 87 | >> 3_068_679_307 = opt principal "y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae"; 88 | >> 3_189_021_458 = 2 : nat; 89 | >> 3_573_748_184 = 1_000 : nat; 90 | >> }, 91 | >> ) 92 | ``` 93 | 94 | To bid cycles for the cycle auction, you need to provide the cycles with your call. Use cycle wallet 95 | to do so: 96 | 97 | ```shell 98 | dfx identity get-wallet 99 | >> rwlgt-iiaaa-aaaaa-aaaaa-cai 100 | 101 | dfx canister --wallet rwlgt-iiaaa-aaaaa-aaaaa-cai call --with-cycles 100000000 \ 102 | r7inp-6aaaa-aaaaa-aaabq-cai bidCycles \ 103 | '(principal "y4nw3-upugh-yyv2b-jv6jy-ppfse-4fkfd-uaqv5-woqup-u3cx3-hah2c-yae")' 104 | >> (variant { 17_724 = 100_000_000 : nat64 }) 105 | 106 | ``` 107 | 108 | # Development 109 | 110 | ## Building 111 | 112 | Use build script to build the release version of the token canister, use the build script: 113 | 114 | ```shell 115 | ./scripts/build.sh 116 | ``` 117 | 118 | ## Running tests 119 | 120 | In order to run tests: 121 | 122 | ```shell 123 | cargo test 124 | ``` 125 | 126 | ## Code coverage 127 | 128 | Use [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) to generate code test coverage report: 129 | 130 | ``` 131 | cargo +nightly llvm-cov --open 132 | ``` 133 | 134 | By default rust coverage tool include all code into the report, including the tests itself. This is not helpful for 135 | understanding the real code coverage. To mitigate this, nightly Rust has `no_coverage` attribute. To apply it to the 136 | test code use `#[cfg_attr(coverage_nightly, no_coverage)]` directive on any function that is run only for testing. 137 | (`coverage_nightly` flag is set by the `cargo-llvm-cov` when run with nightly toolchain) 138 | 139 | ## Enable pre-commit 140 | 141 | Before committing to this repo, install and activate the `pre-commit` tool. 142 | 143 | ```shell 144 | pip install pre-commit 145 | pre-commit install 146 | ``` 147 | 148 | ## Local Run 149 | 150 | ```bash 151 | dfx start --background 152 | dfx deploy 153 | dfx stop 154 | ``` 155 | 156 | ## Candid Files 157 | 158 | In order to generate candid files, run the following command: 159 | 160 | ```bash 161 | cargo run -p factory > src/candid/token-factory.did 162 | cargo run -p token > src/candid/token.did 163 | ``` 164 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 1 3 | round: nearest 4 | range: "80...95" 5 | 6 | status: 7 | project: 8 | default: 9 | threshold: 1% 10 | patch: 11 | default: 12 | enabled: no 13 | -------------------------------------------------------------------------------- /dfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "canisters": { 3 | "token_factory": { 4 | "build": "bash scripts/build.sh", 5 | "candid": "src/candid/token-factory.did", 6 | "wasm": "target/wasm32-unknown-unknown/release/factory.wasm", 7 | "type": "custom" 8 | }, 9 | "token": { 10 | "build": "bash scripts/build.sh", 11 | "candid": "src/candid/token.did", 12 | "wasm": "src/factory/src/token.wasm", 13 | "type": "custom" 14 | } 15 | }, 16 | "networks": { 17 | "local": { 18 | "bind": "127.0.0.1:8000", 19 | "type": "ephemeral" 20 | }, 21 | "testnet": { 22 | "bind": "34.67.183.52:8000", 23 | "type": "ephemeral" 24 | }, 25 | "devnet": { 26 | "bind": "35.192.168.238:8000", 27 | "type": "ephemeral" 28 | } 29 | }, 30 | "version": 1 31 | } -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | cargo build --target wasm32-unknown-unknown --package is20-token-canister --features export-api --release 3 | ic-wasm target/wasm32-unknown-unknown/release/is20-token-canister.wasm -o target/wasm32-unknown-unknown/release/token.wasm shrink 4 | cargo build --target wasm32-unknown-unknown --package token-factory --features export-api --release 5 | ic-wasm target/wasm32-unknown-unknown/release/token-factory.wasm -o target/wasm32-unknown-unknown/release/factory.wasm shrink 6 | cargo run -p token-factory --features export-api > src/candid/token-factory.did 7 | cargo run -p is20-token-canister --features export-api > src/candid/token.did 8 | -------------------------------------------------------------------------------- /scripts/local-run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # To run from the local env 3 | # FMT and clippy runs as a separate jobs on CI 4 | 5 | cargo fmt -- --check 6 | cargo clippy 7 | 8 | cargo test -p token-factory 9 | cargo test -p is20-token --features auction 10 | cargo test -p is20-token-canister 11 | -------------------------------------------------------------------------------- /spec/IS20.md: -------------------------------------------------------------------------------- 1 | # IS20 Token Standard Specification 2 | 3 | The InfinitySwap (IS20) token standard is a fungible token standard for the 4 | Internet Computer (IC). This standard is an 5 | extension of the ICRC-1 standard and also includes some methods from Ethereum's ERC20 token standard. 6 | 7 | Key points: 8 | * compatible with ICRC-1 standard 9 | * ERC20 methods `mint`, `burn` and `transfer` 10 | * batch transfers 11 | * possibility to provide user-claimable rewards for airdrops to account-identifiers 12 | * cycle auctions 13 | 14 | An implementation may choose not to provide some or all the optional methods of this standard. 15 | 16 | ## Accounts and balances 17 | 18 | The users on IC are identified by their `Principal`. Every principal can have multiple `subaccounts` associated with it. 19 | Subaccounts are identified by an arbitrary 32-byte byte array. The subaccount with all bytes set to 0 is a `default 20 | subaccount`. Whenever a subaccount is not specified for an operation, the default subaccount will be used. 21 | 22 | So an account is fully specified by a `Principal` and `Subaccount`: 23 | 24 | ```candid "Type definitions" += 25 | type Subaccount = blob; 26 | type Account = record { owner : principal; subaccount : opt Subaccount; }; 27 | ``` 28 | 29 | The balances of accounts are stored as unsigned integer values. For convenience of representation, the number of decimal 30 | digits can be specified. 31 | 32 | ## Methods 33 | 34 | ### ICRC-1 methods 35 | 36 | The ICRC-1 standard must be fully supported. Refer to the standard specification for detailed description of the methods: 37 | 38 | * `icrc1_name` 39 | * `icrc1_symbol` 40 | * `icrc1_decimals` 41 | * `icrc1_fee` 42 | * `icrc1_metadata` 43 | * `icrc1_total_supply` 44 | * `icrc1_minting_account` 45 | * `icrc1_balance_of` 46 | * `icrc1_transfer` 47 | * `icrc1_supported_standards` 48 | 49 | #### Notes 50 | 51 | 1. `icrc1_supported_standards` return value must include these entries: 52 | * `record { name = "ICRC-1"; url = "https://github.com/dfinity/ICRC-1" }` 53 | * `record { name = "IS20"; url = "https://github.com/infinity-swap/is20" }` 54 | 2. ICRC-1 uses a single method for minting, burning and transferring tokens. This is done by appointing a special 55 | **minting account**. When a transaction is made from this account or to this account, the tokens are created or 56 | removed, and the balance of the minting account remains unchanged. Also the minting account is the receiver of the 57 | fees. IS20 standard separates the owner of the token canister (minting account) and the receiver of the fees. It also 58 | allows the owner (minting account) to have non-zero balance, as IS20 `transfer`, `burn` and `mint` operations work 59 | with this account the same way as with other accounts. Also if the owner is set to be the receiver of the fees, the 60 | fees are added to the account balance and not burned (as per ICRC-1). 61 | 62 | ### IS20 transfers 63 | 64 | #### `mint` 65 | 66 | Creates new tokens to the user. Only the owner of the canister can call this method. 67 | 68 | Arguments: 69 | * `to: Principal` - principal to which account to add created tokens 70 | * `subaccount: opt Subaccount` - subaccount of the `to` principal to mint token 71 | * `amount: Nat` - amount to mint 72 | 73 | The returned value is the ID of the mint transaction. 74 | 75 | ``` 76 | mint : (principal, opt Subaccount, nat) -> (variant { Ok : nat; Err : TxError }); 77 | ``` 78 | 79 | #### `burn` 80 | 81 | Deletes the given amount of tokens. 82 | 83 | Arguments: 84 | * `holder: opt Principal` - the principal to remove the tokens from. If not specified, the tokens are removed from the 85 | caller's account. Only token canister owner can burn tokens of other principal. All other accounts can burn only 86 | their own tokens. In the case a burn is requested for another principal's account and the caller is not the owner, 87 | `TxError::Unauthorized` error is returned. 88 | * `subaccount: opt Subaccount` - subaccount to burn from 89 | * `amount: Nat` - amount to burn 90 | 91 | The returned value is the ID of the burn transaction. 92 | 93 | ``` 94 | burn : (opt principal, opt Subaccount, nat) -> (variant { Ok : nat; Err : TxError }); 95 | ``` 96 | 97 | #### `transfer` 98 | 99 | Transfers tokens from one account to another. This method works exactly as `icrc1_transfer`, but does not make a 100 | distinction between minting account and any other account. 101 | 102 | ``` 103 | type TransferArgs = record { 104 | to : Account; 105 | fee : opt nat; 106 | memo : opt vec nat8; 107 | from_subaccount : opt vec nat8; 108 | created_at_time : opt nat64; 109 | amount : nat; 110 | }; 111 | 112 | transfer : (TransferArgs) -> (variant { Ok : nat; Err : TxError }); 113 | ``` 114 | 115 | #### `batch_transfer` 116 | 117 | Makes multiple transfers at once. This method must guarantee that either all or none of the transfers succeed. If one of 118 | the transactions fails, an error is returned and none of the transactions is applied. 119 | 120 | ``` 121 | type BatchTransferArgs = record { amount : nat; receiver : Account }; 122 | 123 | batch_transfer : (opt vec nat8, vec BatchTransferArgs) -> (variant { Ok : vec nat64; Err : TxError }); 124 | ``` 125 | 126 | ### Claimable tokens (optional) 127 | 128 | Sometimes we want to pay another user tokens on request. ERC20 standard provides `approve` and 129 | `transferFrom` methods for that. IS20 tokens may include a `claim` method which provides similar functionality. 130 | 131 | Tokens holder can transfer their tokens to an ID derived from the claimer `Principal`. If that 132 | principal then calls the `claim` method, the tokens will be transferred from holder's subbaccount to the claimer's 133 | account (default subaccount). Until the tokens are claimed the holder has full control of those tokens. 134 | 135 | The subaccount ID is calculated by the following algorithm: 136 | 137 | ``` 138 | 1. Let claimer's principal id be `principal_id` - byte array of max length of 29 bytes. 139 | 2. Let claimer's subbaccount `claimer_subaccount` be an arbitrary 32-byte long byte string. If not specified, use the value of [0; 32]; 140 | 3. Take SHA224 hash of concatenation of bytes: 141 | 1. b"\x0Aaccount-id" 142 | 2. principal_id 143 | 3. claimer_subaccount 144 | The result `hash` is a byte array of length 28. 145 | 4. Calculate 4 byte CRC32 checksum of the `hash`. 146 | 5. Return concatenation of `checksum` and `hash`. 147 | ``` 148 | 149 | (Note that this is the algorithm to calculate ICP AccountIdentifier "address") 150 | 151 | The method `get_claim_subaccount` can be used to get the subaccount id to transfer tokens to. 152 | 153 | #### `get_claim_subaccount` 154 | 155 | Returns the `Subaccount` id to put the tokens to to make them claimable by the Principal. 156 | 157 | ``` 158 | get_claim_subaccount : (principal, opt Subaccount) -> (vec nat8) query; 159 | ``` 160 | 161 | #### `get_claimable_amount` 162 | 163 | Returns the amount of tokens that can be claimed with the given arguments. 164 | 165 | ``` 166 | get_claimable_amount : (principal, opt Subaccount) -> (nat) query; 167 | ``` 168 | 169 | #### `claim` 170 | 171 | Transfers the tokens from a claimable subaccount to the main account of the caller. If there are no claimable tokens for 172 | the given subaccount, `TxError::NothingToClaim` error is returned. 173 | 174 | ``` 175 | claim : (principal, opt Subaccount) -> (variant { Ok : nat; Err : TxError }); 176 | ``` 177 | 178 | ### Cycle auctions (optional) 179 | 180 | Since IC uses reverse gas model to pay for incoming requests, it's important for token canisters to provide a way to 181 | incentivise users to top it up with cycles. Cycle auctions allow users to exchange canister cycles for a share of the 182 | fees collected for the token transactions. 183 | 184 | An auction is performed in regular intervals set by the `set_auction_period` method. At the beginning of the auction period 185 | `fee_ratio` value is calculated for this period. During this auction period `auction_fees = fee * fee_ratio` share of 186 | the transaction fees are transferred to the auction account and stored until the auction. 187 | 188 | During the auction period users can top up the token canister with cycles using `bid_cycles` method. Their bids are 189 | saved for the auction period. 190 | 191 | At the end of the auction period, the `auction_fees` are distributed between the bidders in proportion to the amount of 192 | cycles they bid. 193 | 194 | #### Common types 195 | 196 | ``` 197 | type AuctionError = variant { 198 | NoBids; 199 | TooEarlyToBeginAuction : nat64; 200 | Unauthorized : text; 201 | BiddingTooSmall; 202 | AuctionNotFound; 203 | }; 204 | ``` 205 | 206 | #### `bid_cycles` 207 | 208 | Top up the token canister with cycles to participate in the auction. This method must be called with cycles, e.g. though 209 | a cycle wallet. The argument of the method is the user principal for whom the bid is being made (this will be different 210 | from the `caller`, becuase the `caller` in this case is a wallet canister). 211 | 212 | ``` 213 | bid_cycles : (principal) -> (variant { Ok : nat64; Err : AuctionError }); 214 | ``` 215 | 216 | #### `bidding_info` 217 | 218 | Provides bidding information for the current auction cycle. 219 | 220 | ``` 221 | type BiddingInfo = record { 222 | caller_cycles : nat64; 223 | auction_period : nat64; 224 | last_auction : nat64; 225 | total_cycles : nat64; 226 | fee_ratio : float64; 227 | }; 228 | bidding_info : () -> (BiddingInfo); 229 | ``` 230 | 231 | #### `set_auction_period` 232 | 233 | Sets the interval with which the cycles auctions are held. This method can only be called by the canister owner. 234 | 235 | ``` 236 | set_auction_period : (Interval) -> (variant { Ok; Err : AuctionError }); 237 | ``` 238 | 239 | ### Transactions history 240 | 241 | The token canister must provide these methods to retrieve the transaction history. Implementations can choose to limit 242 | the maximum length of the transaction history they store. If the requested transaction ids are out of the range of 243 | stored length, an empty array should be returned (not an error). 244 | 245 | #### Common types 246 | 247 | ``` 248 | type TxRecord = record { 249 | to : Account; 250 | fee : nat; 251 | status : TransactionStatus; 252 | from : Account; 253 | memo : opt vec nat8; 254 | operation : Operation; 255 | timestamp : nat64; 256 | caller : principal; 257 | index : nat64; 258 | amount : nat; 259 | }; 260 | ``` 261 | 262 | #### `get_transaction` 263 | 264 | Returns the transaction with the given id. 265 | 266 | ``` 267 | get_transaction : (nat64) -> (TxRecord) query; 268 | ``` 269 | 270 | #### `get_transactions` 271 | 272 | Get an array of transaction. The transactions are returned in reversed order, starting from last to first. 273 | 274 | Arguments: 275 | * `principal: opt Principal` - if set, only the transactions in which the given principal participated are returned 276 | (e.g. the transactions in which the principal was either sender or receiver of the tokens) 277 | * `count: nat64` - number of transactions to return. The canister can choose to limit maximum number it would return. In 278 | case this argument value exceeds this limit, the max number of transactions is returned. 279 | * `skip: opt nat64` - number of transactions to skip (so the latest `skip` transactions will be skipped and `[skip + 1, 280 | skip + 1 + count]` last transactions will be returned). 281 | 282 | ``` 283 | get_transactions : (opt principal, nat64, opt nat64) -> ( 284 | PaginatedResult, 285 | ) query; 286 | ``` 287 | 288 | #### `history_size` 289 | 290 | Returns the total number of transactions made in the token canister. This value will always be `last_transaction_id + 1`. 291 | 292 | ``` 293 | history_size : () -> (nat64) query; 294 | ``` 295 | 296 | -------------------------------------------------------------------------------- /src/candid/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfinity-network/IS20/5fd36dcd01c859b1266d98bff415eaeb9b6d58cb/src/candid/.gitkeep -------------------------------------------------------------------------------- /src/factory/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "token-factory" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | 7 | [features] 8 | default = [] 9 | export-api = ["canister-sdk/factory-api", "canister-sdk/metrics-api"] 10 | 11 | [dependencies] 12 | candid = "0.8" 13 | serde = "1.0" 14 | thiserror = "1.0" 15 | canister-sdk = { workspace = true, features = ["factory"] } 16 | ic-exports = { workspace = true } 17 | ic-stable-structures = { workspace = true } 18 | 19 | token = { path = "../token/api", package = "is20-token" } 20 | -------------------------------------------------------------------------------- /src/factory/src/api.rs: -------------------------------------------------------------------------------- 1 | //! Module : factory 2 | //! Copyright : 2022 InfinitySwap Team 3 | //! Stability : Experimental 4 | 5 | use std::cell::RefCell; 6 | use std::collections::HashMap; 7 | use std::rc::Rc; 8 | 9 | use crate::{error::TokenFactoryError, state}; 10 | use candid::Principal; 11 | use canister_sdk::ic_factory::DEFAULT_ICP_FEE; 12 | use canister_sdk::ic_metrics::{Metrics, MetricsStorage}; 13 | use canister_sdk::{ 14 | ic_canister::{ 15 | init, post_upgrade, pre_upgrade, query, update, Canister, MethodType, PreUpdate, 16 | }, 17 | ic_factory::{ 18 | api::{FactoryCanister, UpgradeResult}, 19 | error::FactoryError, 20 | FactoryConfiguration, FactoryState, 21 | }, 22 | ic_helpers::tokens::Tokens128, 23 | ic_storage, 24 | }; 25 | use token::state::config::Metadata; 26 | 27 | const DEFAULT_LEDGER_PRINCIPAL: Principal = Principal::from_slice(&[0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); 28 | 29 | #[cfg(feature = "export-api")] 30 | mod inspect_message; 31 | 32 | #[derive(Clone, Canister)] 33 | #[canister_no_upgrade_methods] 34 | pub struct TokenFactoryCanister { 35 | #[id] 36 | principal: Principal, 37 | } 38 | 39 | impl Metrics for TokenFactoryCanister { 40 | fn metrics(&self) -> Rc> { 41 | ::get() 42 | } 43 | } 44 | impl PreUpdate for TokenFactoryCanister { 45 | fn pre_update(&self, _method_name: &str, _method_type: MethodType) { 46 | self.update_metrics(); 47 | } 48 | } 49 | 50 | #[allow(dead_code)] 51 | impl TokenFactoryCanister { 52 | #[query] 53 | fn pkg_version(&self) -> &'static str { 54 | option_env!("CARGO_PKG_VERSION").unwrap_or("NOT_FOUND") 55 | } 56 | 57 | #[pre_upgrade] 58 | fn pre_upgrade(&self) { 59 | // All state is stored in stable storage, so nothing to do here 60 | } 61 | 62 | #[post_upgrade] 63 | fn post_upgrade(&self) { 64 | // All state is stored in stable storage, so nothing to do here 65 | } 66 | 67 | #[init] 68 | pub fn init(&self, controller: Principal, ledger_principal: Option) { 69 | let ledger = ledger_principal.unwrap_or(DEFAULT_LEDGER_PRINCIPAL); 70 | 71 | let factory_configuration = 72 | FactoryConfiguration::new(ledger, DEFAULT_ICP_FEE, controller, controller); 73 | 74 | FactoryState::default().reset(factory_configuration); 75 | state::get_state().reset(); 76 | } 77 | 78 | /// Returns the token, or None if it does not exist. 79 | #[query] 80 | pub async fn get_token(&self, name: String) -> Option { 81 | state::get_state().get_token(name) 82 | } 83 | 84 | #[update] 85 | pub async fn set_token_bytecode(&self, bytecode: Vec) -> Result { 86 | state::get_state().set_token_wasm(Some(bytecode.clone())); 87 | self.set_canister_code(bytecode) 88 | } 89 | 90 | /// Creates a new token. 91 | /// 92 | /// Creating a token canister with the factory requires one of the following: 93 | /// * the call must be made through a cycles wallet with enough cycles to cover the canister 94 | /// expenses. The amount of provided cycles must be greater than `10^12`. Most of the cycles 95 | /// will be added to the newly created canister balance, while some will be consumed by the 96 | /// factory 97 | /// * the caller must transfer some amount of ICP to their subaccount into the ICP ledger factory account. 98 | /// The subaccount id can be calculated like this: 99 | /// 100 | /// ```ignore 101 | /// let mut subaccount = [0u8; 32]; 102 | /// let principal_id = caller_id.as_slice(); 103 | /// subaccount[0] = principal_id.len().try_into().unwrap(); 104 | /// subaccount[1..1 + principal_id.len()].copy_from_slice(principal_id); 105 | /// ``` 106 | /// 107 | /// The amount of provided ICP must be greater than the `icp_fee` factory property. This value 108 | /// can be obtained by the `get_icp_fee` query method. The ICP fees are transferred to the 109 | /// principal designated by the factory controller. The canister is then created with some 110 | /// minimum amount of cycles. 111 | /// 112 | /// If the provided ICP amount is greater than required by the factory, extra ICP will not be 113 | /// consumed and can be used to create more canisters, or can be reclaimed by calling `refund_icp` 114 | /// method. 115 | #[update] 116 | pub async fn create_token( 117 | &self, 118 | info: Metadata, 119 | amount: Tokens128, 120 | controller: Option, 121 | ) -> Result { 122 | if info.name.is_empty() { 123 | return Err(TokenFactoryError::InvalidConfiguration( 124 | "name", 125 | "cannot be `None`", 126 | )); 127 | } 128 | 129 | if info.name.as_bytes().len() > 1024 { 130 | return Err(TokenFactoryError::InvalidConfiguration( 131 | "name", 132 | "should be less then 1024 bytes", 133 | )); 134 | } 135 | 136 | if info.symbol.is_empty() { 137 | return Err(TokenFactoryError::InvalidConfiguration( 138 | "symbol", 139 | "cannot be `None`", 140 | )); 141 | } 142 | 143 | let key = info.name.clone(); 144 | if state::get_state().get_token(key.clone()).is_some() { 145 | return Err(TokenFactoryError::AlreadyExists); 146 | } 147 | 148 | let caller = canister_sdk::ic_kit::ic::caller(); 149 | let principal = self 150 | .create_canister((info, amount), controller, Some(caller)) 151 | .await?; 152 | state::get_state().insert_token(key, principal); 153 | 154 | Ok(principal) 155 | } 156 | 157 | #[update] 158 | pub async fn forget_token(&self, name: String) -> Result<(), TokenFactoryError> { 159 | let canister_id = self 160 | .get_token(name.clone()) 161 | .await 162 | .ok_or(TokenFactoryError::FactoryError(FactoryError::NotFound))?; 163 | 164 | self.drop_canister(canister_id, None).await?; 165 | state::get_state().remove_token(name); 166 | 167 | Ok(()) 168 | } 169 | 170 | #[update] 171 | pub async fn upgrade(&mut self) -> Result, FactoryError> { 172 | self.upgrade_canister().await 173 | } 174 | } 175 | 176 | impl FactoryCanister for TokenFactoryCanister {} 177 | 178 | #[cfg(test)] 179 | mod tests { 180 | use super::*; 181 | 182 | #[test] 183 | fn ledger_principal() { 184 | const LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; 185 | let original_principal = Principal::from_text(LEDGER).unwrap(); 186 | assert_eq!(DEFAULT_LEDGER_PRINCIPAL, original_principal); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/factory/src/api/inspect_message.rs: -------------------------------------------------------------------------------- 1 | use crate::state; 2 | use canister_sdk::{ic_cdk, ic_cdk_macros::inspect_message, ic_factory::FactoryState}; 3 | 4 | #[inspect_message] 5 | fn inspect_message() { 6 | let state = state::get_state(); 7 | let factory = FactoryState::default(); 8 | 9 | if ic_cdk::api::call::method_name() == "set_token_bytecode" { 10 | if factory.controller() == canister_sdk::ic_kit::ic::caller() { 11 | return ic_cdk::api::call::accept_message(); 12 | } 13 | 14 | ic_cdk::trap(&format!( 15 | "the caller {} is not a factory controller {}", 16 | canister_sdk::ic_kit::ic::caller(), 17 | factory.controller() 18 | )); 19 | } 20 | 21 | match state.get_token_wasm() { 22 | Some(_) => ic_cdk::api::call::accept_message(), 23 | None => ic_cdk::trap("the factory hasn't been completely intialized yet"), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/factory/src/error.rs: -------------------------------------------------------------------------------- 1 | use candid::CandidType; 2 | use canister_sdk::ic_factory::error::FactoryError; 3 | use thiserror::Error; 4 | 5 | #[derive(Debug, Error, CandidType)] 6 | pub enum TokenFactoryError { 7 | #[error("the property {0} has invalid value: {0}")] 8 | InvalidConfiguration(&'static str, &'static str), 9 | 10 | #[error("a token with the same name is already registered")] 11 | AlreadyExists, 12 | 13 | #[error(transparent)] 14 | FactoryError(#[from] FactoryError), 15 | } 16 | -------------------------------------------------------------------------------- /src/factory/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | mod error; 3 | pub mod state; 4 | 5 | pub use self::api::*; 6 | pub use state::State; 7 | 8 | /// This is a marker added to the wasm to distinguish it from other canisters 9 | #[cfg(feature = "export_api")] 10 | #[no_mangle] 11 | pub static TOKEN_FACTORY_CANISTER_MARKER: &str = "IS20_FACTORY_CANISTER"; 12 | 13 | pub fn idl() -> String { 14 | use crate::error::TokenFactoryError; 15 | use canister_sdk::{ 16 | ic_canister::{generate_idl, Idl}, 17 | ic_factory::{ 18 | api::{FactoryCanister, UpgradeResult}, 19 | error::FactoryError, 20 | }, 21 | ic_helpers::tokens::Tokens128, 22 | }; 23 | use ic_exports::Principal; 24 | use std::collections::HashMap; 25 | use token::state::config::Metadata; 26 | 27 | let canister_idl = generate_idl!(); 28 | let mut factory_idl = ::get_idl(); 29 | factory_idl.merge(&canister_idl); 30 | 31 | candid::bindings::candid::compile(&factory_idl.env.env, &Some(factory_idl.actor)) 32 | } 33 | -------------------------------------------------------------------------------- /src/factory/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("{}", token_factory::idl()); 3 | } 4 | -------------------------------------------------------------------------------- /src/factory/src/state.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::cell::RefCell; 3 | 4 | use candid::{CandidType, Decode, Encode, Principal}; 5 | use ic_stable_structures::{BoundedStorable, MemoryId, StableBTreeMap, StableCell, Storable}; 6 | use serde::Deserialize; 7 | 8 | #[derive(CandidType, Deserialize, Default, Debug)] 9 | pub struct State {} 10 | 11 | impl State { 12 | pub fn reset(&mut self) { 13 | TOKENS_MAP.with(|map| map.borrow_mut().clear()); 14 | WASM_CELL.with(|cell| { 15 | cell.borrow_mut() 16 | .set(StorableWasm::default()) 17 | .expect("failed to reset token wasm in stable memory") 18 | }); 19 | } 20 | 21 | pub fn get_token(&self, name: String) -> Option { 22 | Self::check_name(&name).then_some(())?; 23 | 24 | TOKENS_MAP 25 | .with(|map| map.borrow().get(&StringKey(name))) 26 | .map(|principal| principal.0) 27 | } 28 | 29 | pub fn remove_token(&self, name: String) -> Option { 30 | Self::check_name(&name).then_some(())?; 31 | 32 | TOKENS_MAP 33 | .with(|map| map.borrow_mut().remove(&StringKey(name))) 34 | .map(|principal| principal.0) 35 | } 36 | 37 | pub fn insert_token(&mut self, name: String, principal: Principal) { 38 | TOKENS_MAP.with(|map| { 39 | map.borrow_mut() 40 | .insert(StringKey(name), PrincipalValue(principal)) 41 | }); 42 | } 43 | 44 | pub fn get_token_wasm(&self) -> Option> { 45 | WASM_CELL.with(|cell| cell.borrow().get().0.clone()) 46 | } 47 | 48 | pub fn set_token_wasm(&mut self, wasm: Option>) { 49 | WASM_CELL.with(|cell| { 50 | cell.borrow_mut() 51 | .set(StorableWasm(wasm)) 52 | .expect("failed to set token canister wasm to stable storage"); 53 | }); 54 | } 55 | 56 | fn check_name(name: &str) -> bool { 57 | name.as_bytes().len() <= MAX_TOKEN_LEN_IN_BYTES 58 | } 59 | } 60 | 61 | #[derive(Default, Deserialize, CandidType)] 62 | struct StorableWasm(Option>); 63 | 64 | impl Storable for StorableWasm { 65 | fn to_bytes(&self) -> Cow<'_, [u8]> { 66 | Encode!(self) 67 | .expect("failed to encode StorableWasm for stable storage") 68 | .into() 69 | } 70 | 71 | fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { 72 | Decode!(&bytes, Self).expect("failed to decode StorableWasm from stable storage") 73 | } 74 | } 75 | 76 | #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] 77 | struct StringKey(String); 78 | 79 | impl Storable for StringKey { 80 | fn to_bytes(&self) -> Cow<'_, [u8]> { 81 | self.0.as_bytes().into() 82 | } 83 | 84 | fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { 85 | StringKey(String::from_bytes(bytes)) 86 | } 87 | } 88 | 89 | pub const MAX_TOKEN_LEN_IN_BYTES: usize = 1024; 90 | 91 | impl BoundedStorable for StringKey { 92 | const MAX_SIZE: u32 = MAX_TOKEN_LEN_IN_BYTES as _; 93 | 94 | const IS_FIXED_SIZE: bool = false; 95 | } 96 | 97 | struct PrincipalValue(Principal); 98 | 99 | impl Storable for PrincipalValue { 100 | fn to_bytes(&self) -> Cow<'_, [u8]> { 101 | self.0.as_slice().into() 102 | } 103 | 104 | fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { 105 | PrincipalValue(Principal::from_slice(&bytes)) 106 | } 107 | } 108 | 109 | impl BoundedStorable for PrincipalValue { 110 | const MAX_SIZE: u32 = 29; 111 | const IS_FIXED_SIZE: bool = false; 112 | } 113 | 114 | // starts with 10 because 0..10 reserved for `ic-factory` state. 115 | const WASM_MEMORY_ID: MemoryId = MemoryId::new(10); 116 | const TOKENS_MEMORY_ID: MemoryId = MemoryId::new(11); 117 | 118 | thread_local! { 119 | static WASM_CELL: RefCell> = { 120 | RefCell::new(StableCell::new(WASM_MEMORY_ID, StorableWasm::default()) 121 | .expect("failed to initialize wasm stable storage")) 122 | }; 123 | 124 | static TOKENS_MAP: RefCell> = 125 | RefCell::new(StableBTreeMap::new(TOKENS_MEMORY_ID)); 126 | } 127 | 128 | pub fn get_state() -> State { 129 | State::default() 130 | } 131 | 132 | #[cfg(test)] 133 | mod tests { 134 | use candid::Principal; 135 | use canister_sdk::ic_kit::MockContext; 136 | use ic_stable_structures::Storable; 137 | 138 | use crate::state::{PrincipalValue, StorableWasm}; 139 | use crate::State; 140 | 141 | use super::StringKey; 142 | 143 | #[test] 144 | fn string_key_serialization() { 145 | let key = StringKey("".into()); 146 | let deserialized = StringKey::from_bytes(key.to_bytes()); 147 | assert_eq!(key.0, deserialized.0); 148 | 149 | let key = StringKey("TEST_KEY".into()); 150 | let deserialized = StringKey::from_bytes(key.to_bytes()); 151 | assert_eq!(key.0, deserialized.0); 152 | 153 | let long_key = StringKey(String::from_iter(std::iter::once('c').cycle().take(512))); 154 | let deserialized = StringKey::from_bytes(long_key.to_bytes()); 155 | assert_eq!(long_key.0, deserialized.0); 156 | } 157 | 158 | #[test] 159 | fn principal_value_serialization() { 160 | let val = PrincipalValue(Principal::anonymous()); 161 | let deserialized = PrincipalValue::from_bytes(val.to_bytes()); 162 | assert_eq!(val.0, deserialized.0); 163 | 164 | let val = PrincipalValue(Principal::management_canister()); 165 | let deserialized = PrincipalValue::from_bytes(val.to_bytes()); 166 | assert_eq!(val.0, deserialized.0); 167 | } 168 | 169 | #[test] 170 | fn storable_wasm_serialization() { 171 | let val = StorableWasm(None); 172 | let deserialized = StorableWasm::from_bytes(val.to_bytes()); 173 | assert_eq!(val.0, deserialized.0); 174 | 175 | let val = StorableWasm(Some(vec![])); 176 | let deserialized = StorableWasm::from_bytes(val.to_bytes()); 177 | assert_eq!(val.0, deserialized.0); 178 | 179 | let val = StorableWasm(Some((1..255).collect())); 180 | let deserialized = StorableWasm::from_bytes(val.to_bytes()); 181 | assert_eq!(val.0, deserialized.0); 182 | } 183 | 184 | fn init_state() -> State { 185 | MockContext::new().inject(); 186 | let mut state = State::default(); 187 | state.reset(); 188 | state 189 | } 190 | 191 | #[test] 192 | fn insert_get_remove_tokens() { 193 | let mut state = init_state(); 194 | 195 | state.insert_token("anon".into(), Principal::anonymous()); 196 | state.insert_token("mng".into(), Principal::management_canister()); 197 | 198 | assert_eq!(state.get_token("anon".into()), Some(Principal::anonymous())); 199 | assert_eq!( 200 | state.get_token("mng".into()), 201 | Some(Principal::management_canister()) 202 | ); 203 | assert_eq!(state.get_token("other".into()), None); 204 | 205 | assert_eq!( 206 | state.remove_token("mng".into()), 207 | Some(Principal::management_canister()) 208 | ); 209 | assert_eq!(state.get_token("anon".into()), Some(Principal::anonymous())); 210 | assert_eq!(state.get_token("mng".into()), None); 211 | } 212 | 213 | #[test] 214 | fn set_get_token_wasm() { 215 | let mut state = init_state(); 216 | 217 | state.set_token_wasm(None); 218 | assert_eq!(state.get_token_wasm(), None); 219 | 220 | state.set_token_wasm(Some(vec![])); 221 | assert_eq!(state.get_token_wasm(), Some(vec![])); 222 | 223 | state.set_token_wasm(Some(vec![123; 2048])); 224 | assert_eq!(state.get_token_wasm(), Some(vec![123; 2048])); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/token/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "is20-token" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [features] 7 | default = ["mint_burn", "transfer"] 8 | export-api = ["canister-sdk/auction-api"] 9 | 10 | # Enables cycle auctions 11 | auction = ["canister-sdk/auction"] 12 | 13 | # Enables claim API related functions 14 | claim = [] 15 | 16 | # Enables mint and burn API methods. Enabled by default. 17 | mint_burn = [] 18 | 19 | # Enables API methods for funds transferring. Enabled by default. 20 | transfer = [] 21 | 22 | [dependencies] 23 | candid = "0.8" 24 | num-traits = "0.2" 25 | serde = "1.0" 26 | serde_cbor = "0.11" 27 | canister-sdk = { workspace = true } 28 | ic-stable-structures = { workspace = true } 29 | ic-exports = { workspace = true } 30 | thiserror = "1.0" 31 | 32 | [target.'cfg(not(target_family = "wasm"))'.dependencies] 33 | async-std = { version = "1.10.0", features = ["attributes"] } 34 | 35 | [dev-dependencies] 36 | tokio = { version = "1", features = ["macros", "rt"] } 37 | proptest = "1.0.0" 38 | rand = "0.8" 39 | coverage-helper = "0.1" 40 | -------------------------------------------------------------------------------- /src/token/api/src/account.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | 3 | use canister_sdk::candid::{CandidType, Principal}; 4 | use serde::Deserialize; 5 | 6 | use crate::error::TxError; 7 | 8 | pub static DEFAULT_SUBACCOUNT: Subaccount = [0u8; 32]; 9 | 10 | #[derive(Debug, Clone, CandidType, Deserialize, Copy, PartialEq, Eq)] 11 | pub struct Account { 12 | pub owner: Principal, 13 | pub subaccount: Option, 14 | } 15 | 16 | impl Account { 17 | pub fn new(owner: Principal, subaccount: Option) -> Self { 18 | Self { owner, subaccount } 19 | } 20 | } 21 | 22 | // We use internal type separately from `Account` to make it semantically more correct. This 23 | // simplifies, for example comparison of accounts with default subaccount. 24 | #[derive(Debug, Clone, CandidType, Deserialize, Copy, PartialEq, Eq, Hash)] 25 | pub struct AccountInternal { 26 | pub owner: Principal, 27 | pub subaccount: Subaccount, 28 | } 29 | 30 | impl AccountInternal { 31 | pub fn new(owner: Principal, subaccount: Option) -> Self { 32 | Self { 33 | owner, 34 | subaccount: subaccount.unwrap_or(DEFAULT_SUBACCOUNT), 35 | } 36 | } 37 | } 38 | 39 | impl From for AccountInternal { 40 | fn from(owner: Principal) -> Self { 41 | Self::new(owner, None) 42 | } 43 | } 44 | 45 | impl From for Account { 46 | fn from(owner: Principal) -> Self { 47 | Self { 48 | owner, 49 | subaccount: None, 50 | } 51 | } 52 | } 53 | 54 | impl From for AccountInternal { 55 | fn from(acc: Account) -> Self { 56 | Self::new(acc.owner, acc.subaccount) 57 | } 58 | } 59 | 60 | impl From for Account { 61 | fn from(acc: AccountInternal) -> Self { 62 | let subaccount = if acc.subaccount == DEFAULT_SUBACCOUNT { 63 | None 64 | } else { 65 | Some(acc.subaccount) 66 | }; 67 | 68 | Account { 69 | owner: acc.owner, 70 | subaccount, 71 | } 72 | } 73 | } 74 | 75 | impl Display for AccountInternal { 76 | fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { 77 | if self.subaccount == DEFAULT_SUBACCOUNT { 78 | write!(f, "Account({})", self.owner) 79 | } else { 80 | write!(f, "Account({}, ", self.owner)?; 81 | for b in self.subaccount { 82 | write!(f, "{b:02X}")?; 83 | } 84 | 85 | write!(f, ")") 86 | } 87 | } 88 | } 89 | 90 | pub type Subaccount = [u8; 32]; 91 | 92 | pub struct CheckedAccount(AccountInternal, T); 93 | 94 | impl CheckedAccount { 95 | pub fn inner(&self) -> AccountInternal { 96 | self.0 97 | } 98 | } 99 | 100 | pub struct WithRecipient { 101 | pub recipient: AccountInternal, 102 | } 103 | 104 | impl CheckedAccount { 105 | pub fn with_recipient( 106 | recipient: AccountInternal, 107 | from_subaccount: Option, 108 | ) -> Result { 109 | let caller = canister_sdk::ic_kit::ic::caller(); 110 | let from = AccountInternal::new(caller, from_subaccount); 111 | if recipient == from { 112 | Err(TxError::SelfTransfer) 113 | } else { 114 | Ok(Self(from, WithRecipient { recipient })) 115 | } 116 | } 117 | pub fn recipient(&self) -> AccountInternal { 118 | self.1.recipient 119 | } 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use candid::{Decode, Encode}; 125 | use canister_sdk::ic_kit::mock_principals::alice; 126 | use coverage_helper::test; 127 | 128 | use super::*; 129 | 130 | #[test] 131 | fn compare_default_subaccount_and_none() { 132 | let acc1 = AccountInternal::new(alice(), None); 133 | let acc2 = AccountInternal::new(alice(), Some(DEFAULT_SUBACCOUNT)); 134 | 135 | assert_eq!(acc1, acc2); 136 | } 137 | 138 | #[test] 139 | fn account_display() { 140 | assert_eq!( 141 | format!("{}", AccountInternal::new(alice(), None)), 142 | "Account(sgymv-uiaaa-aaaaa-aaaia-cai)".to_string() 143 | ); 144 | assert_eq!( 145 | format!("{:?}", AccountInternal::new(alice(), None)), 146 | "AccountInternal { owner: Principal { len: 10, bytes: [0, 0, 0, 0, 0, 0, 0, 16, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, subaccount: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }".to_string() 147 | ); 148 | assert_eq!( 149 | format!( 150 | "{}", 151 | AccountInternal::new(alice(), Some(DEFAULT_SUBACCOUNT)) 152 | ), 153 | "Account(sgymv-uiaaa-aaaaa-aaaia-cai)".to_string() 154 | ); 155 | assert_eq!( 156 | format!("{}", AccountInternal::new(alice(), Some([1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255]))), 157 | "Account(sgymv-uiaaa-aaaaa-aaaia-cai, 01000000000000000000000000000000000000000000000000000000000000FF)".to_string() 158 | ); 159 | } 160 | 161 | #[test] 162 | fn serialization() { 163 | let acc = AccountInternal::new(alice(), Some([1; 32])); 164 | let serialized = Encode!(&acc).unwrap(); 165 | let deserialized = Decode!(&serialized, AccountInternal).unwrap(); 166 | 167 | assert_eq!(deserialized, acc); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/token/api/src/canister.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | #[cfg(feature = "auction")] 3 | use canister_sdk::ic_auction::{ 4 | api::Auction, 5 | error::AuctionError, 6 | state::{AuctionInfo, AuctionState}, 7 | }; 8 | use canister_sdk::ic_canister::{ 9 | generate_exports, generate_idl, query, update, Canister, Idl, PreUpdate, 10 | }; 11 | use canister_sdk::ic_helpers::tokens::Tokens128; 12 | use canister_sdk::ic_kit::ic; 13 | pub use inspect::AcceptReason; 14 | 15 | use self::is20_transactions::{ 16 | batch_transfer, burn_as_owner, burn_own_tokens, is20_transfer, mint_as_owner, mint_test_token, 17 | }; 18 | #[cfg(feature = "claim")] 19 | use self::is20_transactions::{claim, get_claim_subaccount}; 20 | use crate::account::{Account, AccountInternal, CheckedAccount, Subaccount}; 21 | use crate::canister::icrc1_transfer::icrc1_transfer; 22 | use crate::error::{TransferError, TxError}; 23 | use crate::principal::{CheckedPrincipal, Owner}; 24 | use crate::state::balances::{Balances, StableBalances}; 25 | use crate::state::config::{StandardRecord, Timestamp, TokenConfig, TokenInfo, Value}; 26 | use crate::state::ledger::{ 27 | BatchTransferArgs, LedgerData, PaginatedResult, TransferArgs, TxReceipt, 28 | }; 29 | use crate::tx_record::{TxId, TxRecord}; 30 | 31 | mod inspect; 32 | 33 | pub mod icrc1_transfer; 34 | 35 | #[cfg(feature = "auction")] 36 | pub mod is20_auction; 37 | pub mod is20_transactions; 38 | 39 | pub(crate) const MAX_TRANSACTION_REQUEST: usize = 2000; 40 | pub(crate) const MAX_ACCOUNT_TRANSACTION_REQUEST: usize = 1000; 41 | // 1 day in seconds. 42 | pub const DEFAULT_AUCTION_PERIOD_SECONDS: Timestamp = 60 * 60 * 24; 43 | 44 | pub enum CanisterUpdate { 45 | Name(String), 46 | Symbol(String), 47 | Fee(Tokens128), 48 | FeeTo(Principal), 49 | Owner(Principal), 50 | MinCycles(u64), 51 | } 52 | 53 | #[cfg(not(feature = "auction"))] 54 | pub trait AuctionCanister {} 55 | 56 | #[cfg(feature = "auction")] 57 | pub trait AuctionCanister: Auction {} 58 | 59 | impl AuctionCanister for T {} 60 | 61 | pub trait TokenCanisterAPI: Canister + Sized + AuctionCanister { 62 | /// The `inspect_message()` call is not exported by default. Add your custom #[inspect_message] 63 | /// function and use this method there to export the `inspect_message()` call. 64 | fn inspect_message(method: &str, caller: Principal) -> Result { 65 | inspect::inspect_message(method, caller) 66 | } 67 | 68 | /********************** METADATA ***********************/ 69 | 70 | #[query(trait = true)] 71 | fn is_test_token(&self) -> bool { 72 | TokenConfig::get_stable().is_test_token 73 | } 74 | 75 | #[query(trait = true)] 76 | fn icrc1_total_supply(&self) -> Tokens128 { 77 | StableBalances.total_supply() 78 | } 79 | 80 | #[query(trait = true)] 81 | fn owner(&self) -> Principal { 82 | TokenConfig::get_stable().owner 83 | } 84 | 85 | #[query(trait = true)] 86 | fn get_token_info(&self) -> TokenInfo { 87 | let TokenConfig { 88 | fee_to, 89 | deploy_time, 90 | .. 91 | } = TokenConfig::get_stable(); 92 | TokenInfo { 93 | metadata: TokenConfig::get_stable().get_metadata(), 94 | fee_to, 95 | history_size: LedgerData::len(), 96 | deployTime: deploy_time, 97 | holderNumber: StableBalances.get_holders().len(), 98 | cycles: canister_sdk::ic_kit::ic::balance(), 99 | } 100 | } 101 | 102 | #[update(trait = true)] 103 | fn set_name(&self, name: String) -> Result<(), TxError> { 104 | let caller = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 105 | self.update_stats(caller, CanisterUpdate::Name(name)); 106 | Ok(()) 107 | } 108 | 109 | #[update(trait = true)] 110 | fn set_symbol(&self, symbol: String) -> Result<(), TxError> { 111 | let caller = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 112 | self.update_stats(caller, CanisterUpdate::Symbol(symbol)); 113 | Ok(()) 114 | } 115 | 116 | #[update(trait = true)] 117 | fn set_fee(&self, fee: Tokens128) -> Result<(), TxError> { 118 | let caller = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 119 | self.update_stats(caller, CanisterUpdate::Fee(fee)); 120 | Ok(()) 121 | } 122 | 123 | #[update(trait = true)] 124 | fn set_fee_to(&self, fee_to: Principal) -> Result<(), TxError> { 125 | let caller = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 126 | self.update_stats(caller, CanisterUpdate::FeeTo(fee_to)); 127 | Ok(()) 128 | } 129 | 130 | #[update(trait = true)] 131 | fn set_owner(&self, owner: Principal) -> Result<(), TxError> { 132 | let caller = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 133 | self.update_stats(caller, CanisterUpdate::Owner(owner)); 134 | Ok(()) 135 | } 136 | 137 | /********************** BALANCES INFO ***********************/ 138 | 139 | /// This method retreieves holders of `Account` and their amounts. 140 | #[query(trait = true)] 141 | fn get_holders(&self, start: usize, limit: usize) -> Vec<(Account, Tokens128)> { 142 | StableBalances 143 | .list_balances(start, limit) 144 | .into_iter() 145 | .map(|(acc, amount)| (acc.into(), amount)) 146 | .collect() 147 | } 148 | 149 | /// Returns the list of the caller's subaccounts with balances. If the caller account does not exist, will 150 | /// return an empty list. 151 | /// 152 | /// It is intentional that the method does not accept the principal to list the subaccounts 153 | /// for, because in some cases the token holder want to keep some of his subaccounts a secret. 154 | /// So only own subaccounts can be listed safely. 155 | #[query(trait = true)] 156 | fn list_subaccounts(&self) -> std::collections::HashMap { 157 | StableBalances.get_subaccounts(ic::caller()) 158 | } 159 | 160 | /********************** CLAIMS ***********************/ 161 | 162 | #[cfg(feature = "claim")] 163 | #[query(trait = true)] 164 | fn get_claimable_amount(&self, holder: Principal, subaccount: Option) -> Tokens128 { 165 | StableBalances::get_claimable_amount(holder, subaccount) 166 | } 167 | 168 | #[cfg(feature = "claim")] 169 | #[query(trait = true)] 170 | fn get_claim_subaccount( 171 | &self, 172 | claimer: Principal, 173 | claimer_subaccount: Option, 174 | ) -> Subaccount { 175 | get_claim_subaccount(claimer, claimer_subaccount) 176 | } 177 | 178 | #[cfg(feature = "claim")] 179 | #[update(trait = true)] 180 | fn claim(&self, holder: Principal, subaccount: Option) -> TxReceipt { 181 | claim(holder, subaccount) 182 | } 183 | 184 | /********************** TRANSACTION HISTORY ***********************/ 185 | 186 | #[query(trait = true)] 187 | fn history_size(&self) -> u64 { 188 | LedgerData::len() 189 | } 190 | 191 | #[query(trait = true)] 192 | fn get_transaction(&self, id: TxId) -> TxRecord { 193 | LedgerData::get(id).unwrap_or_else(|| { 194 | canister_sdk::ic_kit::ic::trap(&format!("Transaction {} does not exist", id)) 195 | }) 196 | } 197 | 198 | /// Returns a list of transactions in paginated form. The `who` is optional, if given, only transactions of the `who` are 199 | /// returned. `count` is the number of transactions to return, `transaction_id` is the transaction index which is used as 200 | /// the offset of the first transaction to return, any 201 | /// 202 | /// It returns `PaginatedResult` a struct, which contains `result` which is a list of transactions `Vec` that meet the requirements of the query, 203 | /// and `next_id` which is the index of the next transaction to return. 204 | #[query(trait = true)] 205 | fn get_transactions( 206 | &self, 207 | who: Option, 208 | count: usize, 209 | transaction_id: Option, 210 | ) -> PaginatedResult { 211 | let count = who 212 | .map_or(MAX_TRANSACTION_REQUEST, |_| MAX_ACCOUNT_TRANSACTION_REQUEST) 213 | .min(count); 214 | 215 | LedgerData::get_transactions(who, count, transaction_id) 216 | } 217 | 218 | /// Returns the total number of transactions related to the user `who`. 219 | #[query(trait = true)] 220 | fn get_user_transaction_count(&self, who: Principal) -> usize { 221 | LedgerData::get_len_user_history(who) 222 | } 223 | 224 | /********************** IS20 TRANSACTIONS ***********************/ 225 | 226 | #[cfg_attr(feature = "transfer", update(trait = true))] 227 | fn transfer(&self, transfer: TransferArgs) -> Result { 228 | let account = CheckedAccount::with_recipient(transfer.to.into(), transfer.from_subaccount)?; 229 | is20_transfer(account, &transfer, self.fee_ratio()) 230 | } 231 | 232 | /// Takes a list of transfers, each of which is a pair of `to` and `value` fields, it returns a `TxReceipt` which contains 233 | /// a vec of transaction index or an error message. The list of transfers is processed in the order they are given. if the `fee` 234 | /// is set, the `fee` amount is applied to each transfer. 235 | /// The balance of the caller is reduced by sum of `value + fee` amount for each transfer. If the total sum of `value + fee` for all transfers, 236 | /// is less than the `balance` of the caller, the transaction will fail with `TxError::InsufficientBalance` error. 237 | #[cfg_attr(feature = "transfer", update(trait = true))] 238 | fn batch_transfer( 239 | &self, 240 | from_subaccount: Option, 241 | transfers: Vec, 242 | ) -> Result, TxError> { 243 | for x in &transfers { 244 | let recipient = x.receiver; 245 | CheckedAccount::with_recipient(recipient.into(), from_subaccount)?; 246 | } 247 | batch_transfer(from_subaccount, transfers, self.fee_ratio()) 248 | } 249 | 250 | #[cfg_attr(feature = "mint_burn", update(trait = true))] 251 | fn mint( 252 | &self, 253 | to: Principal, 254 | to_subaccount: Option, 255 | amount: Tokens128, 256 | ) -> TxReceipt { 257 | if self.is_test_token() { 258 | let test_user = CheckedPrincipal::test_user(&TokenConfig::get_stable())?; 259 | mint_test_token(test_user, to, to_subaccount, amount) 260 | } else { 261 | let owner = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 262 | mint_as_owner(owner, to, to_subaccount, amount) 263 | } 264 | } 265 | 266 | /// Burn `amount` of tokens from `from` principal. 267 | /// If `from` is None, then caller's tokens will be burned. 268 | /// If `from` is Some(_) but method called not by owner, `TxError::Unauthorized` will be returned. 269 | /// If owner calls this method and `from` is Some(who), then who's tokens will be burned. 270 | #[cfg_attr(feature = "mint_burn", update(trait = true))] 271 | fn burn( 272 | &self, 273 | from: Option, 274 | from_subaccount: Option, 275 | amount: Tokens128, 276 | ) -> TxReceipt { 277 | match from { 278 | None => burn_own_tokens(from_subaccount, amount), 279 | Some(from) if from == canister_sdk::ic_kit::ic::caller() => { 280 | burn_own_tokens(from_subaccount, amount) 281 | } 282 | Some(from) => { 283 | let caller = CheckedPrincipal::owner(&TokenConfig::get_stable())?; 284 | burn_as_owner(caller, from, from_subaccount, amount) 285 | } 286 | } 287 | } 288 | 289 | /********************** ICRC-1 METHODS ***********************/ 290 | 291 | #[query(trait = true)] 292 | fn icrc1_balance_of(&self, account: Account) -> Tokens128 { 293 | StableBalances.balance_of(&account.into()) 294 | } 295 | 296 | #[cfg_attr(feature = "transfer", update(trait = true))] 297 | fn icrc1_transfer(&self, transfer: TransferArgs) -> Result { 298 | let account = CheckedAccount::with_recipient(transfer.to.into(), transfer.from_subaccount)?; 299 | 300 | Ok(icrc1_transfer(account, &transfer, self.fee_ratio())?) 301 | } 302 | 303 | #[query(trait = true)] 304 | fn icrc1_name(&self) -> String { 305 | TokenConfig::get_stable().name 306 | } 307 | 308 | #[query(trait = true)] 309 | fn icrc1_symbol(&self) -> String { 310 | TokenConfig::get_stable().symbol 311 | } 312 | 313 | #[query(trait = true)] 314 | fn icrc1_decimals(&self) -> u8 { 315 | TokenConfig::get_stable().decimals 316 | } 317 | 318 | /// Returns the default transfer fee. 319 | #[query(trait = true)] 320 | fn icrc1_fee(&self) -> Tokens128 { 321 | TokenConfig::get_stable().fee 322 | } 323 | #[query(trait = true)] 324 | fn icrc1_metadata(&self) -> Vec<(String, Value)> { 325 | TokenConfig::get_stable().icrc1_metadata() 326 | } 327 | 328 | #[query(trait = true)] 329 | fn icrc1_supported_standards(&self) -> Vec { 330 | TokenConfig::get_stable().supported_standards() 331 | } 332 | 333 | #[query(trait = true)] 334 | fn icrc1_minting_account(&self) -> Option { 335 | Some(TokenConfig::get_stable().owner.into()) 336 | } 337 | 338 | /********************** INTERNAL METHODS ***********************/ 339 | 340 | // Important: This function *must* be defined to be the 341 | // last one in the trait because it depends on the order 342 | // of expansion of update/query(trait = true) methods. 343 | fn get_idl() -> Idl { 344 | generate_idl!() 345 | } 346 | 347 | fn update_stats(&self, _caller: CheckedPrincipal, update: CanisterUpdate) { 348 | use CanisterUpdate::*; 349 | let mut stats = TokenConfig::get_stable(); 350 | match update { 351 | Name(name) => stats.name = name, 352 | Symbol(symbol) => stats.symbol = symbol, 353 | Fee(fee) => stats.fee = fee, 354 | FeeTo(fee_to) => stats.fee_to = fee_to, 355 | Owner(owner) => stats.owner = owner, 356 | MinCycles(min_cycles) => stats.min_cycles = min_cycles, 357 | } 358 | TokenConfig::set_stable(stats) 359 | } 360 | 361 | fn fee_ratio(&self) -> f64 { 362 | #[cfg(feature = "auction")] 363 | return self.bidding_info().fee_ratio; 364 | 365 | #[cfg(not(feature = "auction"))] 366 | 0.0 367 | } 368 | } 369 | 370 | generate_exports!(TokenCanisterAPI, TokenCanisterExports); 371 | 372 | #[cfg(feature = "auction")] 373 | use canister_sdk::ic_storage::IcStorage; 374 | 375 | #[cfg(feature = "auction")] 376 | impl Auction for TokenCanisterExports { 377 | fn auction_state(&self) -> std::rc::Rc> { 378 | AuctionState::get() 379 | } 380 | 381 | fn disburse_rewards(&self) -> Result { 382 | is20_auction::disburse_rewards(&self.auction_state().borrow()) 383 | } 384 | } 385 | 386 | pub fn auction_account() -> AccountInternal { 387 | // There are no sub accounts for the auction principal 388 | AccountInternal::new(Principal::management_canister(), None) 389 | } 390 | 391 | #[cfg(test)] 392 | mod tests { 393 | use canister_sdk::ic_canister::canister_call; 394 | use canister_sdk::ic_kit::inject::get_context; 395 | use canister_sdk::ic_kit::mock_principals::{alice, bob, john}; 396 | use canister_sdk::ic_kit::MockContext; 397 | #[cfg(feature = "claim")] 398 | use canister_sdk::ledger::{AccountIdentifier, Subaccount as SubaccountIdentifier}; 399 | 400 | use crate::mock::TokenCanisterMock; 401 | use crate::{account::DEFAULT_SUBACCOUNT, state::config::Metadata}; 402 | 403 | use super::*; 404 | 405 | // Method for generating random Subaccount. 406 | #[cfg(feature = "claim")] 407 | #[cfg_attr(coverage_nightly, no_coverage)] 408 | fn gen_subaccount() -> Subaccount { 409 | use rand::{thread_rng, Rng}; 410 | 411 | let mut subaccount = [0u8; 32]; 412 | thread_rng().fill(&mut subaccount); 413 | subaccount 414 | } 415 | 416 | #[cfg_attr(coverage_nightly, no_coverage)] 417 | fn test_context() -> (&'static MockContext, TokenCanisterMock) { 418 | let context = MockContext::new().with_caller(john()).inject(); 419 | 420 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 421 | let canister = TokenCanisterMock::from_principal(principal); 422 | 423 | // Refresh canister's state. 424 | TokenConfig::set_stable(TokenConfig::default()); 425 | StableBalances.clear(); 426 | LedgerData::clear(); 427 | 428 | // Due to this update, init() code will get actual 429 | // principal of the canister from ic::id(). 430 | context.update_id(canister.principal()); 431 | 432 | canister.init( 433 | Metadata { 434 | name: "".to_string(), 435 | symbol: "".to_string(), 436 | decimals: 8, 437 | 438 | owner: john(), 439 | fee: Tokens128::from(0), 440 | fee_to: john(), 441 | is_test_token: None, 442 | }, 443 | Tokens128::from(1000), 444 | ); 445 | 446 | // This is to make tests that don't rely on auction state 447 | // pass, because since we are running auction state on each 448 | // endpoint call, it affects `BiddingInfo.fee_ratio` that is 449 | // used for charging fees in `approve` endpoint. 450 | let mut stats = TokenConfig::get_stable(); 451 | stats.min_cycles = 0; 452 | TokenConfig::set_stable(stats); 453 | 454 | canister.mint(alice(), None, 1000.into()).unwrap(); 455 | context.update_caller(alice()); 456 | 457 | (context, canister) 458 | } 459 | 460 | fn test_canister() -> TokenCanisterMock { 461 | let context = MockContext::new().with_caller(alice()).inject(); 462 | 463 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 464 | let canister = TokenCanisterMock::from_principal(principal); 465 | context.update_id(canister.principal()); 466 | 467 | // Refresh canister's state. 468 | TokenConfig::set_stable(TokenConfig::default()); 469 | StableBalances.clear(); 470 | LedgerData::clear(); 471 | 472 | canister.init( 473 | Metadata { 474 | name: "".to_string(), 475 | symbol: "".to_string(), 476 | decimals: 8, 477 | owner: alice(), 478 | fee: Tokens128::from(0), 479 | fee_to: alice(), 480 | is_test_token: None, 481 | }, 482 | Tokens128::from(1000), 483 | ); 484 | 485 | let mut stats = TokenConfig::get_stable(); 486 | stats.min_cycles = 0; 487 | TokenConfig::set_stable(stats); 488 | 489 | canister 490 | } 491 | 492 | #[test] 493 | fn transfer_to_same_account() { 494 | let canister = test_canister(); 495 | let transfer = TransferArgs { 496 | from_subaccount: None, 497 | to: alice().into(), 498 | amount: 100.into(), 499 | fee: None, 500 | memo: None, 501 | created_at_time: None, 502 | }; 503 | 504 | let res = canister.icrc1_transfer(transfer); 505 | assert_eq!( 506 | res, 507 | Err(TransferError::GenericError { 508 | error_code: 500, 509 | message: "self transfer".into() 510 | }) 511 | ) 512 | } 513 | 514 | #[test] 515 | fn transfer_to_same_default_subaccount() { 516 | let canister = test_canister(); 517 | let transfer = TransferArgs { 518 | from_subaccount: Some(crate::account::DEFAULT_SUBACCOUNT), 519 | to: alice().into(), 520 | amount: 100.into(), 521 | fee: None, 522 | memo: None, 523 | created_at_time: None, 524 | }; 525 | 526 | let res = canister.icrc1_transfer(transfer); 527 | assert_eq!( 528 | res, 529 | Err(TransferError::GenericError { 530 | error_code: 500, 531 | message: "self transfer".into() 532 | }) 533 | ); 534 | 535 | let transfer = TransferArgs { 536 | from_subaccount: None, 537 | to: Account::new(alice(), Some(DEFAULT_SUBACCOUNT)), 538 | amount: 100.into(), 539 | fee: None, 540 | memo: None, 541 | created_at_time: None, 542 | }; 543 | 544 | let res = canister.icrc1_transfer(transfer); 545 | assert_eq!( 546 | res, 547 | Err(TransferError::GenericError { 548 | error_code: 500, 549 | message: "self transfer".into() 550 | }) 551 | ); 552 | } 553 | 554 | #[cfg(feature = "claim")] 555 | #[test] 556 | fn test_claim() { 557 | let bob_sub = gen_subaccount(); 558 | let alice_sub = gen_subaccount(); 559 | 560 | let alice_aid = 561 | AccountIdentifier::new(alice().into(), Some(SubaccountIdentifier(alice_sub))); 562 | let bob_aid = AccountIdentifier::new(bob().into(), Some(SubaccountIdentifier(bob_sub))); 563 | 564 | let (ctx, canister) = test_context(); 565 | ctx.update_caller(john()); 566 | 567 | assert!(canister 568 | .mint( 569 | canister.owner(), 570 | Some(alice_aid.to_address()), 571 | Tokens128::from(1000) 572 | ) 573 | .is_ok()); 574 | assert!(canister 575 | .mint( 576 | canister.owner(), 577 | Some(bob_aid.to_address()), 578 | Tokens128::from(2000) 579 | ) 580 | .is_ok()); 581 | 582 | ctx.update_caller(alice()); 583 | assert_eq!( 584 | canister.get_claimable_amount(canister.owner(), Some(alice_sub)), 585 | Tokens128::from(1000) 586 | ); 587 | 588 | let balance_before = canister.icrc1_balance_of(alice().into()); 589 | canister.claim(canister.owner(), Some(alice_sub)).unwrap(); 590 | assert_eq!( 591 | canister.icrc1_balance_of(alice().into()), 592 | (Tokens128::from(1000) + balance_before).unwrap() 593 | ); 594 | assert_eq!( 595 | canister.get_claimable_amount(canister.owner(), Some(alice_sub)), 596 | 0.into() 597 | ); 598 | 599 | ctx.update_caller(bob()); 600 | assert_eq!( 601 | canister.get_claimable_amount(canister.owner(), Some(bob_sub)), 602 | Tokens128::from(2000) 603 | ); 604 | } 605 | 606 | // **** APIs tests **** 607 | 608 | #[tokio::test] 609 | #[cfg_attr(coverage_nightly, no_coverage)] 610 | async fn set_name() { 611 | let (ctx, canister) = test_context(); 612 | ctx.update_id(john()); 613 | canister_call!(canister.set_name("War and Piece".to_string()), Result<(), TxError>) 614 | .await 615 | .unwrap() 616 | .unwrap(); 617 | let info = canister_call!(canister.get_token_info(), TokenInfo) 618 | .await 619 | .unwrap(); 620 | 621 | assert_eq!(info.metadata.name, "War and Piece".to_string()); 622 | 623 | ctx.update_id(bob()); 624 | let res = canister_call!(canister.set_name("Crime and Punishment".to_string()), Result<(), TxError>) 625 | .await 626 | .unwrap(); 627 | 628 | assert_eq!(res, Err(TxError::Unauthorized)); 629 | let info = canister_call!(canister.get_token_info(), TokenInfo) 630 | .await 631 | .unwrap(); 632 | 633 | assert_eq!(info.metadata.name, "War and Piece".to_string()); 634 | let name = canister_call!(canister.icrc1_name(), String).await.unwrap(); 635 | assert_eq!(name, "War and Piece".to_string()); 636 | } 637 | 638 | #[tokio::test] 639 | #[cfg_attr(coverage_nightly, no_coverage)] 640 | async fn set_symbol() { 641 | let (ctx, canister) = test_context(); 642 | ctx.update_id(john()); 643 | canister_call!(canister.set_symbol("MAX".to_string()), Result<(), TxError>) 644 | .await 645 | .unwrap() 646 | .unwrap(); 647 | let info = canister_call!(canister.get_token_info(), TokenInfo) 648 | .await 649 | .unwrap(); 650 | 651 | assert_eq!(info.metadata.symbol, "MAX".to_string()); 652 | 653 | ctx.update_id(bob()); 654 | let res = canister_call!(canister.set_symbol("BOB".to_string()), Result<(), TxError>) 655 | .await 656 | .unwrap(); 657 | 658 | assert_eq!(res, Err(TxError::Unauthorized)); 659 | let info = canister_call!(canister.get_token_info(), TokenInfo) 660 | .await 661 | .unwrap(); 662 | 663 | assert_eq!(info.metadata.symbol, "MAX".to_string()); 664 | let symbol = canister_call!(canister.icrc1_symbol(), String) 665 | .await 666 | .unwrap(); 667 | assert_eq!(symbol, "MAX".to_string()); 668 | } 669 | 670 | #[tokio::test] 671 | #[cfg_attr(coverage_nightly, no_coverage)] 672 | async fn set_fee() { 673 | let (ctx, canister) = test_context(); 674 | ctx.update_id(john()); 675 | canister_call!(canister.set_fee(100500.into()), Result<(), TxError>) 676 | .await 677 | .unwrap() 678 | .unwrap(); 679 | let info = canister_call!(canister.get_token_info(), TokenInfo) 680 | .await 681 | .unwrap(); 682 | 683 | assert_eq!(info.metadata.fee, 100500.into()); 684 | 685 | ctx.update_id(bob()); 686 | let res = canister_call!(canister.set_fee(0.into()), Result<(), TxError>) 687 | .await 688 | .unwrap(); 689 | 690 | assert_eq!(res, Err(TxError::Unauthorized)); 691 | let info = canister_call!(canister.get_token_info(), TokenInfo) 692 | .await 693 | .unwrap(); 694 | 695 | assert_eq!(info.metadata.fee, 100500.into()); 696 | let fee = canister_call!(canister.icrc1_fee(), Tokens128) 697 | .await 698 | .unwrap(); 699 | assert_eq!(fee, 100500.into()); 700 | } 701 | 702 | #[tokio::test] 703 | #[cfg_attr(coverage_nightly, no_coverage)] 704 | async fn set_fee_to() { 705 | let (ctx, canister) = test_context(); 706 | ctx.update_id(john()); 707 | canister_call!(canister.set_fee_to(alice()), Result<(), TxError>) 708 | .await 709 | .unwrap() 710 | .unwrap(); 711 | let info = canister_call!(canister.get_token_info(), TokenInfo) 712 | .await 713 | .unwrap(); 714 | 715 | assert_eq!(info.metadata.fee_to, alice()); 716 | 717 | ctx.update_id(bob()); 718 | let res = canister_call!(canister.set_fee_to(bob()), Result<(), TxError>) 719 | .await 720 | .unwrap(); 721 | 722 | assert_eq!(res, Err(TxError::Unauthorized)); 723 | let info = canister_call!(canister.get_token_info(), TokenInfo) 724 | .await 725 | .unwrap(); 726 | 727 | assert_eq!(info.metadata.fee_to, alice()); 728 | } 729 | 730 | #[tokio::test] 731 | #[cfg_attr(coverage_nightly, no_coverage)] 732 | async fn set_owner() { 733 | let (ctx, canister) = test_context(); 734 | ctx.update_id(john()); 735 | canister_call!(canister.set_owner(alice()), Result<(), TxError>) 736 | .await 737 | .unwrap() 738 | .unwrap(); 739 | let info = canister_call!(canister.get_token_info(), TokenInfo) 740 | .await 741 | .unwrap(); 742 | 743 | assert_eq!(info.metadata.owner, alice()); 744 | 745 | ctx.update_id(bob()); 746 | let res = canister_call!(canister.set_owner(bob()), Result<(), TxError>) 747 | .await 748 | .unwrap(); 749 | 750 | assert_eq!(res, Err(TxError::Unauthorized)); 751 | let info = canister_call!(canister.get_token_info(), TokenInfo) 752 | .await 753 | .unwrap(); 754 | 755 | assert_eq!(info.metadata.owner, alice()); 756 | let owner = canister_call!(canister.owner(), Principal).await.unwrap(); 757 | assert_eq!(owner, alice()); 758 | 759 | let minting_account = canister_call!(canister.icrc1_minting_account(), Principal) 760 | .await 761 | .unwrap(); 762 | assert_eq!(minting_account, Some(alice().into())); 763 | } 764 | 765 | #[tokio::test] 766 | #[cfg_attr(coverage_nightly, no_coverage)] 767 | async fn list_subaccounts() { 768 | let canister = test_canister(); 769 | let subaccount: Subaccount = [1; 32]; 770 | canister 771 | .transfer(TransferArgs { 772 | from_subaccount: None, 773 | to: Account::new(alice(), Some(subaccount)), 774 | amount: 100.into(), 775 | fee: None, 776 | memo: None, 777 | created_at_time: None, 778 | }) 779 | .unwrap(); 780 | 781 | get_context().update_id(alice()); 782 | let list = canister_call!(canister.list_subaccounts(), std::collections::HashMap).await.unwrap(); 783 | assert_eq!(list.len(), 2); 784 | assert_eq!(list[&DEFAULT_SUBACCOUNT], 900.into()); 785 | assert_eq!(list[&subaccount], 100.into()); 786 | } 787 | } 788 | -------------------------------------------------------------------------------- /src/token/api/src/canister/icrc1_transfer.rs: -------------------------------------------------------------------------------- 1 | use crate::account::{AccountInternal, CheckedAccount, WithRecipient}; 2 | use crate::error::TxError; 3 | use crate::state::config::TokenConfig; 4 | use crate::state::ledger::{TransferArgs, TxReceipt}; 5 | 6 | use super::is20_transactions::burn; 7 | use super::is20_transactions::is20_transfer; 8 | use super::is20_transactions::mint; 9 | 10 | pub const TX_WINDOW: u64 = 60_000_000_000; 11 | pub const PERMITTED_DRIFT: u64 = 2 * 60_000_000_000; 12 | 13 | pub fn icrc1_transfer( 14 | caller: CheckedAccount, 15 | transfer: &TransferArgs, 16 | auction_fee_ratio: f64, 17 | ) -> TxReceipt { 18 | let amount = transfer.amount; 19 | let minter = AccountInternal::new(TokenConfig::get_stable().owner, None); 20 | 21 | // Checks and returns error if the fee is not zero 22 | let check_zero_fee = || { 23 | if let Some(t) = transfer.fee { 24 | if !t.is_zero() { 25 | return Err(TxError::BadFee { 26 | expected_fee: 0.into(), 27 | }); 28 | } 29 | } 30 | Ok(()) 31 | }; 32 | 33 | if caller.inner() == minter { 34 | // Minting transfers must have zero fees. 35 | check_zero_fee()?; 36 | return mint(caller.inner().owner, transfer.to.into(), amount); 37 | } 38 | 39 | if caller.recipient() == minter { 40 | // Burning transfers must have zero fees. 41 | check_zero_fee()?; 42 | return burn(caller.recipient().owner, caller.inner(), amount); 43 | } 44 | 45 | is20_transfer(caller, transfer, auction_fee_ratio) 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use std::time::UNIX_EPOCH; 51 | 52 | use candid::Principal; 53 | use canister_sdk::ic_auction::api::Auction; 54 | use canister_sdk::ic_canister::Canister; 55 | use canister_sdk::ic_helpers::tokens::Tokens128; 56 | use canister_sdk::ic_kit::inject::get_context; 57 | use canister_sdk::ic_kit::mock_principals::{alice, bob, john, xtc}; 58 | use canister_sdk::ic_kit::MockContext; 59 | use rand::prelude::*; 60 | 61 | use crate::account::{Account, Subaccount}; 62 | use crate::canister::{auction_account, TokenCanisterAPI}; 63 | use crate::error::{TransferError, TxError}; 64 | use crate::mock::*; 65 | use crate::state::balances::{Balances, StableBalances}; 66 | use crate::state::config::{Metadata, DEFAULT_MIN_CYCLES}; 67 | use crate::state::ledger::{LedgerData, Operation, TransactionStatus}; 68 | 69 | use super::*; 70 | 71 | use coverage_helper::test; 72 | 73 | // Method for generating random Subaccount. 74 | #[cfg_attr(coverage_nightly, no_coverage)] 75 | fn gen_subaccount() -> Subaccount { 76 | let mut subaccount = [0u8; 32]; 77 | thread_rng().fill(&mut subaccount); 78 | subaccount 79 | } 80 | 81 | #[cfg_attr(coverage_nightly, no_coverage)] 82 | fn test_context() -> (&'static MockContext, TokenCanisterMock) { 83 | let context = MockContext::new().with_caller(john()).inject(); 84 | 85 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 86 | let canister = TokenCanisterMock::from_principal(principal); 87 | context.update_id(canister.principal()); 88 | 89 | // Refresh canister's state. 90 | TokenConfig::set_stable(TokenConfig::default()); 91 | StableBalances.clear(); 92 | LedgerData::clear(); 93 | 94 | canister.init( 95 | Metadata { 96 | name: "".to_string(), 97 | symbol: "".to_string(), 98 | decimals: 8, 99 | owner: john(), 100 | fee: Tokens128::from(0), 101 | fee_to: john(), 102 | is_test_token: None, 103 | }, 104 | Tokens128::from(1000), 105 | ); 106 | 107 | // This is to make tests that don't rely on auction state 108 | // pass, because since we are running auction state on each 109 | // endpoint call, it affects `BiddingInfo.fee_ratio` that is 110 | // used for charging fees in `approve` endpoint. 111 | let mut stats = TokenConfig::get_stable(); 112 | stats.min_cycles = 0; 113 | TokenConfig::set_stable(stats); 114 | 115 | canister.mint(alice(), None, 1000.into()).unwrap(); 116 | context.update_caller(alice()); 117 | 118 | (context, canister) 119 | } 120 | 121 | #[cfg_attr(coverage_nightly, no_coverage)] 122 | fn test_canister() -> TokenCanisterMock { 123 | let (_, canister) = test_context(); 124 | canister 125 | } 126 | 127 | #[test] 128 | fn minting_with_nonzero_fee() { 129 | let (_ctx, canister) = test_context(); 130 | 131 | let minter = AccountInternal::new(TokenConfig::get_stable().owner, None); 132 | let to = Account::from(bob()); 133 | 134 | let transfer = TransferArgs { 135 | from_subaccount: Some(minter.subaccount), 136 | to, 137 | amount: Tokens128::from(100), 138 | fee: Some(1.into()), 139 | memo: None, 140 | created_at_time: None, 141 | }; 142 | 143 | assert!( 144 | canister.icrc1_transfer(transfer).is_err(), 145 | "minting with non zero fee must fail!" 146 | ); 147 | } 148 | 149 | #[test] 150 | fn burning_with_nonzero_fee() { 151 | let (_ctx, canister) = test_context(); 152 | 153 | let to = Account::from(TokenConfig::get_stable().owner); 154 | let from_subaccount = Account::from(bob()).subaccount; 155 | 156 | let transfer = TransferArgs { 157 | from_subaccount, 158 | to, 159 | amount: Tokens128::from(100), 160 | fee: Some(1.into()), 161 | memo: None, 162 | created_at_time: None, 163 | }; 164 | 165 | assert!( 166 | canister.icrc1_transfer(transfer).is_err(), 167 | "burning with non zero fee must fail!" 168 | ); 169 | } 170 | 171 | #[test] 172 | fn transfer_without_fee() { 173 | let (ctx, canister) = test_context(); 174 | let alice_sub = gen_subaccount(); 175 | let bob_sub = gen_subaccount(); 176 | 177 | assert_eq!( 178 | Tokens128::from(1000), 179 | canister.icrc1_balance_of(Account::new(alice(), None)) 180 | ); 181 | 182 | let transfer1 = TransferArgs { 183 | from_subaccount: None, 184 | to: Account::from(bob()), 185 | amount: Tokens128::from(100), 186 | fee: None, 187 | memo: None, 188 | created_at_time: None, 189 | }; 190 | 191 | assert!(canister.icrc1_transfer(transfer1).is_ok()); 192 | assert_eq!( 193 | canister.icrc1_balance_of(Account::new(bob(), None)), 194 | Tokens128::from(100) 195 | ); 196 | assert_eq!( 197 | canister.icrc1_balance_of(Account::new(alice(), None)), 198 | Tokens128::from(900) 199 | ); 200 | 201 | ctx.update_caller(john()); 202 | assert!(canister 203 | .mint(alice(), Some(alice_sub), Tokens128::from(100)) 204 | .is_ok()); 205 | 206 | ctx.update_caller(alice()); 207 | let transfer2 = TransferArgs { 208 | from_subaccount: Some(alice_sub), 209 | to: Account::new(bob(), Some(bob_sub)), 210 | amount: Tokens128::from(50), 211 | fee: None, 212 | memo: None, 213 | created_at_time: None, 214 | }; 215 | assert!(canister.icrc1_transfer(transfer2).is_ok()); 216 | assert_eq!( 217 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub))), 218 | Tokens128::from(50) 219 | ); 220 | assert_eq!( 221 | canister.icrc1_balance_of(Account::new(bob(), Some(bob_sub))), 222 | Tokens128::from(50) 223 | ); 224 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(2100)); 225 | } 226 | 227 | #[test] 228 | fn transfer_with_fee() { 229 | let (ctx, canister) = test_context(); 230 | let alice_sub = gen_subaccount(); 231 | let bob_sub = gen_subaccount(); 232 | 233 | let mut stats = TokenConfig::get_stable(); 234 | stats.fee = Tokens128::from(100); 235 | stats.fee_to = john(); 236 | TokenConfig::set_stable(stats); 237 | 238 | let transfer1 = TransferArgs { 239 | from_subaccount: None, 240 | to: Account::from(bob()), 241 | amount: Tokens128::from(200), 242 | fee: None, 243 | memo: None, 244 | created_at_time: None, 245 | }; 246 | 247 | assert!(canister.icrc1_transfer(transfer1).is_ok()); 248 | assert_eq!( 249 | canister.icrc1_balance_of(Account::new(bob(), None)), 250 | Tokens128::from(200) 251 | ); 252 | assert_eq!( 253 | canister.icrc1_balance_of(Account::new(alice(), None)), 254 | Tokens128::from(700) 255 | ); 256 | assert_eq!( 257 | canister.icrc1_balance_of(Account::new(john(), None)), 258 | Tokens128::from(1100) 259 | ); 260 | 261 | ctx.update_caller(john()); 262 | assert!(canister 263 | .mint(alice(), Some(alice_sub), Tokens128::from(1000)) 264 | .is_ok()); 265 | 266 | ctx.update_caller(alice()); 267 | let transfer2 = TransferArgs { 268 | from_subaccount: Some(alice_sub), 269 | to: Account::new(bob(), Some(bob_sub)), 270 | 271 | amount: Tokens128::from(500), 272 | fee: None, 273 | memo: None, 274 | created_at_time: None, 275 | }; 276 | assert!(canister.icrc1_transfer(transfer2).is_ok()); 277 | 278 | assert_eq!( 279 | canister.icrc1_balance_of(Account::new(bob(), Some(bob_sub))), 280 | Tokens128::from(500) 281 | ); 282 | assert_eq!( 283 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub))), 284 | Tokens128::from(400) 285 | ); 286 | } 287 | 288 | #[test] 289 | fn transfer_fee_exceeded() { 290 | let canister = test_canister(); 291 | 292 | let mut stats = TokenConfig::get_stable(); 293 | stats.fee = Tokens128::from(100); 294 | stats.fee_to = john(); 295 | TokenConfig::set_stable(stats); 296 | 297 | let transfer1 = TransferArgs { 298 | from_subaccount: None, 299 | to: Account::from(bob()), 300 | amount: Tokens128::from(200), 301 | fee: Some(Tokens128::from(100)), 302 | memo: None, 303 | created_at_time: None, 304 | }; 305 | 306 | assert!(canister.icrc1_transfer(transfer1).is_ok()); 307 | 308 | let transfer2 = TransferArgs { 309 | from_subaccount: None, 310 | to: Account::from(bob()), 311 | amount: Tokens128::from(200), 312 | fee: Some(Tokens128::from(50)), 313 | memo: None, 314 | created_at_time: None, 315 | }; 316 | assert_eq!( 317 | canister.icrc1_transfer(transfer2), 318 | Err(TransferError::BadFee { 319 | expected_fee: Tokens128::from(100) 320 | }) 321 | ); 322 | 323 | let transfer3 = TransferArgs { 324 | from_subaccount: None, 325 | to: Account::new(bob(), Some(gen_subaccount())), 326 | amount: Tokens128::from(200), 327 | fee: Some(Tokens128::from(50)), 328 | memo: None, 329 | created_at_time: None, 330 | }; 331 | assert_eq!( 332 | canister.icrc1_transfer(transfer3), 333 | Err(TransferError::BadFee { 334 | expected_fee: Tokens128::from(100) 335 | }) 336 | ); 337 | } 338 | 339 | #[test] 340 | fn fees_with_auction_enabled() { 341 | let canister = test_canister(); 342 | 343 | let mut stats = TokenConfig::get_stable(); 344 | stats.fee = Tokens128::from(50); 345 | stats.fee_to = john(); 346 | stats.min_cycles = DEFAULT_MIN_CYCLES; 347 | TokenConfig::set_stable(stats); 348 | 349 | canister 350 | .auction_state() 351 | .borrow_mut() 352 | .bidding_state 353 | .fee_ratio = 0.5; 354 | 355 | let transfer1 = TransferArgs { 356 | from_subaccount: None, 357 | to: Account::from(bob()), 358 | amount: Tokens128::from(100), 359 | fee: None, 360 | memo: None, 361 | created_at_time: None, 362 | }; 363 | 364 | canister.icrc1_transfer(transfer1).unwrap(); 365 | assert_eq!( 366 | canister.icrc1_balance_of(Account::new(bob(), None)), 367 | Tokens128::from(100) 368 | ); 369 | assert_eq!( 370 | canister.icrc1_balance_of(Account::new(alice(), None)), 371 | Tokens128::from(850) 372 | ); 373 | assert_eq!( 374 | canister.icrc1_balance_of(Account::new(john(), None)), 375 | Tokens128::from(1025) 376 | ); 377 | assert_eq!( 378 | canister.icrc1_balance_of(auction_account().into()), 379 | Tokens128::from(25) 380 | ); 381 | } 382 | 383 | #[test] 384 | fn transfer_insufficient_balance() { 385 | let canister = test_canister(); 386 | 387 | let transfer1 = TransferArgs { 388 | from_subaccount: None, 389 | to: Account::from(bob()), 390 | amount: Tokens128::from(1001), 391 | fee: None, 392 | memo: None, 393 | created_at_time: None, 394 | }; 395 | let balance = canister.icrc1_balance_of(Account::new(alice(), None)); 396 | assert_eq!( 397 | canister.icrc1_transfer(transfer1), 398 | Err(TransferError::InsufficientFunds { balance }) 399 | ); 400 | assert_eq!( 401 | canister.icrc1_balance_of(Account::new(alice(), None)), 402 | Tokens128::from(1000) 403 | ); 404 | assert_eq!( 405 | canister.icrc1_balance_of(Account::new(bob(), None)), 406 | Tokens128::from(0) 407 | ); 408 | } 409 | 410 | #[test] 411 | fn transfer_with_fee_insufficient_balance() { 412 | let canister = test_canister(); 413 | 414 | let mut stats = TokenConfig::get_stable(); 415 | stats.fee = Tokens128::from(100); 416 | stats.fee_to = john(); 417 | TokenConfig::set_stable(stats); 418 | 419 | let transfer1 = TransferArgs { 420 | from_subaccount: None, 421 | to: Account::from(bob()), 422 | amount: Tokens128::from(950), 423 | fee: None, 424 | memo: None, 425 | created_at_time: None, 426 | }; 427 | 428 | let balance = canister.icrc1_balance_of(Account::new(alice(), None)); 429 | 430 | assert_eq!( 431 | canister.icrc1_transfer(transfer1), 432 | Err(TransferError::InsufficientFunds { balance }) 433 | ); 434 | assert_eq!( 435 | canister.icrc1_balance_of(Account::new(alice(), None)), 436 | Tokens128::from(1000) 437 | ); 438 | assert_eq!( 439 | canister.icrc1_balance_of(Account::new(bob(), None)), 440 | Tokens128::from(0) 441 | ); 442 | } 443 | 444 | #[test] 445 | fn transfer_wrong_caller() { 446 | let canister = test_canister(); 447 | get_context().update_caller(bob()); 448 | 449 | let transfer1 = TransferArgs { 450 | from_subaccount: None, 451 | to: Account::from(bob()), 452 | amount: Tokens128::from(100), 453 | fee: None, 454 | memo: None, 455 | created_at_time: None, 456 | }; 457 | assert!(matches!( 458 | canister.icrc1_transfer(transfer1), 459 | Err(TransferError::GenericError { .. }) 460 | )); 461 | assert_eq!( 462 | canister.icrc1_balance_of(Account::new(alice(), None)), 463 | Tokens128::from(1000) 464 | ); 465 | assert_eq!( 466 | canister.icrc1_balance_of(Account::new(bob(), None)), 467 | Tokens128::from(0) 468 | ); 469 | 470 | assert_eq!( 471 | canister.icrc1_balance_of(Account::new(alice(), None)), 472 | Tokens128::from(1000) 473 | ); 474 | } 475 | 476 | #[test] 477 | fn transfer_saved_into_history() { 478 | let (ctx, canister) = test_context(); 479 | 480 | let mut stats = TokenConfig::get_stable(); 481 | stats.fee = Tokens128::from(10); 482 | TokenConfig::set_stable(stats); 483 | 484 | let before_history_size = canister.history_size(); 485 | 486 | let transfer1 = TransferArgs { 487 | from_subaccount: None, 488 | to: Account::from(bob()), 489 | amount: Tokens128::from(1001), 490 | fee: None, 491 | memo: None, 492 | created_at_time: None, 493 | }; 494 | 495 | canister.icrc1_transfer(transfer1).unwrap_err(); 496 | assert_eq!(canister.history_size(), before_history_size); 497 | 498 | const COUNT: u64 = 5; 499 | let mut ts = canister_sdk::ic_kit::ic::time(); 500 | for i in 0..COUNT { 501 | let transfer1 = TransferArgs { 502 | from_subaccount: None, 503 | to: Account::from(bob()), 504 | amount: Tokens128::from(100 + i as u128), 505 | fee: None, 506 | memo: None, 507 | created_at_time: None, 508 | }; 509 | ctx.add_time(10); 510 | let id = canister.icrc1_transfer(transfer1).unwrap(); 511 | assert_eq!(canister.history_size() - before_history_size, 1 + i); 512 | let tx = canister.get_transaction(id as u64); 513 | assert_eq!(tx.amount, Tokens128::from(100 + i as u128)); 514 | assert_eq!(tx.fee, Tokens128::from(10)); 515 | assert_eq!(tx.operation, Operation::Transfer); 516 | assert_eq!(tx.status, TransactionStatus::Succeeded); 517 | assert_eq!(tx.index, i + before_history_size); 518 | assert_eq!(tx.from, alice().into()); 519 | assert_eq!(tx.to, bob().into()); 520 | assert!(ts < tx.timestamp); 521 | ts = tx.timestamp; 522 | } 523 | } 524 | 525 | #[test] 526 | fn mint_test_token() { 527 | let alice_sub = gen_subaccount(); 528 | 529 | let canister = test_canister(); 530 | get_context().update_caller(bob()); 531 | assert_eq!( 532 | canister.mint(alice(), None, Tokens128::from(100)), 533 | Err(TxError::Unauthorized) 534 | ); 535 | 536 | let mut stats = TokenConfig::get_stable(); 537 | stats.is_test_token = true; 538 | TokenConfig::set_stable(stats); 539 | 540 | assert!(canister.mint(alice(), None, Tokens128::from(2000)).is_ok()); 541 | assert!(canister.mint(bob(), None, Tokens128::from(5000)).is_ok()); 542 | 543 | assert_eq!( 544 | canister.icrc1_balance_of(Account::new(alice(), None)), 545 | Tokens128::from(3000) 546 | ); 547 | assert_eq!( 548 | canister.icrc1_balance_of(Account::new(bob(), None)), 549 | Tokens128::from(5000) 550 | ); 551 | assert!(canister 552 | .mint(alice(), Some(alice_sub), Tokens128::from(1000)) 553 | .is_ok()); 554 | assert_eq!( 555 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub))), 556 | Tokens128::from(1000) 557 | ); 558 | } 559 | 560 | #[test] 561 | fn mint_by_owner() { 562 | let (ctx, canister) = test_context(); 563 | let alice_sub = gen_subaccount(); 564 | let bob_sub = gen_subaccount(); 565 | ctx.update_caller(john()); 566 | assert!(canister.mint(alice(), None, Tokens128::from(2000)).is_ok()); 567 | assert!(canister.mint(bob(), None, Tokens128::from(5000)).is_ok()); 568 | assert_eq!( 569 | canister.icrc1_balance_of(Account::new(alice(), None)), 570 | Tokens128::from(3000) 571 | ); 572 | assert_eq!( 573 | canister.icrc1_balance_of(Account::new(bob(), None)), 574 | Tokens128::from(5000) 575 | ); 576 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(9000)); 577 | 578 | // mint to subaccounts 579 | assert!(canister 580 | .mint(alice(), Some(alice_sub), Tokens128::from(2000)) 581 | .is_ok()); 582 | assert!(canister 583 | .mint(bob(), Some(bob_sub), Tokens128::from(5000)) 584 | .is_ok()); 585 | 586 | assert_eq!( 587 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub))), 588 | Tokens128::from(2000) 589 | ); 590 | assert_eq!( 591 | canister.icrc1_balance_of(Account::new(bob(), Some(bob_sub))), 592 | Tokens128::from(5000) 593 | ); 594 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(16000)); 595 | } 596 | 597 | #[test] 598 | fn mint_saved_into_history() { 599 | let (ctx, canister) = test_context(); 600 | 601 | let mut stats = TokenConfig::get_stable(); 602 | stats.fee = Tokens128::from(10); 603 | TokenConfig::set_stable(stats); 604 | 605 | ctx.update_caller(john()); 606 | 607 | assert_eq!(canister.history_size(), 2); 608 | 609 | const COUNT: u64 = 5; 610 | let mut ts = canister_sdk::ic_kit::ic::time(); 611 | for i in 0..COUNT { 612 | ctx.add_time(10); 613 | let id = canister 614 | .mint(bob(), None, Tokens128::from(100 + i as u128)) 615 | .unwrap(); 616 | assert_eq!(canister.history_size(), 3 + i); 617 | let tx = canister.get_transaction(id as u64); 618 | assert_eq!(tx.amount, Tokens128::from(100 + i as u128)); 619 | assert_eq!(tx.fee, Tokens128::from(0)); 620 | assert_eq!(tx.operation, Operation::Mint); 621 | assert_eq!(tx.status, TransactionStatus::Succeeded); 622 | assert_eq!(tx.index, i + 2); 623 | assert_eq!(tx.from, john().into()); 624 | assert_eq!(tx.to, bob().into()); 625 | 626 | assert!(ts < tx.timestamp); 627 | ts = tx.timestamp; 628 | } 629 | } 630 | 631 | #[test] 632 | fn burn_by_owner() { 633 | let canister = test_canister(); 634 | assert!(canister.burn(None, None, Tokens128::from(100)).is_ok()); 635 | assert_eq!( 636 | canister.icrc1_balance_of(Account::new(alice(), None)), 637 | Tokens128::from(900) 638 | ); 639 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(1900)); 640 | } 641 | 642 | #[test] 643 | fn burn_too_much() { 644 | let canister = test_canister(); 645 | let balance = canister.icrc1_balance_of(Account::new(alice(), None)); 646 | assert_eq!( 647 | canister.burn(None, None, Tokens128::from(1001)), 648 | Err(TxError::InsufficientFunds { balance }) 649 | ); 650 | assert_eq!( 651 | canister.icrc1_balance_of(Account::new(alice(), None)), 652 | Tokens128::from(1000) 653 | ); 654 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(2000)); 655 | } 656 | 657 | #[test] 658 | fn burn_by_wrong_user() { 659 | let canister = test_canister(); 660 | 661 | get_context().update_caller(bob()); 662 | let balance = canister.icrc1_balance_of(Account::new(bob(), None)); 663 | assert_eq!( 664 | canister.burn(None, None, Tokens128::from(100)), 665 | Err(TxError::InsufficientFunds { balance }) 666 | ); 667 | assert_eq!( 668 | canister.icrc1_balance_of(Account::new(alice(), None)), 669 | Tokens128::from(1000) 670 | ); 671 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(2000)); 672 | } 673 | 674 | #[test] 675 | fn burn_from() { 676 | let bob_sub = gen_subaccount(); 677 | let (ctx, canister) = test_context(); 678 | let bob_balance = Tokens128::from(1000); 679 | ctx.update_caller(john()); 680 | canister.mint(bob(), None, bob_balance).unwrap(); 681 | assert_eq!( 682 | canister.icrc1_balance_of(Account::new(bob(), None)), 683 | bob_balance 684 | ); 685 | canister 686 | .burn(Some(bob()), None, Tokens128::from(100)) 687 | .unwrap(); 688 | assert_eq!( 689 | canister.icrc1_balance_of(Account::new(bob(), None)), 690 | Tokens128::from(900) 691 | ); 692 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(2900)); 693 | // Burn from subaccount 694 | canister.mint(bob(), Some(bob_sub), bob_balance).unwrap(); 695 | assert_eq!( 696 | canister.icrc1_balance_of(Account::new(bob(), Some(bob_sub))), 697 | bob_balance 698 | ); 699 | canister 700 | .burn(Some(bob()), Some(bob_sub), Tokens128::from(100)) 701 | .unwrap(); 702 | assert_eq!( 703 | canister.icrc1_balance_of(Account::new(bob(), Some(bob_sub))), 704 | Tokens128::from(900) 705 | ); 706 | } 707 | 708 | #[test] 709 | fn burn_from_unauthorized() { 710 | let canister = test_canister(); 711 | 712 | get_context().update_caller(bob()); 713 | assert_eq!( 714 | canister.burn(Some(alice()), None, Tokens128::from(100)), 715 | Err(TxError::Unauthorized) 716 | ); 717 | 718 | assert_eq!( 719 | canister.icrc1_balance_of(Account::new(alice(), None)), 720 | Tokens128::from(1000) 721 | ); 722 | assert_eq!(canister.icrc1_total_supply(), Tokens128::from(2000)); 723 | } 724 | 725 | #[test] 726 | fn burn_saved_into_history() { 727 | let (ctx, canister) = test_context(); 728 | 729 | let mut stats = TokenConfig::get_stable(); 730 | stats.fee = Tokens128::from(10); 731 | TokenConfig::set_stable(stats); 732 | 733 | let history_size_before = canister.history_size(); 734 | 735 | ctx.update_caller(john()); 736 | assert_eq!(canister.history_size(), history_size_before); 737 | 738 | const COUNT: u64 = 5; 739 | let mut ts = canister_sdk::ic_kit::ic::time(); 740 | for i in 0..COUNT { 741 | ctx.add_time(10); 742 | let id = canister 743 | .burn(None, None, Tokens128::from(100 + i as u128)) 744 | .unwrap(); 745 | assert_eq!(canister.history_size(), history_size_before + 1 + i); 746 | let tx = canister.get_transaction(id as u64); 747 | assert_eq!(tx.amount, Tokens128::from(100 + i as u128)); 748 | assert_eq!(tx.fee, Tokens128::from(0)); 749 | assert_eq!(tx.operation, Operation::Burn); 750 | assert_eq!(tx.status, TransactionStatus::Succeeded); 751 | assert_eq!(tx.index, history_size_before + i); 752 | assert_eq!(tx.to, john().into()); 753 | assert_eq!(tx.from, john().into()); 754 | assert!(ts < tx.timestamp); 755 | ts = tx.timestamp; 756 | } 757 | } 758 | 759 | #[test] 760 | fn get_transactions_test() { 761 | let canister = test_canister(); 762 | let transfer1 = TransferArgs { 763 | from_subaccount: None, 764 | to: Account::from(bob()), 765 | amount: Tokens128::from(10), 766 | fee: None, 767 | memo: None, 768 | created_at_time: None, 769 | }; 770 | 771 | for _ in 1..=5 { 772 | canister.icrc1_transfer(transfer1.clone()).unwrap(); 773 | } 774 | let transfer2 = TransferArgs { 775 | from_subaccount: None, 776 | to: Account::from(bob()), 777 | 778 | amount: Tokens128::from(10), 779 | fee: None, 780 | memo: None, 781 | created_at_time: None, 782 | }; 783 | canister.icrc1_transfer(transfer2).unwrap(); 784 | let transfer3 = TransferArgs { 785 | from_subaccount: None, 786 | to: Account::from(xtc()), 787 | amount: Tokens128::from(10), 788 | fee: None, 789 | memo: None, 790 | created_at_time: None, 791 | }; 792 | canister.icrc1_transfer(transfer3).unwrap(); 793 | let transfer4 = TransferArgs { 794 | from_subaccount: None, 795 | to: Account::from(john()), 796 | amount: Tokens128::from(10), 797 | fee: None, 798 | memo: None, 799 | created_at_time: None, 800 | }; 801 | canister.icrc1_transfer(transfer4).unwrap(); 802 | 803 | assert_eq!(canister.get_transactions(None, 11, None).result.len(), 10); 804 | assert_eq!(canister.get_transactions(None, 10, Some(3)).result.len(), 4); 805 | assert_eq!( 806 | canister 807 | .get_transactions(Some(bob()), 10, None) 808 | .result 809 | .len(), 810 | 6 811 | ); 812 | assert_eq!( 813 | canister.get_transactions(Some(xtc()), 5, None).result.len(), 814 | 1 815 | ); 816 | assert_eq!( 817 | canister 818 | .get_transactions(Some(alice()), 10, Some(5)) 819 | .result 820 | .len(), 821 | 5 822 | ); 823 | assert_eq!(canister.get_transactions(None, 5, None).next, Some(4)); 824 | assert_eq!( 825 | canister.get_transactions(Some(alice()), 3, Some(5)).next, 826 | Some(2) 827 | ); 828 | assert_eq!( 829 | canister.get_transactions(Some(bob()), 3, Some(2)).next, 830 | None 831 | ); 832 | 833 | let transfer5 = TransferArgs { 834 | from_subaccount: None, 835 | to: Account::from(bob()), 836 | amount: Tokens128::from(10), 837 | fee: None, 838 | memo: None, 839 | created_at_time: None, 840 | }; 841 | 842 | for _ in 1..=10 { 843 | canister.icrc1_transfer(transfer5.clone()).unwrap(); 844 | } 845 | 846 | let txn = canister.get_transactions(None, 5, None); 847 | assert_eq!(txn.result[0].index, 19); 848 | assert_eq!(txn.result[1].index, 18); 849 | assert_eq!(txn.result[2].index, 17); 850 | assert_eq!(txn.result[3].index, 16); 851 | assert_eq!(txn.result[4].index, 15); 852 | let txn2 = canister.get_transactions(None, 5, txn.next); 853 | assert_eq!(txn2.result[0].index, 14); 854 | assert_eq!(txn2.result[1].index, 13); 855 | assert_eq!(txn2.result[2].index, 12); 856 | assert_eq!(txn2.result[3].index, 11); 857 | assert_eq!(txn2.result[4].index, 10); 858 | assert_eq!(canister.get_transactions(None, 5, txn.next).next, Some(9)); 859 | } 860 | 861 | #[test] 862 | #[should_panic] 863 | fn get_transaction_not_existing() { 864 | let canister = test_canister(); 865 | canister.get_transaction(2); 866 | } 867 | 868 | #[test] 869 | fn get_transaction_count() { 870 | let canister = test_canister(); 871 | const COUNT: usize = 10; 872 | let transfer1 = TransferArgs { 873 | from_subaccount: None, 874 | to: Account::from(bob()), 875 | 876 | amount: Tokens128::from(10), 877 | fee: None, 878 | memo: None, 879 | created_at_time: None, 880 | }; 881 | for _ in 1..COUNT { 882 | canister.icrc1_transfer(transfer1.clone()).unwrap(); 883 | } 884 | assert_eq!(canister.get_user_transaction_count(alice()), COUNT); 885 | } 886 | 887 | #[test] 888 | fn valid_transaction_time_window() { 889 | let canister = test_canister(); 890 | 891 | let system_time = std::time::SystemTime::now() 892 | .duration_since(UNIX_EPOCH) 893 | .unwrap() 894 | .as_nanos(); 895 | 896 | let transfer = TransferArgs { 897 | from_subaccount: None, 898 | 899 | to: Account::from(bob()), 900 | amount: Tokens128::from(10), 901 | fee: None, 902 | memo: None, 903 | created_at_time: Some(system_time as u64 + 30_000_000_000), 904 | }; 905 | assert!(canister.icrc1_transfer(transfer).is_ok()); 906 | } 907 | 908 | #[test] 909 | fn invalid_transaction_time_window() { 910 | let canister = test_canister(); 911 | 912 | let system_time = std::time::SystemTime::now() 913 | .duration_since(UNIX_EPOCH) 914 | .unwrap() 915 | .as_nanos(); 916 | 917 | let transfer = TransferArgs { 918 | from_subaccount: None, 919 | to: Account::from(bob()), 920 | amount: Tokens128::from(10), 921 | fee: None, 922 | memo: None, 923 | created_at_time: Some(system_time as u64 - TX_WINDOW * 2), 924 | }; 925 | assert!(canister.icrc1_transfer(transfer).is_err()); 926 | 927 | let transfer = TransferArgs { 928 | from_subaccount: None, 929 | to: Account::from(bob()), 930 | amount: Tokens128::from(10), 931 | fee: None, 932 | memo: None, 933 | created_at_time: Some(system_time as u64 + TX_WINDOW * 2), 934 | }; 935 | assert!(canister.icrc1_transfer(transfer).is_err()); 936 | } 937 | 938 | #[test] 939 | fn test_invalid_self_account_transfer() { 940 | let canister = test_canister(); 941 | assert_eq!( 942 | canister.icrc1_balance_of(Account::new(alice(), None)), 943 | Tokens128::from(1000) 944 | ); 945 | let transfer = TransferArgs { 946 | from_subaccount: None, 947 | to: Account::from(alice()), 948 | amount: Tokens128::from(100), 949 | fee: None, 950 | memo: None, 951 | created_at_time: None, 952 | }; 953 | assert!(canister.icrc1_transfer(transfer).is_err()); 954 | 955 | assert_eq!( 956 | canister.icrc1_balance_of(Account::new(alice(), None)), 957 | Tokens128::from(1000) 958 | ); 959 | 960 | let alice_sub = gen_subaccount(); 961 | 962 | let transfer = TransferArgs { 963 | from_subaccount: Some(alice_sub), 964 | to: Account::new(alice(), Some(alice_sub)), 965 | amount: Tokens128::from(100), 966 | fee: None, 967 | memo: None, 968 | created_at_time: None, 969 | }; 970 | 971 | assert!(canister.icrc1_transfer(transfer.clone()).is_err()); 972 | 973 | assert_eq!( 974 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub))), 975 | Tokens128::from(0) 976 | ); 977 | 978 | assert!(matches!( 979 | canister.icrc1_transfer(transfer), 980 | Err(TransferError::GenericError { .. }) 981 | )); 982 | } 983 | 984 | #[test] 985 | fn test_valid_self_subaccount_transfer() { 986 | let canister = test_canister(); 987 | let alice_sub1 = gen_subaccount(); 988 | assert_eq!( 989 | canister.icrc1_balance_of(Account::new(alice(), None)), 990 | Tokens128::from(1000) 991 | ); 992 | let transfer = TransferArgs { 993 | from_subaccount: None, 994 | to: Account::new(alice(), Some(alice_sub1)), 995 | 996 | amount: Tokens128::from(100), 997 | fee: None, 998 | memo: None, 999 | created_at_time: None, 1000 | }; 1001 | assert!(canister.icrc1_transfer(transfer).is_ok()); 1002 | 1003 | assert_eq!( 1004 | canister.icrc1_balance_of(Account::new(alice(), None)), 1005 | Tokens128::from(900) 1006 | ); 1007 | assert_eq!( 1008 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub1))), 1009 | Tokens128::from(100) 1010 | ); 1011 | 1012 | let alice_sub2 = gen_subaccount(); 1013 | 1014 | let transfer = TransferArgs { 1015 | from_subaccount: Some(alice_sub1), 1016 | to: Account::new(alice(), Some(alice_sub2)), 1017 | amount: Tokens128::from(10), 1018 | fee: None, 1019 | memo: None, 1020 | created_at_time: None, 1021 | }; 1022 | assert!(canister.icrc1_transfer(transfer).is_ok()); 1023 | assert_eq!( 1024 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub2))), 1025 | Tokens128::from(10) 1026 | ); 1027 | assert_eq!( 1028 | canister.icrc1_balance_of(Account::new(alice(), Some(alice_sub1))), 1029 | Tokens128::from(90) 1030 | ); 1031 | } 1032 | } 1033 | 1034 | #[cfg(test)] 1035 | mod proptests { 1036 | use canister_sdk::ic_canister::Canister; 1037 | use canister_sdk::ic_helpers::tokens::Tokens128; 1038 | use canister_sdk::ic_kit::inject::get_context; 1039 | use canister_sdk::ic_kit::MockContext; 1040 | use ic_exports::Principal; 1041 | use proptest::collection::vec; 1042 | use proptest::prelude::*; 1043 | use proptest::sample::Index; 1044 | 1045 | use crate::account::Account; 1046 | use crate::canister::TokenCanisterAPI; 1047 | use crate::error::{TransferError, TxError}; 1048 | use crate::mock::*; 1049 | use crate::state::balances::{Balances, StableBalances}; 1050 | use crate::state::config::Metadata; 1051 | use crate::state::ledger::LedgerData; 1052 | 1053 | use super::*; 1054 | 1055 | #[derive(Debug, Clone, PartialEq, Eq)] 1056 | enum Action { 1057 | Mint { 1058 | minter: Principal, 1059 | recipient: Principal, 1060 | amount: Tokens128, 1061 | }, 1062 | Burn(Tokens128, Principal), 1063 | TransferWithoutFee { 1064 | from: Principal, 1065 | to: Principal, 1066 | amount: Tokens128, 1067 | fee_limit: Option, 1068 | }, 1069 | } 1070 | 1071 | prop_compose! { 1072 | fn select_principal(p: Vec) (index in any::()) -> Principal { 1073 | let i = index.index(p.len()); 1074 | p[i] 1075 | } 1076 | 1077 | } 1078 | 1079 | #[cfg_attr(coverage_nightly, no_coverage)] 1080 | fn make_action(principals: Vec) -> impl Strategy { 1081 | prop_oneof![ 1082 | // Mint 1083 | ( 1084 | make_tokens128(), 1085 | select_principal(principals.clone()), 1086 | select_principal(principals.clone()), 1087 | ) 1088 | .prop_map(|(amount, minter, recipient)| Action::Mint { 1089 | minter, 1090 | recipient, 1091 | amount 1092 | }), 1093 | // Burn 1094 | (make_tokens128(), select_principal(principals.clone())) 1095 | .prop_map(|(amount, principal)| Action::Burn(amount, principal)), 1096 | // Without fee 1097 | ( 1098 | select_principal(principals.clone()), 1099 | select_principal(principals), 1100 | make_tokens128(), 1101 | make_option(), 1102 | ) 1103 | .prop_map(|(from, to, amount, fee_limit)| { 1104 | Action::TransferWithoutFee { 1105 | from, 1106 | to, 1107 | amount, 1108 | fee_limit, 1109 | } 1110 | }), 1111 | // Transfer from 1112 | ] 1113 | } 1114 | 1115 | #[cfg_attr(coverage_nightly, no_coverage)] 1116 | fn make_option() -> impl Strategy> { 1117 | prop_oneof![Just(None), (make_tokens128()).prop_map(Some)] 1118 | } 1119 | 1120 | #[cfg_attr(coverage_nightly, no_coverage)] 1121 | fn make_principal() -> BoxedStrategy { 1122 | (any::<[u8; 29]>().prop_map(|mut bytes| { 1123 | // Make sure the last byte is more than four as the last byte carries special 1124 | // meaning 1125 | bytes[28] = bytes[28].saturating_add(5); 1126 | bytes 1127 | })) 1128 | .prop_map(|bytes| Principal::from_slice(&bytes)) 1129 | .boxed() 1130 | } 1131 | 1132 | prop_compose! { 1133 | fn make_tokens128() (num in "[0-9]{1,10}") -> Tokens128 { 1134 | Tokens128::from(num.parse::().unwrap()) 1135 | } 1136 | } 1137 | prop_compose! { 1138 | fn make_canister() ( 1139 | name in any::(), 1140 | symbol in any::(), 1141 | decimals in any::(), 1142 | total_supply in make_tokens128(), 1143 | fee in make_tokens128(), 1144 | principals in vec(make_principal(), 1..7), 1145 | owner_idx in any::(), 1146 | fee_to_idx in any::(), 1147 | )-> (TokenCanisterMock, Vec) { 1148 | // pick two random principals (they could very well be the same principal twice) 1149 | let owner = principals[owner_idx.index(principals.len())]; 1150 | let fee_to = principals[fee_to_idx.index(principals.len())]; 1151 | MockContext::new().with_caller(owner).inject(); 1152 | let meta = Metadata { 1153 | name, 1154 | symbol, 1155 | decimals, 1156 | owner, 1157 | fee, 1158 | fee_to, 1159 | is_test_token: None, 1160 | }; 1161 | 1162 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 1163 | let canister = TokenCanisterMock::from_principal(principal); 1164 | get_context().update_id(canister.principal()); 1165 | 1166 | // Refresh canister's state. 1167 | TokenConfig::set_stable(TokenConfig::default()); 1168 | StableBalances.clear(); 1169 | LedgerData::clear(); 1170 | 1171 | canister.init(meta,total_supply); 1172 | // This is to make tests that don't rely on auction state 1173 | // pass, because since we are running auction state on each 1174 | // endpoint call, it affects `BiddingInfo.fee_ratio` that is 1175 | // used for charging fees in `approve` endpoint. 1176 | 1177 | let mut stats = TokenConfig::get_stable(); 1178 | stats.min_cycles = 0; 1179 | 1180 | TokenConfig::set_stable(stats); 1181 | (canister, principals) 1182 | } 1183 | } 1184 | #[cfg_attr(coverage_nightly, no_coverage)] 1185 | fn canister_and_actions() -> impl Strategy)> { 1186 | make_canister().prop_flat_map(|(canister, principals)| { 1187 | let actions = vec(make_action(principals), 1..7); 1188 | (Just(canister), actions) 1189 | }) 1190 | } 1191 | 1192 | proptest! { 1193 | #[test] 1194 | fn generic_proptest((canister, actions) in canister_and_actions()) { 1195 | let mut total_minted = Tokens128::ZERO; 1196 | let mut total_burned = Tokens128::ZERO; 1197 | let starting_supply = canister.icrc1_total_supply(); 1198 | for action in actions { 1199 | use Action::*; 1200 | match action { 1201 | Mint { minter, recipient, amount } => { 1202 | get_context().update_caller(minter); 1203 | let original = canister.icrc1_total_supply(); 1204 | let res = canister.mint(recipient, None,amount); 1205 | let expected = if minter == canister.owner() { 1206 | total_minted = (total_minted + amount).unwrap(); 1207 | assert!(matches!(res, Ok(_))); 1208 | (original + amount).unwrap() 1209 | } else { 1210 | assert_eq!(res, Err(TxError::Unauthorized)); 1211 | original 1212 | }; 1213 | assert_eq!(expected, canister.icrc1_total_supply()); 1214 | }, 1215 | Burn(amount, burner) => { 1216 | get_context().update_caller(burner); 1217 | let original = canister.icrc1_total_supply(); 1218 | let balance = canister.icrc1_balance_of(Account::new(burner, None)); 1219 | let res = canister.burn(Some(burner), None, amount); 1220 | if balance < amount { 1221 | prop_assert_eq!(res, Err(TxError::InsufficientFunds { balance })); 1222 | prop_assert_eq!(original, canister.icrc1_total_supply()); 1223 | } else { 1224 | prop_assert!(matches!(res, Ok(_)), "Burn error: {:?}. Balance: {}, amount: {}", res, balance, amount); 1225 | prop_assert_eq!((original - amount).unwrap(), canister.icrc1_total_supply()); 1226 | total_burned = (total_burned + amount).unwrap(); 1227 | } 1228 | }, 1229 | 1230 | TransferWithoutFee{from,to,amount,fee_limit} => { 1231 | if to == canister.owner() || from == canister.owner() { 1232 | // Skip these operation, becase they behave transfer to/from minting 1233 | // account behaves like mint/burn, and we test them in different cases. 1234 | return Ok(()); 1235 | } 1236 | 1237 | get_context().update_caller(from); 1238 | let from_balance = canister.icrc1_balance_of(Account::new(from, None)); 1239 | let to_balance = canister.icrc1_balance_of(Account::new(to, None)); 1240 | let (fee , fee_to) = TokenConfig::get_stable().fee_info(); 1241 | let amount_with_fee = (amount + fee).unwrap(); 1242 | let transfer1 = TransferArgs { 1243 | from_subaccount: None, 1244 | to:Account::new(to, None), 1245 | amount, 1246 | fee: fee_limit, 1247 | memo: None, 1248 | created_at_time: None, 1249 | }; 1250 | let res = canister.icrc1_transfer(transfer1); 1251 | 1252 | if to == from { 1253 | prop_assert!(matches!(res, Err(TransferError::GenericError {..})), "Invalid self transfer error"); 1254 | return Ok(()) 1255 | } 1256 | 1257 | if let Some(fee_limit) = fee_limit { 1258 | if fee_limit != fee && from != canister.owner() && to != canister.owner() { 1259 | prop_assert_eq!(res, Err(TransferError::BadFee { expected_fee: fee })); 1260 | return Ok(()) 1261 | } 1262 | } 1263 | 1264 | if amount.is_zero() { 1265 | prop_assert_eq!(res, Err(TransferError::GenericError { error_code: 500, message: "amount too small".into() })); 1266 | return Ok(()); 1267 | } 1268 | if from_balance < amount_with_fee { 1269 | prop_assert_eq!(res, Err(TransferError::InsufficientFunds { balance:from_balance })); 1270 | return Ok(()); 1271 | } 1272 | 1273 | if fee_to == from { 1274 | prop_assert!(matches!(res, Ok(_))); 1275 | prop_assert_eq!((from_balance - amount).unwrap(), canister.icrc1_balance_of(Account::new(from, None))); 1276 | return Ok(()); 1277 | } 1278 | 1279 | if fee_to == to { 1280 | prop_assert!(matches!(res, Ok(_))); 1281 | prop_assert_eq!(((to_balance + amount).unwrap() + fee).unwrap(), canister.icrc1_balance_of(Account::new(to, None))); 1282 | return Ok(()); 1283 | } 1284 | 1285 | prop_assert!(matches!(res, Ok(_))); 1286 | 1287 | prop_assert_eq!((from_balance - amount_with_fee).unwrap(), canister.icrc1_balance_of(Account::new(from, None))); 1288 | prop_assert_eq!((to_balance + amount).unwrap(), canister.icrc1_balance_of(Account::new(to, None))); 1289 | } 1290 | } 1291 | } 1292 | prop_assert_eq!(((total_minted + starting_supply).unwrap() - total_burned).unwrap(), canister.icrc1_total_supply()); 1293 | } 1294 | } 1295 | } 1296 | -------------------------------------------------------------------------------- /src/token/api/src/canister/inspect.rs: -------------------------------------------------------------------------------- 1 | use candid::{Nat, Principal}; 2 | 3 | use crate::state::{ 4 | balances::{Balances, StableBalances}, 5 | config::TokenConfig, 6 | }; 7 | 8 | static OWNER_METHODS: &[&str] = &[ 9 | "set_auction_period", 10 | "set_fee", 11 | "set_fee_to", 12 | "set_logo", 13 | "set_min_cycles", 14 | "set_name", 15 | "set_symbol", 16 | "set_owner", 17 | ]; 18 | 19 | static TRANSACTION_METHODS: &[&str] = &["burn", "icrc1_transfer"]; 20 | 21 | /// Reason why the method may be accepted. 22 | #[derive(Debug, Clone, Copy)] 23 | pub enum AcceptReason { 24 | /// The call is a part of the IS20 API and can be performed. 25 | Valid, 26 | /// The method isn't a part of the IS20 API, and may require further validation. 27 | NotIS20Method, 28 | } 29 | 30 | /// This function checks if the canister should accept ingress message or not. We allow query 31 | /// calls for anyone, but update calls have different checks to see, if it's reasonable to spend 32 | /// canister cycles on accepting this call. Check the comments in this method for details on 33 | /// the checks for different methods. 34 | pub fn inspect_message(method: &str, caller: Principal) -> Result { 35 | let stats = TokenConfig::get_stable(); 36 | match method { 37 | // These are query methods, so no checks are needed. 38 | #[cfg(feature = "mint_burn")] 39 | "mint" if stats.is_test_token => Ok(AcceptReason::Valid), 40 | #[cfg(feature = "mint_burn")] 41 | "mint" if caller == stats.owner => Ok(AcceptReason::Valid), 42 | #[cfg(feature = "mint_burn")] 43 | "mint" => Err("Only the owner can mint"), 44 | // Owner 45 | m if OWNER_METHODS.contains(&m) && caller == stats.owner => Ok(AcceptReason::Valid), 46 | // Not owner 47 | m if OWNER_METHODS.contains(&m) => { 48 | Err("Owner method is called not by an owner. Rejecting.") 49 | } 50 | #[cfg(any(feature = "transfer", feature = "mint_burn"))] 51 | m if TRANSACTION_METHODS.contains(&m) => { 52 | // These methods requires that the caller have tokens. 53 | 54 | if StableBalances.get_subaccounts(caller).is_empty() { 55 | return Err("Transaction method is not called by a stakeholder. Rejecting."); 56 | } 57 | 58 | // Anything but the `burn` method 59 | if caller == stats.owner || m != "burn" { 60 | return Ok(AcceptReason::Valid); 61 | } 62 | 63 | // It's the `burn` method and the caller isn't the owner. 64 | let from = canister_sdk::ic_cdk::api::call::arg_data::<(Option, Nat)>().0; 65 | if from.is_some() { 66 | return Err("Only the owner can burn other's tokens. Rejecting."); 67 | } 68 | 69 | Ok(AcceptReason::Valid) 70 | } 71 | "bid_cycles" => { 72 | // We reject this message, because a call with cycles cannot be made through ingress, 73 | // only from the wallet canister. 74 | Err("Call with cycles cannot be made through ingress.") 75 | } 76 | _ => Ok(AcceptReason::NotIS20Method), 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/token/api/src/canister/is20_auction.rs: -------------------------------------------------------------------------------- 1 | //! This module contains APIs from IS20 standard providing cycle auction related functionality. 2 | 3 | use canister_sdk::{ 4 | ic_auction::{ 5 | error::AuctionError, 6 | state::{AuctionInfo, AuctionState}, 7 | }, 8 | ic_helpers::tokens::Tokens128, 9 | ic_kit::ic, 10 | }; 11 | use ic_exports::Principal; 12 | 13 | use crate::state::ledger::{BatchTransferArgs, LedgerData}; 14 | use crate::{ 15 | account::AccountInternal, 16 | state::balances::{Balances, StableBalances}, 17 | }; 18 | use crate::{canister::auction_account, state::config::TokenConfig}; 19 | 20 | use super::is20_transactions::batch_transfer_internal; 21 | 22 | pub fn disburse_rewards(auction_state: &AuctionState) -> Result { 23 | let AuctionState { 24 | ref bidding_state, 25 | ref history, 26 | .. 27 | } = *auction_state; 28 | 29 | let total_amount = accumulated_fees(); 30 | let mut transferred_amount = Tokens128::from(0u128); 31 | let total_cycles = bidding_state.cycles_since_auction; 32 | 33 | let first_transaction_id = LedgerData::len(); 34 | 35 | let mut transfers = vec![]; 36 | for (bidder, cycles) in &bidding_state.bids { 37 | let amount = (total_amount * cycles / total_cycles) 38 | .ok_or(AuctionError::NoBids)? 39 | .to_tokens128() 40 | .unwrap_or(Tokens128::MAX); 41 | transfers.push(BatchTransferArgs { 42 | receiver: (*bidder).into(), 43 | amount, 44 | }); 45 | LedgerData::record_auction(*bidder, amount); 46 | transferred_amount = (transferred_amount + amount) 47 | .ok_or_else(|| ic::trap("Token amount overflow on auction bids distribution.")) 48 | .unwrap(); 49 | } 50 | 51 | let stats = TokenConfig::get_stable(); 52 | let (fee, fee_to) = stats.fee_info(); 53 | 54 | if let Err(e) = batch_transfer_internal( 55 | auction_account(), 56 | &transfers, 57 | &mut StableBalances, 58 | fee, 59 | fee_to, 60 | auction_state.bidding_state.fee_ratio, 61 | ) { 62 | ic::trap(&format!("Failed to transfer tokens to the bidders: {e}")); 63 | } 64 | 65 | let last_transaction_id = LedgerData::len() - 1; 66 | let result = AuctionInfo { 67 | auction_id: history.len(), 68 | auction_time: canister_sdk::ic_kit::ic::time(), 69 | tokens_distributed: transferred_amount, 70 | cycles_collected: total_cycles, 71 | fee_ratio: bidding_state.fee_ratio, 72 | first_transaction_id, 73 | last_transaction_id, 74 | }; 75 | 76 | Ok(result) 77 | } 78 | 79 | pub fn accumulated_fees() -> Tokens128 { 80 | let account = AccountInternal::new(Principal::management_canister(), None); 81 | StableBalances.balance_of(&account) 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use canister_sdk::{ 87 | ic_auction::{api::Auction, state::MIN_BIDDING_AMOUNT}, 88 | ic_canister::Canister, 89 | ic_kit::{ 90 | mock_principals::{alice, bob}, 91 | MockContext, 92 | }, 93 | ic_metrics::Interval, 94 | }; 95 | 96 | use crate::mock::*; 97 | use crate::state::config::Metadata; 98 | 99 | use super::*; 100 | 101 | #[cfg_attr(coverage_nightly, no_coverage)] 102 | fn test_context() -> (&'static mut MockContext, TokenCanisterMock) { 103 | let context = MockContext::new().with_caller(alice()).inject(); 104 | 105 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 106 | let canister = TokenCanisterMock::from_principal(principal); 107 | context.update_id(canister.principal()); 108 | 109 | // Refresh canister's state. 110 | TokenConfig::set_stable(TokenConfig::default()); 111 | StableBalances.clear(); 112 | LedgerData::clear(); 113 | 114 | canister.init( 115 | Metadata { 116 | name: "".to_string(), 117 | symbol: "".to_string(), 118 | decimals: 8, 119 | owner: alice(), 120 | fee: Tokens128::from(0), 121 | fee_to: alice(), 122 | is_test_token: None, 123 | }, 124 | Tokens128::from(1000), 125 | ); 126 | 127 | (context, canister) 128 | } 129 | 130 | #[test] 131 | #[cfg_attr(coverage_nightly, no_coverage)] 132 | fn bidding_cycles() { 133 | let (context, canister) = test_context(); 134 | context.update_caller(bob()); 135 | context.update_msg_cycles(2_000_000); 136 | 137 | canister.bid_cycles(bob()).unwrap(); 138 | let info = canister.bidding_info(); 139 | assert_eq!(info.total_cycles, 2_000_000); 140 | assert_eq!(info.caller_cycles, 2_000_000); 141 | 142 | context.update_caller(alice()); 143 | let info = canister.bidding_info(); 144 | assert_eq!(info.total_cycles, 2_000_000); 145 | assert_eq!(info.caller_cycles, 0); 146 | } 147 | 148 | #[test] 149 | #[cfg_attr(coverage_nightly, no_coverage)] 150 | fn bidding_cycles_under_limit() { 151 | let (context, canister) = test_context(); 152 | context.update_msg_cycles(MIN_BIDDING_AMOUNT - 1); 153 | assert_eq!( 154 | canister.bid_cycles(alice()), 155 | Err(AuctionError::BiddingTooSmall) 156 | ); 157 | } 158 | 159 | #[test] 160 | #[cfg_attr(coverage_nightly, no_coverage)] 161 | fn bidding_multiple_times() { 162 | let (context, canister) = test_context(); 163 | context.update_msg_cycles(2_000_000); 164 | canister.bid_cycles(alice()).unwrap(); 165 | 166 | context.update_msg_cycles(2_000_000); 167 | canister.bid_cycles(alice()).unwrap(); 168 | 169 | assert_eq!(canister.bidding_info().caller_cycles, 4_000_000); 170 | } 171 | 172 | #[test] 173 | #[cfg_attr(coverage_nightly, no_coverage)] 174 | fn auction_test() { 175 | let (context, canister) = test_context(); 176 | context.update_msg_cycles(2_000_000); 177 | canister.bid_cycles(alice()).unwrap(); 178 | 179 | context.update_msg_cycles(4_000_000); 180 | canister.bid_cycles(bob()).unwrap(); 181 | 182 | let auction_account = auction_account(); 183 | StableBalances.insert(auction_account, Tokens128::from(6000)); 184 | StableBalances.balance_of(&auction_account); 185 | 186 | context.add_time(10u64.pow(9) * 60 * 60 * 300); 187 | 188 | let result = canister.run_auction().unwrap(); 189 | assert_eq!(result.cycles_collected, 6_000_000); 190 | assert_eq!(result.first_transaction_id, 1); 191 | assert_eq!(result.last_transaction_id, 2); 192 | assert_eq!(result.tokens_distributed, Tokens128::from(6_000)); 193 | 194 | assert_eq!( 195 | StableBalances.balance_of(&bob().into()), 196 | Tokens128::from(4_000) 197 | ); 198 | 199 | let retrieved_result = canister.auction_info(result.auction_id).unwrap(); 200 | assert_eq!(retrieved_result, result); 201 | } 202 | 203 | #[test] 204 | #[cfg_attr(coverage_nightly, no_coverage)] 205 | fn auction_without_bids() { 206 | let (_, canister) = test_context(); 207 | assert_eq!(canister.run_auction(), Err(AuctionError::NoBids)); 208 | } 209 | 210 | #[test] 211 | #[cfg_attr(coverage_nightly, no_coverage)] 212 | fn auction_not_in_time() { 213 | let (context, canister) = test_context(); 214 | context.update_msg_cycles(2_000_000); 215 | canister.bid_cycles(alice()).unwrap(); 216 | 217 | { 218 | let state = canister.auction_state(); 219 | let state = &mut state.borrow_mut().bidding_state; 220 | state.last_auction = canister_sdk::ic_kit::ic::time() - 100_000; 221 | state.auction_period = 1_000_000_000; 222 | } 223 | 224 | let secs_remaining = canister 225 | .auction_state() 226 | .borrow() 227 | .bidding_state 228 | .cooldown_secs_remaining(); 229 | 230 | assert_eq!( 231 | canister.run_auction(), 232 | Err(AuctionError::TooEarlyToBeginAuction(secs_remaining)) 233 | ); 234 | } 235 | 236 | #[test] 237 | #[cfg_attr(coverage_nightly, no_coverage)] 238 | fn setting_min_cycles() { 239 | let (_, canister) = test_context(); 240 | canister.set_min_cycles(100500).unwrap(); 241 | assert_eq!(canister.get_min_cycles(), 100500); 242 | } 243 | 244 | #[test] 245 | #[cfg_attr(coverage_nightly, no_coverage)] 246 | fn setting_min_cycles_not_authorized() { 247 | let (context, canister) = test_context(); 248 | context.update_caller(bob()); 249 | assert_eq!( 250 | canister.set_min_cycles(100500), 251 | Err(AuctionError::Unauthorized(bob().to_string())) 252 | ); 253 | } 254 | 255 | #[test] 256 | #[cfg_attr(coverage_nightly, no_coverage)] 257 | fn setting_auction_period() { 258 | let (context, canister) = test_context(); 259 | context.update_caller(alice()); 260 | canister 261 | .set_auction_period(Interval::Period { seconds: 100500 }) 262 | .unwrap(); 263 | assert_eq!( 264 | canister.bidding_info().auction_period, 265 | 100500 * 10u64.pow(9) 266 | ); 267 | } 268 | 269 | #[test] 270 | #[cfg_attr(coverage_nightly, no_coverage)] 271 | fn setting_auction_period_not_authorized() { 272 | let (context, canister) = test_context(); 273 | context.update_caller(bob()); 274 | assert_eq!( 275 | canister.set_auction_period(Interval::Period { seconds: 100500 }), 276 | Err(AuctionError::Unauthorized(bob().to_string())) 277 | ); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/token/api/src/canister/is20_transactions.rs: -------------------------------------------------------------------------------- 1 | use canister_sdk::ic_helpers::tokens::Tokens128; 2 | use canister_sdk::ic_kit::ic; 3 | #[cfg(feature = "claim")] 4 | use canister_sdk::ledger::{AccountIdentifier, Subaccount as SubaccountIdentifier}; 5 | use ic_exports::Principal; 6 | 7 | use super::auction_account; 8 | use super::icrc1_transfer::{PERMITTED_DRIFT, TX_WINDOW}; 9 | use crate::account::{AccountInternal, CheckedAccount, Subaccount, WithRecipient}; 10 | use crate::error::TxError; 11 | use crate::principal::{CheckedPrincipal, Owner, TestNet}; 12 | use crate::state::balances::{Balances, LocalBalances, StableBalances}; 13 | use crate::state::config::{FeeRatio, TokenConfig}; 14 | use crate::state::ledger::{BatchTransferArgs, LedgerData, TransferArgs, TxReceipt}; 15 | use crate::tx_record::TxId; 16 | 17 | pub fn is20_transfer( 18 | caller: CheckedAccount, 19 | transfer: &TransferArgs, 20 | auction_fee_ratio: f64, 21 | ) -> TxReceipt { 22 | let from = caller.inner(); 23 | let to = caller.recipient(); 24 | let created_at_time = validate_and_get_tx_ts(from.owner, transfer)?; 25 | let TransferArgs { amount, memo, .. } = transfer; 26 | 27 | let stats = TokenConfig::get_stable(); 28 | let (fee, fee_to) = stats.fee_info(); 29 | 30 | if let Some(requested_fee) = transfer.fee { 31 | if fee != requested_fee { 32 | return Err(TxError::BadFee { expected_fee: fee }); 33 | } 34 | } 35 | 36 | transfer_internal( 37 | &mut StableBalances, 38 | from, 39 | to, 40 | *amount, 41 | fee, 42 | fee_to.into(), 43 | FeeRatio::new(auction_fee_ratio), 44 | )?; 45 | 46 | let id = LedgerData::transfer(from, to, *amount, fee, *memo, created_at_time); 47 | Ok(id.into()) 48 | } 49 | 50 | pub(crate) fn transfer_internal( 51 | balances: &mut impl Balances, 52 | from: AccountInternal, 53 | to: AccountInternal, 54 | amount: Tokens128, 55 | fee: Tokens128, 56 | fee_to: AccountInternal, 57 | auction_fee_ratio: FeeRatio, 58 | ) -> Result<(), TxError> { 59 | if amount.is_zero() { 60 | return Err(TxError::AmountTooSmall); 61 | } 62 | 63 | // We use `updates` structure because sometimes from or to can be equal to fee_to or even to 64 | // auction_account, so we must take a carefull approach. 65 | let mut updates = LocalBalances::from_iter([ 66 | (from, balances.balance_of(&from)), 67 | (to, balances.balance_of(&to)), 68 | (fee_to, balances.balance_of(&fee_to)), 69 | (auction_account(), balances.balance_of(&auction_account())), 70 | ]); 71 | 72 | // If `amount + fee` overflows max `Tokens128` value, the balance cannot be larger than this 73 | // value, so we can safely return `InsufficientFunds` error. 74 | let amount_with_fee = (amount + fee).ok_or(TxError::InsufficientFunds { 75 | balance: updates.balance_of(&from), 76 | })?; 77 | 78 | let updated_from_balance = 79 | (updates.balance_of(&from) - amount_with_fee).ok_or(TxError::InsufficientFunds { 80 | balance: updates.balance_of(&from), 81 | })?; 82 | updates.insert(from, updated_from_balance); 83 | 84 | let updated_to_balance = (updates.balance_of(&to) + amount).ok_or(TxError::AmountOverflow)?; 85 | updates.insert(to, updated_to_balance); 86 | 87 | let (owner_fee, auction_fee) = auction_fee_ratio.get_value(fee); 88 | 89 | let updated_fee_to_balance = 90 | (updates.balance_of(&fee_to) + owner_fee).ok_or(TxError::AmountOverflow)?; 91 | updates.insert(fee_to, updated_fee_to_balance); 92 | 93 | let updated_auction_balance = 94 | (updates.balance_of(&auction_account()) + auction_fee).ok_or(TxError::AmountOverflow)?; 95 | updates.insert(auction_account(), updated_auction_balance); 96 | 97 | // At this point all the checks are done and no further errors are possible, so we modify the 98 | // canister state only at this point. 99 | balances.apply_updates(updates.list_balances(0, usize::MAX)); 100 | 101 | Ok(()) 102 | } 103 | 104 | fn validate_and_get_tx_ts(caller: Principal, transfer_args: &TransferArgs) -> Result { 105 | let now = ic::time(); 106 | let from = AccountInternal::new(caller, transfer_args.from_subaccount); 107 | let to = transfer_args.to.into(); 108 | 109 | let created_at_time = match transfer_args.created_at_time { 110 | Some(created_at_time) => { 111 | if now.saturating_sub(created_at_time) > TX_WINDOW { 112 | return Err(TxError::TooOld { 113 | allowed_window_nanos: TX_WINDOW, 114 | }); 115 | } 116 | 117 | if created_at_time.saturating_sub(now) > PERMITTED_DRIFT { 118 | return Err(TxError::CreatedInFuture { ledger_time: now }); 119 | } 120 | 121 | let txs = LedgerData::list_transactions(); 122 | for tx in txs.iter().rev() { 123 | if now.saturating_sub(tx.timestamp) > TX_WINDOW + PERMITTED_DRIFT { 124 | break; 125 | } 126 | 127 | if tx.timestamp == created_at_time 128 | && AccountInternal::from(tx.from) == from 129 | && AccountInternal::from(tx.to) == to 130 | && tx.memo == transfer_args.memo 131 | && tx.amount == transfer_args.amount 132 | && tx.fee == transfer_args.fee.unwrap_or(tx.fee) 133 | { 134 | return Err(TxError::Duplicate { 135 | duplicate_of: tx.index, 136 | }); 137 | } 138 | } 139 | 140 | created_at_time 141 | } 142 | 143 | None => now, 144 | }; 145 | 146 | Ok(created_at_time) 147 | } 148 | 149 | pub fn mint(caller: Principal, to: AccountInternal, amount: Tokens128) -> TxReceipt { 150 | let total_supply = StableBalances.total_supply(); 151 | if (total_supply + amount).is_none() { 152 | // If we allow to mint more then Tokens128::MAX then simple operations such as getting 153 | // total supply or token stats will panic, So we add this check to prevent this. 154 | return Err(TxError::AmountOverflow); 155 | } 156 | 157 | let balance = StableBalances.balance_of(&to); 158 | let new_balance = (balance + amount).ok_or(TxError::AmountOverflow)?; 159 | StableBalances.insert(to, new_balance); 160 | 161 | let id = LedgerData::mint(caller.into(), to, amount); 162 | 163 | Ok(id.into()) 164 | } 165 | 166 | pub fn mint_test_token( 167 | caller: CheckedPrincipal, 168 | to: Principal, 169 | to_subaccount: Option, 170 | amount: Tokens128, 171 | ) -> TxReceipt { 172 | mint( 173 | caller.inner(), 174 | AccountInternal::new(to, to_subaccount), 175 | amount, 176 | ) 177 | } 178 | 179 | pub fn mint_as_owner( 180 | caller: CheckedPrincipal, 181 | to: Principal, 182 | to_subaccount: Option, 183 | amount: Tokens128, 184 | ) -> TxReceipt { 185 | mint( 186 | caller.inner(), 187 | AccountInternal::new(to, to_subaccount), 188 | amount, 189 | ) 190 | } 191 | 192 | pub fn burn(caller: Principal, from: AccountInternal, amount: Tokens128) -> TxReceipt { 193 | let balance = StableBalances.balance_of(&from); 194 | 195 | if !amount.is_zero() && balance.is_zero() { 196 | return Err(TxError::InsufficientFunds { balance }); 197 | } 198 | 199 | let new_balance = (balance - amount).ok_or(TxError::InsufficientFunds { balance })?; 200 | 201 | if new_balance == Tokens128::ZERO { 202 | StableBalances.remove(&from); 203 | } else { 204 | StableBalances.insert(from, new_balance) 205 | } 206 | 207 | let id = LedgerData::burn(caller.into(), from, amount); 208 | Ok(id.into()) 209 | } 210 | 211 | pub fn burn_own_tokens(from_subaccount: Option, amount: Tokens128) -> TxReceipt { 212 | let caller = ic::caller(); 213 | burn( 214 | caller, 215 | AccountInternal::new(caller, from_subaccount), 216 | amount, 217 | ) 218 | } 219 | 220 | pub fn burn_as_owner( 221 | caller: CheckedPrincipal, 222 | from: Principal, 223 | from_subaccount: Option, 224 | amount: Tokens128, 225 | ) -> TxReceipt { 226 | burn( 227 | caller.inner(), 228 | AccountInternal::new(from, from_subaccount), 229 | amount, 230 | ) 231 | } 232 | 233 | #[cfg(feature = "claim")] 234 | pub fn get_claim_subaccount( 235 | claimer: Principal, 236 | claimer_subaccount: Option, 237 | ) -> Subaccount { 238 | let account_id = AccountIdentifier::new( 239 | claimer.into(), 240 | Some(SubaccountIdentifier(claimer_subaccount.unwrap_or_default())), 241 | ); 242 | 243 | account_id.to_address() 244 | } 245 | 246 | #[cfg(feature = "claim")] 247 | pub fn claim(holder: Principal, subaccount: Option) -> TxReceipt { 248 | let caller = canister_sdk::ic_kit::ic::caller(); 249 | let claim_subaccount = get_claim_subaccount(caller, subaccount); 250 | let claim_account = AccountInternal::new(holder, Some(claim_subaccount)); 251 | let amount = StableBalances.balance_of(&claim_account); 252 | if amount.is_zero() { 253 | return Err(TxError::NothingToClaim); 254 | } 255 | 256 | let stats = TokenConfig::get_stable(); 257 | transfer_internal( 258 | &mut StableBalances, 259 | claim_account, 260 | caller.into(), 261 | amount, 262 | 0.into(), 263 | stats.owner.into(), 264 | FeeRatio::default(), 265 | )?; 266 | let id = LedgerData::claim(claim_account, AccountInternal::new(caller, None), amount); 267 | Ok(id.into()) 268 | } 269 | 270 | pub fn batch_transfer( 271 | from_subaccount: Option, 272 | transfers: Vec, 273 | auction_fee_ratio: f64, 274 | ) -> Result, TxError> { 275 | let caller = canister_sdk::ic_kit::ic::caller(); 276 | let from = AccountInternal::new(caller, from_subaccount); 277 | 278 | let stats = TokenConfig::get_stable(); 279 | let (fee, fee_to) = stats.fee_info(); 280 | 281 | batch_transfer_internal( 282 | from, 283 | &transfers, 284 | &mut StableBalances, 285 | fee, 286 | fee_to, 287 | auction_fee_ratio, 288 | )?; 289 | let id = LedgerData::batch_transfer(from, transfers, fee); 290 | Ok(id) 291 | } 292 | 293 | pub(crate) fn batch_transfer_internal( 294 | from: AccountInternal, 295 | transfers: &Vec, 296 | balances: &mut impl Balances, 297 | fee: Tokens128, 298 | fee_to: Principal, 299 | auction_fee_ratio: f64, 300 | ) -> Result<(), TxError> { 301 | let fee_to = AccountInternal::new(fee_to, None); 302 | let auction_acc = auction_account(); 303 | 304 | let mut updates = LocalBalances::from_iter([ 305 | (from, balances.balance_of(&from)), 306 | (fee_to, balances.balance_of(&fee_to)), 307 | (auction_acc, balances.balance_of(&auction_acc)), 308 | ]); 309 | 310 | for transfer in transfers { 311 | let receiver = transfer.receiver.into(); 312 | updates.insert(receiver, balances.balance_of(&receiver)); 313 | } 314 | 315 | for transfer in transfers { 316 | let receiver = transfer.receiver.into(); 317 | transfer_internal( 318 | &mut updates, 319 | from, 320 | receiver, 321 | transfer.amount, 322 | fee, 323 | fee_to, 324 | FeeRatio::new(auction_fee_ratio), 325 | ) 326 | .map_err(|err| match err { 327 | TxError::InsufficientFunds { .. } => TxError::InsufficientFunds { 328 | balance: balances.balance_of(&from), 329 | }, 330 | other => other, 331 | })?; 332 | } 333 | 334 | balances.apply_updates(updates.list_balances(0, usize::MAX)); 335 | Ok(()) 336 | } 337 | 338 | #[cfg(test)] 339 | mod tests { 340 | use canister_sdk::ic_auction::api::Auction; 341 | use canister_sdk::ic_canister::Canister; 342 | use canister_sdk::ic_kit::inject::get_context; 343 | use canister_sdk::ic_kit::mock_principals::{alice, bob, john, xtc}; 344 | use canister_sdk::ic_kit::MockContext; 345 | use coverage_helper::test; 346 | 347 | use super::*; 348 | use crate::account::{Account, DEFAULT_SUBACCOUNT}; 349 | use crate::canister::TokenCanisterAPI; 350 | use crate::mock::TokenCanisterMock; 351 | use crate::state::config::Metadata; 352 | 353 | fn test_canister() -> TokenCanisterMock { 354 | let context = MockContext::new().with_caller(alice()).inject(); 355 | 356 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 357 | let canister = TokenCanisterMock::from_principal(principal); 358 | context.update_id(canister.principal()); 359 | 360 | // Refresh canister's state. 361 | TokenConfig::set_stable(TokenConfig::default()); 362 | StableBalances.clear(); 363 | LedgerData::clear(); 364 | 365 | canister.init( 366 | Metadata { 367 | name: "".to_string(), 368 | symbol: "".to_string(), 369 | decimals: 8, 370 | owner: alice(), 371 | fee: Tokens128::from(0), 372 | fee_to: alice(), 373 | is_test_token: None, 374 | }, 375 | Tokens128::from(1000), 376 | ); 377 | 378 | // This is to make tests that don't rely on auction state 379 | // pass, because since we are running auction state on each 380 | // endpoint call, it affects `BiddingInfo.fee_ratio` that is 381 | // used for charging fees in `approve` endpoint. 382 | let mut stats = TokenConfig::get_stable(); 383 | stats.min_cycles = 0; 384 | TokenConfig::set_stable(stats); 385 | 386 | canister 387 | } 388 | 389 | #[test] 390 | fn batch_transfer_without_fee() { 391 | let canister = test_canister(); 392 | assert_eq!( 393 | Tokens128::from(1000), 394 | canister.icrc1_balance_of(Account::new(alice(), None)) 395 | ); 396 | let transfer1 = BatchTransferArgs { 397 | receiver: Account::new(bob(), None), 398 | amount: Tokens128::from(100), 399 | }; 400 | let transfer2 = BatchTransferArgs { 401 | receiver: Account::new(john(), None), 402 | amount: Tokens128::from(200), 403 | }; 404 | let receipt = canister 405 | .batch_transfer(None, vec![transfer1, transfer2]) 406 | .unwrap(); 407 | assert_eq!(receipt.len(), 2); 408 | assert_eq!( 409 | canister.icrc1_balance_of(Account::new(alice(), None)), 410 | Tokens128::from(700) 411 | ); 412 | assert_eq!( 413 | canister.icrc1_balance_of(Account::new(bob(), None)), 414 | Tokens128::from(100) 415 | ); 416 | assert_eq!( 417 | canister.icrc1_balance_of(Account::new(john(), None)), 418 | Tokens128::from(200) 419 | ); 420 | } 421 | 422 | #[test] 423 | fn batch_transfer_with_fee() { 424 | let canister = test_canister(); 425 | 426 | let mut stats = TokenConfig::get_stable(); 427 | stats.fee = Tokens128::from(50); 428 | stats.fee_to = john(); 429 | TokenConfig::set_stable(stats); 430 | 431 | assert_eq!( 432 | Tokens128::from(1000), 433 | canister.icrc1_balance_of(Account::new(alice(), None)) 434 | ); 435 | let transfer1 = BatchTransferArgs { 436 | receiver: Account::new(bob(), None), 437 | amount: Tokens128::from(100), 438 | }; 439 | let transfer2 = BatchTransferArgs { 440 | receiver: Account::new(xtc(), None), 441 | amount: Tokens128::from(200), 442 | }; 443 | let receipt = canister 444 | .batch_transfer(None, vec![transfer1, transfer2]) 445 | .unwrap(); 446 | assert_eq!(receipt.len(), 2); 447 | assert_eq!( 448 | canister.icrc1_balance_of(Account::new(alice(), None)), 449 | Tokens128::from(600) 450 | ); 451 | assert_eq!( 452 | canister.icrc1_balance_of(Account::new(bob(), None)), 453 | Tokens128::from(100) 454 | ); 455 | assert_eq!( 456 | canister.icrc1_balance_of(Account::new(xtc(), None)), 457 | Tokens128::from(200) 458 | ); 459 | assert_eq!( 460 | canister.icrc1_balance_of(Account::new(john(), None)), 461 | Tokens128::from(100) 462 | ); 463 | } 464 | 465 | #[test] 466 | fn batch_transfer_insufficient_balance() { 467 | let canister = test_canister(); 468 | 469 | let transfer1 = BatchTransferArgs { 470 | receiver: Account::new(bob(), None), 471 | amount: Tokens128::from(500), 472 | }; 473 | let transfer2 = BatchTransferArgs { 474 | receiver: Account::new(john(), None), 475 | amount: Tokens128::from(600), 476 | }; 477 | let receipt = canister.batch_transfer(None, vec![transfer1, transfer2]); 478 | assert!(receipt.is_err()); 479 | let balance = canister.icrc1_balance_of(Account::new(alice(), None)); 480 | assert_eq!(receipt.unwrap_err(), TxError::InsufficientFunds { balance }); 481 | assert_eq!( 482 | canister.icrc1_balance_of(Account::new(alice(), None)), 483 | Tokens128::from(1000) 484 | ); 485 | assert_eq!( 486 | canister.icrc1_balance_of(Account::new(bob(), None)), 487 | Tokens128::from(0) 488 | ); 489 | assert_eq!( 490 | canister.icrc1_balance_of(Account::new(john(), None)), 491 | Tokens128::from(0) 492 | ); 493 | } 494 | 495 | #[test] 496 | fn batch_transfer_overflow() { 497 | let canister = test_canister(); 498 | 499 | let transfer1 = BatchTransferArgs { 500 | receiver: Account::new(bob(), None), 501 | amount: Tokens128::from(u128::MAX - 10), 502 | }; 503 | let transfer2 = BatchTransferArgs { 504 | receiver: Account::new(john(), None), 505 | amount: Tokens128::from(20), 506 | }; 507 | let res = canister.batch_transfer(None, vec![transfer1, transfer2]); 508 | assert_eq!( 509 | res, 510 | Err(TxError::InsufficientFunds { 511 | balance: 1000.into() 512 | }) 513 | ); 514 | } 515 | 516 | #[test] 517 | fn batch_transfer_zero_amount() { 518 | let canister = test_canister(); 519 | 520 | let transfer1 = BatchTransferArgs { 521 | receiver: Account::new(bob(), None), 522 | amount: Tokens128::from(100), 523 | }; 524 | let transfer2 = BatchTransferArgs { 525 | receiver: Account::new(john(), None), 526 | amount: Tokens128::from(0), 527 | }; 528 | let res = canister.batch_transfer(None, vec![transfer1, transfer2]); 529 | assert_eq!(res, Err(TxError::AmountTooSmall)); 530 | } 531 | 532 | #[test] 533 | fn deduplication_error() { 534 | let canister = test_canister(); 535 | let curr_time = ic::time(); 536 | 537 | let transfer = TransferArgs { 538 | from_subaccount: None, 539 | to: Account::new(bob(), None), 540 | amount: 10_000.into(), 541 | fee: None, 542 | memo: None, 543 | created_at_time: Some(curr_time), 544 | }; 545 | 546 | assert!(validate_and_get_tx_ts(alice(), &transfer).is_ok()); 547 | 548 | let tx_id = canister.icrc1_transfer(transfer.clone()).unwrap(); 549 | 550 | assert_eq!( 551 | validate_and_get_tx_ts(alice(), &transfer), 552 | Err(TxError::Duplicate { 553 | duplicate_of: tx_id as u64 554 | }) 555 | ) 556 | } 557 | 558 | #[test] 559 | fn deduplicate_check_pass() { 560 | let canister = test_canister(); 561 | let curr_time = ic::time(); 562 | 563 | let transfer = TransferArgs { 564 | from_subaccount: None, 565 | to: Account::new(bob(), None), 566 | amount: 10_000.into(), 567 | fee: None, 568 | memo: None, 569 | created_at_time: Some(curr_time), 570 | }; 571 | 572 | let _ = canister.icrc1_transfer(transfer.clone()).unwrap(); 573 | assert!(validate_and_get_tx_ts(john(), &transfer).is_ok()); 574 | 575 | let mut tx = transfer.clone(); 576 | tx.from_subaccount = Some([0; 32]); 577 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 578 | 579 | let mut tx = transfer.clone(); 580 | tx.amount = 10_001.into(); 581 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 582 | 583 | let mut tx = transfer.clone(); 584 | tx.fee = Some(0.into()); 585 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 586 | 587 | let mut tx = transfer.clone(); 588 | tx.memo = Some([0; 32]); 589 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 590 | 591 | let mut tx = transfer.clone(); 592 | tx.created_at_time = None; 593 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 594 | 595 | let mut tx = transfer; 596 | tx.created_at_time = Some(curr_time + 1); 597 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 598 | 599 | let transfer = TransferArgs { 600 | from_subaccount: None, 601 | to: Account::new(bob(), None), 602 | amount: 10_000.into(), 603 | fee: None, 604 | memo: Some([1; 32]), 605 | created_at_time: Some(curr_time), 606 | }; 607 | 608 | let _ = canister.icrc1_transfer(transfer.clone()).unwrap(); 609 | assert!(validate_and_get_tx_ts(john(), &transfer).is_ok()); 610 | 611 | let mut tx = transfer.clone(); 612 | tx.memo = None; 613 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 614 | 615 | let mut tx = transfer; 616 | tx.memo = Some([2; 32]); 617 | assert!(validate_and_get_tx_ts(john(), &tx).is_ok()); 618 | } 619 | 620 | #[test] 621 | fn deduplicate_check_no_created_at_time() { 622 | let canister = test_canister(); 623 | 624 | let transfer = TransferArgs { 625 | from_subaccount: None, 626 | to: Account::new(bob(), None), 627 | amount: 10_000.into(), 628 | fee: None, 629 | memo: None, 630 | created_at_time: None, 631 | }; 632 | 633 | let _ = canister.icrc1_transfer(transfer.clone()).unwrap(); 634 | assert!(validate_and_get_tx_ts(alice(), &transfer).is_ok()); 635 | } 636 | 637 | #[test] 638 | fn zero_transfer() { 639 | let canister = test_canister(); 640 | let transfer = TransferArgs { 641 | from_subaccount: None, 642 | to: bob().into(), 643 | amount: 0.into(), 644 | fee: None, 645 | memo: None, 646 | created_at_time: None, 647 | }; 648 | 649 | let caller = CheckedAccount::with_recipient(transfer.to.into(), None).unwrap(); 650 | 651 | let res = is20_transfer(caller, &transfer, canister.bidding_info().fee_ratio); 652 | assert_eq!(res, Err(TxError::AmountTooSmall)); 653 | } 654 | 655 | #[test] 656 | fn transfer_with_overflow() { 657 | let canister = test_canister(); 658 | 659 | let mut stats = TokenConfig::get_stable(); 660 | stats.fee = 100500.into(); 661 | TokenConfig::set_stable(stats); 662 | 663 | let transfer = TransferArgs { 664 | from_subaccount: None, 665 | to: bob().into(), 666 | amount: (u128::MAX - 100000).into(), 667 | fee: None, 668 | memo: None, 669 | created_at_time: None, 670 | }; 671 | 672 | let caller = CheckedAccount::with_recipient(transfer.to.into(), None).unwrap(); 673 | 674 | let res = is20_transfer(caller, &transfer, canister.bidding_info().fee_ratio); 675 | assert_eq!( 676 | res, 677 | Err(TxError::InsufficientFunds { 678 | balance: 1000.into() 679 | }) 680 | ); 681 | } 682 | 683 | #[test] 684 | fn mint_too_much() { 685 | let _ = test_canister(); // initialize context 686 | 687 | mint(alice(), bob().into(), Tokens128::from(u128::MAX - 2000)).unwrap(); 688 | let res = mint(alice(), john().into(), Tokens128::from(2000)); 689 | assert_eq!(res, Err(TxError::AmountOverflow)); 690 | } 691 | 692 | #[test] 693 | fn transfer_to_own_subaccount() { 694 | let canister = test_canister(); 695 | let transfer = TransferArgs { 696 | from_subaccount: None, 697 | to: Account::new(alice(), Some([1; 32])), 698 | amount: (200).into(), 699 | fee: None, 700 | memo: None, 701 | created_at_time: None, 702 | }; 703 | let caller = CheckedAccount::with_recipient(transfer.to.into(), None).unwrap(); 704 | 705 | is20_transfer(caller, &transfer, canister.bidding_info().fee_ratio).unwrap(); 706 | assert_eq!(canister.icrc1_balance_of(alice().into()), 800.into()); 707 | assert_eq!(canister.icrc1_balance_of(transfer.to), 200.into()); 708 | } 709 | 710 | #[test] 711 | fn transfer_using_default_subaccount() { 712 | let canister = test_canister(); 713 | let transfer = TransferArgs { 714 | from_subaccount: None, 715 | to: Account::new(bob(), Some(DEFAULT_SUBACCOUNT)), 716 | amount: 200.into(), 717 | fee: None, 718 | memo: None, 719 | created_at_time: None, 720 | }; 721 | let caller = CheckedAccount::with_recipient(transfer.to.into(), None).unwrap(); 722 | 723 | is20_transfer(caller, &transfer, canister.bidding_info().fee_ratio).unwrap(); 724 | assert_eq!(canister.icrc1_balance_of(bob().into()), 200.into()); 725 | } 726 | 727 | // The transactions in the ledger can be saved not in the order of their `created_at_time` 728 | // value. In this test we check if the deduplication logic works properly in such cases. 729 | #[test] 730 | fn validate_time_transactions_with_strange_ts() { 731 | let canister = test_canister(); 732 | let now = ic::time(); 733 | 734 | let delayed_transfer = TransferArgs { 735 | from_subaccount: None, 736 | to: bob().into(), 737 | amount: 200.into(), 738 | fee: None, 739 | memo: None, 740 | created_at_time: Some(now + 121_000_000_000), 741 | }; 742 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 743 | let result = is20_transfer(caller, &delayed_transfer, canister.bidding_info().fee_ratio); 744 | assert_eq!(result, Err(TxError::CreatedInFuture { ledger_time: now })); 745 | 746 | let transfer = TransferArgs { 747 | from_subaccount: None, 748 | to: bob().into(), 749 | amount: 200.into(), 750 | fee: None, 751 | memo: None, 752 | created_at_time: Some(now), 753 | }; 754 | 755 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 756 | is20_transfer(caller, &transfer, canister.bidding_info().fee_ratio).unwrap(); 757 | 758 | let context = get_context(); 759 | context.update_caller(alice()); 760 | context.add_time(61_000_000_000); 761 | 762 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 763 | let tx_id = 764 | is20_transfer(caller, &delayed_transfer, canister.bidding_info().fee_ratio).unwrap(); 765 | 766 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 767 | let result = is20_transfer(caller, &delayed_transfer, canister.bidding_info().fee_ratio); 768 | assert_eq!( 769 | result, 770 | Err(TxError::Duplicate { 771 | duplicate_of: tx_id as u64 772 | }) 773 | ); 774 | 775 | context.add_time(60_000_000_000); 776 | 777 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 778 | let result = is20_transfer(caller, &delayed_transfer, canister.bidding_info().fee_ratio); 779 | assert_eq!( 780 | result, 781 | Err(TxError::Duplicate { 782 | duplicate_of: tx_id as u64 783 | }) 784 | ); 785 | 786 | context.add_time(180_000_000_000); 787 | 788 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 789 | let result = is20_transfer(caller, &delayed_transfer, canister.bidding_info().fee_ratio); 790 | assert_eq!( 791 | result, 792 | Err(TxError::TooOld { 793 | allowed_window_nanos: 60_000_000_000 794 | }) 795 | ); 796 | 797 | // This last transfer is needed to check if the deduplication logic stops at the right 798 | // moment when iterating over old transactions. It is visible in the test coverage report 799 | // only though. 800 | let transfer = TransferArgs { 801 | from_subaccount: None, 802 | to: bob().into(), 803 | amount: 200.into(), 804 | fee: None, 805 | memo: None, 806 | created_at_time: Some(ic::time()), 807 | }; 808 | 809 | let caller = CheckedAccount::with_recipient(bob().into(), None).unwrap(); 810 | is20_transfer(caller, &transfer, canister.bidding_info().fee_ratio).unwrap(); 811 | } 812 | 813 | #[cfg(feature = "claim")] 814 | #[test] 815 | fn zero_claim_returns_error() { 816 | MockContext::new().with_caller(john()).inject(); 817 | 818 | let res = claim(alice(), None); 819 | assert_eq!(res, Err(TxError::NothingToClaim)); 820 | } 821 | 822 | #[test] 823 | fn burn_removes_empty_entry() { 824 | let _ = test_canister(); 825 | mint(alice(), bob().into(), Tokens128::from(1_000_000)).unwrap(); 826 | assert_ne!(StableBalances.get(&bob().into()), None); 827 | 828 | burn(alice(), bob().into(), Tokens128::from(1_000_000)).unwrap(); 829 | assert_eq!(StableBalances.get(&bob().into()), None); 830 | } 831 | } 832 | -------------------------------------------------------------------------------- /src/token/api/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::state::config::Timestamp; 2 | use candid::{CandidType, Deserialize}; 3 | use canister_sdk::ic_helpers::tokens::Tokens128; 4 | use thiserror::Error; 5 | 6 | #[derive(CandidType, Debug, PartialEq, Deserialize, Error, Eq)] 7 | pub enum TxError { 8 | #[error("unauthorized")] 9 | Unauthorized, 10 | #[error("amount too small")] 11 | AmountTooSmall, 12 | #[error("bad fee {expected_fee}")] 13 | BadFee { expected_fee: Tokens128 }, 14 | #[error("insufficient funds : {balance}")] 15 | InsufficientFunds { balance: Tokens128 }, 16 | #[error("transaction is too old : {allowed_window_nanos}")] 17 | TooOld { allowed_window_nanos: u64 }, 18 | #[error("transaction is created in the future {ledger_time}")] 19 | CreatedInFuture { ledger_time: u64 }, 20 | #[error("transaction is duplicate of {duplicate_of}")] 21 | Duplicate { duplicate_of: u64 }, 22 | #[error("self transfer")] 23 | SelfTransfer, 24 | #[error("amount overflow")] 25 | AmountOverflow, 26 | #[error("account is not found")] 27 | AccountNotFound, 28 | #[error("no claimable tokens are on the requested subaccount")] 29 | NothingToClaim, 30 | } 31 | 32 | // This type is the exact error type from ICRC-1 standard. We use it as the return type for 33 | // icrc1_transfer method to fully comply with the standard. As such, it doesn't need to implement 34 | // `Error` trait, as internally everywhere the `TxError` is used. 35 | #[derive(CandidType, Debug, PartialEq, Deserialize, Eq)] 36 | pub enum TransferError { 37 | BadFee { expected_fee: Tokens128 }, 38 | BadBurn { min_burn_amount: Tokens128 }, 39 | InsufficientFunds { balance: Tokens128 }, 40 | TooOld, 41 | CreatedInFuture { ledger_time: Timestamp }, 42 | Duplicate { duplicate_of: u128 }, 43 | TemporarilyUnavailable, 44 | GenericError { error_code: u128, message: String }, 45 | } 46 | 47 | impl From for TransferError { 48 | fn from(err: TxError) -> Self { 49 | match err { 50 | TxError::BadFee { expected_fee } => Self::BadFee { expected_fee }, 51 | TxError::InsufficientFunds { balance } => Self::InsufficientFunds { balance }, 52 | TxError::TooOld { .. } => Self::TooOld, 53 | TxError::CreatedInFuture { ledger_time } => Self::CreatedInFuture { ledger_time }, 54 | TxError::Duplicate { duplicate_of } => Self::Duplicate { 55 | duplicate_of: duplicate_of as u128, 56 | }, 57 | _ => TransferError::GenericError { 58 | error_code: 500, 59 | message: format!("{err}"), 60 | }, 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/token/api/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(coverage_nightly, feature(no_coverage))] 2 | 3 | pub mod account; 4 | pub mod canister; 5 | pub mod principal; 6 | pub mod state; 7 | 8 | pub mod error; 9 | #[cfg(test)] 10 | pub mod mock; 11 | pub mod tx_record; 12 | -------------------------------------------------------------------------------- /src/token/api/src/mock.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | #[cfg(feature = "auction")] 4 | use canister_sdk::ic_auction::{ 5 | api::Auction, 6 | error::AuctionError, 7 | state::{AuctionInfo, AuctionState}, 8 | }; 9 | use canister_sdk::{ 10 | ic_canister::{self, Canister, PreUpdate}, 11 | ic_helpers::tokens::Tokens128, 12 | ic_metrics::Interval, 13 | ic_storage::IcStorage, 14 | }; 15 | use ic_exports::Principal; 16 | 17 | use crate::{ 18 | account::AccountInternal, 19 | canister::TokenCanisterAPI, 20 | state::{ 21 | balances::{Balances, StableBalances}, 22 | config::{Metadata, TokenConfig}, 23 | ledger::LedgerData, 24 | }, 25 | }; 26 | 27 | #[derive(Debug, Clone, Canister)] 28 | pub struct TokenCanisterMock { 29 | #[id] 30 | principal: Principal, 31 | } 32 | 33 | impl TokenCanisterMock { 34 | #[cfg_attr(coverage_nightly, no_coverage)] 35 | pub fn init(&self, metadata: Metadata, amount: Tokens128) { 36 | let owner_account = AccountInternal::new(metadata.owner, None); 37 | StableBalances.insert(owner_account, amount); 38 | 39 | LedgerData::mint(metadata.owner.into(), metadata.owner.into(), amount); 40 | 41 | TokenConfig::set_stable(metadata.into()); 42 | 43 | #[cfg(feature = "auction")] 44 | { 45 | let auction_state = self.auction_state(); 46 | auction_state.replace(AuctionState::new( 47 | Interval::Period { 48 | seconds: crate::canister::DEFAULT_AUCTION_PERIOD_SECONDS, 49 | }, 50 | canister_sdk::ic_kit::ic::caller(), 51 | )); 52 | } 53 | } 54 | } 55 | 56 | impl PreUpdate for TokenCanisterMock { 57 | #[cfg_attr(coverage_nightly, no_coverage)] 58 | fn pre_update(&self, method_name: &str, method_type: ic_canister::MethodType) { 59 | #[cfg(feature = "auction")] 60 | ::canister_pre_update(self, method_name, method_type); 61 | } 62 | } 63 | 64 | #[cfg(feature = "auction")] 65 | impl Auction for TokenCanisterMock { 66 | fn auction_state(&self) -> Rc> { 67 | AuctionState::get() 68 | } 69 | 70 | fn disburse_rewards(&self) -> Result { 71 | crate::canister::is20_auction::disburse_rewards(&self.auction_state().borrow()) 72 | } 73 | } 74 | 75 | impl TokenCanisterAPI for TokenCanisterMock {} 76 | -------------------------------------------------------------------------------- /src/token/api/src/principal.rs: -------------------------------------------------------------------------------- 1 | use ic_exports::Principal; 2 | 3 | use crate::{error::TxError, state::config::TokenConfig}; 4 | use canister_sdk::ic_kit::ic; 5 | 6 | /// Canister owner 7 | pub struct Owner; 8 | 9 | /// Any principal but the canister 10 | /// has is_test_token set to true 11 | pub struct TestNet; 12 | 13 | pub struct CheckedPrincipal(Principal, T); 14 | 15 | impl CheckedPrincipal { 16 | pub fn inner(&self) -> Principal { 17 | self.0 18 | } 19 | } 20 | 21 | impl CheckedPrincipal { 22 | pub fn owner(config: &TokenConfig) -> Result { 23 | let caller = ic::caller(); 24 | if caller == config.owner { 25 | Ok(Self(caller, Owner)) 26 | } else { 27 | Err(TxError::Unauthorized) 28 | } 29 | } 30 | } 31 | 32 | impl CheckedPrincipal { 33 | pub fn test_user(config: &TokenConfig) -> Result { 34 | let caller = ic::caller(); 35 | if config.is_test_token { 36 | Ok(Self(caller, TestNet)) 37 | } else { 38 | Err(TxError::Unauthorized) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/token/api/src/state.rs: -------------------------------------------------------------------------------- 1 | pub mod balances; 2 | pub mod config; 3 | pub mod ledger; 4 | -------------------------------------------------------------------------------- /src/token/api/src/state/balances.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::cell::RefCell; 3 | use std::collections::HashMap; 4 | 5 | use candid::{CandidType, Deserialize, Principal}; 6 | use canister_sdk::ic_helpers::tokens::Tokens128; 7 | use ic_stable_structures::{BoundedStorable, MemoryId, StableMultimap, Storable}; 8 | 9 | use crate::account::{AccountInternal, Subaccount}; 10 | 11 | pub trait Balances { 12 | /// Write or re-write amount of tokens for specified account. 13 | fn insert(&mut self, account: AccountInternal, token: Tokens128); 14 | 15 | /// Get amount of tokens for the specified account. 16 | fn get(&self, account: &AccountInternal) -> Option; 17 | 18 | /// Remove specified account balance. 19 | fn remove(&mut self, account: &AccountInternal) -> Option; 20 | 21 | /// Get list of `limit` balances, starting with `start`. 22 | fn list_balances(&self, start: usize, limit: usize) -> Vec<(AccountInternal, Tokens128)>; 23 | 24 | /// Get amount of tokens for the specified account. 25 | /// If account is not present, return zero. 26 | fn balance_of(&self, account: &AccountInternal) -> Tokens128 { 27 | self.get(account).unwrap_or_default() 28 | } 29 | 30 | /// Update balances according to `updates` iterator. 31 | fn apply_updates(&mut self, updates: impl IntoIterator) { 32 | for (account, amount) in updates { 33 | self.insert(account, amount); 34 | } 35 | } 36 | 37 | /// List subaccounts for the given principal. 38 | fn get_subaccounts(&self, owner: Principal) -> HashMap { 39 | self.list_balances(0, usize::MAX) 40 | .into_iter() 41 | .filter(|(account, _)| account.owner == owner) 42 | .map(|(account, amount)| (account.subaccount, amount)) 43 | .collect() 44 | } 45 | 46 | /// Return sum of all balances. 47 | fn total_supply(&self) -> Tokens128 { 48 | self.list_balances(0, usize::MAX) 49 | .into_iter() 50 | .fold(Tokens128::ZERO, |a, b| { 51 | (a + b.1).expect("total supply integer overflow") // Checked at mint 52 | }) 53 | } 54 | 55 | /// Get balances map: holder -> subaccount -> tokens. 56 | fn get_holders(&self) -> HashMap> { 57 | let mut holders: HashMap> = HashMap::new(); 58 | for (account, amount) in self.list_balances(0, usize::MAX) { 59 | holders 60 | .entry(account.owner) 61 | .or_default() 62 | .insert(account.subaccount, amount); 63 | } 64 | holders 65 | } 66 | 67 | /// Remove all balances. 68 | fn clear(&mut self) { 69 | for (account, _) in self.list_balances(0, usize::MAX) { 70 | self.remove(&account); 71 | } 72 | } 73 | } 74 | 75 | /// Store balances in stable memory. 76 | pub struct StableBalances; 77 | 78 | impl StableBalances { 79 | #[cfg(feature = "claim")] 80 | pub fn get_claimable_amount(holder: Principal, subaccount: Option) -> Tokens128 { 81 | use canister_sdk::ledger::{AccountIdentifier, Subaccount as SubaccountIdentifier}; 82 | 83 | use crate::account::DEFAULT_SUBACCOUNT; 84 | 85 | let claim_subaccount = AccountIdentifier::new( 86 | canister_sdk::ic_kit::ic::caller().into(), 87 | Some(SubaccountIdentifier( 88 | subaccount.unwrap_or(DEFAULT_SUBACCOUNT), 89 | )), 90 | ) 91 | .to_address(); 92 | 93 | let account = AccountInternal::new(holder, Some(claim_subaccount)); 94 | Self.balance_of(&account) 95 | } 96 | } 97 | 98 | impl Balances for StableBalances { 99 | /// Write or re-write amount of tokens for specified account to stable memory. 100 | fn insert(&mut self, account: AccountInternal, token: Tokens128) { 101 | let principal_key = PrincipalKey(account.owner); 102 | let subaccount_key = SubaccountKey(account.subaccount); 103 | MAP.with(|map| { 104 | map.borrow_mut() 105 | .insert(&principal_key, &subaccount_key, &token.amount) 106 | }); 107 | } 108 | 109 | /// Get amount of tokens for the specified account from stable memory. 110 | fn get(&self, account: &AccountInternal) -> Option { 111 | let principal_key = PrincipalKey(account.owner); 112 | let subaccount_key = SubaccountKey(account.subaccount); 113 | MAP.with(|map| map.borrow_mut().get(&principal_key, &subaccount_key)) 114 | .map(Tokens128::from) 115 | } 116 | 117 | /// Remove specified account balance from the stable memory. 118 | fn remove(&mut self, account: &AccountInternal) -> Option { 119 | let principal_key = PrincipalKey(account.owner); 120 | let subaccount_key = SubaccountKey(account.subaccount); 121 | MAP.with(|map| map.borrow_mut().remove(&principal_key, &subaccount_key)) 122 | .map(Tokens128::from) 123 | } 124 | 125 | fn get_subaccounts(&self, owner: Principal) -> HashMap { 126 | MAP.with(|map| { 127 | map.borrow() 128 | .range(&PrincipalKey(owner)) 129 | .map(|(subaccount, amount)| (subaccount.0, Tokens128::from(amount))) 130 | .collect() 131 | }) 132 | } 133 | 134 | fn list_balances(&self, start: usize, limit: usize) -> Vec<(AccountInternal, Tokens128)> { 135 | MAP.with(|map| { 136 | map.borrow() 137 | .iter() 138 | .skip(start) 139 | .take(limit) 140 | .map(|(principal, subaccount, amount)| { 141 | ( 142 | AccountInternal::new(principal.0, Some(subaccount.0)), 143 | Tokens128::from(amount), 144 | ) 145 | }) 146 | .collect() 147 | }) 148 | } 149 | } 150 | 151 | /// We are saving the `Balances` in this format, as we want to support `Principal` supporting `Subaccount`. 152 | #[derive(Debug, Default, CandidType, Deserialize)] 153 | pub struct LocalBalances(HashMap); 154 | 155 | impl LocalBalances { 156 | pub fn new() -> Self { 157 | Self(HashMap::new()) 158 | } 159 | } 160 | 161 | impl FromIterator<(AccountInternal, Tokens128)> for LocalBalances { 162 | fn from_iter>(iter: T) -> Self { 163 | Self(HashMap::from_iter(iter)) 164 | } 165 | } 166 | 167 | impl Balances for LocalBalances { 168 | fn insert(&mut self, account: AccountInternal, token: Tokens128) { 169 | self.0.insert(account, token); 170 | } 171 | 172 | fn get(&self, account: &AccountInternal) -> Option { 173 | self.0.get(account).copied() 174 | } 175 | 176 | fn list_balances(&self, start: usize, limit: usize) -> Vec<(AccountInternal, Tokens128)> { 177 | let mut holders = self 178 | .0 179 | .iter() 180 | .skip(start) 181 | .take(limit) 182 | .map(|(account, tokens)| (*account, *tokens)) 183 | .collect::>(); 184 | holders.sort_by(|a, b| b.1.cmp(&a.1)); 185 | holders 186 | } 187 | 188 | fn remove(&mut self, account: &AccountInternal) -> Option { 189 | self.0.remove(account) 190 | } 191 | 192 | fn total_supply(&self) -> Tokens128 { 193 | self.0.iter().fold( 194 | Tokens128::ZERO, 195 | |a, b| (a + b.1).expect("total supply integer overflow"), // Checked at mint 196 | ) 197 | } 198 | 199 | fn clear(&mut self) { 200 | self.0.clear() 201 | } 202 | } 203 | 204 | const BALANCES_MEMORY_ID: MemoryId = MemoryId::new(1); 205 | const PRINCIPAL_MAX_LENGTH_IN_BYTES: usize = 29; 206 | const SUBACCOUNT_MAX_LENGTH_IN_BYTES: usize = 32; 207 | 208 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 209 | struct PrincipalKey(Principal); 210 | 211 | impl Storable for PrincipalKey { 212 | fn to_bytes(&self) -> Cow<'_, [u8]> { 213 | self.0.as_slice().into() 214 | } 215 | 216 | /// Expected `Principal::from_slice(&bytes)` is a correct operation. 217 | fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { 218 | PrincipalKey(Principal::from_slice(&bytes)) 219 | } 220 | } 221 | 222 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 223 | struct SubaccountKey(Subaccount); 224 | 225 | impl Storable for SubaccountKey { 226 | fn to_bytes(&self) -> Cow<'_, [u8]> { 227 | self.0.as_slice().into() 228 | } 229 | 230 | /// Expected `bytes.len() == 32`. 231 | fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { 232 | let mut buf = [0u8; SUBACCOUNT_MAX_LENGTH_IN_BYTES]; 233 | buf.copy_from_slice(&bytes); 234 | Self(buf) 235 | } 236 | } 237 | 238 | impl BoundedStorable for PrincipalKey { 239 | const MAX_SIZE: u32 = PRINCIPAL_MAX_LENGTH_IN_BYTES as _; 240 | const IS_FIXED_SIZE: bool = false; 241 | } 242 | 243 | impl BoundedStorable for SubaccountKey { 244 | const MAX_SIZE: u32 = SUBACCOUNT_MAX_LENGTH_IN_BYTES as _; 245 | const IS_FIXED_SIZE: bool = true; 246 | } 247 | 248 | thread_local! { 249 | static MAP: RefCell> = 250 | RefCell::new(StableMultimap::new(BALANCES_MEMORY_ID)); 251 | } 252 | -------------------------------------------------------------------------------- /src/token/api/src/state/config.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, cell::RefCell}; 2 | 3 | use canister_sdk::ic_helpers::tokens::Tokens128; 4 | use ic_exports::candid::{CandidType, Decode, Deserialize, Encode, Int, Nat}; 5 | use ic_exports::Principal; 6 | use ic_stable_structures::{MemoryId, StableCell, Storable}; 7 | 8 | #[derive(Deserialize, CandidType, Clone, Debug)] 9 | pub struct TokenConfig { 10 | pub name: String, 11 | pub symbol: String, 12 | pub decimals: u8, 13 | pub owner: Principal, 14 | pub fee: Tokens128, 15 | pub fee_to: Principal, 16 | pub deploy_time: u64, 17 | pub min_cycles: u64, 18 | pub is_test_token: bool, 19 | } 20 | 21 | impl TokenConfig { 22 | /// Get config data stored in stable memory. 23 | pub fn get_stable() -> TokenConfig { 24 | CELL.with(|c| c.borrow().get().clone()) 25 | } 26 | 27 | /// Store config data in stable memory. 28 | pub fn set_stable(config: TokenConfig) { 29 | CELL.with(|c| c.borrow_mut().set(config)) 30 | .expect("unable to set token config to stable memory") 31 | } 32 | 33 | pub fn fee_info(&self) -> (Tokens128, Principal) { 34 | (self.fee, self.fee_to) 35 | } 36 | 37 | pub fn supported_standards(&self) -> Vec { 38 | vec![ 39 | StandardRecord::new( 40 | "ICRC-1".to_string(), 41 | "https://github.com/dfinity/ICRC-1".to_string(), 42 | ), 43 | StandardRecord::new( 44 | "IS20".to_string(), 45 | "https://github.com/infinity-swap/is20".to_string(), 46 | ), 47 | ] 48 | } 49 | 50 | pub fn icrc1_metadata(&self) -> Vec<(String, Value)> { 51 | vec![ 52 | ("icrc1:symbol".to_string(), Value::Text(self.symbol.clone())), 53 | ("icrc1:name".to_string(), Value::Text(self.name.clone())), 54 | ( 55 | "icrc1:decimals".to_string(), 56 | Value::Nat(Nat::from(self.decimals)), 57 | ), 58 | ("icrc1:fee".to_string(), Value::Nat(self.fee.amount.into())), 59 | ] 60 | } 61 | 62 | pub fn get_metadata(&self) -> Metadata { 63 | Metadata { 64 | name: self.name.clone(), 65 | symbol: self.symbol.clone(), 66 | decimals: self.decimals, 67 | owner: self.owner, 68 | fee: self.fee, 69 | fee_to: self.fee_to, 70 | is_test_token: Some(self.is_test_token), 71 | } 72 | } 73 | } 74 | 75 | impl Default for TokenConfig { 76 | fn default() -> Self { 77 | TokenConfig { 78 | name: "".to_string(), 79 | symbol: "".to_string(), 80 | decimals: 0u8, 81 | owner: Principal::anonymous(), 82 | fee: Tokens128::from(0u128), 83 | fee_to: Principal::anonymous(), 84 | deploy_time: 0, 85 | min_cycles: 0, 86 | is_test_token: false, 87 | } 88 | } 89 | } 90 | 91 | impl Storable for TokenConfig { 92 | // Stable storage expects non-failing serialization/deserialization. 93 | 94 | fn to_bytes(&self) -> Cow<'_, [u8]> { 95 | Cow::Owned(Encode!(self).expect("failed to encode token config")) 96 | } 97 | 98 | fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { 99 | Decode!(&bytes, Self).expect("failed to decode token config") 100 | } 101 | } 102 | 103 | #[derive(Debug, CandidType, Deserialize, Clone, PartialEq, Eq)] 104 | pub struct StandardRecord { 105 | pub name: String, 106 | pub url: String, 107 | } 108 | 109 | impl StandardRecord { 110 | pub fn new(name: String, url: String) -> Self { 111 | Self { name, url } 112 | } 113 | } 114 | 115 | #[allow(non_snake_case)] 116 | #[derive(Deserialize, CandidType, Clone, Debug)] 117 | pub struct Metadata { 118 | pub name: String, 119 | pub symbol: String, 120 | pub decimals: u8, 121 | pub owner: Principal, 122 | pub fee: Tokens128, 123 | pub fee_to: Principal, 124 | pub is_test_token: Option, 125 | } 126 | 127 | // 10T cycles is an equivalent of approximately $10. This should be enough to last the canister 128 | // for the default auction cycle, which is 1 day. 129 | pub const DEFAULT_MIN_CYCLES: u64 = 10_000_000_000_000; 130 | 131 | impl From for TokenConfig { 132 | fn from(md: Metadata) -> Self { 133 | Self { 134 | name: md.name, 135 | symbol: md.symbol, 136 | decimals: md.decimals, 137 | owner: md.owner, 138 | fee: md.fee, 139 | fee_to: md.fee_to, 140 | deploy_time: canister_sdk::ic_kit::ic::time(), 141 | min_cycles: DEFAULT_MIN_CYCLES, 142 | is_test_token: md.is_test_token.unwrap_or(false), 143 | } 144 | } 145 | } 146 | 147 | #[allow(non_snake_case)] 148 | #[derive(Deserialize, CandidType, Clone, Debug)] 149 | pub struct TokenInfo { 150 | pub metadata: Metadata, 151 | pub fee_to: Principal, 152 | pub history_size: u64, 153 | pub deployTime: Timestamp, 154 | pub holderNumber: usize, 155 | pub cycles: u64, 156 | } 157 | 158 | /// Variant type for the metadata endpoint 159 | #[derive(Deserialize, CandidType, Clone, Debug, PartialEq, Eq)] 160 | pub enum Value { 161 | Nat(Nat), 162 | Int(Int), 163 | Text(String), 164 | Blob(Vec), 165 | } 166 | 167 | pub type Timestamp = u64; 168 | 169 | #[derive(CandidType, Default, Debug, Copy, Clone, Deserialize, PartialEq)] 170 | pub struct FeeRatio(f64); 171 | 172 | impl FeeRatio { 173 | pub fn new(value: f64) -> Self { 174 | let adj_value = value.clamp(0.0, 1.0); 175 | Self(adj_value) 176 | } 177 | 178 | /// Returns the tupple (raw_fee, auction_fee). Raw fee is the fee amount to be transferred to 179 | /// the canister owner, and auction_fee is the portion of the fee for the cycle auction. 180 | pub(crate) fn get_value(&self, fee: Tokens128) -> (Tokens128, Tokens128) { 181 | // Both auction fee and owner fee have the same purpose of providing the tokens to pay for 182 | // the canister operations. As such we do not care much about rounding errors in this case. 183 | // The only important thing to make sure that the sum of auction fee and the owner fee is 184 | // equal to the total fee amount. 185 | let auction_fee_amount = Tokens128::from((f64::from(fee) * self.0) as u128); 186 | let owner_fee_amount = fee.saturating_sub(auction_fee_amount); 187 | 188 | (owner_fee_amount, auction_fee_amount) 189 | } 190 | } 191 | 192 | impl From for f64 { 193 | fn from(v: FeeRatio) -> Self { 194 | v.0 195 | } 196 | } 197 | 198 | const CONFIG_MEMORY_ID: MemoryId = MemoryId::new(0); 199 | 200 | thread_local! { 201 | static CELL: RefCell> = { 202 | RefCell::new(StableCell::new(CONFIG_MEMORY_ID, TokenConfig::default()) 203 | .expect("stable memory token config initialization failed")) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/token/api/src/state/ledger.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::collections::HashMap; 3 | 4 | use candid::{CandidType, Deserialize, Principal}; 5 | use canister_sdk::ic_helpers::tokens::Tokens128; 6 | use canister_sdk::ic_kit::ic; 7 | use ic_stable_structures::{MemoryId, StableCell}; 8 | 9 | use crate::account::{Account, AccountInternal, Subaccount}; 10 | use crate::error::TxError; 11 | use crate::state::config::Timestamp; 12 | use crate::tx_record::{TxId, TxRecord}; 13 | 14 | const MAX_HISTORY_LENGTH: usize = 1_000_000; 15 | const HISTORY_REMOVAL_BATCH_SIZE: usize = 10_000; 16 | const TOTAL_TX_COUNT_MEMORY_ID: MemoryId = MemoryId::new(2); 17 | 18 | thread_local! { 19 | static LEDGER: RefCell> = RefCell::default(); 20 | static TOTAL_TX_COUNT: RefCell> = 21 | RefCell::new(StableCell::new(TOTAL_TX_COUNT_MEMORY_ID, 0) 22 | .expect("unable to initialize index offset for ledger")); 23 | } 24 | 25 | pub struct LedgerData; 26 | 27 | impl LedgerData { 28 | pub fn is_empty() -> bool { 29 | Self::with_ledger(|ledger| ledger.is_empty()) 30 | } 31 | 32 | pub fn len() -> u64 { 33 | Self::with_ledger(|ledger| ledger.len()) 34 | } 35 | 36 | pub fn get(id: TxId) -> Option { 37 | Self::with_ledger(|ledger| ledger.get(id)) 38 | } 39 | 40 | pub fn get_transactions( 41 | who: Option, 42 | count: usize, 43 | transaction_id: Option, 44 | ) -> PaginatedResult { 45 | Self::with_ledger(|ledger| ledger.get_transactions(who, count, transaction_id)) 46 | } 47 | 48 | pub fn list_transactions() -> Vec { 49 | Self::with_ledger(|ledger| ledger.iter().cloned().collect()) 50 | } 51 | 52 | pub fn get_len_user_history(user: Principal) -> usize { 53 | Self::with_ledger(|ledger| ledger.get_len_user_history(user)) 54 | } 55 | 56 | pub fn transfer( 57 | from: AccountInternal, 58 | to: AccountInternal, 59 | amount: Tokens128, 60 | fee: Tokens128, 61 | memo: Option, 62 | created_at_time: Timestamp, 63 | ) -> TxId { 64 | Self::with_ledger(|ledger| ledger.transfer(from, to, amount, fee, memo, created_at_time)) 65 | } 66 | 67 | pub fn batch_transfer( 68 | from: AccountInternal, 69 | transfers: Vec, 70 | fee: Tokens128, 71 | ) -> Vec { 72 | Self::with_ledger(|ledger| ledger.batch_transfer(from, transfers, fee)) 73 | } 74 | 75 | pub fn mint(from: AccountInternal, to: AccountInternal, amount: Tokens128) -> TxId { 76 | Self::with_ledger(|ledger| ledger.mint(from, to, amount)) 77 | } 78 | 79 | pub fn burn(caller: AccountInternal, from: AccountInternal, amount: Tokens128) -> TxId { 80 | Self::with_ledger(|ledger| ledger.burn(caller, from, amount)) 81 | } 82 | 83 | pub fn record_auction(to: Principal, amount: Tokens128) { 84 | Self::with_ledger(|ledger| ledger.record_auction(to, amount)) 85 | } 86 | 87 | pub fn claim(claim_account: AccountInternal, to: AccountInternal, amount: Tokens128) -> TxId { 88 | Self::with_ledger(|ledger| ledger.claim(claim_account, to, amount)) 89 | } 90 | 91 | pub fn clear() { 92 | Self::with_ledger(|ledger| ledger.clear()) 93 | } 94 | 95 | fn with_ledger(f: F) -> R 96 | where 97 | F: FnOnce(&mut Ledger) -> R, 98 | { 99 | LEDGER.with(|ledgers| { 100 | let canister_id = ic::id(); 101 | let mut borrowed = ledgers.borrow_mut(); 102 | let ledger = borrowed.entry(canister_id).or_default(); 103 | f(ledger) 104 | }) 105 | } 106 | } 107 | 108 | #[derive(Debug, Default, CandidType, Deserialize)] 109 | pub struct Ledger { 110 | history: Vec, 111 | } 112 | 113 | impl Ledger { 114 | pub fn is_empty(&self) -> bool { 115 | self.len() == 0 116 | } 117 | 118 | pub fn len(&self) -> u64 { 119 | Self::read_total_tx_count() 120 | } 121 | 122 | fn next_id(&self) -> TxId { 123 | Self::read_total_tx_count() 124 | } 125 | 126 | pub fn get(&self, id: TxId) -> Option { 127 | self.history.get(self.get_index(id)?).cloned() 128 | } 129 | 130 | pub fn get_transactions( 131 | &self, 132 | who: Option, 133 | count: usize, 134 | transaction_id: Option, 135 | ) -> PaginatedResult { 136 | let mut transactions = self 137 | .history 138 | .iter() 139 | .rev() 140 | .filter(|&tx| who.map_or(true, |c| tx.contains(c))) 141 | .filter(|tx| transaction_id.map_or(true, |id| id >= tx.index)) 142 | .take(count + 1) 143 | .cloned() 144 | .collect::>(); 145 | 146 | let next_id = if transactions.len() == count + 1 { 147 | Some(transactions.remove(count).index) 148 | } else { 149 | None 150 | }; 151 | 152 | PaginatedResult { 153 | result: transactions, 154 | next: next_id, 155 | } 156 | } 157 | 158 | pub fn iter(&self) -> impl DoubleEndedIterator { 159 | self.history.iter() 160 | } 161 | 162 | fn get_index(&self, id: TxId) -> Option { 163 | let first_stored_tx_id = Self::read_total_tx_count() - self.history.len() as u64; // Always >= 0 164 | if id < first_stored_tx_id || id > usize::MAX as TxId { 165 | None 166 | } else { 167 | Some((id - first_stored_tx_id) as usize) 168 | } 169 | } 170 | 171 | pub fn get_len_user_history(&self, user: Principal) -> usize { 172 | self.history.iter().filter(|&tx| tx.contains(user)).count() 173 | } 174 | 175 | pub fn transfer( 176 | &mut self, 177 | from: AccountInternal, 178 | to: AccountInternal, 179 | amount: Tokens128, 180 | fee: Tokens128, 181 | memo: Option, 182 | created_at_time: Timestamp, 183 | ) -> TxId { 184 | let id = self.next_id(); 185 | self.push(TxRecord::transfer( 186 | id, 187 | from, 188 | to, 189 | amount, 190 | fee, 191 | memo, 192 | created_at_time, 193 | )); 194 | 195 | id 196 | } 197 | 198 | pub fn batch_transfer( 199 | &mut self, 200 | from: AccountInternal, 201 | transfers: Vec, 202 | fee: Tokens128, 203 | ) -> Vec { 204 | transfers 205 | .into_iter() 206 | .map(|x| self.transfer(from, x.receiver.into(), x.amount, fee, None, ic::time())) 207 | .collect() 208 | } 209 | 210 | pub fn mint(&mut self, from: AccountInternal, to: AccountInternal, amount: Tokens128) -> TxId { 211 | let id = self.len(); 212 | self.push(TxRecord::mint(id, from, to, amount)); 213 | 214 | id 215 | } 216 | 217 | pub fn burn( 218 | &mut self, 219 | caller: AccountInternal, 220 | from: AccountInternal, 221 | amount: Tokens128, 222 | ) -> TxId { 223 | let id = self.next_id(); 224 | self.push(TxRecord::burn(id, caller, from, amount)); 225 | 226 | id 227 | } 228 | 229 | pub fn record_auction(&mut self, to: Principal, amount: Tokens128) { 230 | let id = self.next_id(); 231 | self.push(TxRecord::auction(id, to.into(), amount)) 232 | } 233 | 234 | fn push(&mut self, record: TxRecord) { 235 | self.history.push(record); 236 | Self::increase_total_tx_count(); 237 | if self.history.len() > MAX_HISTORY_LENGTH + HISTORY_REMOVAL_BATCH_SIZE { 238 | // We remove first `HISTORY_REMOVAL_BATCH_SIZE` from the history at one go, to prevent 239 | // often relocation of the history vec. 240 | // This removal code can later be changed to moving old history records into another 241 | // storage. 242 | 243 | self.history = self.history[HISTORY_REMOVAL_BATCH_SIZE..].into(); 244 | } 245 | } 246 | 247 | pub fn claim( 248 | &mut self, 249 | claim_account: AccountInternal, 250 | to: AccountInternal, 251 | amount: Tokens128, 252 | ) -> TxId { 253 | let id = self.next_id(); 254 | self.push(TxRecord::claim(id, claim_account, to, amount)); 255 | 256 | id 257 | } 258 | 259 | pub fn clear(&mut self) { 260 | self.history.clear(); 261 | TOTAL_TX_COUNT.with(|count| { 262 | count 263 | .borrow_mut() 264 | .set(0) 265 | .expect("fail to write total tx count") 266 | }); 267 | } 268 | 269 | fn increase_total_tx_count() { 270 | TOTAL_TX_COUNT.with(|count| { 271 | let mut count_mut = count.borrow_mut(); 272 | let prev_count = *count_mut.get(); 273 | count_mut 274 | .set(prev_count + 1) 275 | .expect("fail to write total tx count") 276 | }); 277 | } 278 | 279 | fn read_total_tx_count() -> u64 { 280 | TOTAL_TX_COUNT.with(|offset| *offset.borrow().get()) 281 | } 282 | } 283 | 284 | pub type TxReceipt = Result; 285 | 286 | #[derive(CandidType, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] 287 | pub enum TransactionStatus { 288 | Succeeded, 289 | Failed, 290 | } 291 | 292 | #[derive(CandidType, Debug, Clone, Copy, Deserialize, PartialEq, Eq)] 293 | pub enum Operation { 294 | Approve, 295 | Mint, 296 | Transfer, 297 | TransferFrom, 298 | Burn, 299 | Auction, 300 | Claim, 301 | } 302 | 303 | /// `PaginatedResult` is returned by paginated queries i.e `get_transactions`. 304 | #[derive(Debug, Clone, CandidType, Deserialize)] 305 | pub struct PaginatedResult { 306 | /// The result is the transactions which is the `count` transactions starting from `next` if it exists. 307 | pub result: Vec, 308 | 309 | /// This is the next `id` of the transaction. The `next` is used as offset for the next query if it exits. 310 | pub next: Option, 311 | } 312 | 313 | // Batch transfer arguments. 314 | #[derive(Debug, Clone, CandidType, Deserialize)] 315 | pub struct BatchTransferArgs { 316 | pub receiver: Account, 317 | pub amount: Tokens128, 318 | } 319 | 320 | /// These are the arguments which are taken in the `icrc1_transfer` 321 | #[derive(Debug, Clone, CandidType, Deserialize)] 322 | pub struct TransferArgs { 323 | pub from_subaccount: Option, 324 | pub to: Account, 325 | pub amount: Tokens128, 326 | pub fee: Option, 327 | pub memo: Option, 328 | pub created_at_time: Option, 329 | } 330 | 331 | impl TransferArgs { 332 | pub fn with_amount(&self, amount: Tokens128) -> Self { 333 | Self { 334 | amount, 335 | ..self.clone() 336 | } 337 | } 338 | } 339 | 340 | pub type Memo = [u8; 32]; 341 | -------------------------------------------------------------------------------- /src/token/api/src/tx_record.rs: -------------------------------------------------------------------------------- 1 | use candid::{CandidType, Deserialize, Principal}; 2 | use canister_sdk::ic_helpers::tokens::Tokens128; 3 | use canister_sdk::ic_kit::ic; 4 | 5 | use crate::{ 6 | account::{Account, AccountInternal}, 7 | state::config::Timestamp, 8 | state::ledger::{Memo, Operation, TransactionStatus}, 9 | }; 10 | 11 | pub type TxId = u64; 12 | 13 | // We use `Account` instead of `AccountInternal` in this structure for two reasons: 14 | // 1. It was there before `AccountInternal` was introduced, so if we want to change this type, we 15 | // would need to introduce a new version of the state. 16 | // 2. This structre is returned to the client by APIs, and it's prefered to use `Account` in APIs. 17 | #[derive(Deserialize, CandidType, Debug, Clone)] 18 | pub struct TxRecord { 19 | pub caller: Principal, 20 | pub index: TxId, 21 | pub from: Account, 22 | pub to: Account, 23 | pub amount: Tokens128, 24 | pub fee: Tokens128, 25 | pub timestamp: Timestamp, 26 | pub status: TransactionStatus, 27 | pub operation: Operation, 28 | pub memo: Option, 29 | } 30 | 31 | impl TxRecord { 32 | pub fn transfer( 33 | index: TxId, 34 | from: AccountInternal, 35 | to: AccountInternal, 36 | amount: Tokens128, 37 | fee: Tokens128, 38 | memo: Option, 39 | created_at_time: Timestamp, 40 | ) -> Self { 41 | Self { 42 | caller: from.owner, 43 | index, 44 | from: from.into(), 45 | to: to.into(), 46 | amount, 47 | fee, 48 | timestamp: created_at_time, 49 | status: TransactionStatus::Succeeded, 50 | operation: Operation::Transfer, 51 | memo, 52 | } 53 | } 54 | 55 | pub fn mint( 56 | index: TxId, 57 | from: AccountInternal, 58 | to: AccountInternal, 59 | amount: Tokens128, 60 | ) -> Self { 61 | Self { 62 | caller: from.owner, 63 | index, 64 | from: from.into(), 65 | to: to.into(), 66 | amount, 67 | fee: Tokens128::from(0u128), 68 | timestamp: ic::time(), 69 | status: TransactionStatus::Succeeded, 70 | operation: Operation::Mint, 71 | memo: None, 72 | } 73 | } 74 | 75 | pub fn burn( 76 | index: TxId, 77 | caller: AccountInternal, 78 | from: AccountInternal, 79 | amount: Tokens128, 80 | ) -> Self { 81 | Self { 82 | caller: caller.owner, 83 | index, 84 | from: from.into(), 85 | to: from.into(), 86 | amount, 87 | fee: Tokens128::from(0u128), 88 | timestamp: ic::time(), 89 | status: TransactionStatus::Succeeded, 90 | operation: Operation::Burn, 91 | memo: None, 92 | } 93 | } 94 | 95 | pub fn auction(index: TxId, to: AccountInternal, amount: Tokens128) -> Self { 96 | Self { 97 | caller: to.owner, 98 | index, 99 | from: to.into(), 100 | to: to.into(), 101 | amount, 102 | fee: Tokens128::from(0u128), 103 | timestamp: ic::time(), 104 | status: TransactionStatus::Succeeded, 105 | operation: Operation::Auction, 106 | memo: None, 107 | } 108 | } 109 | 110 | // This is a helper funntion to compare the principal of a transaction record. 111 | pub fn contains(&self, pid: Principal) -> bool { 112 | self.caller == pid || self.from.owner == pid || self.to.owner == pid 113 | } 114 | 115 | pub fn claim(id: u64, from: AccountInternal, to: AccountInternal, amount: Tokens128) -> Self { 116 | Self { 117 | caller: to.owner, 118 | index: id, 119 | from: from.into(), 120 | to: to.into(), 121 | amount, 122 | fee: 0.into(), 123 | timestamp: ic::time(), 124 | status: TransactionStatus::Succeeded, 125 | operation: Operation::Claim, 126 | memo: None, 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/token/impl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "is20-token-canister" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [features] 7 | default = [] 8 | export-api = ["token-api/export-api","canister-sdk/metrics-api"] 9 | 10 | [dependencies] 11 | candid = "0.8" 12 | serde = "1.0" 13 | canister-sdk = { workspace = true, features = ["auction"] } 14 | ic-exports = { workspace = true } 15 | token-api = { path = "../api", package = "is20-token", features = ["auction", "claim"] } 16 | 17 | [target.'cfg(not(target_family = "wasm"))'.dependencies] 18 | async-std = {version = "1.10.0", features = ["attributes"]} 19 | 20 | [dev-dependencies] 21 | coverage-helper = "0.1" 22 | -------------------------------------------------------------------------------- /src/token/impl/src/canister.rs: -------------------------------------------------------------------------------- 1 | use canister_sdk::{ 2 | ic_auction::{ 3 | api::Auction, 4 | error::AuctionError, 5 | state::{AuctionInfo, AuctionState}, 6 | }, 7 | ic_canister::{self, init, post_upgrade, pre_upgrade, Canister, PreUpdate}, 8 | ic_helpers::tokens::Tokens128, 9 | ic_metrics::{Interval, Metrics, MetricsStorage}, 10 | ic_storage::IcStorage, 11 | }; 12 | #[cfg(feature = "export-api")] 13 | use canister_sdk::{ic_cdk, ic_cdk_macros::inspect_message}; 14 | use ic_exports::Principal; 15 | use std::{cell::RefCell, rc::Rc}; 16 | use token_api::{ 17 | account::AccountInternal, 18 | canister::{TokenCanisterAPI, DEFAULT_AUCTION_PERIOD_SECONDS}, 19 | state::{ 20 | balances::{Balances, StableBalances}, 21 | config::{Metadata, TokenConfig}, 22 | ledger::LedgerData, 23 | }, 24 | }; 25 | 26 | #[derive(Debug, Clone, Canister)] 27 | #[canister_no_upgrade_methods] 28 | pub struct TokenCanister { 29 | #[id] 30 | principal: Principal, 31 | } 32 | 33 | impl TokenCanister { 34 | #[init] 35 | pub fn init(&self, metadata: Metadata, amount: Tokens128) { 36 | let owner = metadata.owner; 37 | let owner_account = AccountInternal::new(owner, None); 38 | 39 | StableBalances.clear(); 40 | StableBalances.insert(owner_account, amount); 41 | 42 | LedgerData::mint( 43 | AccountInternal::from(owner), 44 | AccountInternal::from(owner), 45 | amount, 46 | ); 47 | 48 | TokenConfig::set_stable(metadata.into()); 49 | 50 | let auction_state = self.auction_state(); 51 | auction_state.replace(AuctionState::new( 52 | Interval::Period { 53 | seconds: DEFAULT_AUCTION_PERIOD_SECONDS, 54 | }, 55 | owner, 56 | )); 57 | } 58 | 59 | #[pre_upgrade] 60 | fn pre_upgrade(&self) { 61 | // All required canister state stored in stable memory, so no need to save/load anything. 62 | } 63 | 64 | #[post_upgrade] 65 | fn post_upgrade(&self) { 66 | // All required canister state stored in stable memory, so no need to save/load anything. 67 | } 68 | } 69 | 70 | #[cfg(feature = "export-api")] 71 | #[inspect_message] 72 | fn inspect_message() { 73 | use canister_sdk::ic_cdk; 74 | use token_api::canister::AcceptReason; 75 | 76 | let method = ic_cdk::api::call::method_name(); 77 | let caller = ic_cdk::api::caller(); 78 | 79 | let accept_reason = match TokenCanister::inspect_message(&method, caller) { 80 | Ok(accept_reason) => accept_reason, 81 | Err(msg) => ic_cdk::trap(msg), 82 | }; 83 | 84 | match accept_reason { 85 | AcceptReason::Valid => ic_cdk::api::call::accept_message(), 86 | AcceptReason::NotIS20Method => ic_cdk::trap("Unknown method"), 87 | } 88 | } 89 | 90 | impl PreUpdate for TokenCanister { 91 | fn pre_update(&self, method_name: &str, method_type: ic_canister::MethodType) { 92 | ::canister_pre_update(self, method_name, method_type); 93 | self.update_metrics(); 94 | } 95 | } 96 | 97 | impl TokenCanisterAPI for TokenCanister {} 98 | 99 | impl Auction for TokenCanister { 100 | fn auction_state(&self) -> Rc> { 101 | AuctionState::get() 102 | } 103 | 104 | fn disburse_rewards(&self) -> Result { 105 | token_api::canister::is20_auction::disburse_rewards(&self.auction_state().borrow()) 106 | } 107 | } 108 | 109 | impl Metrics for TokenCanister { 110 | fn metrics(&self) -> Rc> { 111 | MetricsStorage::get() 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod test { 117 | use super::*; 118 | use canister_sdk::ic_kit::MockContext; 119 | 120 | #[test] 121 | #[cfg_attr(coverage_nightly, no_coverage)] 122 | fn test_upgrade_from_current() { 123 | MockContext::new().inject(); 124 | 125 | // Set a value on the state and write it to stable storage. 126 | let canister = TokenCanister::init_instance(); 127 | let mut stats = TokenConfig::get_stable(); 128 | stats.name = "To Kill a Mockingbird".to_string(); 129 | TokenConfig::set_stable(stats); 130 | 131 | canister.pre_upgrade(); 132 | canister.post_upgrade(); 133 | 134 | // Upgrade the canister should have the state 135 | // written before pre_upgrade 136 | assert_eq!( 137 | TokenConfig::get_stable().name, 138 | "To Kill a Mockingbird".to_string() 139 | ); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/token/impl/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(coverage_nightly, feature(no_coverage))] 2 | pub mod canister; 3 | 4 | /// This is a marker added to the token wasm to distinguish it from other canisters 5 | #[cfg(feature = "export-api")] 6 | #[no_mangle] 7 | pub static TOKEN_CANISTER_MARKER: &str = "IS20_TOKEN_CANISTER"; 8 | 9 | pub fn idl() -> String { 10 | use crate::canister::TokenCanister; 11 | use canister_sdk::{ic_auction::api::Auction, ic_canister::Idl, ic_helpers::tokens::Tokens128}; 12 | use token_api::canister::TokenCanisterAPI; 13 | use token_api::state::config::Metadata; 14 | 15 | let canister_idl = canister_sdk::ic_canister::generate_idl!(); 16 | let auction_idl = ::get_idl(); 17 | let mut trait_idl = ::get_idl(); 18 | trait_idl.merge(&canister_idl); 19 | trait_idl.merge(&auction_idl); 20 | 21 | candid::bindings::candid::compile(&trait_idl.env.env, &Some(trait_idl.actor)) 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use super::*; 27 | use coverage_helper::test; 28 | 29 | #[test] 30 | fn generated_idl_contains_all_methods() { 31 | let idl = idl(); 32 | let methods = [ 33 | "icrc1_balance_of", 34 | "decimals", 35 | "get_holders", 36 | "get_token_info", 37 | "get_transaction", 38 | "get_transactions", 39 | "get_user_transaction_count", 40 | "history_size", 41 | "icrc1_name", 42 | "owner", 43 | "icrc1_symbol", 44 | "icrc1_total_supply", 45 | "is_test_token", 46 | "set_fee", 47 | "set_fee_to", 48 | "set_name", 49 | "set_symbol", 50 | "set_owner", 51 | "mint", 52 | "burn", 53 | "bid_cycles", 54 | "run_auction", 55 | "bidding_info", 56 | "auction_info", 57 | "set_auction_period", 58 | "set_controller", 59 | "set_min_cycles", 60 | ]; 61 | 62 | for method in methods { 63 | assert!( 64 | idl.contains(method), 65 | "IDL string doesn't contain method \"{method}\"\nidl: {}", 66 | idl 67 | ); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/token/impl/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | print!("{}", is20_token_canister::idl()); 3 | } 4 | -------------------------------------------------------------------------------- /src/token/impl/tests/icrc1.rs: -------------------------------------------------------------------------------- 1 | use canister_sdk::{ 2 | ic_canister::Canister, 3 | ic_helpers::tokens::Tokens128, 4 | ic_kit::{ 5 | mock_principals::{alice, bob, john}, 6 | MockContext, 7 | }, 8 | }; 9 | use ic_exports::Principal; 10 | use is20_token_canister::canister::TokenCanister; 11 | use token_api::{ 12 | account::Account, 13 | canister::TokenCanisterAPI, 14 | error::TransferError, 15 | state::config::{Metadata, StandardRecord, Value}, 16 | state::{ 17 | balances::{Balances, StableBalances}, 18 | config::TokenConfig, 19 | ledger::{LedgerData, TransferArgs}, 20 | }, 21 | }; 22 | 23 | fn init() -> (Metadata, TokenCanister, &'static mut MockContext) { 24 | let context = canister_sdk::ic_kit::MockContext::new().inject(); 25 | 26 | let principal = Principal::from_text("mfufu-x6j4c-gomzb-geilq").unwrap(); 27 | let canister = TokenCanister::from_principal(principal); 28 | context.update_id(canister.principal()); 29 | 30 | // Refresh canister's state. 31 | TokenConfig::set_stable(TokenConfig::default()); 32 | StableBalances.clear(); 33 | LedgerData::clear(); 34 | 35 | let meta = Metadata { 36 | decimals: 11, 37 | fee: 127.into(), 38 | fee_to: alice(), 39 | name: "Testo".into(), 40 | symbol: "TST".into(), 41 | owner: alice(), 42 | is_test_token: None, 43 | }; 44 | canister.init(meta.clone(), 1_000_000_000.into()); 45 | (meta, canister, context) 46 | } 47 | 48 | #[test] 49 | fn meta_fields_getting() { 50 | let (meta, canister, _) = init(); 51 | 52 | assert_eq!(canister.icrc1_name(), meta.name); 53 | assert_eq!(canister.icrc1_symbol(), meta.symbol); 54 | assert_eq!(canister.icrc1_decimals(), meta.decimals); 55 | assert_eq!(canister.icrc1_fee(), meta.fee); 56 | assert_eq!(canister.icrc1_total_supply(), 1_000_000_000.into()); 57 | assert_eq!( 58 | canister.icrc1_balance_of(Account::new(bob(), None)), 59 | 0.into() 60 | ); 61 | assert_eq!( 62 | canister.icrc1_balance_of(Account::new(alice(), None)), 63 | 1_000_000_000.into() 64 | ); 65 | } 66 | 67 | #[test] 68 | fn supported_standards() { 69 | let (_, canister, _) = init(); 70 | 71 | let standards = canister.icrc1_supported_standards(); 72 | assert!(standards.contains(&StandardRecord { 73 | name: "ICRC-1".to_string(), 74 | url: "https://github.com/dfinity/ICRC-1".to_string(), 75 | })); 76 | assert!(standards.contains(&StandardRecord { 77 | name: "IS20".to_string(), 78 | url: "https://github.com/infinity-swap/is20".to_string(), 79 | })); 80 | } 81 | 82 | #[test] 83 | fn metadata() { 84 | let (_, canister, _) = init(); 85 | 86 | let metadata = canister.icrc1_metadata(); 87 | assert!(metadata.contains(&("icrc1:symbol".to_string(), Value::Text("TST".to_string())))); 88 | assert!(metadata.contains(&("icrc1:name".to_string(), Value::Text("Testo".to_string())))); 89 | assert!(metadata.contains(&("icrc1:decimals".to_string(), Value::Nat(11.into())))); 90 | assert!(metadata.contains(&("icrc1:fee".to_string(), Value::Nat(127.into())))); 91 | } 92 | 93 | fn transfer(canister: &TokenCanister, to: Principal, amount: u128) { 94 | canister 95 | .icrc1_transfer(TransferArgs { 96 | from_subaccount: None, 97 | to: Account::new(to, None), 98 | amount: amount.into(), 99 | fee: None, 100 | memo: None, 101 | created_at_time: None, 102 | }) 103 | .unwrap(); 104 | } 105 | 106 | #[test] 107 | fn normal_transfer() { 108 | let (meta, canister, ctx) = init(); 109 | 110 | // This transfer is actually mint transfer which we don't want to test here, so we skip it. 111 | ctx.update_caller(alice()); 112 | transfer(&canister, bob(), 10_000); 113 | 114 | ctx.update_caller(bob()); 115 | transfer(&canister, john(), 5_000); 116 | 117 | assert_eq!( 118 | canister.icrc1_balance_of(Account::new(bob(), None)), 119 | (Tokens128::from(5_000) - meta.fee).unwrap() 120 | ); 121 | assert_eq!( 122 | canister.icrc1_balance_of(Account::new(john(), None)), 123 | Tokens128::from(5_000) 124 | ); 125 | } 126 | 127 | #[test] 128 | fn bad_fee_transfer() { 129 | let (meta, canister, ctx) = init(); 130 | 131 | // This transfer is actually mint transfer which we don't want to test here, so we skip it. 132 | ctx.update_caller(alice()); 133 | transfer(&canister, bob(), 10_000); 134 | 135 | ctx.update_caller(bob()); 136 | let result = canister.icrc1_transfer(TransferArgs { 137 | from_subaccount: None, 138 | to: Account::new(john(), None), 139 | amount: 1000.into(), 140 | fee: Some(126.into()), 141 | memo: None, 142 | created_at_time: None, 143 | }); 144 | 145 | assert_eq!( 146 | result, 147 | Err(TransferError::BadFee { 148 | expected_fee: meta.fee 149 | }) 150 | ); 151 | } 152 | 153 | #[test] 154 | fn too_old_transfer() { 155 | let (_, canister, ctx) = init(); 156 | 157 | // This transfer is actually mint transfer which we don't want to test here, so we skip it. 158 | ctx.update_caller(alice()); 159 | transfer(&canister, bob(), 10_000); 160 | 161 | let curr_ts = canister_sdk::ic_kit::ic::time(); 162 | ctx.update_caller(bob()); 163 | let result = canister.icrc1_transfer(TransferArgs { 164 | from_subaccount: None, 165 | to: Account::new(john(), None), 166 | amount: 1000.into(), 167 | fee: None, 168 | memo: None, 169 | created_at_time: Some(curr_ts - 10 * 60 * 1_000_000_000), 170 | }); 171 | 172 | assert_eq!(result, Err(TransferError::TooOld)) 173 | } 174 | 175 | #[test] 176 | fn created_in_future() { 177 | let (_, canister, ctx) = init(); 178 | 179 | // This transfer is actually mint transfer which we don't want to test here, so we skip it. 180 | ctx.update_caller(alice()); 181 | transfer(&canister, bob(), 10_000); 182 | 183 | let curr_ts = canister_sdk::ic_kit::ic::time(); 184 | ctx.update_caller(bob()); 185 | let result = canister.icrc1_transfer(TransferArgs { 186 | from_subaccount: None, 187 | to: Account::new(john(), None), 188 | amount: 1000.into(), 189 | fee: None, 190 | memo: None, 191 | created_at_time: Some(curr_ts + 3 * 60 * 1_000_000_000), 192 | }); 193 | 194 | assert_eq!( 195 | result, 196 | Err(TransferError::CreatedInFuture { 197 | ledger_time: curr_ts 198 | }) 199 | ) 200 | } 201 | 202 | #[test] 203 | fn duplicate_check() { 204 | let (_, canister, ctx) = init(); 205 | 206 | // This transfer is actually mint transfer which we don't want to test here, so we skip it. 207 | ctx.update_caller(alice()); 208 | transfer(&canister, bob(), 10_000); 209 | 210 | let curr_ts = canister_sdk::ic_kit::ic::time(); 211 | ctx.update_caller(bob()); 212 | let tx_id = canister 213 | .icrc1_transfer(TransferArgs { 214 | from_subaccount: None, 215 | to: Account::new(john(), None), 216 | amount: 1000.into(), 217 | fee: None, 218 | memo: None, 219 | created_at_time: Some(curr_ts), 220 | }) 221 | .unwrap(); 222 | 223 | let result = canister.icrc1_transfer(TransferArgs { 224 | from_subaccount: None, 225 | to: Account::new(john(), None), 226 | amount: 1000.into(), 227 | fee: None, 228 | memo: None, 229 | created_at_time: Some(curr_ts), 230 | }); 231 | 232 | assert_eq!( 233 | result, 234 | Err(TransferError::Duplicate { 235 | duplicate_of: tx_id 236 | }) 237 | ); 238 | } 239 | 240 | #[test] 241 | fn mint_and_burn_transfers() { 242 | let (_, canister, ctx) = init(); 243 | 244 | let original_balance = canister.icrc1_balance_of(Account::new(alice(), None)); 245 | 246 | // This transfer is actually mint transfer which we don't want to test here, so we skip it. 247 | ctx.update_caller(alice()); 248 | transfer(&canister, bob(), 10_000); 249 | 250 | assert_eq!( 251 | canister.icrc1_balance_of(Account::new(alice(), None)), 252 | original_balance, 253 | ); 254 | 255 | ctx.update_caller(bob()); 256 | transfer(&canister, alice(), 5_000); 257 | 258 | assert_eq!( 259 | canister.icrc1_balance_of(Account::new(alice(), None)), 260 | original_balance, 261 | ); 262 | assert_eq!( 263 | canister.icrc1_balance_of(Account::new(bob(), None)), 264 | Tokens128::from(5000), 265 | ); 266 | } 267 | --------------------------------------------------------------------------------