├── .bazelrc ├── .bazelversion ├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── .gitignore ├── BUILD.bazel ├── Cargo.Bazel.lock ├── Cargo.lock ├── Cargo.toml ├── Charters.md ├── LICENSE ├── README.md ├── WORKSPACE.bazel ├── bazel ├── BUILD.bazel ├── didc_repo.bzl ├── didc_test.bzl └── replica_tools.bzl ├── ref ├── Account.mo ├── AccountTest.mo ├── BUILD.bazel ├── ICRC1.mo └── README.md ├── rust-toolchain.toml ├── rustfmt.toml ├── standards ├── ICRC-1 │ ├── BUILD.bazel │ ├── ICRC-1.did │ ├── README.md │ └── TextualEncoding.md ├── ICRC-2 │ ├── BUILD.bazel │ ├── ICRC-2.did │ └── README.md └── ICRC-3 │ ├── HASHINGVALUES.md │ ├── ICRC-3.did │ └── README.md └── test ├── README.md ├── env ├── BUILD.bazel ├── Cargo.toml ├── Changelog.md ├── LICENSE ├── README.md ├── lib.rs ├── replica │ ├── BUILD.bazel │ ├── Cargo.toml │ ├── Changelog.md │ ├── LICENSE │ ├── README.md │ └── lib.rs └── state-machine │ ├── BUILD.bazel │ ├── Cargo.toml │ ├── Changelog.md │ ├── LICENSE │ ├── README.md │ └── lib.rs ├── ref ├── BUILD.bazel └── test.rs ├── replica ├── BUILD.bazel ├── Cargo.toml ├── Changelog.md ├── LICENSE ├── README.md └── lib.rs ├── runner ├── BUILD.bazel ├── Cargo.toml ├── Changelog.md ├── LICENSE ├── README.md └── main.rs └── suite ├── BUILD.bazel ├── Cargo.toml ├── Changelog.md ├── LICENSE ├── README.md └── lib.rs /.bazelrc: -------------------------------------------------------------------------------- 1 | test --test_output=errors 2 | 3 | # Rustfmt 4 | build --@rules_rust//:rustfmt.toml=//:rustfmt.toml 5 | build --aspects=@rules_rust//rust:defs.bzl%rustfmt_aspect 6 | build --output_groups=+rustfmt_checks 7 | 8 | # Clippy 9 | build --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect 10 | build --output_groups=+clippy_checks -------------------------------------------------------------------------------- /.bazelversion: -------------------------------------------------------------------------------- 1 | 6.4.0 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @dfinity/finint 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Mount bazel cache 17 | uses: actions/cache@v1 18 | with: 19 | path: "/home/runner/.cache/bazel" 20 | key: bazel 21 | 22 | - name: Install bazelisk 23 | run: | 24 | curl -LO "https://github.com/bazelbuild/bazelisk/releases/download/v1.12.0/bazelisk-linux-amd64" 25 | mkdir -p "${GITHUB_WORKSPACE}/bin/" 26 | mv bazelisk-linux-amd64 "${GITHUB_WORKSPACE}/bin/bazel" 27 | chmod +x "${GITHUB_WORKSPACE}/bin/bazel" 28 | 29 | - name: Test 30 | run: | 31 | "${GITHUB_WORKSPACE}/bin/bazel" test //... --test_output=errors 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bazel-* 2 | target/ -------------------------------------------------------------------------------- /BUILD.bazel: -------------------------------------------------------------------------------- 1 | package(default_visibility = ["//visibility:public"]) 2 | 3 | exports_files([ 4 | "Cargo.toml", 5 | "rustfmt.toml", 6 | ]) 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "test/env", 5 | "test/env/state-machine", 6 | "test/env/replica", 7 | "test/suite", 8 | "test/runner", 9 | "test/replica", 10 | ] 11 | 12 | [workspace.dependencies] 13 | anyhow = "1.0" 14 | async-trait = "0.1.71" 15 | candid = "0.10.0" 16 | hex = "0.4.3" 17 | ic-agent = "0.31.0" 18 | reqwest = "0.11" 19 | ic-test-state-machine-client = "3.0.0" 20 | rand = "0.8.5" 21 | serde = "^1.0.184" 22 | tempfile = "3.3" 23 | thiserror = "1" 24 | tokio = { version = "1.20.1", features = ["macros"] } 25 | 26 | [workspace.package] 27 | authors = ["DFINITY Stiftung"] 28 | edition = "2018" 29 | repository = "https://github.com/dfinity/ICRC-1" 30 | rust-version = "1.31.0" 31 | license = "Apache-2.0" 32 | description = "A package which provides a test suite that can be run against an ICRC-1 compatible ledger." 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2023 DFINITY Foundation 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ledger & Tokenization Working Group Standards 2 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 3 | 4 | This repository contains standards for the [Internet Computer](https://internetcomputer.org) accepted by the Ledger & Tokenization Working Group. 5 | 6 | ## Standards 7 | 8 | * [ICRC-1: base fungible token standard](/standards/ICRC-1/README.md) 9 | * [ICRC-2: approve and transfer_from](/standards/ICRC-2/README.md) 10 | * [ICRC-3: block log](/standards/ICRC-3/README.md) 11 | -------------------------------------------------------------------------------- /WORKSPACE.bazel: -------------------------------------------------------------------------------- 1 | load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") 2 | load("@bazel_tools//tools/build_defs/repo:git.bzl", "new_git_repository") 3 | load("//bazel:didc_repo.bzl", "didc_repository") 4 | 5 | http_archive( 6 | name = "rules_motoko", 7 | sha256 = "9b677fc5d3b42749d13b7734b3a87d4d40135499a189e843ae3f183965e255b7", 8 | strip_prefix = "rules_motoko-0.1.0", 9 | urls = ["https://github.com/dfinity/rules_motoko/archive/refs/tags/v0.1.0.zip"], 10 | ) 11 | 12 | http_archive( 13 | name = "motoko_base", 14 | build_file_content = """ 15 | filegroup(name = "sources", srcs = glob(["*.mo"]), visibility = ["//visibility:public"]) 16 | """, 17 | sha256 = "582d1c90faa65047354ae7530f09160dd7e04882991287ced7ea7a72bd89d06e", 18 | strip_prefix = "motoko-base-moc-0.6.24/src", 19 | urls = ["https://github.com/dfinity/motoko-base/archive/refs/tags/moc-0.6.24.zip"], 20 | ) 21 | 22 | load("@rules_motoko//motoko:repositories.bzl", "rules_motoko_dependencies") 23 | 24 | rules_motoko_dependencies() 25 | 26 | http_archive( 27 | name = "io_bazel_rules_go", 28 | sha256 = "16e9fca53ed6bd4ff4ad76facc9b7b651a89db1689a2877d6fd7b82aa824e366", 29 | urls = [ 30 | "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.34.0/rules_go-v0.34.0.zip", 31 | "https://github.com/bazelbuild/rules_go/releases/download/v0.34.0/rules_go-v0.34.0.zip", 32 | ], 33 | ) 34 | 35 | load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies") 36 | 37 | go_rules_dependencies() 38 | 39 | go_register_toolchains(version = "1.18.4") 40 | 41 | new_git_repository( 42 | name = "lmt", 43 | build_file_content = """ 44 | load("@io_bazel_rules_go//go:def.bzl", "go_binary") 45 | 46 | go_binary( 47 | name = "lmt", 48 | srcs = ["main.go"], 49 | visibility = ["//visibility:public"], 50 | ) 51 | """, 52 | commit = "62fe18f2f6a6e11c158ff2b2209e1082a4fcd59c", 53 | remote = "https://github.com/driusan/lmt", 54 | shallow_since = "1619009341 -0400", 55 | ) 56 | 57 | didc_repository(name = "didc") 58 | 59 | load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") 60 | 61 | http_archive( 62 | name = "rules_rust", 63 | sha256 = "4a9cb4fda6ccd5b5ec393b2e944822a62e050c7c06f1ea41607f14c4fdec57a2", 64 | urls = ["https://github.com/bazelbuild/rules_rust/releases/download/0.25.1/rules_rust-v0.25.1.tar.gz"], 65 | ) 66 | 67 | load("@rules_rust//rust:repositories.bzl", "rules_rust_dependencies", "rust_register_toolchains") 68 | 69 | rules_rust_dependencies() 70 | 71 | rust_register_toolchains( 72 | edition = "2021", 73 | versions = ["1.71.0"], 74 | ) 75 | 76 | load("@rules_rust//crate_universe:repositories.bzl", "crate_universe_dependencies") 77 | 78 | crate_universe_dependencies() 79 | 80 | load("@rules_rust//crate_universe:defs.bzl", "crates_repository") 81 | 82 | crates_repository( 83 | name = "crate_index", 84 | cargo_lockfile = "//:Cargo.lock", 85 | lockfile = "//:Cargo.Bazel.lock", 86 | manifests = [ 87 | "//:Cargo.toml", 88 | "//test/env:Cargo.toml", 89 | "//test/env/replica:Cargo.toml", 90 | "//test/env/state-machine:Cargo.toml", 91 | "//test/suite:Cargo.toml", 92 | "//test/runner:Cargo.toml", 93 | "//test/replica:Cargo.toml", 94 | ], 95 | ) 96 | 97 | load("@crate_index//:defs.bzl", "crate_repositories") 98 | 99 | crate_repositories() 100 | 101 | load("//bazel:replica_tools.bzl", "replica_tools_repository") 102 | 103 | replica_tools_repository(name = "replica_tools") 104 | -------------------------------------------------------------------------------- /bazel/BUILD.bazel: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/ICRC-1/effeeaa89d8dd75ce3b2215b9c36eed98dafb7a8/bazel/BUILD.bazel -------------------------------------------------------------------------------- /bazel/didc_repo.bzl: -------------------------------------------------------------------------------- 1 | load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") 2 | load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe") 3 | 4 | DIDC_BUILD = """ 5 | package(default_visibility = ["//visibility:public"]) 6 | exports_files(["didc"]) 7 | """ 8 | 9 | def _didc_impl(repository_ctx): 10 | os_name = repository_ctx.os.name 11 | if os_name == "linux": 12 | repository_ctx.download( 13 | url = "https://github.com/dfinity/candid/releases/download/2022-07-13/didc-linux64", 14 | sha256 = "e2bff62f20b23ef2164cb32cd1b7b728c72f5b5c3164a0f7b255e0419e1cbc24", 15 | executable = True, 16 | output = "didc", 17 | ) 18 | elif os_name == "mac os x": 19 | repository_ctx.download( 20 | url = "https://github.com/dfinity/candid/releases/download/2022-07-13/didc-macos", 21 | sha256 = "c0b838eead15c9d6c213ec0078af926f20d4e077fb0725bfd88bf6c1c80b3881", 22 | executable = True, 23 | output = "didc", 24 | ) 25 | else: 26 | fail("Unsupported operating system: " + os_name) 27 | 28 | repository_ctx.file("BUILD.bazel", DIDC_BUILD, executable = False) 29 | 30 | didc_repository = repository_rule( 31 | implementation = _didc_impl, 32 | attrs = {}, 33 | ) 34 | -------------------------------------------------------------------------------- /bazel/didc_test.bzl: -------------------------------------------------------------------------------- 1 | load("@rules_motoko//motoko:defs.bzl", "MotokoActorInfo") 2 | 3 | def _didc_check_impl(ctx): 4 | didc = ctx.executable._didc 5 | script = "\n".join( 6 | ["err=0"] + 7 | [didc.path + " check " + f.short_path + " || err=1" for f in ctx.files.srcs] + 8 | ["exit $err"], 9 | ) 10 | 11 | ctx.actions.write(output = ctx.outputs.executable, content = script) 12 | 13 | files = depset(direct = ctx.files.srcs + [didc]) 14 | runfiles = ctx.runfiles(files = files.to_list()) 15 | 16 | return [DefaultInfo(runfiles = runfiles)] 17 | 18 | DIDC_ATTR = attr.label( 19 | default = Label("@didc"), 20 | executable = True, 21 | allow_single_file = True, 22 | cfg = "exec", 23 | ) 24 | 25 | didc_check_test = rule( 26 | implementation = _didc_check_impl, 27 | attrs = { 28 | "srcs": attr.label_list(allow_files = True), 29 | "_didc": DIDC_ATTR, 30 | }, 31 | test = True, 32 | ) 33 | 34 | def _didc_subtype_check_impl(ctx): 35 | didc = ctx.executable._didc 36 | script = """ 37 | {didc} check {did} {previous} 2>didc_errors.log 38 | if [ -s didc_errors.log ]; then 39 | cat didc_errors.log 40 | exit 1 41 | fi 42 | """.format(didc = didc.path, did = ctx.file.did.short_path, previous = ctx.file.previous.short_path) 43 | 44 | ctx.actions.write(output = ctx.outputs.executable, content = script) 45 | 46 | files = depset(direct = [didc, ctx.file.did, ctx.file.previous]) 47 | runfiles = ctx.runfiles(files = files.to_list()) 48 | 49 | return [DefaultInfo(runfiles = runfiles)] 50 | 51 | didc_subtype_test = rule( 52 | implementation = _didc_subtype_check_impl, 53 | attrs = { 54 | "did": attr.label(allow_single_file = True), 55 | "previous": attr.label(allow_single_file = True), 56 | "_didc": DIDC_ATTR, 57 | }, 58 | test = True, 59 | ) 60 | 61 | def _mo_actor_did_impl(ctx): 62 | did_file = ctx.attr.actor[MotokoActorInfo].didl 63 | return [DefaultInfo(files = depset([did_file]))] 64 | 65 | motoko_actor_did_file = rule( 66 | implementation = _mo_actor_did_impl, 67 | attrs = { 68 | "actor": attr.label(providers = [MotokoActorInfo]), 69 | }, 70 | ) 71 | 72 | def _mo_actor_wasm_impl(ctx): 73 | wasm_file = ctx.attr.actor[MotokoActorInfo].wasm 74 | return [DefaultInfo(files = depset([wasm_file]))] 75 | 76 | motoko_actor_wasm_file = rule( 77 | implementation = _mo_actor_wasm_impl, 78 | attrs = { 79 | "actor": attr.label(providers = [MotokoActorInfo]), 80 | }, 81 | ) 82 | -------------------------------------------------------------------------------- /bazel/replica_tools.bzl: -------------------------------------------------------------------------------- 1 | REPLICA_BUILD = """ 2 | package(default_visibility = ["//visibility:public"]) 3 | 4 | exports_files(["replica", "ic-starter", "canister_sandbox", "sandbox_launcher","ic-test-state-machine"]) 5 | """ 6 | 7 | IC_COMMIT_HASH = "09c3000df0a54c470994ceb5bc33bd8457b02fe7" 8 | 9 | BINARY_HASHES = { 10 | "ic-starter.gz": { 11 | "linux": "8d8c51033cb2cd20049ca4e048144b895684d7a4fdbd07719476797b53ebafb5", 12 | "mac os x": "1f33354049b6c83c8be06344d913a8bcfdb61ba9234706a8bf3cdb3d620723ab", 13 | }, 14 | "replica.gz": { 15 | "linux": "2cd30cca1818b86785b3d9b808612b7c286252363806c70d196c2fcfa48d1188", 16 | "mac os x": "f320fec5733182e1ceb0dd03d19dc5bec01a1bf7763eb282e3fe14b1e1a6e18b", 17 | }, 18 | "canister_sandbox.gz": { 19 | "linux": "11849a543a162f0f25b3dc10f17c177ea054e4fdb8a8c86509c7f87988ce2913", 20 | "mac os x": "4acdd46cf9b1e5be987f6ce72d0118bf9039162e3ff80cd32056da136f753011", 21 | }, 22 | "sandbox_launcher.gz": { 23 | "linux": "96c416bf98724aa3bf72053d06d559f007f8655261b48f435f9104b605c8f77f", 24 | "mac os x": "ed0bc2eeaf282012c8475ddf1ca3369488dc80d385e5b194d2823ae84514ff8a", 25 | }, 26 | "ic-test-state-machine.gz": { 27 | "linux": "2fb622770cfdf815f74f0e2fbaa51795c74ecba334883a4762507aef90352c15", 28 | "mac os x": "c8c7a6daa921b243cb6f1825dd7f80a7a7543234476d50f7becda072b1725c5c", 29 | }, 30 | } 31 | 32 | def _replica_impl(repository_ctx): 33 | repository_ctx.report_progress("Fetching ic-starter") 34 | os_name = repository_ctx.os.name 35 | ic_arch = "" 36 | if os_name == "linux": 37 | ic_arch = "x86_64-linux" 38 | elif os_name == "mac os x": 39 | ic_arch = "x86_64-darwin" 40 | else: 41 | fail("Unsupported operating system: " + os_name) 42 | 43 | repository_ctx.file("BUILD.bazel", REPLICA_BUILD, executable = False) 44 | 45 | for (bin_name, os_to_hash) in BINARY_HASHES.items(): 46 | repository_ctx.report_progress("Fetching " + bin_name) 47 | repository_ctx.download( 48 | url = "https://download.dfinity.systems/ic/{commit}/binaries/{ic_arch}/{bin_name}".format(commit = IC_COMMIT_HASH, ic_arch = ic_arch, bin_name = bin_name), 49 | sha256 = os_to_hash[os_name], 50 | output = bin_name, 51 | ) 52 | bin_path = repository_ctx.path(bin_name) 53 | repository_ctx.execute(["/usr/bin/gunzip", bin_path]) 54 | repository_ctx.execute(["chmod", "755", bin_name.removesuffix(".gz")]) 55 | 56 | _replica = repository_rule( 57 | implementation = _replica_impl, 58 | attrs = {}, 59 | ) 60 | 61 | def replica_tools_repository(name): 62 | _replica(name = name) 63 | -------------------------------------------------------------------------------- /ref/Account.mo: -------------------------------------------------------------------------------- 1 | import Array "mo:base/Array"; 2 | import Blob "mo:base/Blob"; 3 | import Char "mo:base/Char"; 4 | import Text "mo:base/Text"; 5 | import Int "mo:base/Int"; 6 | import Iter "mo:base/Iter"; 7 | import Nat8 "mo:base/Nat8"; 8 | import Nat32 "mo:base/Nat32"; 9 | import Principal "mo:base/Principal"; 10 | import Option "mo:base/Option"; 11 | import Result "mo:base/Result"; 12 | 13 | module { 14 | public type Account = { owner : Principal; subaccount : ?Blob }; 15 | public type ParseError = { 16 | #malformed : Text; 17 | #not_canonical; 18 | #bad_checksum; 19 | }; 20 | 21 | /// Converts an account to text. 22 | public func toText({ owner; subaccount } : Account) : Text { 23 | let ownerText = Principal.toText(owner); 24 | switch (subaccount) { 25 | case (null) { ownerText }; 26 | case (?subaccount) { 27 | assert (subaccount.size() == 32); 28 | if (iterAll(subaccount.vals(), func(b : Nat8) : Bool { b == 0 })) { 29 | ownerText; 30 | } else { 31 | ownerText # "-" # checkSum(owner, subaccount) # "." # displaySubaccount(subaccount); 32 | }; 33 | }; 34 | }; 35 | }; 36 | 37 | /// Parses account from its textual representation. 38 | public func fromText(text : Text) : Result.Result { 39 | let n = text.size(); 40 | 41 | if (n == 0) { 42 | return #err(#malformed("empty")); 43 | }; 44 | 45 | let charsIter = text.chars(); 46 | let chars = Array.tabulate(n, func(_ : Nat) : Char { Option.get(charsIter.next(), ' ') }); 47 | 48 | var lastDash = n; 49 | var dot = n; 50 | 51 | // Find the last dash and the dot. 52 | label l for (i in Iter.range(0, n - 1)) { 53 | if (chars[i] == '-') { lastDash := i }; 54 | if (chars[i] == '.') { dot := i; break l }; 55 | }; 56 | 57 | if (lastDash == n) { 58 | return #err(#malformed("expected at least one dash ('-') character")); 59 | }; 60 | 61 | if (dot == n) { 62 | // No subaccount separator: the principal case. 63 | return #ok({ owner = Principal.fromText(text); subaccount = null }); 64 | }; 65 | 66 | let numSubaccountDigits = (n - dot - 1) : Nat; 67 | 68 | if (numSubaccountDigits > 64) { 69 | return #err(#malformed("the subaccount is too long (expected at most 64 characters)")); 70 | }; 71 | 72 | if (dot < lastDash) { 73 | return #err(#malformed("the subaccount separator does not follow the checksum separator")); 74 | }; 75 | 76 | if (dot - lastDash - 1 : Nat != 7) { 77 | return #err(#bad_checksum); 78 | }; 79 | 80 | // The encoding ends with a dot, the subaccount is empty. 81 | if (dot == (n - 1 : Nat)) { 82 | return #err(#not_canonical); 83 | }; 84 | 85 | // The first digit after the dot must not be a zero. 86 | if (chars[dot + 1] == '0') { 87 | return #err(#not_canonical); 88 | }; 89 | 90 | let principalText = Text.fromIter(iterSlice(chars, 0, lastDash - 1 : Nat)); 91 | let owner = Principal.fromText(principalText); 92 | 93 | var subaccountMut = Array.init(32, 0); 94 | 95 | let subaccountDigits = iterChain( 96 | iterReplicate('0', 64 - numSubaccountDigits : Nat), 97 | iterSlice(chars, dot + 1, n - 1 : Nat), 98 | ); 99 | 100 | // Decode hex backwards into the subaccount array. 101 | for ((i, c) in iterEnumerate(subaccountDigits)) { 102 | let value = switch (decodeHexDigit(c)) { 103 | case (?v) { v }; 104 | case (null) { 105 | return #err(#malformed("invalid hex char: '" # Text.fromChar(c) # "'")); 106 | }; 107 | }; 108 | subaccountMut[i / 2] += value << (4 * Nat8.fromNat(1 - i % 2)); 109 | }; 110 | 111 | // Check that the subaccount is not the default. 112 | if (iterAll(subaccountMut.vals(), func(x : Nat8) : Bool { x == 0 })) { 113 | return #err(#not_canonical); 114 | }; 115 | 116 | let subaccount = Blob.fromArrayMut(subaccountMut); 117 | 118 | if (not iterEqual(checkSum(owner, subaccount).chars(), iterSlice(chars, lastDash + 1, dot - 1 : Nat), Char.equal)) { 119 | return #err(#bad_checksum); 120 | }; 121 | 122 | #ok({ owner = owner; subaccount = ?subaccount }); 123 | }; 124 | 125 | // prettier-ignore 126 | let hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; 127 | 128 | func hexDigit(n : Nat8) : Char { 129 | hexDigits[Nat8.toNat(n)]; 130 | }; 131 | 132 | func decodeHexDigit(c : Char) : ?Nat8 { 133 | switch (c) { 134 | case ('0') { ?0 }; 135 | case ('1') { ?1 }; 136 | case ('2') { ?2 }; 137 | case ('3') { ?3 }; 138 | case ('4') { ?4 }; 139 | case ('5') { ?5 }; 140 | case ('6') { ?6 }; 141 | case ('7') { ?7 }; 142 | case ('8') { ?8 }; 143 | case ('9') { ?9 }; 144 | case ('a') { ?10 }; 145 | case ('b') { ?11 }; 146 | case ('c') { ?12 }; 147 | case ('d') { ?13 }; 148 | case ('e') { ?14 }; 149 | case ('f') { ?15 }; 150 | case _ { null }; 151 | }; 152 | }; 153 | 154 | // prettier-ignore 155 | let crc32Table : [Nat32] = 156 | [ 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f 157 | , 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988 158 | , 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2 159 | , 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7 160 | , 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9 161 | , 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172 162 | , 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c 163 | , 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59 164 | , 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423 165 | , 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924 166 | , 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106 167 | , 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433 168 | , 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d 169 | , 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e 170 | , 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950 171 | , 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65 172 | , 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7 173 | , 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0 174 | , 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa 175 | , 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f 176 | , 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81 177 | , 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a 178 | , 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84 179 | , 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1 180 | , 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb 181 | , 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc 182 | , 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e 183 | , 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b 184 | , 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55 185 | , 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236 186 | , 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28 187 | , 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d 188 | , 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f 189 | , 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38 190 | , 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242 191 | , 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777 192 | , 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69 193 | , 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2 194 | , 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc 195 | , 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9 196 | , 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693 197 | , 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94 198 | , 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d 199 | ]; 200 | 201 | let crc32Seed : Nat32 = 0xffffffff; 202 | 203 | // prettier-ignore 204 | let base32Alphabet = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "2", "3", "4", "5", "6", "7"]; 205 | 206 | func updateCrc(crc : Nat32, byte : Nat8) : Nat32 { 207 | crc32Table[Nat32.toNat(crc ^ Nat32.fromNat(Nat8.toNat(byte)) & 0xff)] ^ (crc >> 8); 208 | }; 209 | 210 | public func checkSum(owner : Principal, subaccount : Blob) : Text { 211 | let crc = crc32Seed ^ iterFold(iterChain(Principal.toBlob(owner).vals(), subaccount.vals()), updateCrc, crc32Seed); 212 | 213 | let d = func(shift : Nat) : Text { 214 | base32Alphabet[Nat32.toNat((crc >> Nat32.fromNat(shift)) & 0x1f)]; 215 | }; 216 | 217 | d(27) # d(22) # d(17) # d(12) # d(7) # d(2) # base32Alphabet[Nat32.toNat((crc & 0x03) << 3)]; 218 | }; 219 | 220 | // Hex-encodes a subaccount, skipping the leading zeros. 221 | func displaySubaccount(subaccount : Blob) : Text { 222 | func nibbles(b : Nat8) : Iter.Iter { 223 | iterChain(iterOnce(b / 16), iterOnce(b % 16)); 224 | }; 225 | 226 | Text.fromIter( 227 | Iter.map( 228 | iterSkipWhile( 229 | iterFlatMap(subaccount.vals(), nibbles), 230 | func(b : Nat8) : Bool { b == 0 }, 231 | ), 232 | hexDigit, 233 | ) 234 | ); 235 | }; 236 | 237 | // Helper functions to deal with iterators 238 | 239 | func iterSlice(a : [T], from : Nat, to : Nat) : Iter.Iter { 240 | Iter.map(Iter.range(from, to), func(i : Nat) : T { a[i] }); 241 | }; 242 | 243 | func iterEqual(xs : Iter.Iter, ys : Iter.Iter, eq : (T, T) -> Bool) : Bool { 244 | loop { 245 | switch ((xs.next(), ys.next())) { 246 | case ((null, null)) { return true }; 247 | case ((null, _)) { return false }; 248 | case ((_, null)) { return false }; 249 | case ((?x, ?y)) { if (not eq(x, y)) { return false } }; 250 | }; 251 | }; 252 | }; 253 | 254 | func iterAll(xs : Iter.Iter, predicate : (T) -> Bool) : Bool { 255 | loop { 256 | switch (xs.next()) { 257 | case (null) { return true }; 258 | case (?x) { if (not predicate(x)) { return false } }; 259 | }; 260 | }; 261 | }; 262 | 263 | func iterSkipWhile(xs : Iter.Iter, predicate : (T) -> Bool) : Iter.Iter { 264 | loop { 265 | switch (xs.next()) { 266 | case (null) { return xs }; 267 | case (?x) { 268 | if (not predicate(x)) { 269 | return iterChain(iterOnce(x), xs); 270 | }; 271 | }; 272 | }; 273 | }; 274 | }; 275 | 276 | func iterEnumerate(xs : Iter.Iter) : Iter.Iter<(Nat, T)> = object { 277 | var counter = 0; 278 | 279 | public func next() : ?(Nat, T) { 280 | switch (xs.next()) { 281 | case (?x) { 282 | let count = counter; 283 | counter += 1; 284 | ?(count, x); 285 | }; 286 | case (null) { null }; 287 | }; 288 | }; 289 | }; 290 | 291 | func iterFlatMap(xs : Iter.Iter, f : (T) -> Iter.Iter) : Iter.Iter = object { 292 | var it = iterEmpty(); 293 | 294 | public func next() : ?S { 295 | loop { 296 | switch (it.next()) { 297 | case (?s) { return ?s }; 298 | case (null) { 299 | switch (xs.next()) { 300 | case (null) { return null }; 301 | case (?x) { it := f(x) }; 302 | }; 303 | }; 304 | }; 305 | }; 306 | }; 307 | }; 308 | 309 | func iterReplicate(x : T, times : Nat) : Iter.Iter = object { 310 | var left = times; 311 | 312 | public func next() : ?T { 313 | if (left == 0) { null } else { left -= 1; ?x }; 314 | }; 315 | }; 316 | 317 | func iterEmpty() : Iter.Iter = object { 318 | public func next() : ?T = null; 319 | }; 320 | 321 | func iterOnce(x : T) : Iter.Iter = object { 322 | var value = ?x; 323 | 324 | public func next() : ?T { 325 | let old_value = value; 326 | value := null; 327 | old_value; 328 | }; 329 | }; 330 | 331 | func iterChain(xs : Iter.Iter, ys : Iter.Iter) : Iter.Iter = object { 332 | public func next() : ?T { 333 | switch (xs.next()) { 334 | case (null) { ys.next() }; 335 | case (?x) { ?x }; 336 | }; 337 | }; 338 | }; 339 | 340 | func iterFold(xs : Iter.Iter, f : (A, T) -> A, seed : A) : A { 341 | var acc = seed; 342 | loop { 343 | switch (xs.next()) { 344 | case (null) { return acc }; 345 | case (?x) { acc := f(acc, x) }; 346 | }; 347 | }; 348 | }; 349 | }; 350 | -------------------------------------------------------------------------------- /ref/AccountTest.mo: -------------------------------------------------------------------------------- 1 | import Array "mo:base/Array"; 2 | import Blob "mo:base/Blob"; 3 | import Debug "mo:base/Debug"; 4 | import Iter "mo:base/Iter"; 5 | import Nat8 "mo:base/Nat8"; 6 | import Option "mo:base/Option"; 7 | import Prelude "mo:base/Prelude"; 8 | import Principal "mo:base/Principal"; 9 | import Result "mo:base/Result"; 10 | import Text "mo:base/Text"; 11 | 12 | import Account "./Account"; 13 | 14 | func hexDigit(b : Nat8) : Nat8 { 15 | switch (b) { 16 | case (48 or 49 or 50 or 51 or 52 or 53 or 54 or 55 or 56 or 57) { b - 48 }; 17 | case (65 or 66 or 67 or 68 or 69 or 70) { 10 + (b - 65) }; 18 | case (97 or 98 or 99 or 100 or 101 or 102) { 10 + (b - 97) }; 19 | case _ { Prelude.nyi() }; 20 | }; 21 | }; 22 | 23 | func decodeHex(t : Text) : Blob { 24 | assert (t.size() % 2 == 0); 25 | let n = t.size() / 2; 26 | let h = Blob.toArray(Text.encodeUtf8(t)); 27 | var b : [var Nat8] = Array.init(n, Nat8.fromNat(0)); 28 | for (i in Iter.range(0, n - 1)) { 29 | b[i] := hexDigit(h[2 * i]) << 4 | hexDigit(h[2 * i + 1]); 30 | }; 31 | Blob.fromArrayMut(b); 32 | }; 33 | 34 | func checkEncode(principalText : Text, subaccount : ?[Nat8], expected : Text) { 35 | let principal = Principal.fromText(principalText); 36 | let encoded = Account.toText({ 37 | owner = principal; 38 | subaccount = Option.map(subaccount, Blob.fromArray); 39 | }); 40 | if (encoded != expected) { 41 | Debug.print("Expected: " # expected # "\nActual: " # encoded); 42 | assert false; 43 | }; 44 | }; 45 | 46 | checkEncode( 47 | "iooej-vlrze-c5tme-tn7qt-vqe7z-7bsj5-ebxlc-hlzgs-lueo3-3yast-pae", 48 | null, 49 | "iooej-vlrze-c5tme-tn7qt-vqe7z-7bsj5-ebxlc-hlzgs-lueo3-3yast-pae", 50 | ); 51 | 52 | checkEncode( 53 | "iooej-vlrze-c5tme-tn7qt-vqe7z-7bsj5-ebxlc-hlzgs-lueo3-3yast-pae", 54 | ?[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], 55 | "iooej-vlrze-c5tme-tn7qt-vqe7z-7bsj5-ebxlc-hlzgs-lueo3-3yast-pae", 56 | ); 57 | 58 | checkEncode( 59 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", 60 | ?[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32], 61 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", 62 | ); 63 | 64 | checkEncode( 65 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", 66 | ?[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, 1], 67 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1", 68 | ); 69 | 70 | func checkDecode(input : Text, expected : Result.Result) { 71 | let account = Account.fromText(input); 72 | if (account != expected) { 73 | Debug.print("Expected: " # debug_show expected # "\nActual: " # debug_show account); 74 | assert false; 75 | }; 76 | }; 77 | 78 | func defAccount(owner : Text) : Account.Account { 79 | { owner = Principal.fromText(owner); subaccount = null }; 80 | }; 81 | 82 | func account(owner : Text, subaccount : [Nat8]) : Account.Account { 83 | { 84 | owner = Principal.fromText(owner); 85 | subaccount = ?Blob.fromArray(subaccount); 86 | }; 87 | }; 88 | 89 | checkDecode( 90 | "iooej-vlrze-c5tme-tn7qt-vqe7z-7bsj5-ebxlc-hlzgs-lueo3-3yast-pae", 91 | #ok(defAccount("iooej-vlrze-c5tme-tn7qt-vqe7z-7bsj5-ebxlc-hlzgs-lueo3-3yast-pae")), 92 | ); 93 | 94 | checkDecode( 95 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", 96 | #ok( 97 | account("k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]) 98 | ), 99 | ); 100 | 101 | checkDecode( 102 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1", 103 | #ok( 104 | account("k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", [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, 1]) 105 | ), 106 | ); 107 | 108 | checkDecode( 109 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.01", 110 | #err(#not_canonical), 111 | ); 112 | 113 | checkDecode( 114 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae.1", 115 | #err(#bad_checksum), 116 | ); 117 | 118 | checkDecode( 119 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627j.1", 120 | #err(#bad_checksum), 121 | ); 122 | 123 | checkDecode( 124 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-7cc627i.1", 125 | #err(#bad_checksum), 126 | ); 127 | 128 | checkDecode( 129 | "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-q6bn32y.", 130 | #err(#not_canonical), 131 | ); 132 | -------------------------------------------------------------------------------- /ref/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@rules_motoko//motoko:defs.bzl", "motoko_binary", "motoko_library", "motoko_test") 2 | load("//bazel:didc_test.bzl", "didc_subtype_test", "motoko_actor_did_file") 3 | 4 | motoko_library( 5 | name = "base", 6 | srcs = ["@motoko_base//:sources"], 7 | ) 8 | 9 | motoko_library( 10 | name = "account", 11 | srcs = ["Account.mo"], 12 | deps = [":base"], 13 | ) 14 | 15 | motoko_test( 16 | name = "account_test", 17 | entry = "AccountTest.mo", 18 | deps = [":account"], 19 | ) 20 | 21 | motoko_binary( 22 | name = "icrc1_ref", 23 | entry = "ICRC1.mo", 24 | visibility = ["//visibility:public"], 25 | deps = [":base"], 26 | ) 27 | 28 | motoko_actor_did_file( 29 | name = "icrc1_ref_did", 30 | actor = ":icrc1_ref", 31 | ) 32 | 33 | didc_subtype_test( 34 | name = "icrc1_ref_candid_check", 35 | did = ":icrc1_ref_did", 36 | previous = "//standards/ICRC-1:ICRC-1.did", 37 | ) 38 | 39 | didc_subtype_test( 40 | name = "icrc2_ref_candid_check", 41 | did = ":icrc1_ref_did", 42 | previous = "//standards/ICRC-2:ICRC-2.did", 43 | ) 44 | -------------------------------------------------------------------------------- /ref/ICRC1.mo: -------------------------------------------------------------------------------- 1 | import Array "mo:base/Array"; 2 | import Blob "mo:base/Blob"; 3 | import Buffer "mo:base/Buffer"; 4 | import Principal "mo:base/Principal"; 5 | import Option "mo:base/Option"; 6 | import Error "mo:base/Error"; 7 | import Time "mo:base/Time"; 8 | import Int "mo:base/Int"; 9 | import Nat8 "mo:base/Nat8"; 10 | import Nat64 "mo:base/Nat64"; 11 | 12 | actor class Ledger(init : { initial_mints : [{ account : { owner : Principal; subaccount : ?Blob }; amount : Nat }]; minting_account : { owner : Principal; subaccount : ?Blob }; token_name : Text; token_symbol : Text; decimals : Nat8; transfer_fee : Nat }) = this { 13 | 14 | public type Account = { owner : Principal; subaccount : ?Subaccount }; 15 | public type Subaccount = Blob; 16 | public type Tokens = Nat; 17 | public type Memo = Blob; 18 | public type Timestamp = Nat64; 19 | public type Duration = Nat64; 20 | public type TxIndex = Nat; 21 | public type TxLog = Buffer.Buffer; 22 | 23 | public type Value = { #Nat : Nat; #Int : Int; #Blob : Blob; #Text : Text }; 24 | 25 | let maxMemoSize = 32; 26 | let permittedDriftNanos : Duration = 60_000_000_000; 27 | let transactionWindowNanos : Duration = 24 * 60 * 60 * 1_000_000_000; 28 | let defaultSubaccount : Subaccount = Blob.fromArrayMut(Array.init(32, 0 : Nat8)); 29 | 30 | public type Operation = { 31 | #Approve : Approve; 32 | #Transfer : Transfer; 33 | #Burn : Transfer; 34 | #Mint : Transfer; 35 | }; 36 | 37 | public type CommonFields = { 38 | memo : ?Memo; 39 | fee : ?Tokens; 40 | created_at_time : ?Timestamp; 41 | }; 42 | 43 | public type Approve = CommonFields and { 44 | from : Account; 45 | spender : Account; 46 | amount : Nat; 47 | expires_at : ?Nat64; 48 | }; 49 | 50 | public type TransferSource = { 51 | #Init; 52 | #Icrc1Transfer; 53 | #Icrc2TransferFrom; 54 | }; 55 | 56 | public type Transfer = CommonFields and { 57 | spender : Account; 58 | source : TransferSource; 59 | to : Account; 60 | from : Account; 61 | amount : Tokens; 62 | }; 63 | 64 | public type Allowance = { allowance : Nat; expires_at : ?Nat64 }; 65 | 66 | public type Transaction = { 67 | operation : Operation; 68 | // Effective fee for this transaction. 69 | fee : Tokens; 70 | timestamp : Timestamp; 71 | }; 72 | 73 | public type DeduplicationError = { 74 | #TooOld; 75 | #Duplicate : { duplicate_of : TxIndex }; 76 | #CreatedInFuture : { ledger_time : Timestamp }; 77 | }; 78 | 79 | public type CommonError = { 80 | #InsufficientFunds : { balance : Tokens }; 81 | #BadFee : { expected_fee : Tokens }; 82 | #TemporarilyUnavailable; 83 | #GenericError : { error_code : Nat; message : Text }; 84 | }; 85 | 86 | public type TransferError = DeduplicationError or CommonError or { 87 | #BadBurn : { min_burn_amount : Tokens }; 88 | }; 89 | 90 | public type ApproveError = DeduplicationError or CommonError or { 91 | #Expired : { ledger_time : Nat64 }; 92 | #AllowanceChanged : { current_allowance : Nat }; 93 | }; 94 | 95 | public type TransferFromError = TransferError or { 96 | #InsufficientAllowance : { allowance : Nat }; 97 | }; 98 | 99 | public type Result = { #Ok : T; #Err : E }; 100 | 101 | // Checks whether two accounts are semantically equal. 102 | func accountsEqual(lhs : Account, rhs : Account) : Bool { 103 | let lhsSubaccount = Option.get(lhs.subaccount, defaultSubaccount); 104 | let rhsSubaccount = Option.get(rhs.subaccount, defaultSubaccount); 105 | 106 | Principal.equal(lhs.owner, rhs.owner) and Blob.equal( 107 | lhsSubaccount, 108 | rhsSubaccount, 109 | ); 110 | }; 111 | 112 | // Computes the balance of the specified account. 113 | func balance(account : Account, log : TxLog) : Nat { 114 | var sum = 0; 115 | for (tx in log.vals()) { 116 | switch (tx.operation) { 117 | case (#Burn(args)) { 118 | if (accountsEqual(args.from, account)) { sum -= args.amount }; 119 | }; 120 | case (#Mint(args)) { 121 | if (accountsEqual(args.to, account)) { sum += args.amount }; 122 | }; 123 | case (#Transfer(args)) { 124 | if (accountsEqual(args.from, account)) { 125 | sum -= args.amount + tx.fee; 126 | }; 127 | if (accountsEqual(args.to, account)) { sum += args.amount }; 128 | }; 129 | case (#Approve(args)) { 130 | if (accountsEqual(args.from, account)) { sum -= tx.fee }; 131 | }; 132 | }; 133 | }; 134 | sum; 135 | }; 136 | 137 | // Computes the total token supply. 138 | func totalSupply(log : TxLog) : Tokens { 139 | var total = 0; 140 | for (tx in log.vals()) { 141 | switch (tx.operation) { 142 | case (#Burn(args)) { total -= args.amount }; 143 | case (#Mint(args)) { total += args.amount }; 144 | case (#Transfer(_)) { total -= tx.fee }; 145 | case (#Approve(_)) { total -= tx.fee }; 146 | }; 147 | }; 148 | total; 149 | }; 150 | 151 | // Finds a transaction in the transaction log. 152 | func findTransfer(transfer : Transfer, log : TxLog) : ?TxIndex { 153 | var i = 0; 154 | for (tx in log.vals()) { 155 | switch (tx.operation) { 156 | case (#Burn(args)) { if (args == transfer) { return ?i } }; 157 | case (#Mint(args)) { if (args == transfer) { return ?i } }; 158 | case (#Transfer(args)) { if (args == transfer) { return ?i } }; 159 | case (_) {}; 160 | }; 161 | i += 1; 162 | }; 163 | null; 164 | }; 165 | 166 | // Finds an approval in the transaction log. 167 | func findApproval(approval : Approve, log : TxLog) : ?TxIndex { 168 | var i = 0; 169 | for (tx in log.vals()) { 170 | switch (tx.operation) { 171 | case (#Approve(args)) { if (args == approval) { return ?i } }; 172 | case (_) {}; 173 | }; 174 | i += 1; 175 | }; 176 | null; 177 | }; 178 | 179 | // Computes allowance of the spender for the specified account. 180 | func allowance(account : Account, spender : Account, now : Nat64) : Allowance { 181 | var i = 0; 182 | var allowance : Nat = 0; 183 | var lastApprovalTs : ?Nat64 = null; 184 | 185 | for (tx in log.vals()) { 186 | // Reset expired approvals, if any. 187 | switch (lastApprovalTs) { 188 | case (?expires_at) { 189 | if (expires_at < tx.timestamp) { 190 | allowance := 0; 191 | lastApprovalTs := null; 192 | }; 193 | }; 194 | case (null) {}; 195 | }; 196 | // Add pending approvals. 197 | switch (tx.operation) { 198 | case (#Approve(args)) { 199 | if (args.from == account and args.spender == spender) { 200 | allowance := args.amount; 201 | lastApprovalTs := args.expires_at; 202 | }; 203 | }; 204 | case (#Transfer(args)) { 205 | if (args.from == account and args.spender == spender) { 206 | assert (allowance > args.amount + tx.fee); 207 | allowance -= args.amount + tx.fee; 208 | }; 209 | }; 210 | case (_) {}; 211 | }; 212 | }; 213 | 214 | switch (lastApprovalTs) { 215 | case (?expires_at) { 216 | if (expires_at < now) { { allowance = 0; expires_at = null } } else { 217 | { 218 | allowance = Int.abs(allowance); 219 | expires_at = ?expires_at; 220 | }; 221 | }; 222 | }; 223 | case (null) { { allowance = allowance; expires_at = null } }; 224 | }; 225 | }; 226 | 227 | // Checks if the principal is anonymous. 228 | func isAnonymous(p : Principal) : Bool { 229 | Blob.equal(Principal.toBlob(p), Blob.fromArray([0x04])); 230 | }; 231 | 232 | // Constructs the transaction log corresponding to the init argument. 233 | func makeGenesisChain() : TxLog { 234 | validateSubaccount(init.minting_account.subaccount); 235 | 236 | let now = Nat64.fromNat(Int.abs(Time.now())); 237 | let log = Buffer.Buffer(100); 238 | for ({ account; amount } in Array.vals(init.initial_mints)) { 239 | validateSubaccount(account.subaccount); 240 | let tx : Transaction = { 241 | operation = #Mint({ 242 | spender = init.minting_account; 243 | source = #Init; 244 | from = init.minting_account; 245 | to = account; 246 | amount = amount; 247 | fee = null; 248 | memo = null; 249 | created_at_time = ?now; 250 | }); 251 | fee = 0; 252 | timestamp = now; 253 | }; 254 | log.add(tx); 255 | }; 256 | log; 257 | }; 258 | 259 | // Traps if the specified blob is not a valid subaccount. 260 | func validateSubaccount(s : ?Subaccount) { 261 | let subaccount = Option.get(s, defaultSubaccount); 262 | assert (subaccount.size() == 32); 263 | }; 264 | 265 | func validateMemo(m : ?Memo) { 266 | switch (m) { 267 | case (null) {}; 268 | case (?memo) { assert (memo.size() <= maxMemoSize) }; 269 | }; 270 | }; 271 | 272 | func checkTxTime(created_at_time : ?Timestamp, now : Timestamp) : Result<(), DeduplicationError> { 273 | let txTime : Timestamp = Option.get(created_at_time, now); 274 | 275 | if ((txTime > now) and (txTime - now > permittedDriftNanos)) { 276 | return #Err(#CreatedInFuture { ledger_time = now }); 277 | }; 278 | 279 | if ((txTime < now) and (now - txTime > transactionWindowNanos + permittedDriftNanos)) { 280 | return #Err(#TooOld); 281 | }; 282 | 283 | #Ok(()); 284 | }; 285 | 286 | // The list of all transactions. 287 | var log : TxLog = makeGenesisChain(); 288 | 289 | // The stable representation of the transaction log. 290 | // Used only during upgrades. 291 | stable var persistedLog : [Transaction] = []; 292 | 293 | system func preupgrade() { 294 | persistedLog := log.toArray(); 295 | }; 296 | 297 | system func postupgrade() { 298 | log := Buffer.Buffer(persistedLog.size()); 299 | for (tx in Array.vals(persistedLog)) { 300 | log.add(tx); 301 | }; 302 | }; 303 | 304 | func recordTransaction(tx : Transaction) : TxIndex { 305 | let idx = log.size(); 306 | log.add(tx); 307 | idx; 308 | }; 309 | 310 | func classifyTransfer(log : TxLog, transfer : Transfer) : Result<(Operation, Tokens), TransferError> { 311 | let minter = init.minting_account; 312 | 313 | if (Option.isSome(transfer.created_at_time)) { 314 | switch (findTransfer(transfer, log)) { 315 | case (?txid) { return #Err(#Duplicate { duplicate_of = txid }) }; 316 | case null {}; 317 | }; 318 | }; 319 | 320 | let result = if (accountsEqual(transfer.from, minter)) { 321 | if (Option.get(transfer.fee, 0) != 0) { 322 | return #Err(#BadFee { expected_fee = 0 }); 323 | }; 324 | (#Mint(transfer), 0); 325 | } else if (accountsEqual(transfer.to, minter)) { 326 | if (Option.get(transfer.fee, 0) != 0) { 327 | return #Err(#BadFee { expected_fee = 0 }); 328 | }; 329 | 330 | if (transfer.amount < init.transfer_fee) { 331 | return #Err(#BadBurn { min_burn_amount = init.transfer_fee }); 332 | }; 333 | 334 | let debitBalance = balance(transfer.from, log); 335 | if (debitBalance < transfer.amount) { 336 | return #Err(#InsufficientFunds { balance = debitBalance }); 337 | }; 338 | 339 | (#Burn(transfer), 0); 340 | } else { 341 | let effectiveFee = init.transfer_fee; 342 | if (Option.get(transfer.fee, effectiveFee) != effectiveFee) { 343 | return #Err(#BadFee { expected_fee = init.transfer_fee }); 344 | }; 345 | 346 | let debitBalance = balance(transfer.from, log); 347 | if (debitBalance < transfer.amount + effectiveFee) { 348 | return #Err(#InsufficientFunds { balance = debitBalance }); 349 | }; 350 | 351 | (#Transfer(transfer), effectiveFee); 352 | }; 353 | #Ok(result); 354 | }; 355 | 356 | func applyTransfer(args : Transfer) : Result { 357 | validateSubaccount(args.from.subaccount); 358 | validateSubaccount(args.to.subaccount); 359 | validateMemo(args.memo); 360 | 361 | let now = Nat64.fromNat(Int.abs(Time.now())); 362 | 363 | switch (checkTxTime(args.created_at_time, now)) { 364 | case (#Ok(_)) {}; 365 | case (#Err(e)) { return #Err(e) }; 366 | }; 367 | 368 | switch (classifyTransfer(log, args)) { 369 | case (#Ok((operation, effectiveFee))) { 370 | #Ok(recordTransaction({ operation = operation; fee = effectiveFee; timestamp = now })); 371 | }; 372 | case (#Err(e)) { #Err(e) }; 373 | }; 374 | }; 375 | 376 | func overflowOk(x : Nat) : Nat { 377 | x; 378 | }; 379 | 380 | public shared ({ caller }) func icrc1_transfer({ 381 | from_subaccount : ?Subaccount; 382 | to : Account; 383 | amount : Tokens; 384 | fee : ?Tokens; 385 | memo : ?Memo; 386 | created_at_time : ?Timestamp; 387 | }) : async Result { 388 | let from = { 389 | owner = caller; 390 | subaccount = from_subaccount; 391 | }; 392 | applyTransfer({ 393 | spender = from; 394 | source = #Icrc1Transfer; 395 | from = from; 396 | to = to; 397 | amount = amount; 398 | fee = fee; 399 | memo = memo; 400 | created_at_time = created_at_time; 401 | }); 402 | }; 403 | 404 | public query func icrc1_balance_of(account : Account) : async Tokens { 405 | balance(account, log); 406 | }; 407 | 408 | public query func icrc1_total_supply() : async Tokens { 409 | totalSupply(log); 410 | }; 411 | 412 | public query func icrc1_minting_account() : async ?Account { 413 | ?init.minting_account; 414 | }; 415 | 416 | public query func icrc1_name() : async Text { 417 | init.token_name; 418 | }; 419 | 420 | public query func icrc1_symbol() : async Text { 421 | init.token_symbol; 422 | }; 423 | 424 | public query func icrc1_decimals() : async Nat8 { 425 | init.decimals; 426 | }; 427 | 428 | public query func icrc1_fee() : async Nat { 429 | init.transfer_fee; 430 | }; 431 | 432 | public query func icrc1_metadata() : async [(Text, Value)] { 433 | [ 434 | ("icrc1:name", #Text(init.token_name)), 435 | ("icrc1:symbol", #Text(init.token_symbol)), 436 | ("icrc1:decimals", #Nat(Nat8.toNat(init.decimals))), 437 | ("icrc1:fee", #Nat(init.transfer_fee)), 438 | ]; 439 | }; 440 | 441 | public query func icrc1_supported_standards() : async [{ 442 | name : Text; 443 | url : Text; 444 | }] { 445 | [ 446 | { 447 | name = "ICRC-1"; 448 | url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1"; 449 | }, 450 | { 451 | name = "ICRC-2"; 452 | url = "https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-2"; 453 | }, 454 | ]; 455 | }; 456 | 457 | public shared ({ caller }) func icrc2_approve({ 458 | from_subaccount : ?Subaccount; 459 | spender : Account; 460 | amount : Nat; 461 | expires_at : ?Nat64; 462 | expected_allowance : ?Nat; 463 | memo : ?Memo; 464 | fee : ?Tokens; 465 | created_at_time : ?Timestamp; 466 | }) : async Result { 467 | validateSubaccount(from_subaccount); 468 | validateMemo(memo); 469 | 470 | let now = Nat64.fromNat(Int.abs(Time.now())); 471 | 472 | switch (checkTxTime(created_at_time, now)) { 473 | case (#Ok(_)) {}; 474 | case (#Err(e)) { return #Err(e) }; 475 | }; 476 | 477 | let approverAccount = { owner = caller; subaccount = from_subaccount }; 478 | let approval = { 479 | from = approverAccount; 480 | spender = spender; 481 | amount = amount; 482 | expires_at = expires_at; 483 | fee = fee; 484 | created_at_time = created_at_time; 485 | memo = memo; 486 | }; 487 | 488 | if (Option.isSome(created_at_time)) { 489 | switch (findApproval(approval, log)) { 490 | case (?txid) { return #Err(#Duplicate { duplicate_of = txid }) }; 491 | case (null) {}; 492 | }; 493 | }; 494 | 495 | switch (expires_at) { 496 | case (?expires_at) { 497 | if (expires_at < now) { return #Err(#Expired { ledger_time = now }) }; 498 | }; 499 | case (null) {}; 500 | }; 501 | 502 | let effectiveFee = init.transfer_fee; 503 | 504 | if (Option.get(fee, effectiveFee) != effectiveFee) { 505 | return #Err(#BadFee({ expected_fee = effectiveFee })); 506 | }; 507 | 508 | switch (expected_allowance) { 509 | case (?expected_allowance) { 510 | let currentAllowance = allowance(approverAccount, spender, now); 511 | if (currentAllowance.allowance != expected_allowance) { 512 | return #Err(#AllowanceChanged({ current_allowance = currentAllowance.allowance })); 513 | }; 514 | }; 515 | case (null) {}; 516 | }; 517 | 518 | let approverBalance = balance(approverAccount, log); 519 | if (approverBalance < init.transfer_fee) { 520 | return #Err(#InsufficientFunds { balance = approverBalance }); 521 | }; 522 | 523 | let txid = recordTransaction({ 524 | operation = #Approve(approval); 525 | fee = effectiveFee; 526 | timestamp = now; 527 | }); 528 | 529 | assert (balance(approverAccount, log) == overflowOk(approverBalance - effectiveFee)); 530 | 531 | #Ok(txid); 532 | }; 533 | 534 | public shared ({ caller }) func icrc2_transfer_from({ 535 | spender_subaccount : ?Subaccount; 536 | from : Account; 537 | to : Account; 538 | amount : Tokens; 539 | fee : ?Tokens; 540 | memo : ?Memo; 541 | created_at_time : ?Timestamp; 542 | }) : async Result { 543 | validateSubaccount(spender_subaccount); 544 | validateSubaccount(from.subaccount); 545 | validateSubaccount(to.subaccount); 546 | validateMemo(memo); 547 | 548 | let spender = { owner = caller; subaccount = spender_subaccount }; 549 | let transfer : Transfer = { 550 | spender = spender; 551 | source = #Icrc2TransferFrom; 552 | from = from; 553 | to = to; 554 | amount = amount; 555 | fee = fee; 556 | memo = memo; 557 | created_at_time = created_at_time; 558 | }; 559 | 560 | if (caller == from.owner) { 561 | return applyTransfer(transfer); 562 | }; 563 | 564 | let now = Nat64.fromNat(Int.abs(Time.now())); 565 | 566 | switch (checkTxTime(created_at_time, now)) { 567 | case (#Ok(_)) {}; 568 | case (#Err(e)) { return #Err(e) }; 569 | }; 570 | 571 | let (operation, effectiveFee) = switch (classifyTransfer(log, transfer)) { 572 | case (#Ok(result)) { result }; 573 | case (#Err(err)) { return #Err(err) }; 574 | }; 575 | 576 | let preTransferAllowance = allowance(from, spender, now); 577 | if (preTransferAllowance.allowance < amount + effectiveFee) { 578 | return #Err(#InsufficientAllowance { allowance = preTransferAllowance.allowance }); 579 | }; 580 | 581 | let txid = recordTransaction({ 582 | operation = operation; 583 | fee = effectiveFee; 584 | timestamp = now; 585 | }); 586 | 587 | let postTransferAllowance = allowance(from, spender, now); 588 | assert (postTransferAllowance.allowance == overflowOk(preTransferAllowance.allowance - (amount + effectiveFee))); 589 | 590 | #Ok(txid); 591 | }; 592 | 593 | public query func icrc2_allowance({ account : Account; spender : Account }) : async Allowance { 594 | allowance(account, spender, Nat64.fromNat(Int.abs(Time.now()))); 595 | }; 596 | }; 597 | -------------------------------------------------------------------------------- /ref/README.md: -------------------------------------------------------------------------------- 1 | # Reference implementation 2 | 3 | This directory contains a reference implementation of the ICRC-1 standard in [Motoko](https://internetcomputer.org/docs/current/developer-docs/build/languages/motoko/). 4 | The goal of this implementation is to faithfully implement all the features of the standard in the most straightforward way. 5 | This implementation is not suitable for production use. 6 | 7 | # Building the code 8 | 9 | 1. Install the [Bazel](https://bazel.build/) build system. 10 | [Bazelisk](https://github.com/bazelbuild/bazelisk) is an easy way to get Bazel on your system. 11 | 2. Build the canister module 12 | bazel build //ref:icrc1_ref 13 | 3. Find the canister module at `bazel-bin/ref/icrc1_ref.wasm`. 14 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.71.0" 3 | components = ["rustfmt", "clippy"] 4 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" -------------------------------------------------------------------------------- /standards/ICRC-1/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("//bazel:didc_test.bzl", "didc_check_test", "didc_subtype_test") 2 | 3 | package(default_visibility = ["//visibility:public"]) 4 | 5 | exports_files([ 6 | "ICRC-1.did", 7 | ]) 8 | 9 | genrule( 10 | name = "candid", 11 | srcs = [":README.md"], 12 | outs = ["ICRC-1-generated.did"], 13 | cmd_bash = "$(location @lmt) $(SRCS); mv ICRC-1.did $@", 14 | exec_tools = ["@lmt"], 15 | ) 16 | 17 | didc_check_test( 18 | name = "extracted_candid_check", 19 | srcs = [":ICRC-1-generated.did"], 20 | ) 21 | 22 | didc_check_test( 23 | name = "committed_candid_check", 24 | srcs = ["ICRC-1.did"], 25 | ) 26 | 27 | didc_subtype_test( 28 | name = "check_generated_subtype", 29 | did = ":ICRC-1-generated.did", 30 | previous = "ICRC-1.did", 31 | ) 32 | 33 | didc_subtype_test( 34 | name = "check_source_subtype", 35 | did = "ICRC-1.did", 36 | previous = ":ICRC-1-generated.did", 37 | ) 38 | -------------------------------------------------------------------------------- /standards/ICRC-1/ICRC-1.did: -------------------------------------------------------------------------------- 1 | // Number of nanoseconds since the UNIX epoch in UTC timezone. 2 | type Timestamp = nat64; 3 | 4 | // Number of nanoseconds between two [Timestamp]s. 5 | type Duration = nat64; 6 | 7 | type Subaccount = blob; 8 | 9 | type Account = record { 10 | owner : principal; 11 | subaccount : opt Subaccount; 12 | }; 13 | 14 | type TransferArgs = record { 15 | from_subaccount : opt Subaccount; 16 | to : Account; 17 | amount : nat; 18 | fee : opt nat; 19 | memo : opt blob; 20 | created_at_time : opt Timestamp; 21 | }; 22 | 23 | type TransferError = variant { 24 | BadFee : record { expected_fee : nat }; 25 | BadBurn : record { min_burn_amount : nat }; 26 | InsufficientFunds : record { balance : nat }; 27 | TooOld; 28 | CreatedInFuture: record { ledger_time : Timestamp }; 29 | Duplicate : record { duplicate_of : nat }; 30 | TemporarilyUnavailable; 31 | GenericError : record { error_code : nat; message : text }; 32 | }; 33 | 34 | type Value = variant { 35 | Nat : nat; 36 | Int : int; 37 | Text : text; 38 | Blob : blob; 39 | }; 40 | 41 | service : { 42 | icrc1_metadata : () -> (vec record { text; Value; }) query; 43 | icrc1_name : () -> (text) query; 44 | icrc1_symbol : () -> (text) query; 45 | icrc1_decimals : () -> (nat8) query; 46 | icrc1_fee : () -> (nat) query; 47 | icrc1_total_supply : () -> (nat) query; 48 | icrc1_minting_account : () -> (opt Account) query; 49 | icrc1_balance_of : (Account) -> (nat) query; 50 | icrc1_transfer : (TransferArgs) -> (variant { Ok : nat; Err : TransferError }); 51 | icrc1_supported_standards : () -> (vec record { name : text; url : text }) query; 52 | } 53 | -------------------------------------------------------------------------------- /standards/ICRC-1/README.md: -------------------------------------------------------------------------------- 1 | 2 | # ICRC-1 Token Standard 3 | 4 | | Status | 5 | |:---------:| 6 | | [Accepted](https://dashboard.internetcomputer.org/proposal/74740) | 7 | 8 | The ICRC-1 is a standard for Fungible Tokens on the [Internet Computer](https://internetcomputer.org). 9 | 10 | ## Data 11 | 12 | ### account 13 | 14 | A `principal` can have multiple accounts. Each account of a `principal` is identified by a 32-byte string called `subaccount`. Therefore an account corresponds to a pair `(principal, subaccount)`. 15 | 16 | The account identified by the subaccount with all bytes set to 0 is the _default account_ of the `principal`. 17 | 18 | ```candid "Type definitions" += 19 | type Subaccount = blob; 20 | type Account = record { owner : principal; subaccount : opt Subaccount; }; 21 | ``` 22 | 23 | ## Methods 24 | 25 | ### icrc1_name 26 | 27 | Returns the name of the token (e.g., `MyToken`). 28 | 29 | ```candid "Methods" += 30 | icrc1_name : () -> (text) query; 31 | ``` 32 | 33 | ### icrc1_symbol 34 | 35 | Returns the symbol of the token (e.g., `ICP`). 36 | 37 | ```candid "Methods" += 38 | icrc1_symbol : () -> (text) query; 39 | ``` 40 | 41 | ### icrc1_decimals 42 | 43 | Returns the number of decimals the token uses (e.g., `8` means to divide the token amount by `100000000` to get its user representation). 44 | 45 | ```candid "Methods" += 46 | icrc1_decimals : () -> (nat8) query; 47 | ``` 48 | 49 | ### icrc1_fee 50 | 51 | Returns the default transfer fee. 52 | 53 | ```candid "Methods" += 54 | icrc1_fee : () -> (nat) query; 55 | ``` 56 | 57 | ### icrc1_metadata 58 | 59 | Returns the list of metadata entries for this ledger. 60 | See the "Metadata" section below. 61 | 62 | ```candid "Type definitions" += 63 | type Value = variant { Nat : nat; Int : int; Text : text; Blob : blob }; 64 | ``` 65 | 66 | ```candid "Methods" += 67 | icrc1_metadata : () -> (vec record { text; Value }) query; 68 | ``` 69 | 70 | ### icrc1_total_supply 71 | 72 | Returns the total number of tokens on all accounts except for the [minting account](#minting_account). 73 | 74 | ```candid "Methods" += 75 | icrc1_total_supply : () -> (nat) query; 76 | ``` 77 | 78 | ### icrc1_minting_account 79 | 80 | Returns the [minting account](#minting_account) if this ledger supports minting and burning tokens. 81 | 82 | ```candid "Methods" += 83 | icrc1_minting_account : () -> (opt Account) query; 84 | ``` 85 | 86 | ### icrc1_balance_of 87 | 88 | Returns the balance of the account given as an argument. 89 | 90 | ```candid "Methods" += 91 | icrc1_balance_of : (Account) -> (nat) query; 92 | ``` 93 | 94 | ### icrc1_transfer 95 | 96 | Transfers `amount` of tokens from account `record { of = caller; subaccount = from_subaccount }` to the `to` account. 97 | The caller pays `fee` tokens for the transfer. 98 | 99 | ```candid "Type definitions" += 100 | type TransferArgs = record { 101 | from_subaccount : opt Subaccount; 102 | to : Account; 103 | amount : nat; 104 | fee : opt nat; 105 | memo : opt blob; 106 | created_at_time : opt nat64; 107 | }; 108 | 109 | type TransferError = variant { 110 | BadFee : record { expected_fee : nat }; 111 | BadBurn : record { min_burn_amount : nat }; 112 | InsufficientFunds : record { balance : nat }; 113 | TooOld; 114 | CreatedInFuture : record { ledger_time: nat64 }; 115 | Duplicate : record { duplicate_of : nat }; 116 | TemporarilyUnavailable; 117 | GenericError : record { error_code : nat; message : text }; 118 | }; 119 | ``` 120 | 121 | ```candid "Methods" += 122 | icrc1_transfer : (TransferArgs) -> (variant { Ok: nat; Err: TransferError; }); 123 | ``` 124 | 125 | The caller pays the `fee`. 126 | If the caller does not set the `fee` argument, the ledger applies the default transfer fee. 127 | If the `fee` argument does not agree with the ledger fee, the ledger MUST return `variant { BadFee = record { expected_fee = ... } }` error. 128 | 129 | The `memo` parameter is an arbitrary blob that has no meaning to the ledger. 130 | The ledger SHOULD allow memos of at least 32 bytes in length. 131 | The ledger SHOULD use the `memo` argument for [transaction deduplication](#transaction_deduplication). 132 | 133 | The `created_at_time` parameter indicates the time (as nanoseconds since the UNIX epoch in the UTC timezone) at which the client constructed the transaction. 134 | The ledger SHOULD reject transactions that have `created_at_time` argument too far in the past or the future, returning `variant { TooOld }` and `variant { CreatedInFuture = record { ledger_time = ... } }` errors correspondingly. 135 | 136 | The result is either the transaction index of the transfer or an error. 137 | 138 | ### icrc1_supported_standards 139 | 140 | Returns the list of standards this ledger implements. 141 | See the ["Extensions"](#extensions) section below. 142 | 143 | ```candid "Methods" += 144 | icrc1_supported_standards : () -> (vec record { name : text; url : text }) query; 145 | ``` 146 | 147 | The result of the call should always have at least one entry, 148 | 149 | ```candid 150 | record { name = "ICRC-1"; url = "https://github.com/dfinity/ICRC-1" } 151 | ``` 152 | 153 | ## Extensions 154 | 155 | The base standard intentionally excludes some ledger functions essential for building a rich DeFi ecosystem, for example: 156 | 157 | - Reliable transaction notifications for smart contracts. 158 | - The block structure and the interface for fetching blocks. 159 | - Pre-signed transactions. 160 | 161 | The standard defines the `icrc1_supported_standards` endpoint to accommodate these and other future extensions. 162 | This endpoint returns names of all specifications (e.g., `"ICRC-42"` or `"DIP-20"`) implemented by the ledger. 163 | 164 | ## Metadata 165 | 166 | A ledger can expose metadata to simplify integration with wallets and improve user experience. 167 | The client can use the [`icrc1_metadata`](#metadata_method) method to fetch the metadata entries. 168 | All the metadata entries are optional. 169 | 170 | ### Key format 171 | 172 | The metadata keys are arbitrary Unicode strings and must follow the pattern `:`, where `` is a string not containing colons. 173 | Namespace `icrc1` is reserved for keys defined in this standard. 174 | 175 | ### Standard metadata entries 176 | | Key | Semantics | Example value 177 | | --- | ------------- | --------- | 178 | | `icrc1:symbol` | The token currency code (see [ISO-4217](https://en.wikipedia.org/wiki/ISO_4217)). When present, should be the same as the result of the [`icrc1_symbol`](#symbol_method) query call. | `variant { Text = "XTKN" }` | 179 | | `icrc1:name` | The name of the token. When present, should be the same as the result of the [`icrc1_name`](#name_method) query call. | `variant { Text = "Test Token" }` | 180 | | `icrc1:decimals` | The number of decimals the token uses. For example, 8 means to divide the token amount by 108 to get its user representation. When present, should be the same as the result of the [`icrc1_decimals`](#decimals_method) query call. | `variant { Nat = 8 }` | 181 | | `icrc1:fee` | The default transfer fee. When present, should be the same as the result of the [`icrc1_fee`](#fee_method) query call. | `variant { Nat = 10_000 }` | 182 | | `icrc1:logo` | The URL of the token logo. The value can contain the actual image if it's a [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs). | `variant { Text = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InJlZCIvPjwvc3ZnPg==" }` | 183 | 184 | 185 | ## Transaction deduplication 186 | 187 | Consider the following scenario: 188 | 189 | 1. An agent sends a transaction to an ICRC-1 ledger hosted on the IC. 190 | 2. The ledger accepts the transaction. 191 | 3. The agent loses the network connection for several minutes and cannot learn about the outcome of the transaction. 192 | 193 | An ICRC-1 ledger SHOULD implement transfer deduplication to simplify the error recovery for agents. 194 | The deduplication covers all transactions submitted within a pre-configured time window `TX_WINDOW` (for example, last 24 hours). 195 | The ledger MAY extend the deduplication window into the future by the `PERMITTED_DRIFT` parameter (for example, 2 minutes) to account for the time drift between the client and the Internet Computer. 196 | 197 | The client can control the deduplication algorithm using the `created_at_time` and `memo` fields of the [`transfer`](#transfer_method) call argument: 198 | * The `created_at_time` field sets the transaction construction time as the number of nanoseconds from the UNIX epoch in the UTC timezone. 199 | * The `memo` field does not have any meaning to the ledger, except that the ledger will not deduplicate transfers with different values of the `memo` field. 200 | 201 | The ledger SHOULD use the following algorithm for transaction deduplication if the client set the `created_at_time` field: 202 | * If `created_at_time` is set and is _before_ `time() - TX_WINDOW - PERMITTED_DRIFT` as observed by the ledger, the ledger should return `variant { TooOld }` error. 203 | * If `created_at_time` is set and is _after_ `time() + PERMITTED_DRIFT` as observed by the ledger, the ledger should return `variant { CreatedInFuture = record { ledger_time = ... } }` error. 204 | * If the ledger observed a structurally equal transfer payload (i.e., all the transfer argument fields and the caller have the same values) at transaction with index `i`, it should return `variant { Duplicate = record { duplicate_of = i } }`. 205 | * Otherwise, the transfer is a new transaction. 206 | 207 | If the client did not set the `created_at_time` field, the ledger SHOULD NOT deduplicate the transaction. 208 | 209 | ## Minting account 210 | 211 | The minting account is a unique account that can create new tokens and acts as the receiver of burnt tokens. 212 | 213 | Transfers _from_ the minting account act as _mint_ transactions depositing fresh tokens on the destination account. 214 | Mint transactions have no fee. 215 | 216 | Transfers _to_ the minting account act as _burn_ transactions, removing tokens from the token supply. 217 | Burn transactions have no fee but might have minimal burn amount requirements. 218 | If the client tries to burn an amount that is too small, the ledger SHOULD reply with 219 | 220 | ``` 221 | variant { Err = variant { BadBurn = record { min_burn_amount = ... } } } 222 | ``` 223 | 224 | The minting account is also the receiver of the fees burnt in regular transfers. 225 | 226 | 235 | -------------------------------------------------------------------------------- /standards/ICRC-1/TextualEncoding.md: -------------------------------------------------------------------------------- 1 | # Textual encoding of ICRC-1 accounts 2 | 3 | | Status | 4 | |:------:| 5 | | Accepted | 6 | 7 | This document specifies the canonical textual representation of ICRC-1 accounts. 8 | 9 | ICRC-1 accounts have two components: the owner (up to 29 bytes) and the subaccount (32 bytes). 10 | If the subaccount is not present, it's considered to be equal to an array with 32 zero bytes. 11 | 12 | ```candid 13 | type Account = { owner : principal; subaccount : opt blob }; 14 | ``` 15 | 16 | ## Default accounts 17 | 18 | The textual representation of the account coincides with the textual encoding of the account owner's principal if the `subaccount` is not set or equal to an array with 32 zero bytes. 19 | 20 | ``` 21 | Account.toText(record { 22 | owner = principal "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae"; 23 | subaccount = null; 24 | }) = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae" 25 | ``` 26 | 27 | ``` 28 | Account.toText(record { 29 | owner = principal "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", 30 | subaccount = opt vec {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}; 31 | }) = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae" 32 | ``` 33 | 34 | ## Non-default accounts 35 | 36 | The textual representation of accounts with non-default subaccounts consists of the following parts: 37 | 1. The textual encoding of the owner's principal as described in the [Internet Computer Interface Specification](https://internetcomputer.org/docs/current/references/ic-interface-spec#textual-ids). 38 | 2. A dash ('-') separating the principal from the checksum. 39 | 3. The CRC-32 checksum of concatenated bytes of the principal (up to 29 bytes) and the subaccount (32 bytes), encoded in [Base 32 encoding](https://datatracker.ietf.org/doc/html/rfc4648#section-6), without padding, and using lower-case letters. 40 | 4. A period ('.') separating the checksum from the subaccount. 41 | 5. The hex-encoded bytes of the subaccount with all the leading '0' characters removed. 42 | 43 | ``` 44 | -. 45 | ``` 46 | 47 | ``` 48 | Account.toText({ owner; ?subaccount }) = { 49 | let checksum = bigEndianBytes(crc32(concatBytes(Principal.toBytes(owner), subaccount))); 50 | Principal.toText(owner) # '-' # base32LowerCaseNoPadding(checksum) # '.' # trimLeading('0', hex(subaccount)) 51 | } 52 | ``` 53 | 54 | In the following example, `dfxgiyy` is the checksum and `102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20` is the hex representation of the subaccount with stripped leading zeros. 55 | 56 | ``` 57 | Account.toText(record { 58 | owner = principal "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", 59 | subaccount = opt vec {1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;17;18;19;20;21;22;23;24;25;26;27;28;29;30;31;32}; 60 | }) = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" 61 | ``` 62 | 63 | ## Examples 64 | 65 | | Text | Result | Comment | 66 | |:----:|:------:|:-------:| 67 | | `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae` | OK: `{ owner = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", subaccount = null }` | A valid principal is a valid account. | 68 | | `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-q6bn32y.` | Error | The representation is not canonical: default subaccount should be omitted. | 69 | | `k2t6j2nvnp4zjm3-25dtz6xhaac7boj5gayfoj3xs-i43lp-teztq-6ae` | Error | Invalid principal encoding. | 70 | | `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1` | OK: `{ owner = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", subaccount = opt blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\01" }` | | 71 | | `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.01` | Error | The representation is not canonical: leading zeros are not allowed in subaccounts. | 72 | | `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae.1` | Error | Missing check sum. | 73 | | `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20` | OK: `{ owner = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae"; subaccount = opt blob "\01\02\03\04\05\06\07\08\09\0a\0b\0c\0d\0e\0f\10\11\12\13\14\15\16\17\18\19\1a\1b\1c\1d\1e\1f\20" }` | | 74 | 75 | ## Libraries 76 | 77 | * [`ic-js`](https://github.com/dfinity/ic-js/tree/main/packages/ledger-icrc#gear-encodeicrcaccount) (JavaScript). 78 | * [`icrc-ledger-types`](https://docs.rs/icrc-ledger-types/0.1.2/icrc_ledger_types/icrc1/account/struct.Account.html) version `0.1.2` and higher (Rust). 79 | * [`Account.mo`](https://github.com/dfinity/ICRC-1/blob/main/ref/Account.mo) (Motoko) 80 | -------------------------------------------------------------------------------- /standards/ICRC-2/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("//bazel:didc_test.bzl", "didc_subtype_test") 2 | 3 | exports_files([ 4 | "ICRC-2.did", 5 | ]) 6 | 7 | genrule( 8 | name = "candid", 9 | srcs = [":README.md"], 10 | outs = ["ICRC-2-generated.did"], 11 | cmd_bash = "$(location @lmt) $(SRCS); mv ICRC-2.did $@", 12 | exec_tools = ["@lmt"], 13 | ) 14 | 15 | didc_subtype_test( 16 | name = "check_generated_subtype", 17 | did = ":ICRC-2-generated.did", 18 | previous = "ICRC-2.did", 19 | ) 20 | 21 | didc_subtype_test( 22 | name = "check_source_subtype", 23 | did = "ICRC-2.did", 24 | previous = ":ICRC-2-generated.did", 25 | ) 26 | -------------------------------------------------------------------------------- /standards/ICRC-2/ICRC-2.did: -------------------------------------------------------------------------------- 1 | type Account = record { 2 | owner : principal; 3 | subaccount : opt blob; 4 | }; 5 | 6 | type ApproveArgs = record { 7 | from_subaccount : opt blob; 8 | spender : Account; 9 | amount : nat; 10 | expected_allowance : opt nat; 11 | expires_at : opt nat64; 12 | fee : opt nat; 13 | memo : opt blob; 14 | created_at_time : opt nat64; 15 | }; 16 | 17 | type ApproveError = variant { 18 | BadFee : record { expected_fee : nat }; 19 | InsufficientFunds : record { balance : nat }; 20 | AllowanceChanged : record { current_allowance : nat }; 21 | Expired : record { ledger_time : nat64 }; 22 | TooOld; 23 | CreatedInFuture: record { ledger_time : nat64 }; 24 | Duplicate : record { duplicate_of : nat }; 25 | TemporarilyUnavailable; 26 | GenericError : record { error_code : nat; message : text }; 27 | }; 28 | 29 | type TransferFromArgs = record { 30 | spender_subaccount : opt blob; 31 | from : Account; 32 | to : Account; 33 | amount : nat; 34 | fee : opt nat; 35 | memo : opt blob; 36 | created_at_time : opt nat64; 37 | }; 38 | 39 | type TransferFromError = variant { 40 | BadFee : record { expected_fee : nat }; 41 | BadBurn : record { min_burn_amount : nat }; 42 | InsufficientFunds : record { balance : nat }; 43 | InsufficientAllowance : record { allowance : nat }; 44 | TooOld; 45 | CreatedInFuture: record { ledger_time : nat64 }; 46 | Duplicate : record { duplicate_of : nat }; 47 | TemporarilyUnavailable; 48 | GenericError : record { error_code : nat; message : text }; 49 | }; 50 | 51 | type AllowanceArgs = record { 52 | account : Account; 53 | spender : Account; 54 | }; 55 | 56 | service : { 57 | icrc1_supported_standards : () -> (vec record { name : text; url : text }) query; 58 | 59 | icrc2_approve : (ApproveArgs) -> (variant { Ok : nat; Err : ApproveError }); 60 | icrc2_transfer_from : (TransferFromArgs) -> (variant { Ok : nat; Err : TransferFromError }); 61 | icrc2_allowance : (AllowanceArgs) -> (record { allowance : nat; expires_at : opt nat64 }) query; 62 | } 63 | -------------------------------------------------------------------------------- /standards/ICRC-2/README.md: -------------------------------------------------------------------------------- 1 | # `ICRC-2`: Approve and Transfer From 2 | 3 | | Status | 4 | |:------:| 5 | | [Accepted](https://dashboard.internetcomputer.org/proposal/122613) | 6 | 7 | ## Abstract 8 | 9 | `ICRC-2` is an extension of the base `ICRC-1` standard. 10 | `ICRC-2` specifies a way for an account owner to delegate token transfers to a third party on the owner's behalf. 11 | 12 | The approve and transfer-from flow is a 2-step process. 13 | 1. Account owner Alice entitles Bob to transfer up to X tokens from her account A by calling the `icrc2_approve` method on the ledger. 14 | 2. Bob can transfer up to X tokens from account A to any account by calling the `icrc2_transfer_from` method on the ledger as if A was Bob's account B. 15 | The number of transfers Bob can initiate from account A is not limited as long as the total amount spent is below X. 16 | 17 | Approvals are not transitive: if Alice approves transfers from her account A to Bob's account B, and Bob approves transfers from his account B to Eva's account E, Eva cannot withdraw tokens from Alice's account through Bob's approval. 18 | 19 | ## Motivation 20 | 21 | The approve-transfer-from pattern became popular in the Ethereum ecosystem thanks to the [ERC-20](https://ethereum.org/en/developers/docs/standards/tokens/erc-20/) token standard. 22 | This interface enables new application capabilities: 23 | 24 | 1. Recurring payments. 25 | Alice can approve a large amount to Bob in advance, allowing Bob to make periodic transfers in smaller installments. 26 | Real-world examples include subscription services and rents. 27 | 28 | 2. Uncertain transfer amounts. 29 | In some applications, such as automatic trading services, the exact price of goods is unknown in advance. 30 | With approve-transfer-from flow, Alice can allow Bob to trade securities on Alice's behalf, buying/selling at yet-unknown price up to a specified limit. 31 | 32 | ## Specification 33 | 34 | > The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119. 35 | 36 | **Canisters implementing the `ICRC-2` standard MUST implement all the functions in the `ICRC-1` interface** 37 | 38 | **Canisters implementing the `ICRC-2` standard MUST include `ICRC-2` in the list returned by the `icrc1_supported_standards` method** 39 | 40 | ## Methods 41 | 42 | ```candid "Type definitions" += 43 | type Account = record { 44 | owner : principal; 45 | subaccount : opt blob; 46 | }; 47 | ``` 48 | 49 | ### icrc2_approve 50 | 51 | This method entitles the `spender` to transfer token `amount` on behalf of the caller from account `{ owner = caller; subaccount = from_subaccount }`. 52 | The number of transfers the `spender` can initiate from the caller's account is unlimited as long as the total amounts and fees of these transfers do not exceed the allowance. 53 | The caller does not need to have the full token `amount` on the specified account for the approval to succeed, just enough tokens to pay the approval fee. 54 | The call resets the allowance and the expiration date for the `spender` account to the given values. 55 | 56 | The ledger SHOULD reject the call if the spender account owner is equal to the source account owner. 57 | 58 | If the `expected_allowance` field is set, the ledger MUST ensure that the current allowance for the `spender` from the caller's account is equal to the given value and return the `AllowanceChanged` error otherwise. 59 | 60 | The ledger MAY cap the allowance if it is too large (for example, larger than the total token supply). 61 | For example, if there are only 100 tokens, and the ledger receives an approval for 120 tokens, the ledger may cap the allowance to 100. 62 | 63 | ```candid "Methods" += 64 | icrc2_approve : (ApproveArgs) -> (variant { Ok : nat; Err : ApproveError }); 65 | ``` 66 | 67 | ```candid "Type definitions" += 68 | type ApproveArgs = record { 69 | from_subaccount : opt blob; 70 | spender : Account; 71 | amount : nat; 72 | expected_allowance : opt nat; 73 | expires_at : opt nat64; 74 | fee : opt nat; 75 | memo : opt blob; 76 | created_at_time : opt nat64; 77 | }; 78 | 79 | type ApproveError = variant { 80 | BadFee : record { expected_fee : nat }; 81 | // The caller does not have enough funds to pay the approval fee. 82 | InsufficientFunds : record { balance : nat }; 83 | // The caller specified the [expected_allowance] field, and the current 84 | // allowance did not match the given value. 85 | AllowanceChanged : record { current_allowance : nat }; 86 | // The approval request expired before the ledger had a chance to apply it. 87 | Expired : record { ledger_time : nat64; }; 88 | TooOld; 89 | CreatedInFuture: record { ledger_time : nat64 }; 90 | Duplicate : record { duplicate_of : nat }; 91 | TemporarilyUnavailable; 92 | GenericError : record { error_code : nat; message : text }; 93 | }; 94 | ``` 95 | 96 | #### Preconditions 97 | 98 | * The caller has enough fees on the `{ owner = caller; subaccount = from_subaccount }` account to pay the approval fee. 99 | * If the `expires_at` field is set, it's greater than the current ledger time. 100 | * If the `expected_allowance` field is set, it's equal to the current allowance for the `spender`. 101 | 102 | #### Postconditions 103 | 104 | * The `spender`'s allowance for the `{ owner = caller; subaccount = from_subaccount }` is equal to the given `amount`. 105 | 106 | ### icrc2_transfer_from 107 | 108 | Transfers a token amount from the `from` account to the `to` account using the allowance of the spender's account (`SpenderAccount = { owner = caller; subaccount = spender_subaccount }`). 109 | The ledger draws the fees from the `from` account. 110 | 111 | ```candid "Methods" += 112 | icrc2_transfer_from : (TransferFromArgs) -> (variant { Ok : nat; Err : TransferFromError }); 113 | ``` 114 | 115 | ```candid "Type definitions" += 116 | type TransferFromError = variant { 117 | BadFee : record { expected_fee : nat }; 118 | BadBurn : record { min_burn_amount : nat }; 119 | // The [from] account does not hold enough funds for the transfer. 120 | InsufficientFunds : record { balance : nat }; 121 | // The caller exceeded its allowance. 122 | InsufficientAllowance : record { allowance : nat }; 123 | TooOld; 124 | CreatedInFuture: record { ledger_time : nat64 }; 125 | Duplicate : record { duplicate_of : nat }; 126 | TemporarilyUnavailable; 127 | GenericError : record { error_code : nat; message : text }; 128 | }; 129 | 130 | type TransferFromArgs = record { 131 | spender_subaccount : opt blob; 132 | from : Account; 133 | to : Account; 134 | amount : nat; 135 | fee : opt nat; 136 | memo : opt blob; 137 | created_at_time : opt nat64; 138 | }; 139 | ``` 140 | 141 | #### Preconditions 142 | 143 | * The allowance for the `SpenderAccount` from the `from` account is large enough to cover the transfer amount and the fees 144 | (`icrc2_allowance({ account = from; spender = SpenderAccount }).allowance >= amount + fee`). 145 | Otherwise, the ledger MUST return an `InsufficientAllowance` error. 146 | 147 | * The `from` account holds enough funds to cover the transfer amount and the fees. 148 | (`icrc1_balance_of(from) >= amount + fee`). 149 | Otherwise, the ledger MUST return an `InsufficientFunds` error. 150 | 151 | #### Postconditions 152 | 153 | * If the `from` account is not equal to the `SpenderAccount`, the `(from, SpenderAccount)` allowance decreases by the transfer amount and the fees. 154 | * The ledger debited the specified `amount` of tokens and fees from the `from` account. 155 | * The ledger credited the specified `amount` to the `to` account. 156 | 157 | ### icrc2_allowance 158 | 159 | Returns the token allowance that the `spender` account can transfer from the specified `account`, and the expiration time for that allowance, if any. 160 | If there is no active approval, the ledger MUST return `{ allowance = 0; expires_at = null }`. 161 | 162 | ```candid "Methods" += 163 | icrc2_allowance : (AllowanceArgs) -> (Allowance) query; 164 | ``` 165 | ```candid "Type definitions" += 166 | type AllowanceArgs = record { 167 | account : Account; 168 | spender : Account; 169 | }; 170 | 171 | type Allowance = record { 172 | allowance : nat; 173 | expires_at : opt nat64; 174 | } 175 | ``` 176 | 177 | ### icrc1_supported_standards 178 | 179 | Returns the list of standards this ledger supports. 180 | Any ledger supporting `ICRC-2` MUST include a record with the `name` field equal to `"ICRC-2"` in that list. 181 | 182 | ```candid "Methods" += 183 | icrc1_supported_standards : () -> (vec record { name : text; url : text }) query; 184 | ``` 185 | 186 | ## Examples 187 | 188 | ### Alice deposits tokens to canister C 189 | 190 | 1. Alice wants to deposit 100 tokens on an `ICRC-2` ledger to canister C. 191 | 2. Alice calls `icrc2_approve` with `spender` set to the canister's default account (`{ owner = C; subaccount = null}`) and `amount` set to the token amount she wants to deposit (100) plus the transfer fee. 192 | 3. Alice can then call some `deposit` method on the canister, which calls `icrc2_transfer_from` with `from` set to Alice's (the caller) account, `to` set to the canister's account, and `amount` set to the token amount she wants to deposit (100). 193 | 4. The canister can now determine from the result of the call whether the transfer was successful. 194 | If it was successful, the canister can now safely commit the deposit to state and know that the tokens are in its account. 195 | 196 | ### Canister C transfers tokens from Alice's account to Bob's account, on Alice's behalf 197 | 198 | 1. Canister C wants to transfer 100 tokens on an `ICRC-2` ledger from Alice's account to Bob's account. 199 | 2. Alice previously approved canister C to transfer tokens on her behalf by calling `icrc2_approve` with `spender` set to the canister's default account (`{ owner = C; subaccount = null }`) and `amount` set to the token amount she wants to allow (100) plus the transfer fee. 200 | 3. During some update call, the canister can now call `icrc2_transfer_from` with `from` set to Alice's account, `to` set to Bob's account, and `amount` set to the token amount she wants to transfer (100). 201 | 4. Once the call completes successfully, Bob has 100 extra tokens on his account, and Alice has 100 (plus the fee) tokens less in her account. 202 | 203 | ### Alice removes her allowance for canister C 204 | 205 | 1. Alice wants to remove her allowance of 100 tokens on an `ICRC-2` ledger for canister C. 206 | 2. Alice calls `icrc2_approve` on the ledger with `spender` set to the canister's default account (`{ owner = C; subaccount = null }`) and `amount` set to 0. 207 | 3. The canister can no longer transfer tokens on Alice's behalf. 208 | 209 | ### Alice atomically removes her allowance for canister C 210 | 211 | 1. Alice wants to remove her allowance of 100 tokens on an `ICRC-2` ledger for canister C. 212 | 2. Alice calls `icrc2_approve` on the ledger with `spender` set to the canister's default account (`{ owner = C; subaccount = null }`), `amount` set to 0, and `expected_allowance` set to 100 tokens. 213 | 3. If the call succeeds, the allowance got removed successfully. 214 | An `AllowanceChanged` error would indicate that canister C used some of the allowance before Alice's call completed. 215 | 216 | 225 | -------------------------------------------------------------------------------- /standards/ICRC-3/HASHINGVALUES.md: -------------------------------------------------------------------------------- 1 | ## Representation independent hashing 2 | 3 | The following pseudocode specifies how to calculate the (representation independent) hash of an element of the Value type. Some test vectors to check compliance of an implementation with this specification follow. 4 | 5 | ``` 6 | type Value = variant { 7 | Blob : blob, 8 | Text : text, 9 | Nat : nat, 10 | Int : int, 11 | Array : vec Value, 12 | Map : vec (text, Value) 13 | }; 14 | 15 | Function hash_value(value) 16 | Initialize hasher as a new instance of SHA256 17 | 18 | Match value with 19 | Nat: 20 | Return SHA256_hash(LEB128_encode(value)) 21 | Int: 22 | Return SHA256_hash(SLEB128_encode(value)) 23 | Text: 24 | Return SHA256_hash(UTF8_encode(value)) 25 | Blob: 26 | Return SHA256_hash(value) 27 | Array: 28 | For each element in value 29 | Update hasher with hash_value(element) 30 | Return hasher.finalize() 31 | Map: 32 | Initialize hashes as empty list 33 | For each (key, val) in value 34 | Add (SHA256_hash(UTF8_encode(key)), hash_value(val)) to hashes 35 | Sort hashes in lexicographical order 36 | For each (key_hash, val_hash) in hashes 37 | Update hasher with key_hash 38 | Update hasher with val_hash 39 | Return hasher.finalize() 40 | Else: 41 | Return error "unsupported value type" 42 | End Function 43 | 44 | Function LEB128_encode(nat_input) 45 | Convert nat_input to LEB128 byte encoding 46 | End Function 47 | 48 | Function SLEB128_encode(integer_input) 49 | Convert integer_input to SLEB128 byte encoding 50 | End Function 51 | 52 | Function UTF8_encode(text) 53 | Convert text to UTF-8 byte array and return it 54 | End Function 55 | 56 | Function SHA256_hash(data) 57 | Initialize a new SHA256 hasher 58 | Update hasher with data 59 | Return hasher.finalize() 60 | End Function 61 | 62 | ``` 63 | 64 | ## Test vectors 65 | 66 | 67 | ```ignorelang 68 | input: Nat(42) 69 | expected output: 684888c0ebb17f374298b65ee2807526c066094c701bcc7ebbe1c1095f494fc1 70 | ``` 71 | 72 | ```ignorelang 73 | input: Int(-42) 74 | expected output: de5a6f78116eca62d7fc5ce159d23ae6b889b365a1739ad2cf36f925a140d0cc 75 | ``` 76 | 77 | 78 | ```ignorelang 79 | input: Text("Hello, World!"), 80 | expected output: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f 81 | ``` 82 | 83 | ```ignorelang 84 | input: Blob(b'\x01\x02\x03\x04') 85 | expected output: 9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a 86 | ``` 87 | 88 | ```ignorelang 89 | input: Array([Nat(3), Text("foo"), Blob(b'\x05\x06')]) 90 | expected output: 514a04011caa503990d446b7dec5d79e19c221ae607fb08b2848c67734d468d6 91 | ``` 92 | 93 | ```ignorelang 94 | input: Map([("from", Blob(b'\x00\xab\xcd\xef\x00\x12\x34\x00\x56\x78\x9a\x00\xbc\xde\xf0\x00\x01\x23\x45\x67\x89\x00\xab\xcd\xef\x01')), 95 | ("to", Blob(b'\x00\xab\x0d\xef\x00\x12\x34\x00\x56\x78\x9a\x00\xbc\xde\xf0\x00\x01\x23\x45\x67\x89\x00\xab\xcd\xef\x01')), 96 | ("amount", Nat(42)), 97 | ("created_at", Nat(1699218263)), 98 | ("memo", Nat(0)) 99 | ]) 100 | 101 | expected output: c56ece650e1de4269c5bdeff7875949e3e2033f85b2d193c2ff4f7f78bdcfc75 102 | ``` -------------------------------------------------------------------------------- /standards/ICRC-3/ICRC-3.did: -------------------------------------------------------------------------------- 1 | type Value = variant { 2 | Blob : blob; 3 | Text : text; 4 | Nat : nat; 5 | Int : int; 6 | Array : vec Value; 7 | Map : vec record { text; Value }; 8 | }; 9 | 10 | type GetArchivesArgs = record { 11 | // The last archive seen by the client. 12 | // The Ledger will return archives coming 13 | // after this one if set, otherwise it 14 | // will return the first archives. 15 | from : opt principal; 16 | }; 17 | 18 | type GetArchivesResult = vec record { 19 | // The id of the archive 20 | canister_id : principal; 21 | 22 | // The first block in the archive 23 | start : nat; 24 | 25 | // The last block in the archive 26 | end : nat; 27 | }; 28 | 29 | type GetBlocksArgs = vec record { start : nat; length : nat }; 30 | 31 | type GetBlocksResult = record { 32 | // Total number of blocks in the 33 | // block log 34 | log_length : nat; 35 | 36 | blocks : vec record { id : nat; block: Value }; 37 | 38 | archived_blocks : vec record { 39 | args : GetBlocksArgs; 40 | callback : func (GetBlocksArgs) -> (GetBlocksResult) query; 41 | }; 42 | }; 43 | 44 | type DataCertificate = record { 45 | // See https://internetcomputer.org/docs/current/references/ic-interface-spec#certification 46 | certificate : blob; 47 | 48 | // CBOR encoded hash_tree 49 | hash_tree : blob; 50 | }; 51 | 52 | service : { 53 | icrc3_get_archives : (GetArchivesArgs) -> (GetArchivesResult) query; 54 | icrc3_get_tip_certificate : () -> (opt DataCertificate) query; 55 | icrc3_get_blocks : (GetBlocksArgs) -> (GetBlocksResult) query; 56 | icrc3_supported_block_types : () -> (vec record { block_type : text; url : text }) query; 57 | }; 58 | -------------------------------------------------------------------------------- /standards/ICRC-3/README.md: -------------------------------------------------------------------------------- 1 | # `ICRC-3`: Block Log 2 | 3 | | Status | 4 | |:------:| 5 | | [Accepted](https://dashboard.internetcomputer.org/proposal/128824) | 6 | 7 | `ICRC-3` is a standard for accessing the block log of a Ledger on the [Internet Computer](https://internetcomputer.org). 8 | 9 | `ICRC-3` specifies: 10 | 1. A way to fetch the archive nodes of a Ledger 11 | 2. A generic format for sharing the block log without information loss. This includes the fields that a block must have 12 | 3. A mechanism to verify the block log on the client side to allow downloading the block log via query calls 13 | 4. A way for new standards to define new transactions types compatible with ICRC-3 14 | 15 | ## Archive Nodes 16 | 17 | The Ledger must expose an endpoint `icrc3_get_archives` listing all the canisters containing its blocks. 18 | 19 | ## Block Log 20 | 21 | The block log is a list of blocks where each block contains the hash of its parent (`phash`). The parent of a block `i` is block `i-1` for `i>0` and `null` for `i=0`. 22 | 23 | ``` 24 | ┌─────────────────────────┐ ┌─────────────────────────┐ 25 | | Block i | | Block i+1 | 26 | ├─────────────────────────┤ ├─────────────────────────┤ 27 | ◄──| phash = hash(Block i-1) |◄─────────| phash = hash(Block i) | 28 | | ... | | ... | 29 | └─────────────────────────┘ └─────────────────────────┘ 30 | 31 | ``` 32 | 33 | ## Value 34 | 35 | The [candid](https://github.com/dfinity/candid) format supports sharing information even when the client and the server involved do not have the same schema (see the [Upgrading and subtyping](https://github.com/dfinity/candid/blob/master/spec/Candid.md#upgrading-and-subtyping) section of the candid spec). While this mechanism allows to evolve services and clients 36 | independently without breaking them, it also means that a client may not receive all the information that the server is sending, e.g. in case the client schema lacks some fields that the server schema has. 37 | 38 | This loss of information is not an option for `ICRC-3`. The client must receive the same exact data the server sent in order to verify it. Verification is done by hashing the data and checking that the result is consistent with what has been certified by the server. 39 | 40 | For this reason, `ICRC-3` introduces the `Value` type which never changes: 41 | 42 | ``` 43 | type Value = variant { 44 | Blob : blob; 45 | Text : text; 46 | Nat : nat; 47 | Int : int; 48 | Array : vec Value; 49 | Map : vec record { text; Value }; 50 | }; 51 | ``` 52 | 53 | Servers must serve the block log as a list of `Value` where each `Value` represent a single block in the block log. 54 | 55 | ## Value Hash 56 | 57 | `ICRC-3` specifies a standard hash function over `Value`. 58 | 59 | This hash function should be used by Ledgers to calculate the hash of the parent of a block and by clients to verify the downloaded block log. 60 | 61 | The hash function is the [representation-independent hashing of structured data](https://internetcomputer.org/docs/current/references/ic-interface-spec#hash-of-map) used by the IC: 62 | - the hash of a `Blob` is the hash of the bytes themselves 63 | - the hash of a `Text` is the hash of the bytes representing the text 64 | - the hash of a `Nat` is the hash of the [`leb128`](https://en.wikipedia.org/wiki/LEB128#Unsigned_LEB128) encoding of the number 65 | - the hash of an `Int` is the hash of the [`sleb128`](https://en.wikipedia.org/wiki/LEB128#Signed_LEB128) encoding of the number 66 | - the hash of an `Array` is the hash of the concatenation of the hashes of all the elements of the array 67 | - the hash of a `Map` is the hash of the concatenation of all the hashed items of the map sorted lexicographically. A hashed item is the tuple composed by the hash of the key and the hash of the value. 68 | 69 | Pseudocode for representation independent hashing of Value, together with test vectors to check compliance with the specification can be found [`here`](HASHINGVALUES.md). 70 | 71 | ## Blocks Verification 72 | 73 | The Ledger MUST certify the last block (tip) recorded. The Ledger MUST allow to download the certificate via the `icrc3_get_tip_certificate` endpoint. The certificate follows the [IC Specification for Certificates](https://internetcomputer.org/docs/current/references/ic-interface-spec#certification). The certificate is comprised of a tree containing the certified data and the signature. The tree MUST contain two labelled values (leafs): 74 | 1. `last_block_index`: the index of the last block in the chain. The values must be expressed as [`leb128`](https://en.wikipedia.org/wiki/LEB128#Unsigned_LEB128) 75 | 2. `last_block_hash`: the hash of the last block in the chain 76 | 77 | Clients SHOULD download the tip certificate first and then download the block backward starting from `last_block_index` and validate the blocks in the process. 78 | 79 | Validation of block `i` is done by checking the block hash against 80 | 1. if `i + 1 < len(chain)` then the parent hash `phash` of the block `i+1` 81 | 2. otherwise the `last_block_hash` in the tip certificate. 82 | 83 | ## Generic Block Schema 84 | 85 | An ICRC-3 compliant Block 86 | 87 | 1. MUST be a `Value` of variant `Map` 88 | 2. MUST contain a field `phash: Blob` which is the hash of its parent if it has a parent block 89 | 3. SHOULD contain a field `btype: String` which uniquely describes the type of the Block. If this field is not set then the block type falls back to ICRC-1 and ICRC-2 for backward compatibility purposes 90 | 91 | ## Interaction with other standards 92 | 93 | Each standard that adheres to `ICRC-3` MUST define the list of block schemas that it introduces. Each block schema MUST: 94 | 95 | 1. extend the [Generic Block Schema](#generic-block-schema) 96 | 2. specify the expected value of `btype`. This MUST be unique accross all the standards. An ICRC-x standard MUST use namespacing for its op identifiers using the following scheme of using the ICRC standard's number as prefix to the name followed by an operation name that must begin with a letter: 97 | 98 | ``` 99 | op = icrc_number op_name 100 | icrc_number = nonzero_digit *digit 101 | nonzero_digit = "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" 102 | digit = "0" / nonzero_digit 103 | op_name = a-z *(a-z / digit / "_" / "-") 104 | ``` 105 | 106 | For instance, `1xfer` is the identifier of the ICRC-1 transfer operation. 107 | 108 | ## Supported Standards 109 | 110 | An ICRC-3 compatible Ledger MUST expose an endpoint listing all the supported block types via the endpoint `icrc3_supported_block_types`. The Ledger MUST return only blocks with `btype` set to one of the values returned by this endpoint. 111 | 112 | ## [ICRC-1](../ICRC-1/README.md) and [ICRC-2](../ICRC-2/README.md) Block Schema 113 | 114 | ICRC-1 and ICRC-2 use the `tx` field to store input from the user and use the external block to store data set by the Ledger. For instance, the amount of a transaction is stored in the field `tx.amt` because it has been specified by the user, while the time when the block was added to the Ledger is stored in the field `ts` because it is set by the Ledger. 115 | 116 | A generic ICRC-1 or ICRC-2 Block: 117 | 118 | 1. it MUST contain a field `ts: Nat` which is the timestamp of when the block was added to the Ledger 119 | 2. if the operation requires a fee and if the `tx` field doesn't specify the fee then it MUST contain a field `fee: Nat` which specifies the fee payed to add this block to the Ledger 120 | 3. its field `tx` 121 | 1. CAN contain a field `op: String` that uniquely defines the type of operation 122 | 2. MUST contain a field `amt: Nat` that represents the amount 123 | 3. MUST contain the `fee: Nat` field for operations that require a fee if the user specifies the fee in the request. If the user does not specify the fee in the request, then this field is not set and the top-level `fee` is set. 124 | 4. CAN contain the `memo: Blob` field if specified by the user 125 | 5. CAN contain the `ts: Nat` field if the user sets the `created_at_time` field in the request. 126 | 127 | Operations that require paying a fee: Transfer, and Approve. 128 | 129 | The type of a generic ICRC-1 or ICRC-2 Block is defined by either the field `btype` or the field `tx.op`. The first approach is preferred, the second one exists for backward compatibility. If both are specified then `btype` defines the type of the block regardless of `tx.op`. 130 | 131 | `icrc3_supported_block_types` should always return all the `btype`s supported by the Ledger even if the Ledger doesn't support the `btype` field yet. For example, if the Ledger supports mint blocks using the backward compatibility schema, i.e. without `btype`, then the endpoint `icrc3_supported_block_types` will have to return `"1mint"` among the supported block types. 132 | 133 | ### Account Type 134 | 135 | ICRC-1 Account is represented as an `Array` containing the `owner` bytes and optionally the subaccount bytes. 136 | 137 | ### Burn Block Schema 138 | 139 | 1. the `btype` field MUST be `"1burn"` or `tx.op` field MUST be `"burn"` 140 | 2. it MUST contain a field `tx.from: Account` 141 | 142 | Example with `btype`: 143 | ``` 144 | variant { Map = vec { 145 | record { "btype"; "variant" { Text = "1burn" }}; 146 | record { "phash"; variant { 147 | Blob = blob "\a1\a9p\f5\17\e5\e2\92\87\96(\c8\f1\88iM\0d(tN\f4-~u\19\88\83\d8_\b2\01\ec" 148 | }}; 149 | record { "ts"; variant { Nat = 1_701_108_969_851_098_255 : nat }}; 150 | record { "tx"; variant { Map = vec { 151 | record { "amt"; variant { Nat = 1_228_990 : nat } }; 152 | record { "from"; variant { Array = vec { 153 | variant { Blob = blob "\00\00\00\00\020\00\07\01\01" }; 154 | variant { Blob = blob "&\99\c0H\7f\a4\a5Q\af\c7\f4;\d9\e9\ca\e5 \e3\94\84\b5c\b6\97/\00\e6\a0\e9\d3p\1a" }; 155 | }}}; 156 | record { "memo"; variant { Blob = blob "\82\00\83x\223K7Bg3LUkiXZ5hatPT1b9h3XxJ89DYSU2e\19\07\d0\00" 157 | }}; 158 | }}}; 159 | }}; 160 | ``` 161 | 162 | Example without `btype`: 163 | ``` 164 | variant { Map = vec { 165 | record { "phash"; variant { 166 | Blob = blob "\a1\a9p\f5\17\e5\e2\92\87\96(\c8\f1\88iM\0d(tN\f4-~u\19\88\83\d8_\b2\01\ec" 167 | }}; 168 | record { "ts"; variant { Nat = 1_701_108_969_851_098_255 : nat }}; 169 | record { "tx"; variant { Map = vec { 170 | record { "op"; variant { Text = "burn" } }; 171 | record { "amt"; variant { Nat = 1_228_990 : nat } }; 172 | record { "from"; variant { Array = vec { 173 | variant { Blob = blob "\00\00\00\00\020\00\07\01\01" }; 174 | variant { Blob = blob "&\99\c0H\7f\a4\a5Q\af\c7\f4;\d9\e9\ca\e5 \e3\94\84\b5c\b6\97/\00\e6\a0\e9\d3p\1a" }; 175 | }}}; 176 | record { "memo"; variant { Blob = blob "\82\00\83x\223K7Bg3LUkiXZ5hatPT1b9h3XxJ89DYSU2e\19\07\d0\00" 177 | }}; 178 | }}}; 179 | }}; 180 | ``` 181 | 182 | #### Mint Block Schema 183 | 184 | 1. the `btype` field MUST be `"1mint"` or the `tx.op` field MUST be `"mint"` 185 | 2. it MUST contain a field `tx.to: Account` 186 | 187 | Example with `btype`: 188 | ``` 189 | variant { Map = vec { 190 | record { "btype"; "variant" { Text = "1mint" }}; 191 | record { "ts"; variant { Nat = 1_675_241_149_669_614_928 : nat } }; 192 | record { "tx"; variant { Map = vec { 193 | record { "amt"; variant { Nat = 100_000 : nat } }; 194 | record { "to"; variant { Array = vec { 195 | variant { Blob = blob "Z\d0\ea\e8;\04*\c2CY\8b\delN\ea>]\ff\12^. WGj0\10\e4\02" }; 196 | }}}; 197 | }}}; 198 | }}; 199 | ``` 200 | 201 | Example without `btype`: 202 | ``` 203 | variant { Map = vec { 204 | record { "ts"; variant { Nat = 1_675_241_149_669_614_928 : nat } }; 205 | record { "tx"; variant { Map = vec { 206 | record { "op"; variant { Text = "mint" } }; 207 | record { "amt"; variant { Nat = 100_000 : nat } }; 208 | record { "to"; variant { Array = vec { 209 | variant { Blob = blob "Z\d0\ea\e8;\04*\c2CY\8b\delN\ea>]\ff\12^. WGj0\10\e4\02" }; 210 | }}}; 211 | }}}; 212 | }}; 213 | ``` 214 | 215 | #### Transfer and Transfer From Block Schema 216 | 217 | 1. the `btype` field MUST be 218 | 1. `"2xfer"` for `icrc2_transfer_from` blocks 219 | 2. `"1xfer"` for `icrc1_transfer` blocks 220 | 1. if `btype` is not set then `tx.op` field MUST be `"xfer"` 221 | 2. it MUST contain a field `tx.from: Account` 222 | 3. it MUST contain a field `tx.to: Account` 223 | 4. it CAN contain a field `tx.spender: Account` 224 | 225 | Example with `btype`: 226 | ``` 227 | variant { Map = vec { 228 | record { "btype"; "variant" { Text = "1xfer" }}; 229 | record { "fee"; variant { Nat = 10 : nat } }; 230 | record { "phash"; variant { Blob = 231 | blob "h,,\97\82\ff.\9cx&l\a2e\e7KFVv\d1\89\beJ\c5\c5\ad,h\5c<\ca\ce\be" 232 | }}; 233 | record { "ts"; variant { Nat = 1_701_109_006_692_276_133 : nat } }; 234 | record { "tx"; variant { Map = vec { 235 | record { "amt"; variant { Nat = 609_618 : nat } }; 236 | record { "from"; variant { Array = vec { 237 | variant { Blob = blob "\00\00\00\00\00\f0\13x\01\01" }; 238 | variant { Blob = blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00" }; 239 | }}}; 240 | record { "to"; variant { Array = vec { 241 | variant { Blob = blob " \ef\1f\83Zs\0a?\dc\d5y\e7\ccS\9f\0b\14a\ac\9f\fb\f0bf\f3\a9\c7D\02" }; 242 | variant { Blob = blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00" }; 243 | }}}; 244 | }}}; 245 | }}; 246 | ``` 247 | 248 | Example without `btype`: 249 | ``` 250 | variant { Map = vec { 251 | record { "fee"; variant { Nat = 10 : nat } }; 252 | record { "phash"; variant { Blob = 253 | blob "h,,\97\82\ff.\9cx&l\a2e\e7KFVv\d1\89\beJ\c5\c5\ad,h\5c<\ca\ce\be" 254 | }}; 255 | record { "ts"; variant { Nat = 1_701_109_006_692_276_133 : nat } }; 256 | record { "tx"; variant { Map = vec { 257 | record { "op"; variant { Text = "xfer" } }; 258 | record { "amt"; variant { Nat = 609_618 : nat } }; 259 | record { "from"; variant { Array = vec { 260 | variant { Blob = blob "\00\00\00\00\00\f0\13x\01\01" }; 261 | variant { Blob = blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00" }; 262 | }}}; 263 | record { "to"; variant { Array = vec { 264 | variant { Blob = blob " \ef\1f\83Zs\0a?\dc\d5y\e7\ccS\9f\0b\14a\ac\9f\fb\f0bf\f3\a9\c7D\02" }; 265 | variant { Blob = blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00" }; 266 | }}}; 267 | }}}; 268 | }}; 269 | ``` 270 | 271 | #### Approve Block Schema 272 | 273 | 1. the `btype` field MUST be `"2approve"` or `tx.op` field MUST be `"approve"` 274 | 2. it MUST contain a field `tx.from: Account` 275 | 3. it MUST contain a field `tx.spender: Account` 276 | 4. it CAN contain a field `tx.expected_allowance: Nat` if set by the user 277 | 5. it CAN contain a field `tx.expires_at: Nat` if set by the user 278 | 279 | Example with `btype`: 280 | ``` 281 | variant { Map = vec { 282 | record { "btype"; "variant" { Text = "2approve" }}; 283 | record { "fee"; variant { Nat = 10 : nat } }; 284 | record { "phash"; variant { 285 | Blob = blob ";\f7\bet\b6\90\b7\ea2\f4\98\a5\b0\60\a5li3\dcXN\1f##2\b5\db\de\b1\b3\02\f5" 286 | }}; 287 | record { "ts"; variant { Nat = 1_701_167_840_950_358_788 : nat } }; 288 | record { "tx"; variant { Map = vec { 289 | record { "amt"; variant { Nat = 18_446_744_073_709_551_615 : nat } }; 290 | record { "from"; variant { Array = vec { 291 | variant { Blob = blob "\16c\e1\91v\eb\e5)\84:\b2\80\13\cc\09\02\01\a8\03[X\a5\a0\d3\1f\e4\c3{\02" }; 292 | }}}; 293 | record { "spender"; variant { Array = vec { 294 | variant { Blob = blob "\00\00\00\00\00\e0\1dI\01\01" }; 295 | }}}; 296 | }}}; 297 | }}}; 298 | ``` 299 | 300 | Example without `btype`: 301 | ``` 302 | variant { Map = vec { 303 | record { "fee"; variant { Nat = 10 : nat } }; 304 | record { "phash"; variant { 305 | Blob = blob ";\f7\bet\b6\90\b7\ea2\f4\98\a5\b0\60\a5li3\dcXN\1f##2\b5\db\de\b1\b3\02\f5" 306 | }}; 307 | record { "ts"; variant { Nat = 1_701_167_840_950_358_788 : nat } }; 308 | record { "tx"; variant { Map = vec { 309 | record { "op"; variant { Text = "approve" } }; 310 | record { "amt"; variant { Nat = 18_446_744_073_709_551_615 : nat } }; 311 | record { "from"; variant { Array = vec { 312 | variant { Blob = blob "\16c\e1\91v\eb\e5)\84:\b2\80\13\cc\09\02\01\a8\03[X\a5\a0\d3\1f\e4\c3{\02" }; 313 | }}}; 314 | record { "spender"; variant { Array = vec { 315 | variant { Blob = blob "\00\00\00\00\00\e0\1dI\01\01" }; 316 | }}}; 317 | }}}; 318 | }}}; 319 | ``` 320 | 321 | ## Specification 322 | 323 | ### `icrc3_get_blocks` 324 | 325 | ``` 326 | type Value = variant { 327 | Blob : blob; 328 | Text : text; 329 | Nat : nat; // do we need this or can we just use Int? 330 | Int : int; 331 | Array : vec Value; 332 | Map : vec record { text; Value }; 333 | }; 334 | 335 | type GetArchivesArgs = record { 336 | // The last archive seen by the client. 337 | // The Ledger will return archives coming 338 | // after this one if set, otherwise it 339 | // will return the first archives. 340 | from : opt principal; 341 | }; 342 | 343 | type GetArchivesResult = vec record { 344 | // The id of the archive 345 | canister_id : principal; 346 | 347 | // The first block in the archive 348 | start : nat; 349 | 350 | // The last block in the archive 351 | end : nat; 352 | }; 353 | 354 | type GetBlocksArgs = vec record { start : nat; length : nat }; 355 | 356 | type GetBlocksResult = record { 357 | // Total number of blocks in the block log 358 | log_length : nat; 359 | 360 | // Blocks found locally to the Ledger 361 | blocks : vec record { id : nat; block: Value }; 362 | 363 | // List of callbacks to fetch the blocks that are not local 364 | // to the Ledger, i.e. archived blocks 365 | archived_blocks : vec record { 366 | args : GetBlocksArgs; 367 | callback : func (GetBlocksArgs) -> (GetBlocksResult) query; 368 | }; 369 | }; 370 | 371 | service : { 372 | icrc3_get_archives : (GetArchivesArgs) -> (GetArchivesResult) query; 373 | icrc3_get_blocks : (GetBlocksArgs) -> (GetBlocksResult) query; 374 | }; 375 | ``` 376 | 377 | ### `icrc3_get_tip_certificate` 378 | 379 | ``` 380 | // See https://internetcomputer.org/docs/current/references/ic-interface-spec#certification 381 | type DataCertificate = record { 382 | 383 | // Signature of the root of the hash_tree 384 | certificate : blob; 385 | 386 | // CBOR encoded hash_tree 387 | hash_tree : blob; 388 | }; 389 | 390 | service : { 391 | icrc3_get_tip_certificate : () -> (opt DataCertificate) query; 392 | }; 393 | ``` 394 | 395 | ### `icrc3_supported_block_types` 396 | 397 | ``` 398 | service : { 399 | icrc3_supported_block_types : () -> (vec record { block_type : text; url : text }) query; 400 | }; 401 | ``` 402 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # ICRC-1 acceptance test suite 2 | 3 | This directory contains acceptance tests for ICRC-1 ledgers. 4 | You'll need either Cargo (best installed via [rustup.rs](https://rustup.rs/)) or Bazel (best installed via [bazelisk](https://github.com/bazelbuild/bazelisk)) installed to run the test suite. 5 | 6 | The test checks that a ledger with the specified `CANISTER_ID` deployed to a `REPLICA_URL` complies with the ICRC-1 specification, given that the account identified by a secret key encoded in `identity.pem` has enough funds to execute the test. 7 | 8 | Execute the following command to rust the test suite: 9 | 10 | ``` 11 | # ========== 12 | # With Cargo 13 | # ========== 14 | 15 | $ cargo run --bin icrc1-test-runner -- -u REPLICA_URL -c CANISTER_ID -s identity.pem 16 | 17 | # ========== 18 | # With Bazel 19 | # ========== 20 | 21 | $ bazel run //test/runner -- -u REPLICA_URL -c CANISTER_ID -s identity.pem 22 | 23 | # for example 24 | 25 | $ bazel run //test/runner -- -u http://localhost:9000 -c rrkah-fqaaa-aaaaa-aaaaq-cai -s ~/.config/dfx/identity/test/identity.pem 26 | ``` 27 | -------------------------------------------------------------------------------- /test/env/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_library") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | exports_files(["Cargo.toml"]) 7 | 8 | MACRO_DEPENDENCIES = [ 9 | "@crate_index//:async-trait", 10 | ] 11 | 12 | rust_library( 13 | name = "env", 14 | srcs = ["lib.rs"], 15 | crate_name = "icrc1_test_env", 16 | deps = all_crate_deps( 17 | normal = True, 18 | ), 19 | proc_macro_deps = MACRO_DEPENDENCIES, 20 | ) 21 | -------------------------------------------------------------------------------- /test/env/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icrc1-test-env" 3 | version = "0.1.2" 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | license = { workspace = true } 7 | rust-version = { workspace = true } 8 | repository = { workspace = true } 9 | description = { workspace = true } 10 | 11 | [lib] 12 | path = "lib.rs" 13 | 14 | [dependencies] 15 | anyhow = { workspace = true } 16 | async-trait = { workspace = true } 17 | candid = { workspace = true } 18 | serde = { workspace = true } 19 | thiserror = { workspace = true } 20 | -------------------------------------------------------------------------------- /test/env/Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.2] - 2024-01-16 8 | ### Changed 9 | - Use candid 0.10 10 | 11 | ## [0.1.1] - 2023-09-05 12 | ### Changed 13 | - Updated candid to the latest version. 14 | 15 | ## [0.1.0] - 2023-07-26 16 | ### Added 17 | - Original release. -------------------------------------------------------------------------------- /test/env/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /test/env/README.md: -------------------------------------------------------------------------------- 1 | # ICRC1 Test Suite Environment 2 | ======================= 3 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 4 | ======================= 5 | This crate provides the traits that define the environment for the ICRC1 test suite. -------------------------------------------------------------------------------- /test/env/lib.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use candid::utils::{ArgumentDecoder, ArgumentEncoder}; 3 | use candid::Principal; 4 | use candid::{CandidType, Int, Nat}; 5 | use serde::Deserialize; 6 | use thiserror::Error; 7 | 8 | pub type Subaccount = [u8; 32]; 9 | 10 | #[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)] 11 | pub struct Account { 12 | pub owner: Principal, 13 | pub subaccount: Option, 14 | } 15 | 16 | impl From for Account { 17 | fn from(owner: Principal) -> Self { 18 | Self { 19 | owner, 20 | subaccount: None, 21 | } 22 | } 23 | } 24 | 25 | #[derive(CandidType, Deserialize, PartialEq, Clone, Debug)] 26 | pub struct SupportedStandard { 27 | pub name: String, 28 | pub url: String, 29 | } 30 | 31 | #[derive(CandidType, Clone, Debug, Deserialize, PartialEq)] 32 | pub enum Value { 33 | Text(String), 34 | Blob(Vec), 35 | Nat(Nat), 36 | Int(Int), 37 | } 38 | 39 | #[derive(CandidType, Deserialize, PartialEq, Eq, Debug, Clone, Error)] 40 | pub enum TransferError { 41 | #[error("Invalid transfer fee, the ledger expected fee {expected_fee}")] 42 | BadFee { expected_fee: Nat }, 43 | #[error("Invalid burn amount, the minimal burn amount is {min_burn_amount}")] 44 | BadBurn { min_burn_amount: Nat }, 45 | #[error("The account owner doesn't have enough funds to for the transfer, balance: {balance}")] 46 | InsufficientFunds { balance: Nat }, 47 | #[error("created_at_time is too far in the past")] 48 | TooOld, 49 | #[error("created_at_time is too far in the future, ledger time: {ledger_time}")] 50 | CreatedInFuture { ledger_time: u64 }, 51 | #[error("the transfer is a duplicate of transaction {duplicate_of}")] 52 | Duplicate { duplicate_of: Nat }, 53 | #[error("the ledger is temporarily unavailable")] 54 | TemporarilyUnavailable, 55 | #[error("generic error (code {error_code}): {message}")] 56 | GenericError { error_code: Nat, message: String }, 57 | } 58 | 59 | #[derive(CandidType, Debug, Clone)] 60 | pub struct Transfer { 61 | from_subaccount: Option, 62 | amount: Nat, 63 | to: Account, 64 | fee: Option, 65 | created_at_time: Option, 66 | memo: Option>, 67 | } 68 | 69 | impl Transfer { 70 | pub fn amount_to(amount: impl Into, to: impl Into) -> Self { 71 | Self { 72 | from_subaccount: None, 73 | amount: amount.into(), 74 | to: to.into(), 75 | fee: None, 76 | created_at_time: None, 77 | memo: None, 78 | } 79 | } 80 | 81 | pub fn from_subaccount(mut self, from_subaccount: Subaccount) -> Self { 82 | self.from_subaccount = Some(from_subaccount); 83 | self 84 | } 85 | 86 | pub fn fee(mut self, fee: impl Into) -> Self { 87 | self.fee = Some(fee.into()); 88 | self 89 | } 90 | 91 | pub fn created_at_time(mut self, time: u64) -> Self { 92 | self.created_at_time = Some(time); 93 | self 94 | } 95 | 96 | pub fn memo(mut self, memo: impl Into>) -> Self { 97 | self.memo = Some(memo.into()); 98 | self 99 | } 100 | } 101 | 102 | #[derive(CandidType, Clone, Debug, PartialEq, Eq)] 103 | pub struct ApproveArgs { 104 | pub from_subaccount: Option, 105 | pub spender: Account, 106 | pub amount: Nat, 107 | pub expected_allowance: Option, 108 | pub expires_at: Option, 109 | pub memo: Option>, 110 | pub fee: Option, 111 | pub created_at_time: Option, 112 | } 113 | 114 | impl ApproveArgs { 115 | pub fn approve_amount(amount: impl Into, spender: impl Into) -> Self { 116 | Self { 117 | amount: amount.into(), 118 | fee: None, 119 | created_at_time: None, 120 | memo: None, 121 | from_subaccount: None, 122 | spender: spender.into(), 123 | expected_allowance: None, 124 | expires_at: None, 125 | } 126 | } 127 | 128 | pub fn expected_allowance(mut self, expected_allowance: Nat) -> Self { 129 | self.expected_allowance = Some(expected_allowance); 130 | self 131 | } 132 | 133 | pub fn fee(mut self, fee: impl Into) -> Self { 134 | self.fee = Some(fee.into()); 135 | self 136 | } 137 | 138 | pub fn created_at_time(mut self, time: u64) -> Self { 139 | self.created_at_time = Some(time); 140 | self 141 | } 142 | 143 | pub fn expires_at(mut self, time: u64) -> Self { 144 | self.expires_at = Some(time); 145 | self 146 | } 147 | 148 | pub fn memo(mut self, memo: impl Into>) -> Self { 149 | self.memo = Some(memo.into()); 150 | self 151 | } 152 | } 153 | 154 | #[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq, Error)] 155 | pub enum ApproveError { 156 | #[error("Invalid transfer fee, the ledger expected fee {expected_fee}")] 157 | BadFee { expected_fee: Nat }, 158 | #[error("The account owner doesn't have enough funds to for the approval, balance: {balance}")] 159 | InsufficientFunds { balance: Nat }, 160 | #[error("The allowance changed, current allowance: {current_allowance}")] 161 | AllowanceChanged { current_allowance: Nat }, 162 | #[error("the approval expiration time is in the past, ledger time: {ledger_time}")] 163 | Expired { ledger_time: u64 }, 164 | #[error("created_at_time is too far in the past")] 165 | TooOld, 166 | #[error("created_at_time is too far in the future, ledger time: {ledger_time}")] 167 | CreatedInFuture { ledger_time: u64 }, 168 | #[error("the transfer is a duplicate of transaction {duplicate_of}")] 169 | Duplicate { duplicate_of: Nat }, 170 | #[error("the ledger is temporarily unavailable")] 171 | TemporarilyUnavailable, 172 | #[error("generic error (code {error_code}): {message}")] 173 | GenericError { error_code: Nat, message: String }, 174 | } 175 | 176 | #[derive(CandidType, Clone, Debug, PartialEq, Eq)] 177 | pub struct TransferFromArgs { 178 | pub spender_subaccount: Option, 179 | pub from: Account, 180 | pub to: Account, 181 | pub amount: Nat, 182 | pub fee: Option, 183 | pub memo: Option>, 184 | pub created_at_time: Option, 185 | } 186 | 187 | impl TransferFromArgs { 188 | pub fn transfer_from( 189 | amount: impl Into, 190 | to: impl Into, 191 | from: impl Into, 192 | ) -> Self { 193 | Self { 194 | spender_subaccount: None, 195 | amount: amount.into(), 196 | to: to.into(), 197 | fee: None, 198 | created_at_time: None, 199 | memo: None, 200 | from: from.into(), 201 | } 202 | } 203 | 204 | pub fn from_subaccount(mut self, spender_subaccount: Subaccount) -> Self { 205 | self.spender_subaccount = Some(spender_subaccount); 206 | self 207 | } 208 | 209 | pub fn fee(mut self, fee: impl Into) -> Self { 210 | self.fee = Some(fee.into()); 211 | self 212 | } 213 | 214 | pub fn created_at_time(mut self, time: u64) -> Self { 215 | self.created_at_time = Some(time); 216 | self 217 | } 218 | 219 | pub fn memo(mut self, memo: impl Into>) -> Self { 220 | self.memo = Some(memo.into()); 221 | self 222 | } 223 | } 224 | 225 | #[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq, Error)] 226 | pub enum TransferFromError { 227 | #[error("Invalid transfer fee, the ledger expected fee {expected_fee}")] 228 | BadFee { expected_fee: Nat }, 229 | #[error("Invalid burn amount, the minimal burn amount is {min_burn_amount}")] 230 | BadBurn { min_burn_amount: Nat }, 231 | #[error("The account owner doesn't have enough funds to for the transfer, balance: {balance}")] 232 | InsufficientFunds { balance: Nat }, 233 | #[error("The account owner doesn't have allowance for the transfer, allowance: {allowance}")] 234 | InsufficientAllowance { allowance: Nat }, 235 | #[error("created_at_time is too far in the past")] 236 | TooOld, 237 | #[error("created_at_time is too far in the future, ledger time: {ledger_time}")] 238 | CreatedInFuture { ledger_time: u64 }, 239 | #[error("the transfer is a duplicate of transaction {duplicate_of}")] 240 | Duplicate { duplicate_of: Nat }, 241 | #[error("the ledger is temporarily unavailable")] 242 | TemporarilyUnavailable, 243 | #[error("generic error (code {error_code}): {message}")] 244 | GenericError { error_code: Nat, message: String }, 245 | } 246 | 247 | #[derive(CandidType, Clone, Debug, PartialEq, Eq)] 248 | pub struct AllowanceArgs { 249 | pub account: Account, 250 | pub spender: Account, 251 | } 252 | 253 | #[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] 254 | pub struct Allowance { 255 | pub allowance: Nat, 256 | #[serde(default)] 257 | pub expires_at: Option, 258 | } 259 | 260 | #[async_trait(?Send)] 261 | pub trait LedgerEnv { 262 | /// Creates a new environment pointing to the same ledger but using a new caller. 263 | fn fork(&self) -> Self; 264 | 265 | /// Returns the caller's principal. 266 | fn principal(&self) -> Principal; 267 | 268 | /// Returns the approximation of the current ledger time. 269 | fn time(&self) -> std::time::SystemTime; 270 | 271 | /// Executes a query call with the specified arguments on the ledger. 272 | async fn query(&self, method: &str, input: Input) -> anyhow::Result 273 | where 274 | Input: ArgumentEncoder + std::fmt::Debug, 275 | Output: for<'a> ArgumentDecoder<'a>; 276 | 277 | /// Executes an update call with the specified arguments on the ledger. 278 | async fn update(&self, method: &str, input: Input) -> anyhow::Result 279 | where 280 | Input: ArgumentEncoder + std::fmt::Debug, 281 | Output: for<'a> ArgumentDecoder<'a>; 282 | } 283 | 284 | pub mod icrc1 { 285 | use crate::{Account, LedgerEnv, SupportedStandard, Transfer, TransferError, Value}; 286 | use candid::Nat; 287 | 288 | pub async fn transfer( 289 | ledger: &impl LedgerEnv, 290 | arg: Transfer, 291 | ) -> anyhow::Result> { 292 | ledger.update("icrc1_transfer", (arg,)).await.map(|(t,)| t) 293 | } 294 | 295 | pub async fn balance_of( 296 | ledger: &impl LedgerEnv, 297 | account: impl Into, 298 | ) -> anyhow::Result { 299 | ledger 300 | .query("icrc1_balance_of", (account.into(),)) 301 | .await 302 | .map(|(t,)| t) 303 | } 304 | 305 | pub async fn supported_standards( 306 | ledger: &impl LedgerEnv, 307 | ) -> anyhow::Result> { 308 | ledger 309 | .query("icrc1_supported_standards", ()) 310 | .await 311 | .map(|(t,)| t) 312 | } 313 | 314 | pub async fn metadata(ledger: &impl LedgerEnv) -> anyhow::Result> { 315 | ledger.query("icrc1_metadata", ()).await.map(|(t,)| t) 316 | } 317 | 318 | pub async fn minting_account(ledger: &impl LedgerEnv) -> anyhow::Result> { 319 | ledger 320 | .query("icrc1_minting_account", ()) 321 | .await 322 | .map(|(t,)| t) 323 | } 324 | 325 | pub async fn token_name(ledger: &impl LedgerEnv) -> anyhow::Result { 326 | ledger.query("icrc1_name", ()).await.map(|(t,)| t) 327 | } 328 | 329 | pub async fn token_symbol(ledger: &impl LedgerEnv) -> anyhow::Result { 330 | ledger.query("icrc1_symbol", ()).await.map(|(t,)| t) 331 | } 332 | 333 | pub async fn token_decimals(ledger: &impl LedgerEnv) -> anyhow::Result { 334 | ledger.query("icrc1_decimals", ()).await.map(|(t,)| t) 335 | } 336 | 337 | pub async fn transfer_fee(ledger: &impl LedgerEnv) -> anyhow::Result { 338 | ledger.query("icrc1_fee", ()).await.map(|(t,)| t) 339 | } 340 | } 341 | 342 | pub mod icrc2 { 343 | use crate::{ 344 | Allowance, AllowanceArgs, ApproveArgs, ApproveError, LedgerEnv, TransferFromArgs, 345 | TransferFromError, 346 | }; 347 | use candid::Nat; 348 | 349 | pub async fn approve( 350 | ledger: &impl LedgerEnv, 351 | arg: ApproveArgs, 352 | ) -> anyhow::Result> { 353 | ledger.update("icrc2_approve", (arg,)).await.map(|(t,)| t) 354 | } 355 | 356 | pub async fn transfer_from( 357 | ledger: &impl LedgerEnv, 358 | arg: TransferFromArgs, 359 | ) -> anyhow::Result> { 360 | ledger 361 | .update("icrc2_transfer_from", (arg,)) 362 | .await 363 | .map(|(t,)| t) 364 | } 365 | 366 | pub async fn allowance( 367 | ledger: &impl LedgerEnv, 368 | arg: AllowanceArgs, 369 | ) -> anyhow::Result { 370 | ledger.query("icrc2_allowance", (arg,)).await.map(|(t,)| t) 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /test/env/replica/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_library") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | exports_files(["Cargo.toml"]) 7 | 8 | MACRO_DEPENDENCIES = [ 9 | "@crate_index//:async-trait", 10 | ] 11 | 12 | rust_library( 13 | name = "replica", 14 | srcs = ["lib.rs"], 15 | crate_name = "icrc1_test_env_replica", 16 | deps = all_crate_deps( 17 | normal = True, 18 | )+ [ "//test/env", 19 | ], 20 | proc_macro_deps = MACRO_DEPENDENCIES, 21 | ) 22 | -------------------------------------------------------------------------------- /test/env/replica/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icrc1-test-env-replica" 3 | version = "0.1.2" 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | license = { workspace = true } 7 | rust-version = { workspace = true } 8 | repository = { workspace = true } 9 | description = { workspace = true } 10 | 11 | [lib] 12 | path = "lib.rs" 13 | 14 | [dependencies] 15 | anyhow = { workspace = true } 16 | candid = { workspace = true } 17 | ic-agent = { workspace = true } 18 | rand = { workspace = true } 19 | ring = "0.16.20" 20 | hex = { workspace = true } 21 | async-trait = { workspace = true } 22 | icrc1-test-env = { version = "0.1.2", path = "../" } 23 | -------------------------------------------------------------------------------- /test/env/replica/Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.2] - 2024-01-16 8 | ### Changed 9 | - Use candid 0.10 10 | 11 | ## [0.1.1] - 2023-09-05 12 | ### Changed 13 | - Updated candid to the latest version. 14 | 15 | ## [0.1.0] - 2023-07-26 16 | ### Added 17 | - Original release. -------------------------------------------------------------------------------- /test/env/replica/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /test/env/replica/README.md: -------------------------------------------------------------------------------- 1 | # ICRC1 Test Suite Replica Environment 2 | ======================= 3 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 4 | ======================= 5 | This crate provides the replica implementation for the environment of the ICRC1 test suite. -------------------------------------------------------------------------------- /test/env/replica/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use async_trait::async_trait; 3 | use candid::utils::{decode_args, encode_args, ArgumentDecoder, ArgumentEncoder}; 4 | use candid::Principal; 5 | use ic_agent::identity::BasicIdentity; 6 | use ic_agent::Agent; 7 | use icrc1_test_env::LedgerEnv; 8 | use ring::rand::SystemRandom; 9 | use std::sync::{Arc, Mutex}; 10 | use std::time::SystemTime; 11 | 12 | pub fn fresh_identity(rand: &SystemRandom) -> BasicIdentity { 13 | use ring::signature::Ed25519KeyPair as KeyPair; 14 | 15 | let doc = KeyPair::generate_pkcs8(rand).expect("failed to generate an ed25519 key pair"); 16 | 17 | let key_pair = KeyPair::from_pkcs8(doc.as_ref()) 18 | .expect("failed to construct a key pair from a pkcs8 document"); 19 | BasicIdentity::from_key_pair(key_pair) 20 | } 21 | 22 | #[derive(Clone)] 23 | pub struct ReplicaLedger { 24 | rand: Arc>, 25 | agent: Arc, 26 | canister_id: Principal, 27 | } 28 | 29 | #[async_trait(?Send)] 30 | impl LedgerEnv for ReplicaLedger { 31 | fn fork(&self) -> Self { 32 | let mut agent = Arc::clone(&self.agent); 33 | Arc::make_mut(&mut agent).set_identity({ 34 | let r = self.rand.lock().expect("failed to grab a lock"); 35 | fresh_identity(&r) 36 | }); 37 | Self { 38 | rand: Arc::clone(&self.rand), 39 | agent, 40 | canister_id: self.canister_id, 41 | } 42 | } 43 | 44 | fn principal(&self) -> Principal { 45 | self.agent 46 | .get_principal() 47 | .expect("failed to get agent principal") 48 | } 49 | 50 | fn time(&self) -> SystemTime { 51 | // The replica relies on the system time by default. 52 | // Unfortunately, this assumption might break during the time 53 | // shifts, but it's probably good enough for tests. 54 | SystemTime::now() 55 | } 56 | 57 | async fn query(&self, method: &str, input: Input) -> anyhow::Result 58 | where 59 | Input: ArgumentEncoder + std::fmt::Debug, 60 | Output: for<'a> ArgumentDecoder<'a>, 61 | { 62 | let debug_inputs = format!("{:?}", input); 63 | let in_bytes = encode_args(input) 64 | .with_context(|| format!("Failed to encode arguments {}", debug_inputs))?; 65 | let bytes = self 66 | .agent 67 | .query(&self.canister_id, method) 68 | .with_arg(in_bytes) 69 | .call() 70 | .await 71 | .with_context(|| { 72 | format!( 73 | "failed to call method {} on {} with args {}", 74 | method, self.canister_id, debug_inputs 75 | ) 76 | })?; 77 | 78 | decode_args(&bytes).with_context(|| { 79 | format!( 80 | "Failed to decode method {} response into type {}, bytes: {}", 81 | method, 82 | std::any::type_name::(), 83 | hex::encode(bytes) 84 | ) 85 | }) 86 | } 87 | 88 | async fn update(&self, method: &str, input: Input) -> anyhow::Result 89 | where 90 | Input: ArgumentEncoder + std::fmt::Debug, 91 | Output: for<'a> ArgumentDecoder<'a>, 92 | { 93 | let debug_inputs = format!("{:?}", input); 94 | let in_bytes = encode_args(input) 95 | .with_context(|| format!("Failed to encode arguments {}", debug_inputs))?; 96 | let bytes = self 97 | .agent 98 | .update(&self.canister_id, method) 99 | .with_arg(in_bytes) 100 | .call_and_wait() 101 | .await 102 | .with_context(|| { 103 | format!( 104 | "failed to call method {} on {} with args {}", 105 | method, self.canister_id, debug_inputs 106 | ) 107 | })?; 108 | 109 | decode_args(&bytes).with_context(|| { 110 | format!( 111 | "Failed to decode method {} response into type {}, bytes: {}", 112 | method, 113 | std::any::type_name::(), 114 | hex::encode(bytes) 115 | ) 116 | }) 117 | } 118 | } 119 | 120 | impl ReplicaLedger { 121 | pub fn new(agent: Agent, canister_id: Principal) -> Self { 122 | Self { 123 | rand: Arc::new(Mutex::new(SystemRandom::new())), 124 | agent: Arc::new(agent), 125 | canister_id, 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /test/env/state-machine/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_library") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | exports_files(["Cargo.toml"]) 7 | 8 | MACRO_DEPENDENCIES = [ 9 | "@crate_index//:async-trait", 10 | ] 11 | 12 | rust_library( 13 | name = "state-machine", 14 | srcs = ["lib.rs"], 15 | crate_name = "icrc1_test_env_state_machine", 16 | deps = all_crate_deps( 17 | normal = True, 18 | ) + [ "//test/env", 19 | ], 20 | proc_macro_deps = MACRO_DEPENDENCIES, 21 | ) 22 | -------------------------------------------------------------------------------- /test/env/state-machine/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icrc1-test-env-state-machine" 3 | version = "0.1.2" 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | license = { workspace = true } 7 | rust-version = { workspace = true } 8 | repository = { workspace = true } 9 | description = { workspace = true } 10 | 11 | [lib] 12 | path = "lib.rs" 13 | 14 | [dependencies] 15 | anyhow = { workspace = true } 16 | candid = { workspace = true } 17 | async-trait = { workspace = true } 18 | hex = { workspace = true } 19 | ic-test-state-machine-client = { workspace = true } 20 | icrc1-test-env = { version = "0.1.2", path = "../" } -------------------------------------------------------------------------------- /test/env/state-machine/Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.2] - 2024-01-16 8 | ### Changed 9 | - Use candid 0.10 10 | 11 | ## [0.1.1] - 2023-09-05 12 | ### Changed 13 | - Updated candid to the latest version. 14 | 15 | ## [0.1.0] - 2023-07-26 16 | ### Added 17 | - Original release. -------------------------------------------------------------------------------- /test/env/state-machine/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /test/env/state-machine/README.md: -------------------------------------------------------------------------------- 1 | # ICRC1 Test Suite State-Machine Environment 2 | ======================= 3 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 4 | ======================= 5 | This crate provides the state-machine implementation for the environment of the ICRC1 test suite. -------------------------------------------------------------------------------- /test/env/state-machine/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use async_trait::async_trait; 3 | use candid::utils::{decode_args, encode_args, ArgumentDecoder, ArgumentEncoder}; 4 | use candid::Principal; 5 | use ic_test_state_machine_client::StateMachine; 6 | use icrc1_test_env::LedgerEnv; 7 | use std::sync::atomic::{AtomicU64, Ordering}; 8 | use std::sync::Arc; 9 | 10 | fn new_principal(n: u64) -> Principal { 11 | let mut bytes = n.to_le_bytes().to_vec(); 12 | bytes.push(0xfe); 13 | bytes.push(0x01); 14 | Principal::try_from_slice(&bytes[..]).unwrap() 15 | } 16 | 17 | #[derive(Clone)] 18 | pub struct SMLedger { 19 | counter: Arc, 20 | sm: Arc, 21 | sender: Principal, 22 | canister_id: Principal, 23 | } 24 | 25 | #[async_trait(?Send)] 26 | impl LedgerEnv for SMLedger { 27 | fn fork(&self) -> Self { 28 | Self { 29 | counter: self.counter.clone(), 30 | sm: self.sm.clone(), 31 | sender: new_principal(self.counter.fetch_add(1, Ordering::Relaxed)), 32 | canister_id: self.canister_id, 33 | } 34 | } 35 | 36 | fn principal(&self) -> Principal { 37 | self.sender 38 | } 39 | 40 | fn time(&self) -> std::time::SystemTime { 41 | self.sm.time() 42 | } 43 | 44 | async fn query(&self, method: &str, input: Input) -> anyhow::Result 45 | where 46 | Input: ArgumentEncoder + std::fmt::Debug, 47 | Output: for<'a> ArgumentDecoder<'a>, 48 | { 49 | let debug_inputs = format!("{:?}", input); 50 | let in_bytes = encode_args(input) 51 | .with_context(|| format!("Failed to encode arguments {}", debug_inputs))?; 52 | match self 53 | .sm 54 | .query_call( 55 | Principal::from_slice(self.canister_id.as_slice()), 56 | Principal::from_slice(self.sender.as_slice()), 57 | method, 58 | in_bytes, 59 | ) 60 | .map_err(|err| anyhow::Error::msg(err.to_string()))? 61 | { 62 | ic_test_state_machine_client::WasmResult::Reply(bytes) => decode_args(&bytes) 63 | .with_context(|| { 64 | format!( 65 | "Failed to decode method {} response into type {}, bytes: {}", 66 | method, 67 | std::any::type_name::(), 68 | hex::encode(bytes) 69 | ) 70 | }), 71 | ic_test_state_machine_client::WasmResult::Reject(msg) => { 72 | return Err(anyhow::Error::msg(format!( 73 | "Query call to ledger {:?} was rejected: {}", 74 | self.canister_id, msg 75 | ))) 76 | } 77 | } 78 | } 79 | 80 | async fn update(&self, method: &str, input: Input) -> anyhow::Result 81 | where 82 | Input: ArgumentEncoder + std::fmt::Debug, 83 | Output: for<'a> ArgumentDecoder<'a>, 84 | { 85 | let debug_inputs = format!("{:?}", input); 86 | let in_bytes = encode_args(input) 87 | .with_context(|| format!("Failed to encode arguments {}", debug_inputs))?; 88 | match self 89 | .sm 90 | .update_call(self.canister_id, self.sender, method, in_bytes) 91 | .map_err(|err| anyhow::Error::msg(err.to_string())) 92 | .with_context(|| { 93 | format!( 94 | "failed to execute update call {} on canister {}", 95 | method, self.canister_id 96 | ) 97 | })? { 98 | ic_test_state_machine_client::WasmResult::Reply(bytes) => decode_args(&bytes) 99 | .with_context(|| { 100 | format!( 101 | "Failed to decode method {} response into type {}, bytes: {}", 102 | method, 103 | std::any::type_name::(), 104 | hex::encode(bytes) 105 | ) 106 | }), 107 | ic_test_state_machine_client::WasmResult::Reject(msg) => { 108 | return Err(anyhow::Error::msg(format!( 109 | "Query call to ledger {:?} was rejected: {}", 110 | self.canister_id, msg 111 | ))) 112 | } 113 | } 114 | } 115 | } 116 | 117 | impl SMLedger { 118 | pub fn new(sm: Arc, canister_id: Principal, sender: Principal) -> Self { 119 | Self { 120 | counter: Arc::new(AtomicU64::new(0)), 121 | sm, 122 | canister_id, 123 | sender, 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/ref/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_test") 3 | load("//bazel:didc_test.bzl", "motoko_actor_wasm_file") 4 | 5 | package(default_visibility = ["//visibility:public"]) 6 | 7 | motoko_actor_wasm_file( 8 | name = "ref_wasm", 9 | actor = "//ref:icrc1_ref", 10 | ) 11 | 12 | rust_test( 13 | name = "ref", 14 | srcs = ["test.rs"], 15 | args = ["--nocapture"], 16 | compile_data = [ 17 | ":ref_wasm", 18 | ], 19 | crate_name = "icrc1_test_ref", 20 | data = [ 21 | "@replica_tools//:canister_sandbox", 22 | "@replica_tools//:ic-starter", 23 | "@replica_tools//:ic-test-state-machine", 24 | "@replica_tools//:replica", 25 | "@replica_tools//:sandbox_launcher", 26 | ], 27 | env = { 28 | "IC_REPLICA_PATH": "$(rootpath @replica_tools//:replica)", 29 | "IC_STARTER_PATH": "$(rootpath @replica_tools//:ic-starter)", 30 | "SANDBOX_LAUNCHER": "$(rootpath @replica_tools//:sandbox_launcher)", 31 | "CANISTER_SANDBOX": "$(rootpath @replica_tools//:canister_sandbox)", 32 | "STATE_MACHINE_BINARY": "$(rootpath @@replica_tools//:ic-test-state-machine)", 33 | }, 34 | rustc_env = { 35 | "REF_WASM_PATH": "$(location :ref_wasm)", 36 | }, 37 | use_libtest_harness = False, 38 | deps = [ 39 | "//test/env", 40 | "//test/env/replica", 41 | "//test/env/state-machine", 42 | "//test/replica", 43 | "//test/suite", 44 | "@crate_index//:candid", 45 | "@crate_index//:ic-agent", 46 | "@crate_index//:ic-test-state-machine-client", 47 | "@crate_index//:ring", 48 | "@crate_index//:serde", 49 | "@crate_index//:tokio", 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /test/ref/test.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | use candid::{CandidType, Decode, Encode, Nat}; 3 | use ic_agent::Agent; 4 | use ic_agent::Identity; 5 | use ic_test_state_machine_client::StateMachine; 6 | use icrc1_test_env_replica::fresh_identity; 7 | use icrc1_test_env_replica::ReplicaLedger; 8 | use icrc1_test_env_state_machine::SMLedger; 9 | use icrc1_test_replica::start_replica; 10 | use ring::rand::SystemRandom; 11 | use serde::{Deserialize, Serialize}; 12 | use std::sync::Arc; 13 | 14 | const REF_WASM: &[u8] = include_bytes!(env!("REF_WASM_PATH")); 15 | 16 | #[derive(CandidType, Deserialize, Debug)] 17 | struct Account { 18 | owner: Principal, 19 | subaccount: Option<[u8; 32]>, 20 | } 21 | 22 | #[derive(CandidType, Debug)] 23 | struct Mints { 24 | account: Account, 25 | amount: Nat, 26 | } 27 | 28 | #[derive(CandidType)] 29 | struct RefInitArg { 30 | initial_mints: Vec, 31 | minting_account: Account, 32 | token_name: String, 33 | token_symbol: String, 34 | decimals: u8, 35 | transfer_fee: Nat, 36 | } 37 | 38 | async fn install_canister(agent: &Agent, wasm: &[u8], init_arg: &[u8]) -> Principal { 39 | #[derive(CandidType, Deserialize)] 40 | struct CreateCanisterResult { 41 | canister_id: Principal, 42 | } 43 | 44 | #[derive(CandidType)] 45 | struct Settings { 46 | controllers: Option>, 47 | } 48 | 49 | #[derive(CandidType)] 50 | struct CreateCanisterRequest { 51 | amount: Option, 52 | settings: Option, 53 | } 54 | 55 | #[derive(CandidType, Serialize)] 56 | enum InstallMode { 57 | #[serde(rename = "install")] 58 | Install, 59 | } 60 | 61 | #[derive(CandidType, Serialize)] 62 | struct InstallCode<'a> { 63 | canister_id: Principal, 64 | mode: InstallMode, 65 | wasm_module: &'a [u8], 66 | arg: &'a [u8], 67 | } 68 | 69 | let response_bytes = agent 70 | .update( 71 | &Principal::management_canister(), 72 | "provisional_create_canister_with_cycles", 73 | ) 74 | .with_arg( 75 | Encode!(&CreateCanisterRequest { 76 | amount: Some(candid::Nat::from(1_000_000_000_000u64)), 77 | settings: Some(Settings { 78 | controllers: Some(vec![agent.get_principal().unwrap()]), 79 | }) 80 | }) 81 | .unwrap(), 82 | ) 83 | .call_and_wait() 84 | .await 85 | .expect("failed to create a canister"); 86 | 87 | let canister_id = Decode!(&response_bytes, CreateCanisterResult) 88 | .expect("failed to decode response") 89 | .canister_id; 90 | 91 | agent 92 | .update(&Principal::management_canister(), "install_code") 93 | .with_arg( 94 | Encode!(&InstallCode { 95 | canister_id, 96 | mode: InstallMode::Install, 97 | wasm_module: wasm, 98 | arg: init_arg, 99 | }) 100 | .unwrap(), 101 | ) 102 | .call_and_wait() 103 | .await 104 | .expect("failed to install canister"); 105 | canister_id 106 | } 107 | 108 | fn sm_env() -> StateMachine { 109 | let ic_test_state_machine_path = std::fs::canonicalize( 110 | std::env::var_os("STATE_MACHINE_BINARY").expect("missing ic-starter binary"), 111 | ) 112 | .unwrap(); 113 | 114 | StateMachine::new( 115 | &ic_test_state_machine_path 116 | .into_os_string() 117 | .into_string() 118 | .unwrap(), 119 | false, 120 | ) 121 | } 122 | 123 | async fn test_replica() { 124 | let replica_path = 125 | std::fs::canonicalize(std::env::var_os("IC_REPLICA_PATH").expect("missing replica binary")) 126 | .unwrap(); 127 | 128 | let ic_starter_path = std::fs::canonicalize( 129 | std::env::var_os("IC_STARTER_PATH").expect("missing ic-starter binary"), 130 | ) 131 | .unwrap(); 132 | 133 | let sandbox_launcher_path = std::fs::canonicalize( 134 | std::env::var_os("SANDBOX_LAUNCHER").expect("missing sandbox_launcher"), 135 | ) 136 | .unwrap(); 137 | 138 | let canister_sandbox_path = std::fs::canonicalize( 139 | std::env::var_os("CANISTER_SANDBOX").expect("missing canister_sandbox"), 140 | ) 141 | .unwrap(); 142 | 143 | let (mut agent, _replica_context) = start_replica( 144 | &replica_path, 145 | &ic_starter_path, 146 | &sandbox_launcher_path, 147 | &canister_sandbox_path, 148 | ) 149 | .await; 150 | 151 | // We need a fresh identity to be used for the tests 152 | // This identity simulates the identity a user would parse to the binary 153 | let p1 = fresh_identity(&SystemRandom::new()); 154 | 155 | let init_arg = Encode!(&RefInitArg { 156 | initial_mints: vec![Mints { 157 | account: Account { 158 | owner: p1.sender().unwrap(), 159 | subaccount: None 160 | }, 161 | amount: Nat::from(100_000_000u32) 162 | }], 163 | minting_account: Account { 164 | owner: agent.get_principal().unwrap(), 165 | subaccount: None 166 | }, 167 | token_name: "Test token".to_string(), 168 | token_symbol: "XTK".to_string(), 169 | decimals: 8, 170 | transfer_fee: Nat::from(10_000u16), 171 | }) 172 | .unwrap(); 173 | 174 | let canister_id = install_canister(&agent, REF_WASM, &init_arg).await; 175 | 176 | // We need to set the identity of the agent to that of what a user would parse 177 | agent.set_identity(p1); 178 | let env = ReplicaLedger::new(agent, canister_id); 179 | let tests = icrc1_test_suite::test_suite(env).await; 180 | 181 | if !icrc1_test_suite::execute_tests(tests).await { 182 | std::process::exit(1); 183 | } 184 | } 185 | 186 | async fn test_state_machine() { 187 | let sm_env = sm_env(); 188 | // We need a fresh identity to be used for the tests 189 | // This identity simulates the identity a user would parse to the binary 190 | let minter = fresh_identity(&SystemRandom::new()); 191 | let p1 = fresh_identity(&SystemRandom::new()); 192 | 193 | // The tests expect the parsed identity to have enough ICP to run the tests 194 | let init_arg = Encode!(&RefInitArg { 195 | initial_mints: vec![Mints { 196 | account: Account { 197 | owner: p1.sender().unwrap(), 198 | subaccount: None 199 | }, 200 | amount: Nat::from(100_000_000u32) 201 | }], 202 | minting_account: Account { 203 | owner: minter.sender().unwrap(), 204 | subaccount: None 205 | }, 206 | token_name: "Test token".to_string(), 207 | token_symbol: "XTK".to_string(), 208 | decimals: 8, 209 | transfer_fee: Nat::from(10_000u16), 210 | }) 211 | .unwrap(); 212 | 213 | let canister_id = sm_env.create_canister(Some(minter.sender().unwrap())); 214 | 215 | sm_env.install_canister( 216 | canister_id, 217 | (*REF_WASM).to_vec(), 218 | init_arg, 219 | Some(minter.sender().unwrap()), 220 | ); 221 | 222 | let env = SMLedger::new(Arc::new(sm_env), canister_id, p1.sender().unwrap()); 223 | 224 | let tests = icrc1_test_suite::test_suite(env).await; 225 | 226 | if !icrc1_test_suite::execute_tests(tests).await { 227 | std::process::exit(1); 228 | } 229 | } 230 | 231 | #[tokio::main] 232 | async fn main() { 233 | test_state_machine().await; 234 | 235 | test_replica().await; 236 | } 237 | -------------------------------------------------------------------------------- /test/replica/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_library") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | exports_files(["Cargo.toml"]) 7 | 8 | rust_library( 9 | name = "replica", 10 | srcs = ["lib.rs"], 11 | crate_name = "icrc1_test_replica", 12 | deps = all_crate_deps( 13 | normal = True, 14 | ), 15 | ) 16 | -------------------------------------------------------------------------------- /test/replica/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icrc1-test-replica" 3 | version = "0.1.2" 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | license = { workspace = true } 7 | rust-version = { workspace = true } 8 | repository = { workspace = true } 9 | description = { workspace = true } 10 | 11 | [lib] 12 | path = "lib.rs" 13 | 14 | [dependencies] 15 | ic-agent = { workspace = true } 16 | reqwest = { workspace = true } 17 | tempfile = { workspace = true } 18 | tokio = { workspace = true } -------------------------------------------------------------------------------- /test/replica/Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.2] - 2024-01-16 8 | ### Changed 9 | - Use candid 0.10 10 | 11 | ## [0.1.1] - 2023-09-05 12 | ### Changed 13 | - Updated candid to the latest version. 14 | 15 | ## [0.1.0] - 2023-07-26 16 | ### Added 17 | - Original release. -------------------------------------------------------------------------------- /test/replica/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /test/replica/README.md: -------------------------------------------------------------------------------- 1 | # ICRC1 Test Suite Replica 2 | ======================= 3 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 4 | ======================= 5 | This crate provides a local replica for the ICRC1 test suite. -------------------------------------------------------------------------------- /test/replica/lib.rs: -------------------------------------------------------------------------------- 1 | use ic_agent::agent::http_transport::reqwest_transport::ReqwestHttpReplicaV2Transport; 2 | use ic_agent::identity::BasicIdentity; 3 | use ic_agent::Agent; 4 | use std::path::Path; 5 | use std::process::{Child, Command}; 6 | use std::sync::Arc; 7 | use tokio::time::{sleep, Duration}; 8 | 9 | struct KillOnDrop(Child); 10 | 11 | pub struct ReplicaContext { 12 | _proc: KillOnDrop, 13 | _state: tempfile::TempDir, 14 | port: u16, 15 | } 16 | 17 | impl ReplicaContext { 18 | pub fn port(&self) -> u16 { 19 | self.port 20 | } 21 | } 22 | 23 | impl Drop for KillOnDrop { 24 | fn drop(&mut self) { 25 | let _ = self.0.kill(); 26 | } 27 | } 28 | 29 | fn test_identity() -> BasicIdentity { 30 | BasicIdentity::from_pem( 31 | &b"-----BEGIN PRIVATE KEY----- 32 | MFMCAQEwBQYDK2VwBCIEIJKDIfd1Ybt48Z23cVEbjL2DGj1P5iDYmthcrptvBO3z 33 | oSMDIQCJuBJPWt2WWxv0zQmXcXMjY+fP0CJSsB80ztXpOFd2ZQ== 34 | -----END PRIVATE KEY-----"[..], 35 | ) 36 | .expect("failed to parse identity from PEM") 37 | } 38 | 39 | pub async fn start_replica( 40 | replica_bin: &Path, 41 | ic_starter_bin: &Path, 42 | sandbox_launcher_bin: &Path, 43 | canister_sandbox_bin: &Path, 44 | ) -> (Agent, ReplicaContext) { 45 | let state = tempfile::TempDir::new().expect("failed to create a temporary directory"); 46 | 47 | let port_file = state.path().join("replica.port"); 48 | 49 | assert!( 50 | ic_starter_bin.exists(), 51 | "ic-starter path {} does not exist", 52 | ic_starter_bin.display(), 53 | ); 54 | assert!( 55 | replica_bin.exists(), 56 | "replica path {} does not exist", 57 | replica_bin.display(), 58 | ); 59 | assert!( 60 | sandbox_launcher_bin.exists(), 61 | "sandbox_launcher path {} does not exist", 62 | sandbox_launcher_bin.display(), 63 | ); 64 | assert!( 65 | canister_sandbox_bin.exists(), 66 | "canister_sandbox path {} does not exist", 67 | canister_sandbox_bin.display(), 68 | ); 69 | 70 | let replica_path = format!( 71 | "{}:{}{}", 72 | sandbox_launcher_bin.parent().unwrap().display(), 73 | canister_sandbox_bin.parent().unwrap().display(), 74 | std::env::var("PATH").map_or("".into(), |s| format!(":{}", s)), 75 | ); 76 | 77 | let mut cmd = Command::new(ic_starter_bin); 78 | cmd.env("RUST_MIN_STACK", "8192000") 79 | .env("PATH", replica_path) 80 | .arg("--replica-path") 81 | .arg(replica_bin) 82 | .arg("--state-dir") 83 | .arg(state.path()) 84 | .arg("--create-funds-whitelist") 85 | .arg("*") 86 | .arg("--log-level") 87 | .arg("critical") 88 | .arg("--subnet-type") 89 | .arg("system") 90 | .arg("--subnet-features") 91 | .arg("canister_sandboxing") 92 | .arg("--http-port-file") 93 | .arg(&port_file) 94 | .arg("--initial-notary-delay-millis") 95 | .arg("600"); 96 | 97 | #[cfg(target_os = "macos")] 98 | cmd.args(["--consensus-pool-backend", "rocksdb"]); 99 | 100 | let _proc = KillOnDrop( 101 | cmd.stdout(std::process::Stdio::inherit()) 102 | .stderr(std::process::Stdio::inherit()) 103 | .spawn() 104 | .unwrap_or_else(|e| { 105 | panic!( 106 | "Failed to execute ic-starter (path = {}, exists? = {}): {}", 107 | ic_starter_bin.display(), 108 | ic_starter_bin.exists(), 109 | e 110 | ) 111 | }), 112 | ); 113 | 114 | let mut tries_left = 100; 115 | while tries_left > 0 && !port_file.exists() { 116 | sleep(Duration::from_millis(100)).await; 117 | tries_left -= 1; 118 | } 119 | 120 | if !port_file.exists() { 121 | panic!("Port file does not exist"); 122 | } 123 | 124 | let port_bytes = std::fs::read(&port_file).expect("failed to read port file"); 125 | let port: u16 = String::from_utf8(port_bytes) 126 | .unwrap() 127 | .parse() 128 | .expect("failed to parse port"); 129 | 130 | let client = reqwest::ClientBuilder::new() 131 | .build() 132 | .expect("failed to build an HTTP client"); 133 | 134 | let transport = Arc::new( 135 | ReqwestHttpReplicaV2Transport::create_with_client( 136 | format!("http://localhost:{}", port), 137 | client, 138 | ) 139 | .expect("failed to construct replica transport"), 140 | ); 141 | 142 | let agent = Agent::builder() 143 | .with_transport(transport) 144 | .with_identity(test_identity()) 145 | .build() 146 | .expect("failed to build agent"); 147 | 148 | let mut tries_left = 100; 149 | let mut ok = false; 150 | let mut last_status = None; 151 | while tries_left > 0 && !ok { 152 | match agent.status().await { 153 | Ok(status) => { 154 | ok = status.replica_health_status == Some("healthy".to_string()); 155 | if let Some(root_key) = status.root_key.as_ref() { 156 | agent.set_root_key(root_key.clone()) 157 | } 158 | last_status = Some(status); 159 | } 160 | Err(_) => { 161 | sleep(Duration::from_millis(500)).await; 162 | tries_left -= 1; 163 | } 164 | } 165 | } 166 | 167 | if !ok { 168 | panic!( 169 | "Replica did not become healthy on port {}, status: {:?}", 170 | port, last_status 171 | ); 172 | } 173 | 174 | ( 175 | agent, 176 | ReplicaContext { 177 | _proc, 178 | _state: state, 179 | port, 180 | }, 181 | ) 182 | } 183 | -------------------------------------------------------------------------------- /test/runner/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_binary") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | exports_files(["Cargo.toml"]) 7 | 8 | rust_binary( 9 | name = "runner", 10 | srcs = ["main.rs"], 11 | crate_name = "icrc1_test_runner", 12 | deps = all_crate_deps( 13 | normal = True, 14 | ) + [ 15 | "//test/env", 16 | "//test/env/replica", 17 | "//test/suite", 18 | "@crate_index//:candid", 19 | ], 20 | ) 21 | -------------------------------------------------------------------------------- /test/runner/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icrc1-test-runner" 3 | version = "0.1.2" 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | license = { workspace = true } 7 | rust-version = { workspace = true } 8 | repository = { workspace = true } 9 | description = { workspace = true } 10 | 11 | [[bin]] 12 | name = "runner" 13 | path = "main.rs" 14 | 15 | [dependencies] 16 | icrc1-test-env = { version ="0.1.2", path = "../env" } 17 | icrc1-test-env-replica = { version ="0.1.2", path = "../env/replica" } 18 | icrc1-test-suite = { version ="0.1.2", path = "../suite" } 19 | ic-agent = { workspace = true } 20 | pico-args = "0.5" 21 | reqwest = { workspace = true } 22 | tokio = { workspace = true } 23 | candid = { workspace = true } 24 | anyhow = { workspace = true } -------------------------------------------------------------------------------- /test/runner/Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.2] - 2024-01-16 8 | ### Changed 9 | - Use candid 0.10 10 | 11 | ## [0.1.1] - 2023-09-05 12 | ### Changed 13 | - Updated candid to the latest version. 14 | 15 | ## [0.1.0] - 2023-07-26 16 | ### Added 17 | - Original release. -------------------------------------------------------------------------------- /test/runner/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /test/runner/README.md: -------------------------------------------------------------------------------- 1 | # ICRC1 Test Suite Binary 2 | ======================= 3 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 4 | ======================= 5 | This crate provides the binary to the test suite for icrc1 ledgers. -------------------------------------------------------------------------------- /test/runner/main.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | use ic_agent::agent::http_transport::reqwest_transport::ReqwestHttpReplicaV2Transport; 3 | use ic_agent::identity::BasicIdentity; 4 | use ic_agent::Agent; 5 | use icrc1_test_env_replica::ReplicaLedger; 6 | use pico_args::Arguments; 7 | use std::path::PathBuf; 8 | use std::sync::Arc; 9 | 10 | fn print_help() { 11 | println!( 12 | r#"{} OPTIONS 13 | Options: 14 | -u, --url URL The url of a replica hosting the ledger 15 | 16 | -c, --canister PRINCIPAL The canister id of the ledger 17 | 18 | -s, --secret-key PATH The path to the PEM file of the identity 19 | holding enough funds for the test 20 | "#, 21 | std::env::args().next().unwrap() 22 | ) 23 | } 24 | 25 | #[tokio::main(flavor = "current_thread")] 26 | async fn main() { 27 | let mut args = Arguments::from_env(); 28 | if args.contains(["-h", "--help"]) { 29 | print_help(); 30 | std::process::exit(0); 31 | } 32 | 33 | let canister_id = args 34 | .value_from_fn(["-c", "--canister"], |s: &str| Principal::from_text(s)) 35 | .unwrap_or_else(|e| { 36 | eprintln!("Failed to parse ledger canister id: {}", e); 37 | print_help(); 38 | std::process::exit(1); 39 | }); 40 | 41 | let url: String = args.value_from_str(["-u", "--url"]).unwrap_or_else(|e| { 42 | eprintln!("Failed to parse ledger URL: {}", e); 43 | print_help(); 44 | std::process::exit(1); 45 | }); 46 | 47 | let key_path: PathBuf = args 48 | .value_from_str(["-s", "--secret-key"]) 49 | .unwrap_or_else(|e| { 50 | eprintln!("Failed to parse secret key path: {}", e); 51 | print_help(); 52 | std::process::exit(1); 53 | }); 54 | 55 | let identity = BasicIdentity::from_pem_file(&key_path).unwrap_or_else(|e| { 56 | panic!( 57 | "failed to parse secret key PEM from file {}: {}", 58 | key_path.display(), 59 | e 60 | ) 61 | }); 62 | 63 | let client = reqwest::ClientBuilder::new() 64 | .build() 65 | .expect("failed to build an HTTP client"); 66 | 67 | let transport = Arc::new( 68 | ReqwestHttpReplicaV2Transport::create_with_client(url, client) 69 | .expect("failed to construct replica transport"), 70 | ); 71 | 72 | let agent = Agent::builder() 73 | .with_transport(transport) 74 | .with_identity(identity) 75 | .build() 76 | .expect("failed to build agent"); 77 | 78 | agent 79 | .fetch_root_key() 80 | .await 81 | .expect("agent failed to fetch the root key"); 82 | 83 | let env = ReplicaLedger::new(agent, canister_id); 84 | let tests = icrc1_test_suite::test_suite(env).await; 85 | 86 | if !icrc1_test_suite::execute_tests(tests).await { 87 | std::process::exit(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/suite/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@crate_index//:defs.bzl", "all_crate_deps") 2 | load("@rules_rust//rust:defs.bzl", "rust_library") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | exports_files(["Cargo.toml"]) 7 | 8 | rust_library( 9 | name = "suite", 10 | srcs = ["lib.rs"], 11 | crate_name = "icrc1_test_suite", 12 | deps = all_crate_deps( 13 | normal = True, 14 | ) + ["//test/env"], 15 | ) 16 | -------------------------------------------------------------------------------- /test/suite/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icrc1-test-suite" 3 | version = "0.1.2" 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | license = { workspace = true } 7 | rust-version = { workspace = true } 8 | repository = { workspace = true } 9 | description = { workspace = true } 10 | 11 | [lib] 12 | path = "lib.rs" 13 | 14 | [dependencies] 15 | anyhow = "1.0" 16 | candid = { workspace = true } 17 | futures = "0.3.24" 18 | icrc1-test-env = { version ="0.1.2", path = "../env" } 19 | -------------------------------------------------------------------------------- /test/suite/Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.2] - 2024-01-16 8 | ### Changed 9 | - Use candid 0.10 10 | 11 | ## [0.1.1] - 2023-09-05 12 | ### Changed 13 | - Make amounts transferred and approved in tests independent of the ledger fee and relative within a single test 14 | - Updated candid to the latest version. 15 | ### Added 16 | - Tests for the ICRC-2 standard. 17 | 18 | ## [0.1.0] - 2023-07-26 19 | ### Added 20 | - Original release. -------------------------------------------------------------------------------- /test/suite/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /test/suite/README.md: -------------------------------------------------------------------------------- 1 | # ICRC1 Test Suite 2 | ======================= 3 | [![CI](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml/badge.svg)](https://github.com/dfinity/ICRC-1/actions/workflows/ci.yml) 4 | ======================= 5 | This crate provides a test suite for icrc1 ledgers. -------------------------------------------------------------------------------- /test/suite/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Context}; 2 | use candid::Nat; 3 | use futures::StreamExt; 4 | use icrc1_test_env::icrc1::{ 5 | balance_of, metadata, minting_account, supported_standards, token_decimals, token_name, 6 | token_symbol, transfer, transfer_fee, 7 | }; 8 | use icrc1_test_env::icrc2::{allowance, approve, transfer_from}; 9 | use icrc1_test_env::ApproveArgs; 10 | use icrc1_test_env::TransferFromArgs; 11 | use icrc1_test_env::{Account, LedgerEnv, Transfer, TransferError, Value}; 12 | use icrc1_test_env::{AllowanceArgs, ApproveError, TransferFromError}; 13 | use std::future::Future; 14 | use std::pin::Pin; 15 | use std::time::SystemTime; 16 | 17 | pub enum Outcome { 18 | Passed, 19 | Skipped { reason: String }, 20 | } 21 | 22 | pub type TestResult = anyhow::Result; 23 | 24 | pub struct Test { 25 | name: String, 26 | action: Pin>>, 27 | } 28 | 29 | pub fn test(name: impl Into, body: impl Future + 'static) -> Test { 30 | Test { 31 | name: name.into(), 32 | action: Box::pin(body), 33 | } 34 | } 35 | 36 | fn lookup<'a, K, V, U>(meta: &'a [(K, V)], key: &U) -> Option<&'a V> 37 | where 38 | K: PartialEq, 39 | U: ?Sized, 40 | { 41 | meta.iter().find_map(|(k, v)| (k == key).then_some(v)) 42 | } 43 | 44 | fn assert_equal(lhs: T, rhs: T) -> anyhow::Result<()> { 45 | if lhs != rhs { 46 | bail!("{:?} ≠ {:?}", lhs, rhs) 47 | } 48 | Ok(()) 49 | } 50 | 51 | fn assert_not_equal(lhs: T, rhs: T) -> anyhow::Result<()> { 52 | if lhs == rhs { 53 | bail!("{:?} = {:?}", lhs, rhs) 54 | } 55 | Ok(()) 56 | } 57 | 58 | fn time_nanos(ledger_env: &impl LedgerEnv) -> u64 { 59 | ledger_env 60 | .time() 61 | .duration_since(SystemTime::UNIX_EPOCH) 62 | .unwrap() 63 | .as_nanos() as u64 64 | } 65 | 66 | async fn assert_balance( 67 | ledger: &impl LedgerEnv, 68 | account: impl Into, 69 | expected: impl Into, 70 | ) -> anyhow::Result<()> { 71 | let account = account.into(); 72 | let actual = balance_of(ledger, account.clone()).await?; 73 | let expected = expected.into(); 74 | 75 | if expected != actual { 76 | bail!( 77 | "Expected the balance of account {:?} to be {}, got {}", 78 | account, 79 | expected, 80 | actual 81 | ) 82 | } 83 | Ok(()) 84 | } 85 | 86 | async fn assert_allowance( 87 | ledger_env: &impl LedgerEnv, 88 | from: impl Into, 89 | spender: impl Into, 90 | expected_allowance: impl Into, 91 | expires_at: Option, 92 | ) -> anyhow::Result<()> { 93 | let from: Account = from.into(); 94 | let spender: Account = spender.into(); 95 | let expected_allowance: Nat = expected_allowance.into(); 96 | let allowance = allowance( 97 | ledger_env, 98 | AllowanceArgs { 99 | account: from.clone(), 100 | spender: spender.clone(), 101 | }, 102 | ) 103 | .await?; 104 | if allowance.allowance != expected_allowance { 105 | bail!( 106 | "Expected the {:?} -> {:?} allowance to be {}, got {}", 107 | from, 108 | spender, 109 | expected_allowance, 110 | allowance.allowance 111 | ); 112 | } 113 | if allowance.expires_at != expires_at { 114 | bail!("Approval {:?} -> {:?} , wrong expiration", from, spender,); 115 | } 116 | Ok(()) 117 | } 118 | 119 | async fn setup_test_account( 120 | ledger_env: &impl LedgerEnv, 121 | amount: Nat, 122 | ) -> anyhow::Result { 123 | let balance = balance_of(ledger_env, ledger_env.principal()).await?; 124 | assert!(balance >= amount.clone() + transfer_fee(ledger_env).await?); 125 | let receiver_env = ledger_env.fork(); 126 | let receiver = receiver_env.principal(); 127 | assert_balance(&receiver_env, receiver, 0u8).await?; 128 | let _tx = transfer(ledger_env, Transfer::amount_to(amount.clone(), receiver)).await??; 129 | assert_balance( 130 | &receiver_env, 131 | Account { 132 | owner: receiver, 133 | subaccount: None, 134 | }, 135 | amount.clone(), 136 | ) 137 | .await?; 138 | Ok(receiver_env) 139 | } 140 | 141 | /// Checks whether the ledger supports token transfers and handles 142 | /// default sub accounts correctly. 143 | pub async fn icrc1_test_transfer(ledger_env: impl LedgerEnv) -> TestResult { 144 | let fee = transfer_fee(&ledger_env).await?; 145 | let transfer_amount = Nat::from(10_000u16); 146 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 147 | let p1_env = setup_test_account(&ledger_env, initial_balance).await?; 148 | let p2_env = ledger_env.fork(); 149 | 150 | let balance_p1 = balance_of(&p1_env, p1_env.principal()).await?; 151 | let balance_p2 = balance_of(&p2_env, p2_env.principal()).await?; 152 | 153 | let _tx = transfer( 154 | &p1_env, 155 | Transfer::amount_to(transfer_amount.clone(), p2_env.principal()), 156 | ) 157 | .await??; 158 | 159 | assert_balance( 160 | &ledger_env, 161 | Account { 162 | owner: p2_env.principal(), 163 | subaccount: None, 164 | }, 165 | balance_p2.clone() + transfer_amount.clone(), 166 | ) 167 | .await?; 168 | 169 | assert_balance( 170 | &ledger_env, 171 | Account { 172 | owner: p2_env.principal(), 173 | subaccount: Some([0; 32]), 174 | }, 175 | balance_p2 + transfer_amount.clone(), 176 | ) 177 | .await?; 178 | 179 | assert_balance( 180 | &ledger_env, 181 | p1_env.principal(), 182 | balance_p1 - transfer_amount.clone() - fee, 183 | ) 184 | .await?; 185 | 186 | Ok(Outcome::Passed) 187 | } 188 | 189 | /// Checks whether the ledger supports token burns. 190 | /// Skips the checks if the ledger does not have a minting account. 191 | pub async fn icrc1_test_burn(ledger_env: impl LedgerEnv) -> TestResult { 192 | let minting_account = match minting_account(&ledger_env).await? { 193 | Some(account) => account, 194 | None => { 195 | return Ok(Outcome::Skipped { 196 | reason: "the ledger does not support burn transactions".to_string(), 197 | }); 198 | } 199 | }; 200 | 201 | assert_balance(&ledger_env, minting_account.clone(), 0u8) 202 | .await 203 | .context("minting account cannot hold any funds")?; 204 | 205 | let burn_amount = Nat::from(10_000u16); 206 | let p1_env = setup_test_account(&ledger_env, burn_amount.clone()).await?; 207 | 208 | // Burning tokens is done by sending the burned amount to the minting account 209 | let _tx = transfer( 210 | &p1_env, 211 | Transfer::amount_to(burn_amount.clone(), minting_account.clone()), 212 | ) 213 | .await? 214 | .with_context(|| { 215 | format!( 216 | "failed to transfer {} tokens to {:?}", 217 | burn_amount, 218 | minting_account.clone() 219 | ) 220 | })?; 221 | 222 | assert_balance(&p1_env, p1_env.principal(), 0u8).await?; 223 | assert_balance(&ledger_env, minting_account, 0u8).await?; 224 | 225 | Ok(Outcome::Passed) 226 | } 227 | 228 | /// Checks whether the ledger metadata entries agree with named methods. 229 | pub async fn icrc1_test_metadata(ledger: impl LedgerEnv) -> TestResult { 230 | let mut metadata = metadata(&ledger).await?; 231 | metadata.sort_by(|l, r| l.0.cmp(&r.0)); 232 | 233 | for ((k1, _), (k2, _)) in metadata.iter().zip(metadata.iter().skip(1)) { 234 | if k1 == k2 { 235 | bail!("Key {} is duplicated in the metadata", k1); 236 | } 237 | } 238 | 239 | if let Some(name) = lookup(&metadata, "icrc1:name") { 240 | assert_equal(&Value::Text(token_name(&ledger).await?), name) 241 | .context("icrc1:name metadata entry does not match the icrc1_name endpoint")?; 242 | } 243 | if let Some(sym) = lookup(&metadata, "icrc1:symbol") { 244 | assert_equal(&Value::Text(token_symbol(&ledger).await?), sym) 245 | .context("icrc1:symol metadata entry does not match the icrc1_symbol endpoint")?; 246 | } 247 | if let Some(meta_decimals) = lookup(&metadata, "icrc1:decimals") { 248 | let decimals = token_decimals(&ledger).await?; 249 | assert_equal(&Value::Nat(Nat::from(decimals)), meta_decimals) 250 | .context("icrc1:decimals metadata entry does not match the icrc1_decimals endpoint")?; 251 | } 252 | if let Some(fee) = lookup(&metadata, "icrc1:fee") { 253 | assert_equal(&Value::Nat(transfer_fee(&ledger).await?), fee) 254 | .context("icrc1:fee metadata entry does not match the icrc1_fee endpoint")?; 255 | } 256 | Ok(Outcome::Passed) 257 | } 258 | 259 | /// Checks whether the ledger advertizes support for ICRC-1 standard. 260 | pub async fn icrc1_test_supported_standards(ledger: impl LedgerEnv) -> anyhow::Result { 261 | let stds = supported_standards(&ledger).await?; 262 | if !stds.iter().any(|std| std.name == "ICRC-1") { 263 | bail!("The ledger does not claim support for ICRC-1: {:?}", stds); 264 | } 265 | 266 | Ok(Outcome::Passed) 267 | } 268 | 269 | /// Checks whether the ledger advertizes support for ICRC-2 standard. 270 | pub async fn icrc2_test_supported_standards(ledger: impl LedgerEnv) -> anyhow::Result { 271 | let stds = supported_standards(&ledger).await?; 272 | // If the ledger claims to support ICRC-2 it also needs to support ICRC-1 273 | if !(stds.iter().any(|std| std.name == "ICRC-2") && stds.iter().any(|std| std.name == "ICRC-1")) 274 | { 275 | bail!( 276 | "The ledger does not claim support for ICRC-1 and ICRC-2: {:?}", 277 | stds 278 | ); 279 | } 280 | 281 | Ok(Outcome::Passed) 282 | } 283 | 284 | /// Checks basic functionality of the ICRC-2 approve endpoint. 285 | pub async fn icrc2_test_approve(ledger_env: impl LedgerEnv) -> anyhow::Result { 286 | let fee = transfer_fee(&ledger_env).await?; 287 | let initial_balance: Nat = fee.clone() * 2u8; 288 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 289 | let p2_env = ledger_env.fork(); 290 | let p2_subaccount = Account { 291 | owner: p2_env.principal(), 292 | subaccount: Some([1; 32]), 293 | }; 294 | let approve_amount = fee.clone(); 295 | 296 | approve( 297 | &p1_env, 298 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()), 299 | ) 300 | .await??; 301 | 302 | assert_allowance( 303 | &p1_env, 304 | p1_env.principal(), 305 | p2_env.principal(), 306 | approve_amount.clone(), 307 | None, 308 | ) 309 | .await?; 310 | 311 | assert_allowance( 312 | &p1_env, 313 | p1_env.principal(), 314 | p2_subaccount.clone(), 315 | 0u8, 316 | None, 317 | ) 318 | .await?; 319 | 320 | assert_balance(&ledger_env, p1_env.principal(), fee.clone()).await?; 321 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 322 | assert_balance(&ledger_env, p2_subaccount.clone(), 0u8).await?; 323 | 324 | // Approval for a subaccount. 325 | approve( 326 | &p1_env, 327 | ApproveArgs::approve_amount(approve_amount.clone() * 2u8, p2_subaccount.clone()), 328 | ) 329 | .await??; 330 | 331 | assert_allowance( 332 | &p1_env, 333 | p1_env.principal(), 334 | p2_env.principal(), 335 | approve_amount.clone(), 336 | None, 337 | ) 338 | .await?; 339 | 340 | assert_allowance( 341 | &p1_env, 342 | p1_env.principal(), 343 | p2_subaccount.clone(), 344 | approve_amount.clone() * 2u8, 345 | None, 346 | ) 347 | .await?; 348 | 349 | assert_balance(&ledger_env, p1_env.principal(), 0u8).await?; 350 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 351 | assert_balance(&ledger_env, p2_subaccount, 0u8).await?; 352 | 353 | // Insufficient funds to pay the fee for a second approval 354 | match approve( 355 | &p1_env, 356 | ApproveArgs::approve_amount(fee.clone() * 3u8, p2_env.principal()), 357 | ) 358 | .await? 359 | { 360 | Ok(_) => bail!("expected ApproveError::InsufficientFunds, got Ok result"), 361 | Err(e) => match e { 362 | ApproveError::InsufficientFunds { balance } => { 363 | if balance != 0u8 { 364 | bail!("wrong balance, expected 0, got: {}", balance); 365 | } 366 | } 367 | _ => return Err(e).context("expected ApproveError::InsufficientFunds"), 368 | }, 369 | } 370 | 371 | Ok(Outcome::Passed) 372 | } 373 | 374 | /// Checks the ICRC-2 approve endpoint for correct handling of the expiration functionality. 375 | pub async fn icrc2_test_approve_expiration(ledger_env: impl LedgerEnv) -> anyhow::Result { 376 | let fee = transfer_fee(&ledger_env).await?; 377 | let initial_balance: Nat = fee.clone() * 2u8; 378 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 379 | let p2_env = ledger_env.fork(); 380 | let approve_amount = fee.clone(); 381 | let now = time_nanos(&ledger_env); 382 | 383 | // Expiration in the past 384 | match approve( 385 | &p1_env, 386 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()).expires_at(now - 1), 387 | ) 388 | .await? 389 | { 390 | Ok(_) => bail!("expected ApproveError::Expired, got Ok result"), 391 | Err(e) => match e { 392 | ApproveError::Expired { .. } => {} 393 | _ => return Err(e).context("expected ApproveError::Expired"), 394 | }, 395 | } 396 | 397 | assert_allowance(&p1_env, p1_env.principal(), p2_env.principal(), 0u8, None).await?; 398 | 399 | assert_balance(&ledger_env, p1_env.principal(), initial_balance.clone()).await?; 400 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 401 | 402 | // Correct expiration in the future 403 | let expiration = u64::MAX; 404 | approve( 405 | &p1_env, 406 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()) 407 | .expires_at(expiration), 408 | ) 409 | .await??; 410 | 411 | assert_allowance( 412 | &p1_env, 413 | p1_env.principal(), 414 | p2_env.principal(), 415 | approve_amount.clone(), 416 | Some(expiration), 417 | ) 418 | .await?; 419 | 420 | assert_balance(&ledger_env, p1_env.principal(), fee).await?; 421 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 422 | 423 | // Change expiration 424 | let new_expiration = expiration - 1; 425 | approve( 426 | &p1_env, 427 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()) 428 | .expires_at(new_expiration), 429 | ) 430 | .await??; 431 | 432 | assert_allowance( 433 | &p1_env, 434 | p1_env.principal(), 435 | p2_env.principal(), 436 | approve_amount, 437 | Some(new_expiration), 438 | ) 439 | .await?; 440 | 441 | assert_balance(&ledger_env, p1_env.principal(), 0u8).await?; 442 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 443 | 444 | Ok(Outcome::Passed) 445 | } 446 | 447 | /// Checks the ICRC-2 approve endpoint for correct handling of the expected allowance functionality. 448 | pub async fn icrc2_test_approve_expected_allowance( 449 | ledger_env: impl LedgerEnv, 450 | ) -> anyhow::Result { 451 | let fee = transfer_fee(&ledger_env).await?; 452 | let initial_balance: Nat = fee.clone() * 2u8; 453 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 454 | let p2_env = ledger_env.fork(); 455 | let approve_amount = fee.clone(); 456 | 457 | approve( 458 | &p1_env, 459 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()), 460 | ) 461 | .await??; 462 | 463 | // Wrong expected allowance 464 | let new_approve_amount: Nat = fee.clone() * 2u8; 465 | match approve( 466 | &p1_env, 467 | ApproveArgs::approve_amount(new_approve_amount.clone(), p2_env.principal()) 468 | .expected_allowance(fee.clone() * 2u8), 469 | ) 470 | .await? 471 | { 472 | Ok(_) => bail!("expected ApproveError::AllowanceChanged, got Ok result"), 473 | Err(e) => match e { 474 | ApproveError::AllowanceChanged { current_allowance } => { 475 | if current_allowance != approve_amount { 476 | bail!( 477 | "wrong current_allowance, expected {}, got: {}", 478 | approve_amount, 479 | current_allowance 480 | ); 481 | } 482 | } 483 | _ => return Err(e).context("expected ApproveError::AllowanceChanged"), 484 | }, 485 | } 486 | 487 | // Correct expected allowance 488 | approve( 489 | &p1_env, 490 | ApproveArgs::approve_amount(new_approve_amount.clone(), p2_env.principal()) 491 | .expected_allowance(approve_amount), 492 | ) 493 | .await??; 494 | 495 | assert_allowance( 496 | &p1_env, 497 | p1_env.principal(), 498 | p2_env.principal(), 499 | new_approve_amount, 500 | None, 501 | ) 502 | .await?; 503 | 504 | assert_balance(&ledger_env, p1_env.principal(), 0u8).await?; 505 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 506 | 507 | Ok(Outcome::Passed) 508 | } 509 | 510 | /// Checks the basic functionality of the ICRC-2 transfer from endpoint. 511 | pub async fn icrc2_test_transfer_from(ledger_env: impl LedgerEnv) -> anyhow::Result { 512 | let fee = transfer_fee(&ledger_env).await?; 513 | // Charge account with some tokens plus two times the transfer fee, once for approving and once for transferring 514 | let transfer_amount = fee.clone(); 515 | let initial_balance: Nat = transfer_amount.clone() * 2u8 + fee.clone() * 2u8; 516 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 517 | let p2_env = ledger_env.fork(); 518 | let p3_env = ledger_env.fork(); 519 | 520 | // Approve amount needs to be the transferred amount + the fee for transferring 521 | let approve_amount: Nat = transfer_amount.clone() + fee.clone(); 522 | 523 | approve( 524 | &p1_env, 525 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()), 526 | ) 527 | .await??; 528 | 529 | // Transferred amount has to be smaller than the approved amount minus the fee for transfering tokens 530 | let transfer_amount = approve_amount - fee.clone() - Nat::from(1u8); 531 | transfer_from( 532 | &p2_env, 533 | TransferFromArgs::transfer_from( 534 | transfer_amount.clone(), 535 | p3_env.principal(), 536 | p1_env.principal(), 537 | ), 538 | ) 539 | .await??; 540 | 541 | assert_balance( 542 | &ledger_env, 543 | p1_env.principal(), 544 | // Balance should be the initial balance minus two times the fee, once for the approve and once for the transfer, and the transferred amount 545 | initial_balance - fee.clone() - fee - transfer_amount.clone(), 546 | ) 547 | .await?; 548 | // Balance of spender should not change 549 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 550 | // Beneficiary should get the amount transferred 551 | assert_balance(&ledger_env, p3_env.principal(), transfer_amount).await?; 552 | 553 | assert_allowance( 554 | &p1_env, 555 | p1_env.principal(), 556 | p2_env.principal(), 557 | Nat::from(1u8), 558 | None, 559 | ) 560 | .await?; 561 | Ok(Outcome::Passed) 562 | } 563 | 564 | /// Checks the ICRC-2 transfer from endpoint for correct handling of the insufficient funds error. 565 | pub async fn icrc2_test_transfer_from_insufficient_funds( 566 | ledger_env: impl LedgerEnv, 567 | ) -> anyhow::Result { 568 | let fee = transfer_fee(&ledger_env).await?; 569 | let transfer_amount = fee.clone(); 570 | // The initial balance is not enough to cover the fee for approval and transfer_from. 571 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 572 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 573 | let p2_env = ledger_env.fork(); 574 | let p3_env = ledger_env.fork(); 575 | 576 | // Approve sufficient amount. 577 | let approve_amount: Nat = transfer_amount.clone() + fee.clone(); 578 | approve( 579 | &p1_env, 580 | ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()), 581 | ) 582 | .await??; 583 | 584 | match transfer_from( 585 | &p2_env, 586 | TransferFromArgs::transfer_from( 587 | transfer_amount.clone(), 588 | p3_env.principal(), 589 | p1_env.principal(), 590 | ), 591 | ) 592 | .await? 593 | { 594 | Ok(_) => bail!("expected TransferFromError::InsufficientFunds, got Ok result"), 595 | Err(e) => match e { 596 | TransferFromError::InsufficientFunds { balance } => { 597 | if balance != transfer_amount { 598 | bail!( 599 | "wrong balance, expected {}, got: {}", 600 | transfer_amount, 601 | balance 602 | ); 603 | } 604 | } 605 | _ => return Err(e).context("expected TransferFromError::InsufficientFunds"), 606 | }, 607 | } 608 | 609 | // p1_env balance was reduced by the approval fee. 610 | assert_balance(&ledger_env, p1_env.principal(), transfer_amount).await?; 611 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 612 | assert_balance(&ledger_env, p3_env.principal(), 0u8).await?; 613 | 614 | // Allowance is not changed. 615 | assert_allowance( 616 | &p1_env, 617 | p1_env.principal(), 618 | p2_env.principal(), 619 | approve_amount, 620 | None, 621 | ) 622 | .await?; 623 | 624 | Ok(Outcome::Passed) 625 | } 626 | 627 | /// Checks the ICRC-2 transfer from endpoint for correct handling of the insufficient allowance error. 628 | pub async fn icrc2_test_transfer_from_insufficient_allowance( 629 | ledger_env: impl LedgerEnv, 630 | ) -> anyhow::Result { 631 | let fee = transfer_fee(&ledger_env).await?; 632 | let transfer_amount = fee.clone(); 633 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 634 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 635 | let p2_env = ledger_env.fork(); 636 | let p3_env = ledger_env.fork(); 637 | 638 | match transfer_from( 639 | &p2_env, 640 | TransferFromArgs::transfer_from( 641 | transfer_amount.clone(), 642 | p3_env.principal(), 643 | p1_env.principal(), 644 | ), 645 | ) 646 | .await? 647 | { 648 | Ok(_) => bail!("expected TransferFromError::InsufficientAllowance, got Ok result"), 649 | Err(e) => match e { 650 | TransferFromError::InsufficientAllowance { allowance } => { 651 | if allowance != 0u8 { 652 | bail!("wrong allowance, expected 0, got: {}", allowance); 653 | } 654 | } 655 | _ => return Err(e).context("expected TransferFromError::InsufficientAllowance"), 656 | }, 657 | } 658 | 659 | // Balances are not changed. 660 | assert_balance(&ledger_env, p1_env.principal(), initial_balance).await?; 661 | assert_balance(&ledger_env, p2_env.principal(), 0u8).await?; 662 | assert_balance(&ledger_env, p3_env.principal(), 0u8).await?; 663 | 664 | Ok(Outcome::Passed) 665 | } 666 | 667 | /// Checks the ICRC-2 transfer from endpoint for correct handling of self transfers. 668 | pub async fn icrc2_test_transfer_from_self(ledger_env: impl LedgerEnv) -> anyhow::Result { 669 | let fee = transfer_fee(&ledger_env).await?; 670 | let transfer_amount = fee.clone(); 671 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 672 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 673 | let p2_env = ledger_env.fork(); 674 | 675 | // icrc2_transfer_from does not require approval if spender == from 676 | transfer_from( 677 | &p1_env, 678 | TransferFromArgs::transfer_from( 679 | transfer_amount.clone(), 680 | p2_env.principal(), 681 | p1_env.principal(), 682 | ), 683 | ) 684 | .await??; 685 | 686 | // Transferred the transfer_amount and paid fee; the balance is now 0. 687 | assert_balance(&ledger_env, p1_env.principal(), 0u8).await?; 688 | // Beneficiary should get the amount transferred. 689 | assert_balance(&ledger_env, p2_env.principal(), transfer_amount).await?; 690 | 691 | Ok(Outcome::Passed) 692 | } 693 | 694 | /// Checks whether the ledger applies deduplication of transactions correctly 695 | pub async fn icrc1_test_tx_deduplication(ledger_env: impl LedgerEnv) -> anyhow::Result { 696 | let fee = transfer_fee(&ledger_env).await?; 697 | let transfer_amount = Nat::from(10_000u64); 698 | let initial_balance: Nat = transfer_amount.clone() * 7u8 + fee.clone() * 7u8; 699 | // Create two test accounts and transfer some tokens to the first account. Also charge them with enough tokens so they can pay the transfer fees 700 | let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?; 701 | let p2_env = p1_env.fork(); 702 | 703 | // Deduplication should not happen if the created_at_time field is unset. 704 | let transfer_args = Transfer::amount_to(transfer_amount.clone(), p2_env.principal()); 705 | transfer(&p1_env, transfer_args.clone()) 706 | .await? 707 | .context("failed to execute the first no-dedup transfer")?; 708 | 709 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone()).await?; 710 | 711 | transfer(&p1_env, transfer_args.clone()) 712 | .await? 713 | .context("failed to execute the second no-dedup transfer")?; 714 | 715 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 2u8).await?; 716 | 717 | // Setting the created_at_time field changes the transaction 718 | // identity, so the transfer should succeed. 719 | let transfer_args = transfer_args.created_at_time(time_nanos(&ledger_env)); 720 | 721 | let txid = match transfer(&p1_env, transfer_args.clone()).await? { 722 | Ok(txid) => txid, 723 | Err(TransferError::TooOld) => { 724 | return Ok(Outcome::Skipped { 725 | reason: "the ledger does not support deduplication".to_string(), 726 | }) 727 | } 728 | Err(e) => return Err(e).context("failed to execute the first dedup transfer"), 729 | }; 730 | 731 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 3u8).await?; 732 | 733 | // Sending the same transfer again should trigger deduplication. 734 | assert_equal( 735 | Err(TransferError::Duplicate { 736 | duplicate_of: txid.clone(), 737 | }), 738 | transfer(&p1_env, transfer_args.clone()).await?, 739 | )?; 740 | 741 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 3u8).await?; 742 | 743 | // Explicitly setting the fee field changes the transaction 744 | // identity, so the transfer should succeed. 745 | let transfer_args = transfer_args.fee(fee.clone()); 746 | 747 | let txid_2 = transfer(&p1_env, transfer_args.clone()) 748 | .await? 749 | .context("failed to execute the transfer with an explicitly set fee field")?; 750 | 751 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 4u8).await?; 752 | 753 | assert_not_equal(&txid, &txid_2).context("duplicate txid")?; 754 | 755 | // Sending the same transfer again should trigger deduplication. 756 | assert_equal( 757 | Err(TransferError::Duplicate { 758 | duplicate_of: txid_2.clone(), 759 | }), 760 | transfer(&p1_env, transfer_args.clone()).await?, 761 | )?; 762 | 763 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 4u8).await?; 764 | 765 | // A custom memo changes the transaction identity, so the transfer 766 | // should succeed. 767 | let transfer_args = transfer_args.memo(vec![1, 2, 3]); 768 | 769 | let txid_3 = transfer(&p1_env, transfer_args.clone()) 770 | .await? 771 | .context("failed to execute the transfer with an explicitly set memo field")?; 772 | 773 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 5u8).await?; 774 | 775 | assert_not_equal(&txid, &txid_3).context("duplicate txid")?; 776 | assert_not_equal(&txid_2, &txid_3).context("duplicate txid")?; 777 | 778 | // Sending the same transfer again should trigger deduplication. 779 | assert_equal( 780 | Err(TransferError::Duplicate { 781 | duplicate_of: txid_3, 782 | }), 783 | transfer(&p1_env, transfer_args.clone()).await?, 784 | )?; 785 | 786 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 5u8).await?; 787 | 788 | let now = time_nanos(&ledger_env); 789 | 790 | // Transactions with different subaccounts (even if it's None and 791 | // Some([0; 32])) should not be considered duplicates. 792 | 793 | transfer( 794 | &p1_env, 795 | Transfer::amount_to( 796 | transfer_amount.clone(), 797 | Account { 798 | owner: p2_env.principal(), 799 | subaccount: None, 800 | }, 801 | ) 802 | .memo(vec![0]) 803 | .created_at_time(now), 804 | ) 805 | .await? 806 | .context("failed to execute the transfer with an empty subaccount")?; 807 | 808 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 6u8).await?; 809 | 810 | transfer( 811 | &p1_env, 812 | Transfer::amount_to( 813 | transfer_amount.clone(), 814 | Account { 815 | owner: p2_env.principal(), 816 | subaccount: Some([0; 32]), 817 | }, 818 | ) 819 | .memo(vec![0]) 820 | .created_at_time(now), 821 | ) 822 | .await? 823 | .context("failed to execute the transfer with the default subaccount")?; 824 | 825 | assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 7u8).await?; 826 | 827 | Ok(Outcome::Passed) 828 | } 829 | 830 | /// Checks the ICRC-2 transfer from endpoint for correct handling of the insufficient bad fee error. 831 | pub async fn icrc1_test_bad_fee(ledger_env: impl LedgerEnv) -> anyhow::Result { 832 | let fee = transfer_fee(&ledger_env).await?; 833 | let transfer_amount = Nat::from(10_000u16); 834 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 835 | // Create two test accounts and transfer some tokens to the first account 836 | let p1_env = setup_test_account(&ledger_env, initial_balance).await?; 837 | let p2_env = p1_env.fork(); 838 | 839 | let mut transfer_args = Transfer::amount_to(transfer_amount.clone(), p2_env.principal()); 840 | // Set incorrect fee 841 | transfer_args = transfer_args.fee(fee.clone() + Nat::from(1u8)); 842 | match transfer(&ledger_env, transfer_args.clone()).await? { 843 | Ok(_) => return Err(anyhow::Error::msg("Expected Bad Fee Error")), 844 | Err(err) => match err { 845 | TransferError::BadFee { expected_fee } => { 846 | if expected_fee != transfer_fee(&ledger_env).await? { 847 | return Err(anyhow::Error::msg(format!( 848 | "Expected BadFee argument to be {}, got {}", 849 | fee, expected_fee 850 | ))); 851 | } 852 | } 853 | _ => return Err(anyhow::Error::msg("Expected BadFee error")), 854 | }, 855 | } 856 | Ok(Outcome::Passed) 857 | } 858 | 859 | /// Checks the ICRC-2 transfer from endpoint for correct handling of the future transfer error. 860 | pub async fn icrc1_test_future_transfer(ledger_env: impl LedgerEnv) -> anyhow::Result { 861 | let fee = transfer_fee(&ledger_env).await?; 862 | let transfer_amount = Nat::from(10_000u16); 863 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 864 | // Create two test accounts and transfer some tokens to the first account 865 | let p1_env = setup_test_account(&ledger_env, initial_balance).await?; 866 | let p2_env = p1_env.fork(); 867 | 868 | let mut transfer_args = Transfer::amount_to(transfer_amount, p2_env.principal()); 869 | 870 | // Set created time in the future 871 | transfer_args = transfer_args.created_at_time(u64::MAX); 872 | match transfer(&ledger_env, transfer_args).await? { 873 | Err(TransferError::CreatedInFuture { ledger_time: _ }) => Ok(Outcome::Passed), 874 | other => bail!("expected CreatedInFuture error, got: {:?}", other), 875 | } 876 | } 877 | 878 | /// Checks the ICRC-2 transfer from endpoint for correct handling of the length of the memo. 879 | pub async fn icrc1_test_memo_bytes_length(ledger_env: impl LedgerEnv) -> anyhow::Result { 880 | let fee = transfer_fee(&ledger_env).await?; 881 | let transfer_amount = Nat::from(10_000u16); 882 | let initial_balance: Nat = transfer_amount.clone() + fee.clone(); 883 | // Create two test accounts and transfer some tokens to the first account 884 | let p1_env = setup_test_account(&ledger_env, initial_balance).await?; 885 | let p2_env = p1_env.fork(); 886 | 887 | let transfer_args = Transfer::amount_to(transfer_amount, p2_env.principal()).memo([1u8; 32]); 888 | // Ledger should accept memos of at least 32 bytes; 889 | match transfer(&ledger_env, transfer_args.clone()).await? { 890 | Ok(_) => Ok(Outcome::Passed), 891 | Err(err) => bail!( 892 | "Expected memo with 32 bytes to succeed but got error: {:?}", 893 | err 894 | ), 895 | } 896 | } 897 | 898 | /// Returns the entire list of icrc1 tests. 899 | pub fn icrc1_test_suite(env: impl LedgerEnv + 'static + Clone) -> Vec { 900 | vec![ 901 | test("icrc1:transfer", icrc1_test_transfer(env.clone())), 902 | test("icrc1:burn", icrc1_test_burn(env.clone())), 903 | test("icrc1:metadata", icrc1_test_metadata(env.clone())), 904 | test( 905 | "icrc1:supported_standards", 906 | icrc1_test_supported_standards(env.clone()), 907 | ), 908 | test( 909 | "icrc1:tx_deduplication", 910 | icrc1_test_tx_deduplication(env.clone()), 911 | ), 912 | test( 913 | "icrc1:memo_bytes_length", 914 | icrc1_test_memo_bytes_length(env.clone()), 915 | ), 916 | test( 917 | "icrc1:future_transfers", 918 | icrc1_test_future_transfer(env.clone()), 919 | ), 920 | test("icrc1:bad_fee", icrc1_test_bad_fee(env)), 921 | ] 922 | } 923 | 924 | /// Returns the entire list of icrc2 tests. 925 | pub fn icrc2_test_suite(env: impl LedgerEnv + 'static + Clone) -> Vec { 926 | vec![ 927 | test( 928 | "icrc2:supported_standards", 929 | icrc2_test_supported_standards(env.clone()), 930 | ), 931 | test("icrc2:approve", icrc2_test_approve(env.clone())), 932 | test( 933 | "icrc2:approve_expiration", 934 | icrc2_test_approve_expiration(env.clone()), 935 | ), 936 | test( 937 | "icrc2:approve_expected_allowance", 938 | icrc2_test_approve_expected_allowance(env.clone()), 939 | ), 940 | test("icrc2:transfer_from", icrc2_test_transfer_from(env.clone())), 941 | test( 942 | "icrc2:transfer_from_insufficient_funds", 943 | icrc2_test_transfer_from_insufficient_funds(env.clone()), 944 | ), 945 | test( 946 | "icrc2:transfer_from_insufficient_allowance", 947 | icrc2_test_transfer_from_insufficient_allowance(env.clone()), 948 | ), 949 | test( 950 | "icrc2:transfer_from_self", 951 | icrc2_test_transfer_from_self(env.clone()), 952 | ), 953 | ] 954 | } 955 | 956 | pub async fn test_suite(env: impl LedgerEnv + 'static + Clone) -> Vec { 957 | match supported_standards(&env).await { 958 | Ok(standard) => { 959 | let mut tests = vec![]; 960 | if standard.iter().any(|std| std.name == "ICRC-1") { 961 | tests.append(&mut icrc1_test_suite(env.clone())); 962 | } 963 | if standard.iter().any(|std| std.name == "ICRC-2") { 964 | tests.append(&mut icrc2_test_suite(env)); 965 | } 966 | tests 967 | } 968 | Err(_) => { 969 | println!("No standard is supported by the given ledger: Is the endpoint 'icrc1_supported_standards' implemented correctly?"); 970 | vec![] 971 | } 972 | } 973 | } 974 | /// Executes the list of tests concurrently and prints results using 975 | /// the TAP protocol (https://testanything.org/). 976 | pub async fn execute_tests(tests: Vec) -> bool { 977 | use futures::stream::FuturesOrdered; 978 | 979 | let mut names = Vec::new(); 980 | let mut futures = FuturesOrdered::new(); 981 | 982 | for test in tests.into_iter() { 983 | names.push(test.name); 984 | futures.push_back(test.action); 985 | } 986 | 987 | println!("TAP version 14"); 988 | println!("1..{}", futures.len()); 989 | 990 | let mut idx = 0; 991 | let mut success = true; 992 | while let Some(result) = futures.next().await { 993 | match result { 994 | Ok(Outcome::Passed) => { 995 | println!("ok {} - {}", idx + 1, names[idx]); 996 | } 997 | Ok(Outcome::Skipped { reason }) => { 998 | println!("ok {} - {} # SKIP {}", idx + 1, names[idx], reason); 999 | } 1000 | Err(err) => { 1001 | success = false; 1002 | 1003 | for line in format!("{:?}", err).lines() { 1004 | println!("# {}", line); 1005 | } 1006 | 1007 | println!("not ok {} - {}", idx + 1, names[idx]); 1008 | } 1009 | } 1010 | idx += 1; 1011 | } 1012 | 1013 | success 1014 | } 1015 | --------------------------------------------------------------------------------