├── .config ├── spellcheck.dic ├── spellcheck.md └── spellcheck.toml ├── .github ├── CODEOWNERS ├── actions │ └── needs_success │ │ └── action.yaml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── check.yml │ ├── integration.yml │ └── pr.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── SECURITY.md ├── USAGE.md ├── canister_ids.json ├── dev-tools.json ├── dfx.json ├── didc.toml ├── package-lock.json ├── package.json ├── rust-toolchain.toml ├── scripts ├── bind.cycles_depositor.sh ├── bind.cycles_ledger.sh ├── build.cycles_depositor.args.sh ├── build.cycles_depositor.sh ├── did.sh ├── fmt ├── fmt-cargo ├── fmt-frontend ├── fmt-json ├── fmt-md ├── fmt-rs ├── fmt-sh ├── fmt-yaml ├── lint ├── lint-git ├── lint-rs ├── lint-sh ├── pic-install ├── setup ├── setup-cargo-binstall ├── setup-rust ├── test-rs └── test.integration.sh └── src ├── api ├── Cargo.toml └── src │ ├── caller.rs │ ├── cycles.rs │ ├── error.rs │ ├── lib.rs │ └── vendor.rs ├── declarations ├── cycles_ledger │ ├── .gitignore │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── example_paid_service │ ├── example_paid_service.did │ ├── example_paid_service.did.d.ts │ ├── example_paid_service.did.js │ ├── index.d.ts │ └── index.js ├── example ├── README.md ├── app_backend │ ├── Cargo.toml │ ├── example_app_backend.did │ └── src │ │ └── lib.rs ├── paid_service │ ├── Cargo.toml │ ├── example-paid-service.did │ ├── src │ │ ├── lib.rs │ │ └── state.rs │ └── tests │ │ └── it │ │ ├── attached_cycles.rs │ │ ├── caller_pays_icrc2_cycles.rs │ │ ├── caller_pays_icrc2_tokens.rs │ │ ├── main.rs │ │ ├── patron_pays_icrc2_cycles.rs │ │ ├── patron_pays_icrc2_tokens.rs │ │ ├── pic_util.rs │ │ └── util │ │ ├── cycles_depositor.rs │ │ ├── cycles_ledger.rs │ │ ├── mod.rs │ │ ├── pic_canister.rs │ │ └── test_environment.rs └── paid_service_api │ ├── Cargo.toml │ └── src │ └── lib.rs ├── guard ├── Cargo.toml └── src │ ├── guards │ ├── any.rs │ ├── attached_cycles.rs │ ├── caller_pays_icrc2_cycles.rs │ ├── caller_pays_icrc2_tokens.rs │ ├── mod.rs │ ├── patron_pays_icrc2_cycles.rs │ └── patron_pays_icrc2_tokens.rs │ └── lib.rs └── papi ├── Cargo.toml └── src └── lib.rs /.config/spellcheck.dic: -------------------------------------------------------------------------------- 1 | 1 2 | Ethereum 3 | ERC20 4 | config 5 | Wasm 6 | ICP 7 | L2 8 | L2s 9 | ckUSDC 10 | ckETH 11 | ckBTC 12 | TLDR 13 | -------------------------------------------------------------------------------- /.config/spellcheck.md: -------------------------------------------------------------------------------- 1 | # Spell checking 2 | 3 | A custom dictionary is available containing terms used in this project but not (yet) widely recognized in English. 4 | 5 | The dictionary is in the `hunspell` format. This is widely recognized and will probably work with your spell checker of choice. 6 | 7 | ## Dictionary format 8 | 9 | See [`hunspell`'s man page](https://manpages.ubuntu.com/manpages/focal/man5/hunspell.5.html) and the [`nuspell` documentation with examples](https://github.com/nuspell/nuspell/wiki/Dictionary-File-Format). 10 | -------------------------------------------------------------------------------- /.config/spellcheck.toml: -------------------------------------------------------------------------------- 1 | [Hunspell] 2 | lang = "en_GB" 3 | search_dirs = [".", ".config"] 4 | extra_dictionaries = [ "spellcheck.dic", "en_US.dic" ] 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # IMPORTANT: The last matching entry has the highest precedence. 2 | 3 | # The GIX team members who maintain this repo: 4 | * @bitdivine 5 | 6 | # The codebase is owned by the Governance & Identity Experience team at DFINITY 7 | # For questions, reach out to: 8 | .github/CODEOWNERS @dfinity/gix 9 | -------------------------------------------------------------------------------- /.github/actions/needs_success/action.yaml: -------------------------------------------------------------------------------- 1 | name: 'Needed jobs succeeded' 2 | description: | 3 | Checks that the jobs needed by the current job succeeded. 4 | 5 | If one of the needed jobs fails, GitHub will by default skip the 6 | current job and consider a skipped job to be successful. If 7 | instead you wish the current job to fail if any job needed by 8 | the current one fails, you need to: 9 | 10 | - set the current job to always run. (use: `if: $ {{ always() }}`) 11 | - check the result of all the needed jobs. (use this action) 12 | inputs: 13 | needs: 14 | description: "JSON describing the needed jobs. (use: `'$ {{ toJson(needs) }}'`)" 15 | required: true 16 | runs: 17 | using: "composite" 18 | steps: 19 | - name: "Check that we have inputs" 20 | shell: bash 21 | run: | 22 | all_jobs="$(echo '${{ inputs.needs }}' | jq 'to_entries[]')" 23 | [[ "$all_jobs" != "" ]] || { 24 | echo "ERROR: No needs found" 25 | exit 1 26 | } 27 | - name: "Check that all jobs in 'needs' are successful" 28 | shell: bash 29 | run: | 30 | unsuccessful_jobs="$(echo '${{ inputs.needs }}' | jq 'to_entries[] | select(.value.result != "success")')" 31 | if [[ "$unsuccessful_jobs" == "" ]] 32 | then echo "Congratulations, you may pass." 33 | else echo "You shall not pass: Some required tests did not succeed:" 34 | echo "$unsuccessful_jobs" 35 | exit 1 36 | fi 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/en/enterprise-cloud@latest/code-security/dependabot/dependabot-version-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "cargo" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | commit-message: 11 | prefix: "chore(cargo deps): " 12 | prefix-development: "chore(cargo deps-dev): " 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | commit-message: 18 | prefix: "chore(github-actions): " 19 | prefix-development: "chore(github-actions-dev): " 20 | - package-ecosystem: "npm" 21 | directory: "/" 22 | schedule: 23 | interval: "weekly" 24 | commit-message: 25 | prefix: "chore(npm deps): " 26 | prefix-development: "chore(npm deps-dev): " 27 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | 4 | 5 | # Changes 6 | 7 | 8 | 9 | # Tests 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: 'Code checks' 2 | on: 3 | push: 4 | workflow_dispatch: 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | defaults: 9 | run: 10 | shell: bash -euxlo pipefail {0} 11 | jobs: 12 | format: 13 | name: 'Format' 14 | runs-on: ubuntu-24.04 15 | env: 16 | TITLE: ${{ github.event.pull_request.title }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Install tools 21 | run: ./scripts/setup cargo-binstall shfmt yq cargo-sort 22 | - name: Install node dependencies 23 | run: npm ci --no-audit 24 | - name: Format 25 | run: ./scripts/fmt 26 | - name: Check formatted 27 | run: | 28 | test -z "$(git status --porcelain | grep -v --file .relaxedfmt)" || { 29 | echo "FIX: Please run ./scripts/fmt" 30 | git diff 31 | exit 1 32 | } 33 | rust-tests: 34 | runs-on: ${{ matrix.os }} 35 | strategy: 36 | matrix: 37 | os: [ubuntu-24.04] 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Have relevant files changed? 41 | uses: dorny/paths-filter@v3 42 | id: changes 43 | with: 44 | filters: | 45 | test: 46 | - '.github/workflows/check.yml' 47 | - '**/*.rs' 48 | - '**/Cargo.*' 49 | - 'rust-toolchain.toml' 50 | - uses: actions/cache@v4 51 | if: steps.changes.outputs.test == 'true' 52 | with: 53 | path: | 54 | ~/.cargo/registry 55 | ~/.cargo/git 56 | target 57 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-1 58 | - name: Lint rust code 59 | if: steps.changes.outputs.test == 'true' 60 | run: ./scripts/lint-rs 61 | - name: Run Unit Tests 62 | if: steps.changes.outputs.test == 'true' 63 | shell: bash 64 | run: scripts/test-rs 65 | installation: 66 | runs-on: ${{ matrix.os }} 67 | strategy: 68 | matrix: 69 | os: [ubuntu-24.04] 70 | steps: 71 | - uses: actions/checkout@v4 72 | - name: Have relevant files changed? 73 | uses: dorny/paths-filter@v3 74 | id: changes 75 | with: 76 | filters: | 77 | test: 78 | - '!**/*.md' 79 | - uses: actions/cache@v4 80 | if: steps.changes.outputs.test == 'true' 81 | with: 82 | path: | 83 | ~/.cargo/registry 84 | ~/.cargo/git 85 | target 86 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-1 87 | - name: Install dfx 88 | uses: dfinity/setup-dfx@main 89 | - name: Start dfx 90 | run: dfx start --clean --background 91 | - name: Deploy all canisters 92 | run: dfx deploy 93 | - name: Stop dfx 94 | run: dfx stop 95 | check-pass: 96 | needs: ["format", "rust-tests", "installation"] 97 | if: ${{ always() }} 98 | runs-on: ubuntu-24.04 99 | steps: 100 | - uses: actions/checkout@v4 101 | - uses: ./.github/actions/needs_success 102 | with: 103 | needs: '${{ toJson(needs) }}' 104 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: 'Integration checks' 2 | on: 3 | push: 4 | workflow_dispatch: 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | defaults: 9 | run: 10 | shell: bash -euxlo pipefail {0} 11 | jobs: 12 | integration: 13 | name: Integration tests 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Install dfx 19 | uses: dfinity/setup-dfx@main 20 | - name: Start dfx 21 | run: dfx start --clean --background 22 | - name: Deploy canisters 23 | run: dfx deploy 24 | - run: ./scripts/test.integration.sh 25 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: 'PR' 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | - synchronize 9 | - labeled 10 | jobs: 11 | pr: 12 | name: 'PR' 13 | runs-on: ubuntu-24.04 14 | env: 15 | TITLE: ${{ github.event.pull_request.title }} 16 | steps: 17 | - name: Conventional commits 18 | run: | 19 | if [[ "$TITLE" =~ ^(feat|fix|chore|build|ci|docs|style|refactor|perf|test)( ?\([-a-zA-Z0-9, ]+\))\!?\: ]]; then 20 | echo "PR Title passes" 21 | else 22 | echo "PR Title does not match conventions: $TITLE" 23 | echo " verb(scope): description" 24 | echo "or for a breaking change:" 25 | echo " verb(scope)!: description" 26 | echo "For scope, please use the affected canister name(s), rust crate name(s) or 'ci' for infrastructure changes." 27 | exit 1 28 | fi 29 | pr-pass: 30 | needs: ["pr"] 31 | if: ${{ always() }} 32 | runs-on: ubuntu-24.04 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: ./.github/actions/needs_success 36 | with: 37 | needs: '${{ toJson(needs) }}' 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust build artefacts 2 | /target 3 | # npm dependencies 4 | node_modules 5 | # Testing tool 6 | pocket-ic 7 | pocket-ic.gz 8 | # dfx working dir 9 | .dfx 10 | # dfx env file 11 | .env 12 | # Local branches 13 | branches 14 | # Build artefacts 15 | out 16 | # Temp files 17 | ,* 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["src/api", "src/declarations/cycles_ledger", "src/example/app_backend", "src/example/paid_service", "src/example/paid_service_api", "src/guard", "src/papi"] 3 | resolver = "2" 4 | 5 | [workspace.dependencies] 6 | pocket-ic = "4.0.0" 7 | candid = "0.10.13" 8 | ic-cdk = "0.17.1" 9 | ic-cdk-macros = "0.17.0" 10 | serde = "1" 11 | serde_bytes = "0.11" 12 | ic-papi-api = { path = "src/api" } 13 | ic-papi-guard = { path = "src/guard" } 14 | ic-stable-structures = "0.6.8" 15 | ic-ledger-types = "0.13.0" 16 | ic-cycles-ledger-client = { path = "src/declarations/cycles_ledger" } 17 | example-paid-service-api = { path = "src/example/paid_service_api" } 18 | lazy_static = { version = "1.5.0" } 19 | hex = { version = "0.4.3" } 20 | 21 | [profile.release] 22 | lto = true 23 | opt-level = 'z' 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 DFINITY Stiftung. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code checks](https://github.com/dfinity/papi/actions/workflows/check.yml/badge.svg)](https://github.com/dfinity/papi/actions/workflows/check.yml) 2 | [![Integration checks](https://github.com/dfinity/papi/actions/workflows/integration.yml/badge.svg)](https://github.com/dfinity/papi/actions/workflows/integration.yml) 3 | 4 | # Get paid for your API. 5 | 6 | ## TLDR 7 | 8 | **`papi` adds a payment gateway to an API. Choose which cryptocurrencies you would like to be paid in, and how much each API call should cost, and the `papi` integration will handle the rest.** 9 | 10 | ## Details 11 | 12 | ### Choice of cryptocurrency 13 | 14 | The following cryptocurrencies are currently supported: 15 | 16 | | Name | Token | Comment | 17 | | ---------- | ------ | ----------------------------------------------------------------------------------------------------- | 18 | | Bitcoin | ckBTC | High speed, low transaction costs are enabled via decentralized chain keys. | 19 | | Ethereum | ckETH | High speed, low transaction costs are enabled via decentralized chain keys. | 20 | | US Dollar | ckUSDC | High speed, low transaction costs are enabled via decentralized chain keys. | 21 | | ICP Cycles | XDR | The native utility token of the Internet Computer, tied in price to the IMF XDR basket of currencies. | 22 | | ICP | ICP | The governance token of the Internet Computer. | 23 | 24 | And many more. All tokens that support the ICRC-2 standard can be used. We are considering how best to add other currencies such as native Eth; ck* tokens provide fast and inexpensive settlement but there will be use cases where native tokens may be wanted. 25 | 26 | ### Chain keys: ckBTC, ckETH, ckUSDC, ... 27 | 28 | APIs require high speed, low latency and low transaction fees. Otherwise the user experience will be terrible. Chain Key provides a standard, cryptocurrency-agnostic, decentralized way of delivering these necessary properties. If you are excited by technical details, you will be glad to know that Chain Key Technology enables making L2s on the ICP with threshold keys. ICP provides the high performance and the threshold keys provide the foundation for making the L2 decentralized. 29 | 30 | ### Technical Integration 31 | 32 | You will need to define a default currency for payment and annotate API methods with how much you would like to charge for each call. The payment method can be either passed explicitly by the caller or you can specify one fixed payment method in your canister. Payment is currently supported by attached cycles or ICRC2 transfer; more methods are likely to be added in future. For ICRC-2, the customer will have to approve the payment in advance. In the case of payment with ICP cycles, payment is attached directly to the API call. 33 | 34 | 39 | 40 | #### Examples 41 | 42 | This API requires payment in cycles, directly to the canister. The acceptable payment types are configured like this: 43 | ``` 44 | lazy_static! { 45 | pub static ref PAYMENT_GUARD: PaymentGuard<3> = PaymentGuard { 46 | supported: [ 47 | VendorPaymentConfig::AttachedCycles, 48 | VendorPaymentConfig::CallerPaysIcrc2Cycles, 49 | VendorPaymentConfig::PatronPaysIcrc2Cycles, 50 | ], 51 | }; 52 | } 53 | ``` 54 | 55 | The API is protected like this: 56 | ``` 57 | #[update] 58 | is_prime(x: u32, payment: Option) -> Result { 59 | let fee = 1_000_000_000; 60 | PAYMENT_GUARD.deduct(payment.unwrap_or(VendorPaymentConfig::AttachedCycles), fee).await?; 61 | // Now check whether the number really is prime: 62 | ... 63 | } 64 | ``` 65 | 66 | A user MAY pay by attaching cycles directly to API call: 67 | 68 | ``` 69 | dfx canister call "$MATH_CANISTER_ID" --with-cycles 10000 is_prime '(1234567)' 70 | ``` 71 | 72 | A user MAY also pre-approve payment, then make the call: 73 | 74 | ``` 75 | dfx canister call $CYCLES_LEDGER icrc2_approve ' 76 | record { 77 | amount = 10000; 78 | spender = record { 79 | owner = principal "'${MATH_CANISTER_ID}'"; 80 | }; 81 | } 82 | ' 83 | 84 | dfx canister call "$MATH_CANISTER_ID" is_prime '(1234567, opt variant { CallerPaysIcrc2Cycles })' 85 | ``` 86 | 87 | Finally, there are complex use cases where another user pays on behalf of the caller. In this case, the payer needs to set aside some funds for the caller in a sub-account and approve the payment. The funds can be used only by that caller: 88 | 89 | ``` 90 | # Payer: 91 | ## Add funds to a subaccount for the caller: 92 | CALLER_ACCOUNT="$(dfx ledger account-id --of-principal "$CALLER")" 93 | SUBACCOUNT_ID="$(dfx ledger account-id --subaccount "$CALLER_ACCOUNT")" 94 | dfx cycles transfer "$SUBACCOUNT_ID" 200000 95 | ## Authorize payment: 96 | dfx canister call $CYCLES_LEDGER icrc2_approve ' 97 | record { 98 | amount = 10000; 99 | from_subaccount = "'${CALLER_ACCOUNT}'"; 100 | spender = record { 101 | owner = principal "'${MATH_CANISTER_ID}'"; 102 | }; 103 | } 104 | ' 105 | 106 | # Caller: 107 | ## The caller needs to specify the payment source explicitly: 108 | PAYER_ACCOUNT="$(dfx ledger account-id --of-principal "$PAYER")" 109 | dfx canister call "$MATH_CANISTER_ID" paid_is_prime ' 110 | ( 111 | 1234, 112 | opt variant { 113 | PatronPaysIcrc2Cycles = record { 114 | owner = principal "PAYER_ACCOUNT"; 115 | } 116 | }, 117 | ) 118 | ' 119 | ``` 120 | 121 | Your canister will retrieve the pre-approved payment before proceeding with the API call. 122 | 123 | ## Licence & Contribution 124 | 125 | This repository is released under the [Apache2 license](./LICENSE). 126 | 127 | Unfortunately we are unable to accept contributions yet. When we do, we will provide a contribution guide. 128 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | DFINITY takes the security of our software products seriously, which includes all source code repositories under the [DFINITY](https://github.com/dfinity) GitHub organization. 4 | 5 | > [!IMPORTANT] 6 | > [DFINITY Foundation](https://dfinity.org) has a [Internet Computer (ICP) Bug Bounty program](https://dfinity.org/bug-bounty/) that rewards researchers for finding and reporting vulnerabilities in the Internet Computer. Please check the scope and eligibility criteria outlined in the policy to see if the vulnerability you found qualifies for a reward. 7 | 8 | ## How to report a vulnerability 9 | 10 | We appreciate your help in keeping our projects secure. 11 | If you believe you have found a security vulnerability in any of our repositories, please report it resonsibly to us as described below: 12 | 13 | 1. **Do not disclose the vulnerability publicly.** Public disclosure could be exploited by attackers before it can be fixed. 14 | 2. **Send an email to securitybugs@dfinity.org.** Please include the following information in your email: 15 | * A description of the vulnerability 16 | * Steps to reproduce the vulnerability 17 | * Risk rating of the vulnerability 18 | * Any other relevant information 19 | 20 | We will respond to your report within 72 hours and work with you to fix the vulnerability as soon as possible. 21 | 22 | ### Security Updates 23 | 24 | We are committed to fixing security vulnerabilities in a timely manner. Once a security vulnerability is reported, we will: 25 | 26 | * Investigate the report and confirm the vulnerability. 27 | * Develop a fix for the vulnerability. 28 | * Release a new version of the project that includes the fix. 29 | * Announce the security fix in the project's release notes. 30 | 31 | ## Preferred Language 32 | 33 | We prefer all communications to be in English. 34 | 35 | ## Disclaimer 36 | 37 | This security policy is subject to change at any time. 38 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Creating a paid API 4 | 5 | ### Accept many payment types 6 | 7 | - In your canister, you need to decide which payment types you wish to accept. In [this example](./src/example/paid_service/src/state.rs) a `PAYMENT_GUARD` is defined that accepts a variety of payment options. 8 | - In the API methods, you need to [deduct payment](./src/example/paid_service/src/lib.rs) before doing work. 9 | -------------------------------------------------------------------------------- /canister_ids.json: -------------------------------------------------------------------------------- 1 | { 2 | "example_paid_service": { 3 | "ic": "2vxsx-fae" 4 | }, 5 | "example_app_backend": { 6 | "ic": "2vxsx-fae" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /dev-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "rustup": { 3 | "method": "curl", 4 | "version": "1.25.2", 5 | "url": "https://raw.githubusercontent.com/rust-lang/rustup/1.25.2/rustup-init.sh", 6 | "pipe": [["sh"]] 7 | }, 8 | "n": { 9 | "method": "curl", 10 | "version": "v9.0.1", 11 | "url": "https://github.com/tj/n/blob/v9.0.1/bin/n" 12 | }, 13 | "shfmt": { 14 | "method": "go", 15 | "version": "v3.5.1", 16 | "source": "mvdan.cc/sh/v3/cmd/shfmt" 17 | }, 18 | "yq": { 19 | "method": "go", 20 | "version": "v4.33.3", 21 | "source": "github.com/mikefarah/yq/v4" 22 | }, 23 | "node": { 24 | "method": "n", 25 | "version": "18.14.2" 26 | }, 27 | "cargo-binstall": { 28 | "method": "sh", 29 | "version": "1.7.4" 30 | }, 31 | "cargo-audit": { 32 | "method": "cargo-binstall", 33 | "version": "0.13.1" 34 | }, 35 | "cargo-sort": { 36 | "method": "cargo-binstall", 37 | "version": "1.0.9" 38 | }, 39 | "candid-extractor": { 40 | "method": "cargo-binstall", 41 | "version": "0.1.4" 42 | }, 43 | "cargo-tarpaulin": { 44 | "method": "cargo-binstall", 45 | "version": "0.26.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /dfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "dfx": "0.25.0", 3 | "canisters": { 4 | "example_paid_service": { 5 | "candid": "src/example/paid_service/example-paid-service.did", 6 | "package": "example_paid_service", 7 | "type": "rust", 8 | "optimize": "cycles", 9 | "gzip": true, 10 | "dependencies": ["cycles_ledger"] 11 | }, 12 | "example_app_backend": { 13 | "candid": "src/example/app_backend/example_app_backend.did", 14 | "package": "example_app_backend", 15 | "type": "rust" 16 | }, 17 | "cycles_ledger": { 18 | "type": "custom", 19 | "candid": "https://github.com/dfinity/cycles-ledger/releases/download/cycles-ledger-v1.0.1/cycles-ledger.did", 20 | "wasm": "https://github.com/dfinity/cycles-ledger/releases/download/cycles-ledger-v1.0.1/cycles-ledger.wasm.gz", 21 | "init_arg": "( variant { Init = record { index_id = null; max_blocks_per_request = 9_999 : nat64 }},)", 22 | "specified_id": "um5iw-rqaaa-aaaaq-qaaba-cai", 23 | "remote": { 24 | "id": { 25 | "ic": "um5iw-rqaaa-aaaaq-qaaba-cai" 26 | } 27 | } 28 | }, 29 | "cycles_depositor": { 30 | "dependencies": ["cycles_ledger"], 31 | "type": "custom", 32 | "build": "scripts/build.cycles_depositor.sh", 33 | "init_arg_file": "out/cycles_depositor.args.did", 34 | "wasm": "out/cycles_depositor.wasm.gz", 35 | "candid": "out/cycles_depositor.did" 36 | } 37 | }, 38 | "defaults": { 39 | "build": { 40 | "args": "", 41 | "packtool": "" 42 | } 43 | }, 44 | "output_env_file": ".env", 45 | "version": 1 46 | } 47 | -------------------------------------------------------------------------------- /didc.toml: -------------------------------------------------------------------------------- 1 | # Examples: https://github.com/dfinity/candid/tree/master/tools/didc#toml-config 2 | 3 | [rust] 4 | visibility = "pub" 5 | attributes = "#[derive(CandidType, Deserialize, Debug, Clone)]" 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "engines": { 3 | "node": ">=16.0.0", 4 | "npm": ">=7.0.0" 5 | }, 6 | "name": "cf-signer", 7 | "scripts": { 8 | "build": "npm run build --workspaces --if-present", 9 | "format": "prettier --write './**/*.{ts,js,mjs,json,scss,css,svelte,html,md}'", 10 | "prebuild": "npm run prebuild --workspaces --if-present", 11 | "pretest": "npm run prebuild --workspaces --if-present", 12 | "start": "npm start --workspaces --if-present", 13 | "test": "npm test --workspaces --if-present" 14 | }, 15 | "type": "module", 16 | "workspaces": [ 17 | "src/example-frontend" 18 | ], 19 | "devDependencies": { 20 | "@dfinity/auth-client": "^2.4.1", 21 | "@dfinity/identity": "^2.0.0", 22 | "prettier": "^3.5.3", 23 | "prettier-plugin-organize-imports": "^4.1.0", 24 | "prettier-plugin-svelte": "^3.3.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | # Please, from time to time, update this to the latest stable version on Rust Forge: https://forge.rust-lang.org/ 3 | channel = "1.85.0" 4 | components = ["rustfmt", "clippy"] 5 | targets = [ "wasm32-unknown-unknown"] 6 | -------------------------------------------------------------------------------- /scripts/bind.cycles_depositor.sh: -------------------------------------------------------------------------------- 1 | didc bind --target rs --config didc.toml .dfx/local/canisters/cycles_depositor/cycles_depositor.did 2 | -------------------------------------------------------------------------------- /scripts/bind.cycles_ledger.sh: -------------------------------------------------------------------------------- 1 | didc bind --target rs --config didc.toml .dfx/local/canisters/cycles_ledger/cycles_ledger.did 2 | -------------------------------------------------------------------------------- /scripts/build.cycles_depositor.args.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | print_help() { 5 | cat <<-EOF 6 | Creates the cycles_depositor installation arguments. 7 | 8 | The file is installed at the location defined for 'cycles_depositor' in 'dfx.json'. 9 | EOF 10 | } 11 | 12 | [[ "${1:-}" != "--help" ]] || { 13 | print_help 14 | exit 0 15 | } 16 | 17 | DFX_NETWORK="${DFX_NETWORK:-local}" 18 | ARG_FILE="$(jq -r .canisters.cycles_depositor.init_arg_file dfx.json)" 19 | 20 | #### 21 | # Computes the install args, overwriting any existing args file. 22 | 23 | CANISTER_ID_CYCLES_LEDGER="${CANISTER_ID_CYCLES_LEDGER:-$(dfx canister id cycles_ledger --network "$DFX_NETWORK")}" 24 | 25 | # .. Creates the init args file 26 | rm -f "$ARG_FILE" 27 | mkdir -p "$(dirname "$ARG_FILE")" 28 | cat <"$ARG_FILE" 29 | (record { ledger_id = principal "$CANISTER_ID_CYCLES_LEDGER" }) 30 | EOF 31 | 32 | #### 33 | # Success 34 | cat <"$CANDID_FILE" 38 | fi 39 | 40 | #### 41 | # Downloads the Wasm file, if it does not exist already. 42 | if test -e "$WASM_FILE"; then 43 | echo "Using existing cycles_depositor Wasm file" 44 | else 45 | mkdir -p "$(dirname "$WASM_FILE")" 46 | curl -sSL "$WASM_URL" >"$WASM_FILE" 47 | fi 48 | 49 | #### 50 | # Computes the install args, overwriting any existing args file. 51 | scripts/build.cycles_depositor.args.sh 52 | 53 | # Success 54 | cat <"$candid_file" 30 | echo "Written: $candid_file" 31 | } 32 | 33 | CANISTERS=(example_app_backend example_paid_service) 34 | 35 | for canister in ${CANISTERS[@]}; do 36 | generate_did "$canister" 37 | done 38 | -------------------------------------------------------------------------------- /scripts/fmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | cd "$(dirname "$(realpath "$0")")/.." 4 | 5 | set -x 6 | time xargs -P8 -I{} bash -c "{}" <"$1" 11 | } 12 | 13 | readarray -t files < <(json_files) 14 | for file in "${files[@]}"; do 15 | format_json "$file" 16 | git diff --exit-code "$file" >/dev/null || echo "$file" 17 | done 18 | -------------------------------------------------------------------------------- /scripts/fmt-md: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$(realpath "$0")")/.." || exit 4 | 5 | print_help() { 6 | cat <<-EOF 7 | 8 | Formats markdown files. 9 | EOF 10 | # TODO: Consider using clap. If not, describe flags here. 11 | } 12 | 13 | list_files() { 14 | git ls-files "${@}" | filter 15 | } 16 | 17 | # Selects eligible files; filenames are read from stdin, one line per filename. 18 | # TODO: Formatting fails in the frontend directory, due to some npx or prettier magic. Fix. 19 | # TODO: Remove the union merge rule for one week, to help PRs that struggle to merge correctly. 20 | filter() { 21 | grep -E '[.]md$' | grep -v frontend | grep -v CHANGELOG 22 | } 23 | 24 | case "${1:-}" in 25 | --help) print_help && exit 0 ;; 26 | --list | -l) list_files ;; 27 | --check | -c) list_files | xargs npx prettier --check ;; 28 | --modified | -m) list_files --modified | xargs npx prettier --write ;; 29 | "") list_files | xargs npx prettier --write ;; 30 | --) 31 | shift 1 32 | for file in "$@"; do npx prettier --write "$file"; done 33 | ;; 34 | *) for file in "$@"; do npx prettier --write "$file"; done ;; 35 | esac 36 | -------------------------------------------------------------------------------- /scripts/fmt-rs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | cd "$(dirname "$(realpath "$0")")/.." 4 | 5 | cargo fmt 6 | -------------------------------------------------------------------------------- /scripts/fmt-sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd "$(dirname "$(realpath "$0")")/.." || exit 3 | 4 | print_help() { 5 | cat <<-EOF 6 | 7 | Formats bash scripts. 8 | EOF 9 | # TODO: Consider using clap for argument parsing. If not, describe the flags here. 10 | } 11 | 12 | # Lists all files that should be formatted. 13 | list_files() { 14 | git ls-files | filter 15 | } 16 | 17 | # Selects eligible files; filenames are read from stdin, one line per filename. 18 | filter() { 19 | while read -r line; do if [[ "$line" = *.sh ]] || file "$line" | grep -qw Bourne; then echo "$line"; fi; done 20 | } 21 | 22 | # Formatting options 23 | options=(-i 2) 24 | 25 | case "${1:-}" in 26 | --help) print_help && exit 0 ;; 27 | --list | -l) list_files ;; 28 | --check | -c) list_files | xargs -P8 -I{} shfmt "${options[@]}" -d "{}" ;; 29 | --modified | -m) git ls-files --modified | filter | xargs -P8 -I{} shfmt -l -w "${options[@]}" "{}" ;; 30 | "") list_files | xargs -P8 -I{} shfmt -l -w "${options[@]}" "{}" ;; 31 | --) 32 | shift 1 33 | for file in "$@"; do shfmt -l -w "${options[@]}" "$file"; done 34 | ;; 35 | *) for file in "$@"; do shfmt -l -w "${options[@]}" "$file"; done ;; 36 | esac 37 | -------------------------------------------------------------------------------- /scripts/fmt-yaml: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd "$(dirname "$(realpath "$0")")/.." || exit 3 | 4 | print_help() { 5 | cat <<-EOF 6 | 7 | Formats YAML files. 8 | EOF 9 | # TODO: Consider using clap. If not, describe flags here. 10 | } 11 | 12 | list_files() { 13 | git ls-files | filter 14 | } 15 | 16 | # Selects eligible files; filenames are read from stdin, one line per filename. 17 | filter() { 18 | grep -E '[.]ya?ml$' 19 | } 20 | 21 | case "${1:-}" in 22 | --help) print_help && exit 0 ;; 23 | --list | -l) list_files ;; 24 | --check | -c) list_files | while read -r line; do diff <(yq . "$line") "$line"; done | if grep .; then exit 1; fi ;; 25 | --modified | -m) git ls-files --modified | filter | while read -r line; do yq --inplace . "$line"; done ;; 26 | "") list_files | while read -r line; do yq --inplace . "$line"; done ;; 27 | --) 28 | shift 1 29 | for file in "$@"; do yq --inplace . "$file"; done 30 | ;; 31 | *) for file in "$@"; do yq --inplace . "$file"; done ;; 32 | esac 33 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | cd "$(dirname "$(realpath "$0")")/.." 4 | 5 | set -x 6 | ./scripts/lint-git 7 | ./scripts/lint-rs 8 | ./scripts/lint-sh 9 | # NOTE: This list is NOT complete. Some lint commands 10 | # need to be encoded as scripts, then they can be added 11 | # to the list above. 12 | -------------------------------------------------------------------------------- /scripts/lint-git: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | untracked_files="$(git ls-files --others --exclude-standard)" 4 | [[ "${untracked_files:-}" == "" ]] || { 5 | echo "ERROR: Untracked files:" 6 | echo 7 | echo "$untracked_files" 8 | echo 9 | echo "Please add these files to git or to .gitignore or remove them." 10 | exit 1 11 | } 12 | -------------------------------------------------------------------------------- /scripts/lint-rs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | # Lint the rust code 4 | cargo clippy --locked --target wasm32-unknown-unknown --all-features -- -D warnings -W clippy::pedantic -A clippy::module-name-repetitions -A clippy::struct-field-names -A clippy::missing_errors_doc 5 | -------------------------------------------------------------------------------- /scripts/lint-sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$(realpath "$0")")/.." || exit 4 | 5 | print_help() { 6 | cat <<-EOF 7 | 8 | Checks bash scripts for error-prone code. 9 | EOF 10 | # TODO: Consider moving to clap. If not, descibe flags here. 11 | } 12 | 13 | # Lists all bash files in the repo. 14 | list_files() { 15 | git ls-files | filter 16 | } 17 | 18 | # Selects bash files; filenames are read from stdin, one line per filename, bash filenames are written to stdout. 19 | filter() { 20 | while read -r line; do if [[ "$line" = *.sh ]] || file "$line" | grep -qw Bourne; then echo "$line"; fi; done 21 | } 22 | 23 | lint=(shellcheck -e SC1090 -e SC2119 -e SC1091) 24 | 25 | case "${1:-}" in 26 | --help) print_help && exit 0 ;; 27 | "") list_files | xargs -P4 -I{} "${lint[@]}" "{}" ;; 28 | --modified | -m) git ls-files --modified | filter | xargs -P8 -I{} "${lint[@]}" "{}" ;; 29 | --) 30 | shift 1 31 | "${lint[@]}" "${@}" 32 | ;; 33 | *) "${lint[@]}" "${@}" ;; 34 | esac 35 | -------------------------------------------------------------------------------- /scripts/pic-install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Needs env vars: 4 | POCKET_IC_SERVER_VERSION="${POCKET_IC_SERVER_VERSION:-5.0.0}" 5 | POCKET_IC_SERVER_PATH="${POCKET_IC_SERVER_PATH:-./pocket-ic}" 6 | 7 | if [[ $OSTYPE == "linux-gnu"* ]] || [[ $RUNNER_OS == "Linux" ]]; then 8 | PLATFORM=linux 9 | elif [[ $OSTYPE == "darwin"* ]] || [[ $RUNNER_OS == "macOS" ]]; then 10 | PLATFORM=darwin 11 | else 12 | echo "OS not supported: ${OSTYPE:-$RUNNER_OS}" 13 | exit 1 14 | fi 15 | 16 | if "$POCKET_IC_SERVER_PATH" --version 2>/dev/null | grep -wq "$POCKET_IC_SERVER_VERSION"; then 17 | echo "PocketIC server already exists at: $POCKET_IC_SERVER_PATH" 18 | echo "Skipping download." 19 | else 20 | echo "Downloading PocketIC." 21 | curl -sSL https://github.com/dfinity/pocketic/releases/download/${POCKET_IC_SERVER_VERSION}/pocket-ic-x86_64-${PLATFORM}.gz -o ${POCKET_IC_SERVER_PATH}.gz 22 | gunzip ${POCKET_IC_SERVER_PATH}.gz 23 | chmod +x ${POCKET_IC_SERVER_PATH} 24 | fi 25 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | for tool in "${@}"; do 5 | echo "Installing '$tool'..." 6 | export tool 7 | install_method="$(jq -r '.[env.tool].method' dev-tools.json)" 8 | echo " install_method: $install_method" 9 | case "$install_method" in 10 | "sh") 11 | version="$(jq -r '.[env.tool].version' dev-tools.json)" "$0-$tool" 12 | ;; 13 | "cargo-install") 14 | cargo install "$tool@$(jq -r '.[env.tool].version' dev-tools.json)" 15 | ;; 16 | "cargo-binstall") 17 | cargo binstall --force --no-confirm "${tool}@$(jq -r '.[env.tool].version' dev-tools.json)" 18 | ;; 19 | "go") 20 | GOBIN="$HOME/.local/bin" go install "$(jq -r '.[env.tool].source' dev-tools.json)@$(jq -r '.[env.tool].version' dev-tools.json)" 21 | ;; 22 | "snap") 23 | sudo snap install "$tool" 24 | ;; 25 | *) 26 | echo "ERROR: Unsupported install method '$install_method'" 27 | exit 1 28 | ;; 29 | esac 30 | done 31 | -------------------------------------------------------------------------------- /scripts/setup-cargo-binstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euxo pipefail 4 | 5 | cd "$(mktemp -d)" 6 | 7 | base_url="https://github.com/cargo-bins/cargo-binstall/releases/download/${version:+v}${version:-latest}/cargo-binstall-" 8 | 9 | os="$(uname -s)" 10 | if [ "$os" == "Darwin" ]; then 11 | url="${base_url}universal-apple-darwin.zip" 12 | curl -LO --proto '=https' --tlsv1.2 -sSf "$url" 13 | unzip cargo-binstall-universal-apple-darwin.zip 14 | elif [ "$os" == "Linux" ]; then 15 | machine="$(uname -m)" 16 | if [ "$machine" == "armv7l" ]; then 17 | machine="armv7" 18 | fi 19 | target="${machine}-unknown-linux-musl" 20 | if [ "$machine" == "armv7" ]; then 21 | target="${target}eabihf" 22 | fi 23 | 24 | url="${base_url}${target}.tgz" 25 | curl -L --proto '=https' --tlsv1.2 -sSf "$url" | tar -xvzf - 26 | elif [ "${OS-}" = "Windows_NT" ]; then 27 | machine="$(uname -m)" 28 | target="${machine}-pc-windows-msvc" 29 | url="${base_url}${target}.zip" 30 | curl -LO --proto '=https' --tlsv1.2 -sSf "$url" 31 | unzip "cargo-binstall-${target}.zip" 32 | else 33 | echo "Unsupported OS ${os}" 34 | exit 1 35 | fi 36 | 37 | ./cargo-binstall -y --force cargo-binstall 38 | 39 | CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" 40 | 41 | if ! [[ ":$PATH:" == *":$CARGO_HOME/bin:"* ]]; then 42 | if [ -n "${CI:-}" ] && [ -n "${GITHUB_PATH:-}" ]; then 43 | echo "$CARGO_HOME/bin" >>"$GITHUB_PATH" 44 | else 45 | echo 46 | printf "\033[0;31mYour path is missing %s, you might want to add it.\033[0m\n" "$CARGO_HOME/bin" 47 | echo 48 | fi 49 | fi 50 | -------------------------------------------------------------------------------- /scripts/setup-rust: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Installs rust 3 | 4 | set -euo pipefail 5 | 6 | SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | cd "$SCRIPTS_DIR/.." 8 | 9 | function run() { 10 | echo 1>&2 "running $*" 11 | rc=0 && "$@" || rc="$?" 12 | if ! [ "$rc" -eq 0 ]; then 13 | echo 1>&2 "Bootstrap command failed: $*" 14 | exit "$rc" 15 | fi 16 | } 17 | 18 | rust_version=$(sed -n 's/^channel[[:space:]]*=[[:space:]]"\(.*\)"/\1/p' rust-toolchain.toml) 19 | echo "using rust version '$rust_version'" 20 | 21 | # here we set the toolchain to 'none' and rustup will pick up on ./rust-toolchain.toml 22 | run curl --fail https://sh.rustup.rs -sSf | run sh -s -- -y --default-toolchain "none" --no-modify-path 23 | 24 | # make sure the packages are actually installed (rustup waits for the first invoke to lazyload) 25 | cargo --version 26 | cargo clippy --version 27 | cargo fmt --version 28 | -------------------------------------------------------------------------------- /scripts/test-rs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | print_help() { 4 | echo "Runs rust unit tests." 5 | } 6 | 7 | [[ "${1:-}" != "--help" ]] || print_help 8 | 9 | cargo test --lib --bins "${@}" 10 | -------------------------------------------------------------------------------- /scripts/test.integration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | # TODO: Run only if needed 5 | dfx deploy 6 | 7 | export POCKET_IC_SERVER_VERSION=5.0.0 8 | export POCKET_IC_SERVER_PATH="target/pocket-ic" 9 | export POCKET_IC_BIN="${PWD}/${POCKET_IC_SERVER_PATH}" 10 | export POCKET_IC_MUTE_SERVER="" 11 | scripts/pic-install 12 | 13 | # Run tests 14 | echo "Running integration tests." 15 | cargo test -p example_paid_service "${@}" 16 | -------------------------------------------------------------------------------- /src/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ic-papi-api" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | candid = { workspace = true } 8 | hex = { workspace = true } 9 | ic-cycles-ledger-client = { workspace = true } 10 | ic-ledger-types = { workspace = true } 11 | serde = { workspace = true } 12 | serde_bytes = { workspace = true } 13 | -------------------------------------------------------------------------------- /src/api/src/caller.rs: -------------------------------------------------------------------------------- 1 | //! Types used primarily by the caller of the payment API. 2 | use candid::{CandidType, Deserialize, Principal}; 3 | pub use ic_cycles_ledger_client::Account; 4 | 5 | /// How a caller states that they will pay. 6 | #[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] 7 | #[non_exhaustive] 8 | pub enum PaymentType { 9 | /// The caller is paying with cycles attached to the call. 10 | /// 11 | /// Note: This is available to inter-canister aclls only; not to ingress messages. 12 | /// 13 | /// Note: The API does not require additional arguments to support this payment type. 14 | AttachedCycles, 15 | /// The caller is paying with cycles from their main account on the cycles ledger. 16 | CallerPaysIcrc2Cycles, 17 | /// A patron is paying with cycles on behalf of the caller. 18 | PatronPaysIcrc2Cycles(PatronPaysIcrc2Cycles), 19 | /// The caller is paying with tokens from their main account on the specified ledger. 20 | CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens), 21 | /// A patron is paying, on behalf of the caller, from an account on the specified ledger. 22 | PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens), 23 | } 24 | 25 | pub type PatronPaysIcrc2Cycles = Account; 26 | 27 | #[derive(Debug, CandidType, Deserialize, Copy, Clone, Eq, PartialEq)] 28 | pub struct CallerPaysIcrc2Tokens { 29 | pub ledger: Principal, 30 | } 31 | 32 | #[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] 33 | pub struct PatronPaysIcrc2Tokens { 34 | pub ledger: Principal, 35 | pub patron: Account, 36 | } 37 | 38 | pub type TokenAmount = u64; 39 | -------------------------------------------------------------------------------- /src/api/src/cycles.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | 3 | /// The cycles ledger canister ID on mainnet. 4 | /// 5 | /// Note: This canister ID should also be used in test environments. The dfx.json 6 | /// can implement this with: 7 | /// ``` 8 | /// { "canisters": { 9 | /// "cycles_ledger": { 10 | /// ... 11 | /// "specified_id": "um5iw-rqaaa-aaaaq-qaaba-cai", 12 | /// "remote": { 13 | /// "id": { 14 | /// "ic": "um5iw-rqaaa-aaaaq-qaaba-cai" 15 | /// } 16 | /// } 17 | /// }, 18 | /// ... 19 | /// } 20 | /// ``` 21 | pub const MAINNET_CYCLES_LEDGER_CANISTER_ID: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; 22 | 23 | /// The `MAINNET_CYCLES_LEDGER_CANISTER_ID` as a `Principal`. 24 | /// 25 | /// # Panics 26 | /// - If the `MAINNET_CYCLES_LEDGER_CANISTER_ID` is not a valid `Principal`. 27 | #[must_use] 28 | pub fn cycles_ledger_canister_id() -> Principal { 29 | Principal::from_text(MAINNET_CYCLES_LEDGER_CANISTER_ID) 30 | .expect("Invalid cycles ledger canister ID provided at compile time.") 31 | } 32 | -------------------------------------------------------------------------------- /src/api/src/error.rs: -------------------------------------------------------------------------------- 1 | //! Payment API error types. 2 | use candid::{CandidType, Deserialize, Principal}; 3 | pub use ic_cycles_ledger_client::Account; 4 | use ic_cycles_ledger_client::{TransferFromError, WithdrawFromError}; 5 | 6 | use crate::caller::TokenAmount; 7 | 8 | #[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] 9 | #[non_exhaustive] 10 | pub enum PaymentError { 11 | UnsupportedPaymentType, 12 | LedgerUnreachable { 13 | ledger: Principal, 14 | }, 15 | LedgerWithdrawFromError { 16 | ledger: Principal, 17 | error: WithdrawFromError, 18 | }, 19 | LedgerTransferFromError { 20 | ledger: Principal, 21 | error: TransferFromError, 22 | }, 23 | InsufficientFunds { 24 | needed: TokenAmount, 25 | available: TokenAmount, 26 | }, 27 | InvalidPatron, 28 | } 29 | -------------------------------------------------------------------------------- /src/api/src/lib.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | pub use ic_cycles_ledger_client::Account; 3 | use ic_ledger_types::Subaccount; 4 | use serde_bytes::ByteBuf; 5 | 6 | pub mod caller; 7 | pub mod cycles; 8 | pub mod error; 9 | pub mod vendor; 10 | pub use caller::PaymentType; 11 | pub use error::PaymentError; 12 | pub use vendor::Icrc2Payer; 13 | 14 | const SUB_ACCOUNT_ZERO: Subaccount = Subaccount([0; 32]); 15 | #[must_use] 16 | pub fn principal2account(principal: &Principal) -> ByteBuf { 17 | // Note: The AccountIdentifier type contains bytes but has no API to access them. 18 | // There is a ticket to address this here: https://github.com/dfinity/cdk-rs/issues/519 19 | // TODO: Simplify this when an API that provides bytes is available. 20 | let hex_str = ic_ledger_types::AccountIdentifier::new(principal, &SUB_ACCOUNT_ZERO).to_hex(); 21 | hex::decode(&hex_str) 22 | .unwrap_or_else(|_| { 23 | unreachable!( 24 | "Failed to decode hex account identifier we just created: {}", 25 | hex_str 26 | ) 27 | }) 28 | .into() 29 | } 30 | -------------------------------------------------------------------------------- /src/api/src/vendor.rs: -------------------------------------------------------------------------------- 1 | //! Types used primartily by the vendor of the payment API. 2 | use candid::{CandidType, Deserialize, Principal}; 3 | pub use ic_cycles_ledger_client::Account; 4 | 5 | use crate::caller::TokenAmount; 6 | 7 | /// Billing options that may be offered to a customer. 8 | #[derive(Debug, CandidType, Deserialize, Copy, Clone, Eq, PartialEq)] 9 | #[non_exhaustive] 10 | pub enum PaymentOption { 11 | /// The caller is paying with cycles attached to the call. 12 | /// 13 | /// Note: This is available to inter-canister aclls only; not to ingress messages. 14 | /// 15 | /// Note: The API does not require additional arguments to support this payment type. 16 | AttachedCycles { fee: Option }, 17 | /// The caller is paying with cycles from their main account on the cycles ledger. 18 | CallerPaysIcrc2Cycles { fee: Option }, 19 | /// A patron is paying, on behalf of the caller, from their main account on the cycles ledger. 20 | PatronPaysIcrc2Cycles { fee: Option }, 21 | } 22 | 23 | /// User's payment details for an ICRC2 payment. 24 | #[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] 25 | pub struct Icrc2Payer { 26 | /// The customer's principal and (optionally) subaccount. 27 | /// 28 | /// By default, the caller's main account is used. 29 | pub account: Option, 30 | /// The spender, if different from the payer. 31 | pub spender_subaccount: Option, 32 | /// The ledger canister ID. 33 | /// 34 | /// Note: This is included in order to improve error messages if the caller tries to use the wrong ledger. 35 | pub ledger_canister_id: Option, 36 | /// Corresponds to the `created_at_time` field in ICRC2. 37 | pub created_at_time: Option, 38 | } 39 | -------------------------------------------------------------------------------- /src/declarations/cycles_ledger/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /src/declarations/cycles_ledger/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ic-cycles-ledger-client" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | candid = { workspace = true } 8 | ic-cdk = { workspace = true } 9 | serde = { workspace = true } 10 | serde_bytes = { workspace = true } 11 | -------------------------------------------------------------------------------- /src/declarations/cycles_ledger/src/lib.rs: -------------------------------------------------------------------------------- 1 | // This is an experimental feature to generate Rust binding from Candid. 2 | // You may want to manually adjust some of the types. 3 | #![allow(dead_code, unused_imports)] 4 | use candid::{self, CandidType, Deserialize, Principal}; 5 | use ic_cdk::api::call::CallResult as Result; 6 | 7 | #[derive(CandidType, Deserialize, Debug, Clone)] 8 | pub enum ChangeIndexId { 9 | SetTo(Principal), 10 | Unset, 11 | } 12 | #[derive(CandidType, Deserialize, Debug, Clone)] 13 | pub struct UpgradeArgs { 14 | pub change_index_id: Option, 15 | pub max_blocks_per_request: Option, 16 | } 17 | #[derive(CandidType, Deserialize, Debug, Clone)] 18 | pub struct InitArgs { 19 | pub index_id: Option, 20 | pub max_blocks_per_request: u64, 21 | } 22 | #[derive(CandidType, Deserialize, Debug, Clone)] 23 | pub enum LedgerArgs { 24 | Upgrade(Option), 25 | Init(InitArgs), 26 | } 27 | #[derive(CandidType, Deserialize, Debug, Clone)] 28 | pub struct SubnetFilter { 29 | pub subnet_type: Option, 30 | } 31 | #[derive(CandidType, Deserialize, Debug, Clone)] 32 | pub enum SubnetSelection { 33 | Filter(SubnetFilter), 34 | Subnet { subnet: Principal }, 35 | } 36 | #[derive(CandidType, Deserialize, Debug, Clone)] 37 | pub struct CanisterSettings { 38 | pub freezing_threshold: Option, 39 | pub controllers: Option>, 40 | pub reserved_cycles_limit: Option, 41 | pub memory_allocation: Option, 42 | pub compute_allocation: Option, 43 | } 44 | #[derive(CandidType, Deserialize, Debug, Clone)] 45 | pub struct CmcCreateCanisterArgs { 46 | pub subnet_selection: Option, 47 | pub settings: Option, 48 | } 49 | #[derive(CandidType, Deserialize, Debug, Clone)] 50 | pub struct CreateCanisterArgs { 51 | pub from_subaccount: Option, 52 | pub created_at_time: Option, 53 | pub amount: candid::Nat, 54 | pub creation_args: Option, 55 | } 56 | pub type BlockIndex = candid::Nat; 57 | #[derive(CandidType, Deserialize, Debug, Clone)] 58 | pub struct CreateCanisterSuccess { 59 | pub block_id: BlockIndex, 60 | pub canister_id: Principal, 61 | } 62 | #[derive(CandidType, Deserialize, Debug, Clone)] 63 | pub enum CreateCanisterError { 64 | GenericError { 65 | message: String, 66 | error_code: candid::Nat, 67 | }, 68 | TemporarilyUnavailable, 69 | Duplicate { 70 | duplicate_of: candid::Nat, 71 | canister_id: Option, 72 | }, 73 | CreatedInFuture { 74 | ledger_time: u64, 75 | }, 76 | FailedToCreate { 77 | error: String, 78 | refund_block: Option, 79 | fee_block: Option, 80 | }, 81 | TooOld, 82 | InsufficientFunds { 83 | balance: candid::Nat, 84 | }, 85 | } 86 | #[derive(CandidType, Deserialize, Debug, Clone, Eq, PartialEq)] 87 | pub struct Account { 88 | pub owner: Principal, 89 | pub subaccount: Option, 90 | } 91 | #[derive(CandidType, Deserialize, Debug, Clone)] 92 | pub struct CreateCanisterFromArgs { 93 | pub spender_subaccount: Option, 94 | pub from: Account, 95 | pub created_at_time: Option, 96 | pub amount: candid::Nat, 97 | pub creation_args: Option, 98 | } 99 | #[derive(CandidType, Deserialize, Debug, Clone, Eq, PartialEq)] 100 | pub enum RejectionCode { 101 | NoError, 102 | CanisterError, 103 | SysTransient, 104 | DestinationInvalid, 105 | Unknown, 106 | SysFatal, 107 | CanisterReject, 108 | } 109 | #[derive(CandidType, Deserialize, Debug, Clone)] 110 | pub enum CreateCanisterFromError { 111 | FailedToCreateFrom { 112 | create_from_block: Option, 113 | rejection_code: RejectionCode, 114 | refund_block: Option, 115 | approval_refund_block: Option, 116 | rejection_reason: String, 117 | }, 118 | GenericError { 119 | message: String, 120 | error_code: candid::Nat, 121 | }, 122 | TemporarilyUnavailable, 123 | InsufficientAllowance { 124 | allowance: candid::Nat, 125 | }, 126 | Duplicate { 127 | duplicate_of: candid::Nat, 128 | canister_id: Option, 129 | }, 130 | CreatedInFuture { 131 | ledger_time: u64, 132 | }, 133 | TooOld, 134 | InsufficientFunds { 135 | balance: candid::Nat, 136 | }, 137 | } 138 | #[derive(CandidType, Deserialize, Debug, Clone)] 139 | pub struct DepositArgs { 140 | pub to: Account, 141 | pub memo: Option, 142 | } 143 | #[derive(CandidType, Deserialize, Debug, Clone)] 144 | pub struct DepositResult { 145 | pub balance: candid::Nat, 146 | pub block_index: BlockIndex, 147 | } 148 | #[derive(CandidType, Deserialize, Debug, Clone)] 149 | pub struct HttpRequest { 150 | pub url: String, 151 | pub method: String, 152 | pub body: serde_bytes::ByteBuf, 153 | pub headers: Vec<(String, String)>, 154 | } 155 | #[derive(CandidType, Deserialize, Debug, Clone)] 156 | pub struct HttpResponse { 157 | pub body: serde_bytes::ByteBuf, 158 | pub headers: Vec<(String, String)>, 159 | pub status_code: u16, 160 | } 161 | #[derive(CandidType, Deserialize, Debug, Clone)] 162 | pub enum MetadataValue { 163 | Int(candid::Int), 164 | Nat(candid::Nat), 165 | Blob(serde_bytes::ByteBuf), 166 | Text(String), 167 | } 168 | #[derive(CandidType, Deserialize, Debug, Clone)] 169 | pub struct SupportedStandard { 170 | pub url: String, 171 | pub name: String, 172 | } 173 | #[derive(CandidType, Deserialize, Debug, Clone)] 174 | pub struct TransferArgs { 175 | pub to: Account, 176 | pub fee: Option, 177 | pub memo: Option, 178 | pub from_subaccount: Option, 179 | pub created_at_time: Option, 180 | pub amount: candid::Nat, 181 | } 182 | #[derive(CandidType, Deserialize, Debug, Clone)] 183 | pub enum TransferError { 184 | GenericError { 185 | message: String, 186 | error_code: candid::Nat, 187 | }, 188 | TemporarilyUnavailable, 189 | BadBurn { 190 | min_burn_amount: candid::Nat, 191 | }, 192 | Duplicate { 193 | duplicate_of: candid::Nat, 194 | }, 195 | BadFee { 196 | expected_fee: candid::Nat, 197 | }, 198 | CreatedInFuture { 199 | ledger_time: u64, 200 | }, 201 | TooOld, 202 | InsufficientFunds { 203 | balance: candid::Nat, 204 | }, 205 | } 206 | #[derive(CandidType, Deserialize, Debug, Clone)] 207 | pub struct AllowanceArgs { 208 | pub account: Account, 209 | pub spender: Account, 210 | } 211 | #[derive(CandidType, Deserialize, Debug, Clone)] 212 | pub struct Allowance { 213 | pub allowance: candid::Nat, 214 | pub expires_at: Option, 215 | } 216 | #[derive(CandidType, Deserialize, Debug, Clone)] 217 | pub struct ApproveArgs { 218 | pub fee: Option, 219 | pub memo: Option, 220 | pub from_subaccount: Option, 221 | pub created_at_time: Option, 222 | pub amount: candid::Nat, 223 | pub expected_allowance: Option, 224 | pub expires_at: Option, 225 | pub spender: Account, 226 | } 227 | #[derive(CandidType, Deserialize, Debug, Clone)] 228 | pub enum ApproveError { 229 | GenericError { 230 | message: String, 231 | error_code: candid::Nat, 232 | }, 233 | TemporarilyUnavailable, 234 | Duplicate { 235 | duplicate_of: candid::Nat, 236 | }, 237 | BadFee { 238 | expected_fee: candid::Nat, 239 | }, 240 | AllowanceChanged { 241 | current_allowance: candid::Nat, 242 | }, 243 | CreatedInFuture { 244 | ledger_time: u64, 245 | }, 246 | TooOld, 247 | Expired { 248 | ledger_time: u64, 249 | }, 250 | InsufficientFunds { 251 | balance: candid::Nat, 252 | }, 253 | } 254 | #[derive(CandidType, Deserialize, Debug, Clone)] 255 | pub struct TransferFromArgs { 256 | pub to: Account, 257 | pub fee: Option, 258 | pub spender_subaccount: Option, 259 | pub from: Account, 260 | pub memo: Option, 261 | pub created_at_time: Option, 262 | pub amount: candid::Nat, 263 | } 264 | #[derive(CandidType, Deserialize, Debug, Clone, Eq, PartialEq)] 265 | pub enum TransferFromError { 266 | GenericError { 267 | message: String, 268 | error_code: candid::Nat, 269 | }, 270 | TemporarilyUnavailable, 271 | InsufficientAllowance { 272 | allowance: candid::Nat, 273 | }, 274 | BadBurn { 275 | min_burn_amount: candid::Nat, 276 | }, 277 | Duplicate { 278 | duplicate_of: candid::Nat, 279 | }, 280 | BadFee { 281 | expected_fee: candid::Nat, 282 | }, 283 | CreatedInFuture { 284 | ledger_time: u64, 285 | }, 286 | TooOld, 287 | InsufficientFunds { 288 | balance: candid::Nat, 289 | }, 290 | } 291 | #[derive(CandidType, Deserialize, Debug, Clone)] 292 | pub struct GetArchivesArgs { 293 | pub from: Option, 294 | } 295 | #[derive(CandidType, Deserialize, Debug, Clone)] 296 | pub struct GetArchivesResultItem { 297 | pub end: candid::Nat, 298 | pub canister_id: Principal, 299 | pub start: candid::Nat, 300 | } 301 | pub type GetArchivesResult = Vec; 302 | #[derive(CandidType, Deserialize, Debug, Clone)] 303 | pub struct GetBlocksArgsItem { 304 | pub start: candid::Nat, 305 | pub length: candid::Nat, 306 | } 307 | pub type GetBlocksArgs = Vec; 308 | #[derive(CandidType, Deserialize, Debug, Clone)] 309 | pub enum Value { 310 | Int(candid::Int), 311 | Map(Vec<(String, Box)>), 312 | Nat(candid::Nat), 313 | Nat64(u64), 314 | Blob(serde_bytes::ByteBuf), 315 | Text(String), 316 | Array(Vec>), 317 | } 318 | #[derive(CandidType, Deserialize, Debug, Clone)] 319 | pub struct GetBlocksResultBlocksItem { 320 | pub id: candid::Nat, 321 | pub block: Box, 322 | } 323 | candid::define_function!(pub GetBlocksResultArchivedBlocksItemCallback : ( 324 | GetBlocksArgs, 325 | ) -> (GetBlocksResult) query); 326 | #[derive(CandidType, Deserialize, Debug, Clone)] 327 | pub struct GetBlocksResultArchivedBlocksItem { 328 | pub args: GetBlocksArgs, 329 | pub callback: GetBlocksResultArchivedBlocksItemCallback, 330 | } 331 | #[derive(CandidType, Deserialize, Debug, Clone)] 332 | pub struct GetBlocksResult { 333 | pub log_length: candid::Nat, 334 | pub blocks: Vec, 335 | pub archived_blocks: Vec, 336 | } 337 | #[derive(CandidType, Deserialize, Debug, Clone)] 338 | pub struct DataCertificate { 339 | pub certificate: serde_bytes::ByteBuf, 340 | pub hash_tree: serde_bytes::ByteBuf, 341 | } 342 | #[derive(CandidType, Deserialize, Debug, Clone)] 343 | pub struct SupportedBlockType { 344 | pub url: String, 345 | pub block_type: String, 346 | } 347 | #[derive(CandidType, Deserialize, Debug, Clone)] 348 | pub struct WithdrawArgs { 349 | pub to: Principal, 350 | pub from_subaccount: Option, 351 | pub created_at_time: Option, 352 | pub amount: candid::Nat, 353 | } 354 | #[derive(CandidType, Deserialize, Debug, Clone)] 355 | pub enum WithdrawError { 356 | FailedToWithdraw { 357 | rejection_code: RejectionCode, 358 | fee_block: Option, 359 | rejection_reason: String, 360 | }, 361 | GenericError { 362 | message: String, 363 | error_code: candid::Nat, 364 | }, 365 | TemporarilyUnavailable, 366 | Duplicate { 367 | duplicate_of: candid::Nat, 368 | }, 369 | BadFee { 370 | expected_fee: candid::Nat, 371 | }, 372 | InvalidReceiver { 373 | receiver: Principal, 374 | }, 375 | CreatedInFuture { 376 | ledger_time: u64, 377 | }, 378 | TooOld, 379 | InsufficientFunds { 380 | balance: candid::Nat, 381 | }, 382 | } 383 | #[derive(CandidType, Deserialize, Debug, Clone)] 384 | pub struct WithdrawFromArgs { 385 | pub to: Principal, 386 | pub spender_subaccount: Option, 387 | pub from: Account, 388 | pub created_at_time: Option, 389 | pub amount: candid::Nat, 390 | } 391 | #[derive(CandidType, Deserialize, Debug, Clone, Eq, PartialEq)] 392 | pub enum WithdrawFromError { 393 | GenericError { 394 | message: String, 395 | error_code: candid::Nat, 396 | }, 397 | TemporarilyUnavailable, 398 | InsufficientAllowance { 399 | allowance: candid::Nat, 400 | }, 401 | Duplicate { 402 | duplicate_of: BlockIndex, 403 | }, 404 | InvalidReceiver { 405 | receiver: Principal, 406 | }, 407 | CreatedInFuture { 408 | ledger_time: u64, 409 | }, 410 | TooOld, 411 | FailedToWithdrawFrom { 412 | withdraw_from_block: Option, 413 | rejection_code: RejectionCode, 414 | refund_block: Option, 415 | approval_refund_block: Option, 416 | rejection_reason: String, 417 | }, 418 | InsufficientFunds { 419 | balance: candid::Nat, 420 | }, 421 | } 422 | 423 | pub struct Service(pub Principal); 424 | impl Service { 425 | pub async fn create_canister( 426 | &self, 427 | arg0: &CreateCanisterArgs, 428 | ) -> Result<(std::result::Result,)> { 429 | ic_cdk::call(self.0, "create_canister", (arg0,)).await 430 | } 431 | pub async fn create_canister_from( 432 | &self, 433 | arg0: &CreateCanisterFromArgs, 434 | ) -> Result<(std::result::Result,)> { 435 | ic_cdk::call(self.0, "create_canister_from", (arg0,)).await 436 | } 437 | pub async fn deposit(&self, arg0: &DepositArgs) -> Result<(DepositResult,)> { 438 | ic_cdk::call(self.0, "deposit", (arg0,)).await 439 | } 440 | pub async fn http_request(&self, arg0: &HttpRequest) -> Result<(HttpResponse,)> { 441 | ic_cdk::call(self.0, "http_request", (arg0,)).await 442 | } 443 | pub async fn icrc_1_balance_of(&self, arg0: &Account) -> Result<(candid::Nat,)> { 444 | ic_cdk::call(self.0, "icrc1_balance_of", (arg0,)).await 445 | } 446 | pub async fn icrc_1_decimals(&self) -> Result<(u8,)> { 447 | ic_cdk::call(self.0, "icrc1_decimals", ()).await 448 | } 449 | pub async fn icrc_1_fee(&self) -> Result<(candid::Nat,)> { 450 | ic_cdk::call(self.0, "icrc1_fee", ()).await 451 | } 452 | pub async fn icrc_1_metadata(&self) -> Result<(Vec<(String, MetadataValue)>,)> { 453 | ic_cdk::call(self.0, "icrc1_metadata", ()).await 454 | } 455 | pub async fn icrc_1_minting_account(&self) -> Result<(Option,)> { 456 | ic_cdk::call(self.0, "icrc1_minting_account", ()).await 457 | } 458 | pub async fn icrc_1_name(&self) -> Result<(String,)> { 459 | ic_cdk::call(self.0, "icrc1_name", ()).await 460 | } 461 | pub async fn icrc_1_supported_standards(&self) -> Result<(Vec,)> { 462 | ic_cdk::call(self.0, "icrc1_supported_standards", ()).await 463 | } 464 | pub async fn icrc_1_symbol(&self) -> Result<(String,)> { 465 | ic_cdk::call(self.0, "icrc1_symbol", ()).await 466 | } 467 | pub async fn icrc_1_total_supply(&self) -> Result<(candid::Nat,)> { 468 | ic_cdk::call(self.0, "icrc1_total_supply", ()).await 469 | } 470 | pub async fn icrc_1_transfer( 471 | &self, 472 | arg0: &TransferArgs, 473 | ) -> Result<(std::result::Result,)> { 474 | ic_cdk::call(self.0, "icrc1_transfer", (arg0,)).await 475 | } 476 | pub async fn icrc_2_allowance(&self, arg0: &AllowanceArgs) -> Result<(Allowance,)> { 477 | ic_cdk::call(self.0, "icrc2_allowance", (arg0,)).await 478 | } 479 | pub async fn icrc_2_approve( 480 | &self, 481 | arg0: &ApproveArgs, 482 | ) -> Result<(std::result::Result,)> { 483 | ic_cdk::call(self.0, "icrc2_approve", (arg0,)).await 484 | } 485 | pub async fn icrc_2_transfer_from( 486 | &self, 487 | arg0: &TransferFromArgs, 488 | ) -> Result<(std::result::Result,)> { 489 | ic_cdk::call(self.0, "icrc2_transfer_from", (arg0,)).await 490 | } 491 | pub async fn icrc_3_get_archives( 492 | &self, 493 | arg0: &GetArchivesArgs, 494 | ) -> Result<(GetArchivesResult,)> { 495 | ic_cdk::call(self.0, "icrc3_get_archives", (arg0,)).await 496 | } 497 | pub async fn icrc_3_get_blocks(&self, arg0: &GetBlocksArgs) -> Result<(GetBlocksResult,)> { 498 | ic_cdk::call(self.0, "icrc3_get_blocks", (arg0,)).await 499 | } 500 | pub async fn icrc_3_get_tip_certificate(&self) -> Result<(Option,)> { 501 | ic_cdk::call(self.0, "icrc3_get_tip_certificate", ()).await 502 | } 503 | pub async fn icrc_3_supported_block_types(&self) -> Result<(Vec,)> { 504 | ic_cdk::call(self.0, "icrc3_supported_block_types", ()).await 505 | } 506 | pub async fn withdraw( 507 | &self, 508 | arg0: &WithdrawArgs, 509 | ) -> Result<(std::result::Result,)> { 510 | ic_cdk::call(self.0, "withdraw", (arg0,)).await 511 | } 512 | pub async fn withdraw_from( 513 | &self, 514 | arg0: &WithdrawFromArgs, 515 | ) -> Result<(std::result::Result,)> { 516 | ic_cdk::call(self.0, "withdraw_from", (arg0,)).await 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /src/declarations/example_paid_service/example_paid_service.did: -------------------------------------------------------------------------------- 1 | type PaymentError = variant { 2 | InsufficientFunds : record { needed : nat64; available : nat64 }; 3 | }; 4 | type Result = variant { Ok : text; Err : PaymentError }; 5 | service : { cost_1000_attached_cycles : () -> (Result); free : () -> (text) } 6 | -------------------------------------------------------------------------------- /src/declarations/example_paid_service/example_paid_service.did.d.ts: -------------------------------------------------------------------------------- 1 | import type { Principal } from "@dfinity/principal"; 2 | import type { ActorMethod } from "@dfinity/agent"; 3 | import type { IDL } from "@dfinity/candid"; 4 | 5 | export type PaymentError = { 6 | InsufficientFunds: { needed: bigint; available: bigint }; 7 | }; 8 | export type Result = { Ok: string } | { Err: PaymentError }; 9 | export interface _SERVICE { 10 | cost_1000_attached_cycles: ActorMethod<[], Result>; 11 | free: ActorMethod<[], string>; 12 | } 13 | export declare const idlFactory: IDL.InterfaceFactory; 14 | export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; 15 | -------------------------------------------------------------------------------- /src/declarations/example_paid_service/example_paid_service.did.js: -------------------------------------------------------------------------------- 1 | export const idlFactory = ({ IDL }) => { 2 | const PaymentError = IDL.Variant({ 3 | InsufficientFunds: IDL.Record({ 4 | needed: IDL.Nat64, 5 | available: IDL.Nat64, 6 | }), 7 | }); 8 | const Result = IDL.Variant({ Ok: IDL.Text, Err: PaymentError }); 9 | return IDL.Service({ 10 | cost_1000_attached_cycles: IDL.Func([], [Result], []), 11 | free: IDL.Func([], [IDL.Text], []), 12 | }); 13 | }; 14 | export const init = ({ IDL }) => { 15 | return []; 16 | }; 17 | -------------------------------------------------------------------------------- /src/declarations/example_paid_service/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActorSubclass, 3 | HttpAgentOptions, 4 | ActorConfig, 5 | Agent, 6 | } from "@dfinity/agent"; 7 | import type { Principal } from "@dfinity/principal"; 8 | import type { IDL } from "@dfinity/candid"; 9 | 10 | import { _SERVICE } from "./example_paid_service.did"; 11 | 12 | export declare const idlFactory: IDL.InterfaceFactory; 13 | export declare const canisterId: string; 14 | 15 | export declare interface CreateActorOptions { 16 | /** 17 | * @see {@link Agent} 18 | */ 19 | agent?: Agent; 20 | /** 21 | * @see {@link HttpAgentOptions} 22 | */ 23 | agentOptions?: HttpAgentOptions; 24 | /** 25 | * @see {@link ActorConfig} 26 | */ 27 | actorOptions?: ActorConfig; 28 | } 29 | 30 | /** 31 | * Intializes an {@link ActorSubclass}, configured with the provided SERVICE interface of a canister. 32 | * @constructs {@link ActorSubClass} 33 | * @param {string | Principal} canisterId - ID of the canister the {@link Actor} will talk to 34 | * @param {CreateActorOptions} options - see {@link CreateActorOptions} 35 | * @param {CreateActorOptions["agent"]} options.agent - a pre-configured agent you'd like to use. Supercedes agentOptions 36 | * @param {CreateActorOptions["agentOptions"]} options.agentOptions - options to set up a new agent 37 | * @see {@link HttpAgentOptions} 38 | * @param {CreateActorOptions["actorOptions"]} options.actorOptions - options for the Actor 39 | * @see {@link ActorConfig} 40 | */ 41 | export declare const createActor: ( 42 | canisterId: string | Principal, 43 | options?: CreateActorOptions, 44 | ) => ActorSubclass<_SERVICE>; 45 | 46 | /** 47 | * Intialized Actor using default settings, ready to talk to a canister using its candid interface 48 | * @constructs {@link ActorSubClass} 49 | */ 50 | export declare const example_paid_service: ActorSubclass<_SERVICE>; 51 | -------------------------------------------------------------------------------- /src/declarations/example_paid_service/index.js: -------------------------------------------------------------------------------- 1 | import { Actor, HttpAgent } from "@dfinity/agent"; 2 | 3 | // Imports and re-exports candid interface 4 | import { idlFactory } from "./example_paid_service.did.js"; 5 | export { idlFactory } from "./example_paid_service.did.js"; 6 | 7 | /* CANISTER_ID is replaced by webpack based on node environment 8 | * Note: canister environment variable will be standardized as 9 | * process.env.CANISTER_ID_ 10 | * beginning in dfx 0.15.0 11 | */ 12 | export const canisterId = process.env.CANISTER_ID_EXAMPLE_PAID_SERVICE; 13 | 14 | export const createActor = (canisterId, options = {}) => { 15 | const agent = options.agent || new HttpAgent({ ...options.agentOptions }); 16 | 17 | if (options.agent && options.agentOptions) { 18 | console.warn( 19 | "Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent.", 20 | ); 21 | } 22 | 23 | // Fetch root key for certificate validation during development 24 | if (process.env.DFX_NETWORK !== "ic") { 25 | agent.fetchRootKey().catch((err) => { 26 | console.warn( 27 | "Unable to fetch root key. Check to ensure that your local replica is running", 28 | ); 29 | console.error(err); 30 | }); 31 | } 32 | 33 | // Creates an actor with using the candid interface and the HttpAgent 34 | return Actor.createActor(idlFactory, { 35 | agent, 36 | canisterId, 37 | ...options.actorOptions, 38 | }); 39 | }; 40 | 41 | export const example_paid_service = canisterId 42 | ? createActor(canisterId) 43 | : undefined; 44 | -------------------------------------------------------------------------------- /src/example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | In this example we have: 4 | 5 | - `service`: A canister that offers an API but charges a fee for its use. 6 | - `app`: An application that makes use of `service`. 7 | 8 | The integration tests demonstrate various ways in which the API can be used. 9 | -------------------------------------------------------------------------------- /src/example/app_backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "example_app_backend" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | candid = { workspace = true } 11 | ic-cdk = { workspace = true } 12 | ic-cdk-macros = { workspace = true } 13 | ic-papi-api = { workspace = true } 14 | 15 | [dev-dependencies] 16 | pocket-ic = { workspace = true } 17 | -------------------------------------------------------------------------------- /src/example/app_backend/example_app_backend.did: -------------------------------------------------------------------------------- 1 | type PaymentError = variant { 2 | LedgerWithdrawFromError : record { 3 | error : WithdrawFromError; 4 | ledger : principal; 5 | }; 6 | LedgerUnreachable : record { ledger : principal }; 7 | InvalidPatron; 8 | LedgerTransferFromError : record { 9 | error : TransferFromError; 10 | ledger : principal; 11 | }; 12 | UnsupportedPaymentType; 13 | InsufficientFunds : record { needed : nat64; available : nat64 }; 14 | }; 15 | type RejectionCode = variant { 16 | NoError; 17 | CanisterError; 18 | SysTransient; 19 | DestinationInvalid; 20 | Unknown; 21 | SysFatal; 22 | CanisterReject; 23 | }; 24 | type Result = variant { Ok : text; Err : PaymentError }; 25 | type TransferFromError = variant { 26 | GenericError : record { message : text; error_code : nat }; 27 | TemporarilyUnavailable; 28 | InsufficientAllowance : record { allowance : nat }; 29 | BadBurn : record { min_burn_amount : nat }; 30 | Duplicate : record { duplicate_of : nat }; 31 | BadFee : record { expected_fee : nat }; 32 | CreatedInFuture : record { ledger_time : nat64 }; 33 | TooOld; 34 | InsufficientFunds : record { balance : nat }; 35 | }; 36 | type WithdrawFromError = variant { 37 | GenericError : record { message : text; error_code : nat }; 38 | TemporarilyUnavailable; 39 | InsufficientAllowance : record { allowance : nat }; 40 | Duplicate : record { duplicate_of : nat }; 41 | InvalidReceiver : record { receiver : principal }; 42 | CreatedInFuture : record { ledger_time : nat64 }; 43 | TooOld; 44 | FailedToWithdrawFrom : record { 45 | withdraw_from_block : opt nat; 46 | rejection_code : RejectionCode; 47 | refund_block : opt nat; 48 | approval_refund_block : opt nat; 49 | rejection_reason : text; 50 | }; 51 | InsufficientFunds : record { balance : nat }; 52 | }; 53 | service : { 54 | call_with_attached_cycles : (record { principal; text; nat64 }) -> (Result); 55 | } 56 | -------------------------------------------------------------------------------- /src/example/app_backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | use ic_cdk::api::{call::call_with_payment128, is_controller}; 3 | use ic_cdk_macros::{export_candid, update}; 4 | use ic_papi_api::PaymentError; 5 | 6 | /// Calls an arbitrary method on an arbitrary canister with an arbitrary amount of cycles attached. 7 | /// 8 | /// Note: This is for demonstration purposes only. To avoid cycle theft, the API may be aclled by a controller only. 9 | #[update()] 10 | async fn call_with_attached_cycles( 11 | call_params: (Principal, String, u64), 12 | ) -> Result { 13 | assert!( 14 | is_controller(&ic_cdk::caller()), 15 | "The caller must be a controller." 16 | ); 17 | let (canister_id, method, cycles) = call_params; 18 | let arg = (); 19 | let (ans,): (Result,) = 20 | call_with_payment128(canister_id, &method, arg, u128::from(cycles)) 21 | .await 22 | .unwrap(); 23 | ans 24 | } 25 | 26 | export_candid!(); 27 | -------------------------------------------------------------------------------- /src/example/paid_service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "example_paid_service" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | candid = { workspace = true } 11 | example-paid-service-api = { workspace = true } 12 | ic-cdk = { workspace = true } 13 | ic-cdk-macros = { workspace = true } 14 | ic-papi-api = { workspace = true } 15 | ic-papi-guard = { workspace = true } 16 | ic-stable-structures = { workspace = true } 17 | lazy_static = { workspace = true } 18 | serde = { workspace = true } 19 | serde_bytes = { workspace = true } 20 | 21 | [dev-dependencies] 22 | ic-cycles-ledger-client = { workspace = true } 23 | pocket-ic = { workspace = true } 24 | -------------------------------------------------------------------------------- /src/example/paid_service/example-paid-service.did: -------------------------------------------------------------------------------- 1 | type Account = record { owner : principal; subaccount : opt blob }; 2 | type CallerPaysIcrc2Tokens = record { ledger : principal }; 3 | type InitArgs = record { ledger : principal }; 4 | type PatronPaysIcrc2Tokens = record { ledger : principal; patron : Account }; 5 | type PaymentError = variant { 6 | LedgerWithdrawFromError : record { 7 | error : WithdrawFromError; 8 | ledger : principal; 9 | }; 10 | LedgerUnreachable : CallerPaysIcrc2Tokens; 11 | InvalidPatron; 12 | LedgerTransferFromError : record { 13 | error : TransferFromError; 14 | ledger : principal; 15 | }; 16 | UnsupportedPaymentType; 17 | InsufficientFunds : record { needed : nat64; available : nat64 }; 18 | }; 19 | type PaymentType = variant { 20 | PatronPaysIcrc2Tokens : PatronPaysIcrc2Tokens; 21 | AttachedCycles; 22 | CallerPaysIcrc2Cycles; 23 | CallerPaysIcrc2Tokens : CallerPaysIcrc2Tokens; 24 | PatronPaysIcrc2Cycles : Account; 25 | }; 26 | type RejectionCode = variant { 27 | NoError; 28 | CanisterError; 29 | SysTransient; 30 | DestinationInvalid; 31 | Unknown; 32 | SysFatal; 33 | CanisterReject; 34 | }; 35 | type Result = variant { Ok : text; Err : PaymentError }; 36 | type TransferFromError = variant { 37 | GenericError : record { message : text; error_code : nat }; 38 | TemporarilyUnavailable; 39 | InsufficientAllowance : record { allowance : nat }; 40 | BadBurn : record { min_burn_amount : nat }; 41 | Duplicate : record { duplicate_of : nat }; 42 | BadFee : record { expected_fee : nat }; 43 | CreatedInFuture : record { ledger_time : nat64 }; 44 | TooOld; 45 | InsufficientFunds : record { balance : nat }; 46 | }; 47 | type WithdrawFromError = variant { 48 | GenericError : record { message : text; error_code : nat }; 49 | TemporarilyUnavailable; 50 | InsufficientAllowance : record { allowance : nat }; 51 | Duplicate : record { duplicate_of : nat }; 52 | InvalidReceiver : record { receiver : principal }; 53 | CreatedInFuture : record { ledger_time : nat64 }; 54 | TooOld; 55 | FailedToWithdrawFrom : record { 56 | withdraw_from_block : opt nat; 57 | rejection_code : RejectionCode; 58 | refund_block : opt nat; 59 | approval_refund_block : opt nat; 60 | rejection_reason : text; 61 | }; 62 | InsufficientFunds : record { balance : nat }; 63 | }; 64 | service : (opt InitArgs) -> { 65 | caller_pays_1b_icrc2_cycles : () -> (Result); 66 | caller_pays_1b_icrc2_tokens : () -> (Result); 67 | cost_1000_attached_cycles : () -> (Result); 68 | cost_1b : (PaymentType) -> (Result); 69 | free : () -> (text); 70 | } 71 | -------------------------------------------------------------------------------- /src/example/paid_service/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod state; 2 | 3 | use example_paid_service_api::InitArgs; 4 | use ic_cdk::init; 5 | use ic_cdk_macros::{export_candid, update}; 6 | use ic_papi_api::cycles::cycles_ledger_canister_id; 7 | use ic_papi_api::{PaymentError, PaymentType}; 8 | use ic_papi_guard::guards::PaymentGuardTrait; 9 | use ic_papi_guard::guards::{ 10 | attached_cycles::AttachedCyclesPayment, 11 | caller_pays_icrc2_cycles::CallerPaysIcrc2CyclesPaymentGuard, 12 | caller_pays_icrc2_tokens::CallerPaysIcrc2TokensPaymentGuard, 13 | }; 14 | use state::{set_init_args, PAYMENT_GUARD}; 15 | 16 | #[init] 17 | fn init(init_args: Option) { 18 | if let Some(init_args) = init_args { 19 | set_init_args(init_args); 20 | } 21 | } 22 | 23 | #[update()] 24 | fn free() -> String { 25 | "Yes, I am free!".to_string() 26 | } 27 | 28 | /// An API method that requires cycles to be attached directly to the call. 29 | #[update()] 30 | async fn cost_1000_attached_cycles() -> Result { 31 | AttachedCyclesPayment::default().deduct(1000).await?; 32 | Ok("Yes, you paid 1000 cycles!".to_string()) 33 | } 34 | 35 | /// An API method that requires 1 billion cycles using an ICRC-2 approve with default parameters. 36 | #[update()] 37 | async fn caller_pays_1b_icrc2_cycles() -> Result { 38 | CallerPaysIcrc2CyclesPaymentGuard::default() 39 | .deduct(1_000_000_000) 40 | .await?; 41 | Ok("Yes, you paid 1 billion cycles!".to_string()) 42 | } 43 | 44 | /// An API method that requires 1 billion tokens (in this case cycles) using an ICRC-2 approve with default parameters. 45 | /// 46 | /// The tokens will be transferred to the vendor's main account on the ledger. 47 | #[update()] 48 | async fn caller_pays_1b_icrc2_tokens() -> Result { 49 | CallerPaysIcrc2TokensPaymentGuard { 50 | ledger: cycles_ledger_canister_id(), 51 | } 52 | .deduct(1_000_000_000) 53 | .await?; 54 | Ok("Yes, you paid 1 billion tokens!".to_string()) 55 | } 56 | 57 | /// An API method that requires 1 billion cycles, paid in whatever way the client chooses. 58 | #[update()] 59 | async fn cost_1b(payment: PaymentType) -> Result { 60 | let fee = 1_000_000_000; 61 | PAYMENT_GUARD.deduct(payment, fee).await?; 62 | Ok("Yes, you paid 1 billion cycles!".to_string()) 63 | } 64 | 65 | export_candid!(); 66 | -------------------------------------------------------------------------------- /src/example/paid_service/src/state.rs: -------------------------------------------------------------------------------- 1 | use candid::Principal; 2 | use example_paid_service_api::InitArgs; 3 | use ic_papi_guard::guards::any::{PaymentGuard, VendorPaymentConfig}; 4 | use lazy_static::lazy_static; 5 | use std::cell::RefCell; 6 | 7 | thread_local! { 8 | pub static INIT_ARGS: RefCell> = const {RefCell::new(None)}; 9 | } 10 | lazy_static! { 11 | pub static ref PAYMENT_GUARD: PaymentGuard<5> = PaymentGuard { 12 | supported: [ 13 | VendorPaymentConfig::AttachedCycles, 14 | VendorPaymentConfig::CallerPaysIcrc2Cycles, 15 | VendorPaymentConfig::PatronPaysIcrc2Cycles, 16 | VendorPaymentConfig::CallerPaysIcrc2Tokens { 17 | ledger: payment_ledger(), 18 | }, 19 | VendorPaymentConfig::PatronPaysIcrc2Tokens { 20 | ledger: payment_ledger(), 21 | }, 22 | ], 23 | }; 24 | } 25 | 26 | pub fn init_element(f: F) -> T 27 | where 28 | F: FnOnce(&InitArgs) -> T, 29 | { 30 | INIT_ARGS.with(|init_args| f(init_args.borrow().as_ref().expect("No init args provided"))) 31 | } 32 | 33 | /// Provides the canister id of the ledger used for payments. 34 | pub fn payment_ledger() -> Principal { 35 | init_element(|init_args| init_args.ledger) 36 | } 37 | 38 | /// Sets the payment ledger canister ID. 39 | pub fn set_init_args(init_args: InitArgs) { 40 | INIT_ARGS.set(Some(init_args)); 41 | } 42 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/attached_cycles.rs: -------------------------------------------------------------------------------- 1 | use crate::util::pic_canister::{PicCanister, PicCanisterTrait}; 2 | use candid::Principal; 3 | use ic_papi_api::PaymentError; 4 | use pocket_ic::PocketIc; 5 | use std::sync::Arc; 6 | 7 | pub struct AttachedCyclesTestSetup { 8 | /// The PocketIC instance. 9 | #[allow(dead_code)] 10 | // The Arc is used; this makes it accessible without having to refer to a specific canister. 11 | pic: Arc, 12 | /// The canister providing the API. 13 | api_canister: PicCanister, 14 | /// The canister consuming the API. 15 | customer_canister: PicCanister, 16 | } 17 | impl Default for AttachedCyclesTestSetup { 18 | fn default() -> Self { 19 | let pic = Arc::new(PocketIc::new()); 20 | let api_canister = PicCanister::new( 21 | pic.clone(), 22 | &PicCanister::cargo_wasm_path("example_paid_service"), 23 | ); 24 | let customer_canister = PicCanister::new( 25 | pic.clone(), 26 | &PicCanister::cargo_wasm_path("example_app_backend"), 27 | ); 28 | Self { 29 | pic, 30 | api_canister, 31 | customer_canister, 32 | } 33 | } 34 | } 35 | 36 | #[test] 37 | fn test_setup_works() { 38 | let _setup = AttachedCyclesTestSetup::default(); 39 | } 40 | 41 | #[test] 42 | fn inter_canister_call_succeeds_with_sufficient_cycles_only() { 43 | let setup = AttachedCyclesTestSetup::default(); 44 | for cycles in 995u64..1005 { 45 | let args = ( 46 | setup.api_canister.canister_id(), 47 | "cost_1000_attached_cycles".to_string(), 48 | cycles, 49 | ); 50 | let result: Result, String> = setup.customer_canister.update( 51 | Principal::anonymous(), 52 | "call_with_attached_cycles", 53 | args, 54 | ); 55 | let result = result.expect("Failed to reach paid API"); 56 | if cycles < 1000 { 57 | assert_eq!( 58 | result, 59 | Err(PaymentError::InsufficientFunds { 60 | needed: 1000, 61 | available: cycles 62 | }), 63 | "Should have failed with only {} cycles attached", 64 | cycles 65 | ); 66 | } else { 67 | assert_eq!( 68 | result, 69 | Ok("Yes, you paid 1000 cycles!".to_string()), 70 | "Should have succeeded with {} cycles attached", 71 | cycles 72 | ); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/caller_pays_icrc2_cycles.rs: -------------------------------------------------------------------------------- 1 | //! Tests for the `PaymentType::CallerPaysIcrc2Cycles` payment type. 2 | use crate::util::pic_canister::PicCanisterTrait; 3 | use crate::util::test_environment::{PaidMethods, TestSetup, LEDGER_FEE}; 4 | use candid::Nat; 5 | use ic_papi_api::{PaymentError, PaymentType}; 6 | 7 | /// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected 8 | /// on an API method that has only the corresponding guard. 9 | /// 10 | /// Notes: 11 | /// - The caller does not need to specify any payment arguments. (See `call_paid_service(...)` in the test.) 12 | /// - The caller needs to pay the API cost plus one ledger fee, for the privilege of using this payment type. (See `user_approves_payment_for_paid_service(...)` in the test.) 13 | #[test] 14 | fn caller_pays_cycles_by_icrc2() { 15 | let setup = TestSetup::default(); 16 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 17 | // Ok, now we should be able to make an API call with an ICRC-2 approve. 18 | let method = PaidMethods::Cost1bIcrc2Cycles; 19 | // Pre-approve payment 20 | setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); 21 | // Check that the user has been charged for the approve. 22 | expected_user_balance -= LEDGER_FEE; 23 | setup.assert_user_balance_eq( 24 | expected_user_balance, 25 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 26 | ); 27 | // Now make an API call 28 | // Check the canister cycles balance beforehand 29 | let service_canister_cycles_before = setup.pic.cycle_balance(setup.paid_service.canister_id); 30 | // Call the API 31 | let response: Result = setup.call_paid_service(setup.user, method, ()); 32 | assert_eq!( 33 | response, 34 | Ok("Yes, you paid 1 billion cycles!".to_string()), 35 | "Should have succeeded with an accurate prepayment", 36 | ); 37 | let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); 38 | assert!( 39 | service_canister_cycles_after > service_canister_cycles_before, 40 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 41 | service_canister_cycles_before - service_canister_cycles_after 42 | ); 43 | expected_user_balance -= method.cost() + LEDGER_FEE; 44 | setup.assert_user_balance_eq( 45 | expected_user_balance, 46 | "Expected the user balance to be the initial balance minus the ledger and API fees" 47 | .to_string(), 48 | ); 49 | } 50 | 51 | /// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected 52 | /// on an API method that requires the payment argument to be declared explicitly. 53 | #[test] 54 | fn caller_pays_cycles_by_named_icrc2() { 55 | let setup = TestSetup::default(); 56 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 57 | // Ok, now we should be able to make an API call with an ICRC-2 approve. 58 | let method = PaidMethods::Cost1b; 59 | // Pre-approve payment 60 | setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); 61 | // Check that the user has been charged for the approve. 62 | expected_user_balance -= LEDGER_FEE; 63 | setup.assert_user_balance_eq( 64 | expected_user_balance, 65 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 66 | ); 67 | // Now make an API call 68 | // Check the canister cycles balance beforehand 69 | let service_canister_cycles_before = setup.pic.cycle_balance(setup.paid_service.canister_id); 70 | // Call the API 71 | let response: Result = 72 | setup.call_paid_service(setup.user, method, PaymentType::CallerPaysIcrc2Cycles); 73 | assert_eq!( 74 | response, 75 | Ok("Yes, you paid 1 billion cycles!".to_string()), 76 | "Should have succeeded with an accurate prepayment", 77 | ); 78 | let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); 79 | assert!( 80 | service_canister_cycles_after > service_canister_cycles_before, 81 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 82 | service_canister_cycles_before - service_canister_cycles_after 83 | ); 84 | expected_user_balance -= method.cost() + LEDGER_FEE; 85 | setup.assert_user_balance_eq( 86 | expected_user_balance, 87 | "Expected the user balance to be the initial balance minus the ledger and API fees" 88 | .to_string(), 89 | ); 90 | } 91 | 92 | /// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected with a range of approval amounts near the required amount. 93 | /// 94 | /// - The call should succeed if the ICRC2 approval is greater than or equal to the cost of the method. 95 | /// - The user's main cycles account has cycles deducted when a call succeeds. 96 | /// - The cycle balance of the canister providing the paid service increases when a call succeeds. 97 | /// - Note: Given that the canister consumes cycles as part of the operation, we check that the balance increases but do not check an exact amount. 98 | #[test] 99 | fn caller_pays_icrc2_cycles_works_with_large_enough_approval() { 100 | let setup = TestSetup::default(); 101 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 102 | 103 | // Try calling a method with a range of approval amounts. The call should succeed if the 104 | // ICRC2 approval is greater than or equal to the cost of the method. 105 | let method = PaidMethods::Cost1bIcrc2Cycles; 106 | for payment in (method.cost() - 5)..(method.cost() + 5) { 107 | for _repetition in 0..2 { 108 | // Pre-approve payment 109 | setup.user_approves_payment_for_paid_service(payment + LEDGER_FEE); 110 | // Check that the user has been charged for the approve. 111 | expected_user_balance -= LEDGER_FEE; 112 | setup.assert_user_balance_eq( 113 | expected_user_balance, 114 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 115 | ); 116 | 117 | // Check the balance beforehand 118 | let service_canister_cycles_before = 119 | setup.pic.cycle_balance(setup.paid_service.canister_id); 120 | // Call the API 121 | let response: Result = 122 | setup.call_paid_service(setup.user, method, ()); 123 | if payment < method.cost() { 124 | assert_eq!( 125 | response, 126 | Err(PaymentError::LedgerWithdrawFromError { 127 | ledger: setup.ledger.canister_id(), 128 | error: ic_cycles_ledger_client::WithdrawFromError::InsufficientAllowance { 129 | allowance: Nat::from(payment + LEDGER_FEE), 130 | } 131 | }), 132 | "Should have failed with only {} cycles attached", 133 | payment 134 | ); 135 | setup.assert_user_balance_eq( 136 | expected_user_balance, 137 | "Expected the user balance to be unchanged by a failed ICRC2".to_string(), 138 | ); 139 | } else { 140 | assert_eq!( 141 | response, 142 | Ok("Yes, you paid 1 billion cycles!".to_string()), 143 | "Should have succeeded with {} cycles attached", 144 | payment 145 | ); 146 | let service_canister_cycles_after = 147 | setup.pic.cycle_balance(setup.paid_service.canister_id); 148 | assert!( 149 | service_canister_cycles_after > service_canister_cycles_before, 150 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 151 | service_canister_cycles_before - service_canister_cycles_after 152 | ); 153 | expected_user_balance -= method.cost() + LEDGER_FEE; 154 | setup.assert_user_balance_eq( 155 | expected_user_balance, 156 | "Expected the user balance to be the initial balance minus the ledger and API fees" 157 | .to_string(), 158 | ); 159 | } 160 | } 161 | } 162 | } 163 | 164 | /// Verifies that a user can pay for multiple API calls with a single approval. 165 | #[test] 166 | fn caller_pays_icrc2_cycles_supports_multiple_calls_with_a_single_approval() { 167 | let setup = TestSetup::default(); 168 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 169 | 170 | // Exercise the protocol... 171 | // Pre-approve a large sum. 172 | setup.user_approves_payment_for_paid_service(expected_user_balance); 173 | // Check that the user has been charged for the approve. 174 | expected_user_balance -= LEDGER_FEE; 175 | setup.assert_user_balance_eq( 176 | expected_user_balance, 177 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 178 | ); 179 | // Now make several API calls 180 | let method = PaidMethods::Cost1bIcrc2Cycles; 181 | for _repetition in 0..5 { 182 | // Check the balance beforehand 183 | let service_canister_cycles_before = 184 | setup.pic.cycle_balance(setup.paid_service.canister_id); 185 | // Call the API 186 | let response: Result = 187 | setup.call_paid_service(setup.user, method, ()); 188 | assert_eq!( 189 | response, 190 | Ok("Yes, you paid 1 billion cycles!".to_string()), 191 | "Should have succeeded with a generous prepayment", 192 | ); 193 | let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); 194 | assert!( 195 | service_canister_cycles_after > service_canister_cycles_before, 196 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 197 | service_canister_cycles_before - service_canister_cycles_after 198 | ); 199 | expected_user_balance -= method.cost() + LEDGER_FEE; 200 | setup.assert_user_balance_eq( 201 | expected_user_balance, 202 | "Expected the user balance to be the initial balance minus the ledger and API fees" 203 | .to_string(), 204 | ); 205 | } 206 | } 207 | 208 | /// Verifies that a user cannot pay without an ICRC2 approval. 209 | #[test] 210 | fn caller_needs_to_approve() { 211 | let setup = TestSetup::default(); 212 | // Ok, now we should be able to make an API call with an ICRC-2 approve. 213 | let method = PaidMethods::Cost1b; 214 | // Call the API 215 | let response: Result = 216 | setup.call_paid_service(setup.user, method, PaymentType::CallerPaysIcrc2Cycles); 217 | assert_eq!( 218 | response, 219 | Err(PaymentError::LedgerWithdrawFromError { 220 | ledger: setup.ledger.canister_id(), 221 | error: ic_cycles_ledger_client::WithdrawFromError::InsufficientAllowance { 222 | allowance: Nat::default(), 223 | } 224 | }), 225 | "Should have failed without an ICRC2 approve" 226 | ); 227 | } 228 | 229 | /// Verifies that an authorized ICRC2 approval cannot be used by another caller. 230 | #[test] 231 | fn payment_cannot_be_used_by_another_caller() { 232 | let setup = TestSetup::default(); 233 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 234 | // Ok, now we should be able to make an API call with an ICRC-2 approve. 235 | let method = PaidMethods::Cost1b; 236 | // Pre-approve payment 237 | setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); 238 | // Check that the user has been charged for the approve. 239 | expected_user_balance -= LEDGER_FEE; 240 | setup.assert_user_balance_eq( 241 | expected_user_balance, 242 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 243 | ); 244 | 245 | // Attempt by another user to make an API call 246 | { 247 | let response: Result = setup.call_paid_service( 248 | setup.unauthorized_user, 249 | method, 250 | PaymentType::CallerPaysIcrc2Cycles, 251 | ); 252 | assert_eq!( 253 | response, 254 | Err(PaymentError::LedgerWithdrawFromError { 255 | ledger: setup.ledger.canister_id(), 256 | error: ic_cycles_ledger_client::WithdrawFromError::InsufficientAllowance { 257 | allowance: Nat::default(), 258 | } 259 | }), 260 | "User should not be able to use another user's ICRC2 approval" 261 | ); 262 | } 263 | 264 | // Now make an API call 265 | // Check the canister cycles balance beforehand 266 | let service_canister_cycles_before = setup.pic.cycle_balance(setup.paid_service.canister_id); 267 | // Call the API 268 | let response: Result = 269 | setup.call_paid_service(setup.user, method, PaymentType::CallerPaysIcrc2Cycles); 270 | assert_eq!( 271 | response, 272 | Ok("Yes, you paid 1 billion cycles!".to_string()), 273 | "Should have succeeded with an accurate prepayment", 274 | ); 275 | let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); 276 | assert!( 277 | service_canister_cycles_after > service_canister_cycles_before, 278 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 279 | service_canister_cycles_before - service_canister_cycles_after 280 | ); 281 | expected_user_balance -= method.cost() + LEDGER_FEE; 282 | setup.assert_user_balance_eq( 283 | expected_user_balance, 284 | "Expected the user balance to be the initial balance minus the ledger and API fees" 285 | .to_string(), 286 | ); 287 | } 288 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/caller_pays_icrc2_tokens.rs: -------------------------------------------------------------------------------- 1 | //! Tests for the `PaymentType::CallerPaysIcrc2Tokens` payment type. 2 | use crate::util::cycles_ledger::Account; 3 | use crate::util::pic_canister::PicCanisterTrait; 4 | use crate::util::test_environment::{PaidMethods, TestSetup, LEDGER_FEE}; 5 | use candid::Nat; 6 | use ic_papi_api::caller::CallerPaysIcrc2Tokens; 7 | use ic_papi_api::cycles::cycles_ledger_canister_id; 8 | use ic_papi_api::{PaymentError, PaymentType}; 9 | 10 | /// Verifies that the `PaymentType::CallerPaysIcrc2Cycles` payment type works as expected 11 | /// on an API method that has only the corresponding guard. 12 | /// 13 | /// Notes: 14 | /// - The caller does not need to specify any payment arguments. (See `call_paid_service(...)` in the test.) 15 | /// - The caller needs to pay the API cost plus one ledger fee, for the privilege of using this payment type. (See `user_approves_payment_for_paid_service(...)` in the test.) 16 | /// - The ledger fee may vary depending on the ledger. The customer will need to make an allowance for the fee, either by finding out the exact fee or making an allowance with the maximum the caller is prepared to pay. 17 | /// - This test use the cycles ledger as an ICRC-compliant ledger. 18 | /// - TODO: Test with other ICRC-2 ledgers as well. 19 | #[test] 20 | fn caller_pays_icrc2_tokens() { 21 | let setup = TestSetup::default(); 22 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 23 | // Ok, now we should be able to make an API call with an ICRC-2 approve. 24 | let method = PaidMethods::CallerPays1bIcrc2Tokens; 25 | // Pre-approve payment 26 | setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); 27 | // Check that the user has been charged for the approve. 28 | expected_user_balance -= LEDGER_FEE; 29 | setup.assert_user_balance_eq( 30 | expected_user_balance, 31 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 32 | ); 33 | // Now make an API call 34 | // Call the API 35 | let response: Result = setup.call_paid_service(setup.user, method, ()); 36 | assert_eq!( 37 | response, 38 | Ok("Yes, you paid 1 billion tokens!".to_string()), 39 | "Should have succeeded with an accurate prepayment", 40 | ); 41 | expected_user_balance -= method.cost() + LEDGER_FEE; 42 | setup.assert_user_balance_eq( 43 | expected_user_balance, 44 | "Expected the user balance to be the initial balance minus the ledger and API fees" 45 | .to_string(), 46 | ); 47 | 48 | // The service ledger account should have been credited 49 | { 50 | let service_balance = setup 51 | .ledger 52 | .icrc_1_balance_of( 53 | setup.paid_service.canister_id(), 54 | &Account { 55 | owner: setup.paid_service.canister_id(), 56 | subaccount: None, 57 | }, 58 | ) 59 | .expect("Could not get service balance"); 60 | assert_eq!( 61 | service_balance, 62 | Nat::from(method.cost()), 63 | "Expected the service balance to be the cost of the API call" 64 | ); 65 | } 66 | } 67 | 68 | /// Verifies that the caller can pay for an API call with ICRC-2 tokens explicitly. 69 | #[test] 70 | fn caller_pays_icrc2_tokens_explicitly() { 71 | let setup = TestSetup::default(); 72 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 73 | // Ok, now we should be able to make an API call with an ICRC-2 approve. 74 | let method = PaidMethods::Cost1b; 75 | // Pre-approve payment 76 | setup.user_approves_payment_for_paid_service(method.cost() + LEDGER_FEE); 77 | // Check that the user has been charged for the approve. 78 | expected_user_balance -= LEDGER_FEE; 79 | setup.assert_user_balance_eq( 80 | expected_user_balance, 81 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 82 | ); 83 | // Now make an API call 84 | { 85 | let response: Result = setup.call_paid_service( 86 | setup.user, 87 | method, 88 | PaymentType::CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens { 89 | ledger: cycles_ledger_canister_id(), 90 | }), 91 | ); 92 | assert_eq!( 93 | response, 94 | Ok("Yes, you paid 1 billion cycles!".to_string()), 95 | "Should have succeeded with an accurate prepayment", 96 | ); 97 | } 98 | // Verifies that the user account has been debited. 99 | { 100 | expected_user_balance -= method.cost() + LEDGER_FEE; 101 | setup.assert_user_balance_eq( 102 | expected_user_balance, 103 | "Expected the user balance to be the initial balance minus the ledger and API fees" 104 | .to_string(), 105 | ); 106 | } 107 | 108 | // Verifies that the canister ledger account has been credited with the payment. 109 | { 110 | let service_balance = setup 111 | .ledger 112 | .icrc_1_balance_of( 113 | setup.paid_service.canister_id(), 114 | &Account { 115 | owner: setup.paid_service.canister_id(), 116 | subaccount: None, 117 | }, 118 | ) 119 | .expect("Could not get service balance"); 120 | assert_eq!( 121 | service_balance, 122 | Nat::from(method.cost()), 123 | "Expected the service balance to be the cost of the API call" 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/main.rs: -------------------------------------------------------------------------------- 1 | mod attached_cycles; 2 | mod caller_pays_icrc2_cycles; 3 | mod caller_pays_icrc2_tokens; 4 | mod patron_pays_icrc2_cycles; 5 | mod patron_pays_icrc2_tokens; 6 | mod util; 7 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/patron_pays_icrc2_cycles.rs: -------------------------------------------------------------------------------- 1 | //! Tests for the `PaymentType::PatronPaysIcrc2Cycles` payment type. 2 | use crate::util::cycles_ledger::{Account, ApproveArgs}; 3 | use crate::util::pic_canister::PicCanisterTrait; 4 | use crate::util::test_environment::{PaidMethods, TestSetup, LEDGER_FEE}; 5 | use candid::Nat; 6 | use ic_papi_api::{principal2account, PaymentError, PaymentType}; 7 | 8 | /// Verifies that `user` can pay cycles for `user2`: 9 | /// 10 | /// - The patron needs to approve the API cost plus the ledger fee. 11 | /// - An unauthorized user should not be able to use that approval. 12 | /// - `user2` should be able to make the API call. 13 | #[test] 14 | fn user_pays_tokens_for_user2() { 15 | let setup = TestSetup::default(); 16 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 17 | 18 | // Here the user pays for user2. 19 | let patron = setup.user; 20 | let caller = setup.user2; 21 | let method = PaidMethods::Cost1b; 22 | let payment_arg = PaymentType::PatronPaysIcrc2Cycles(ic_papi_api::Account { 23 | owner: setup.user, 24 | subaccount: None, 25 | }); 26 | 27 | // Pre-approve payments 28 | { 29 | setup 30 | .ledger 31 | .icrc_2_approve( 32 | patron, 33 | &ApproveArgs { 34 | spender: Account { 35 | owner: setup.paid_service.canister_id(), 36 | subaccount: Some(principal2account(&caller)), 37 | }, 38 | amount: Nat::from(method.cost() + LEDGER_FEE), 39 | ..ApproveArgs::default() 40 | }, 41 | ) 42 | .expect("Failed to call the ledger to approve") 43 | .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); 44 | // Check that the user has been charged for the approve. 45 | expected_user_balance -= LEDGER_FEE; 46 | setup.assert_user_balance_eq( 47 | expected_user_balance, 48 | "Expected the user==patron balance to be charged for the ICRC2 approve".to_string(), 49 | ); 50 | } 51 | // An unauthorized user should not be able to make a call. 52 | { 53 | let response: Result = 54 | setup.call_paid_service(setup.unauthorized_user, method, &payment_arg); 55 | assert_eq!( 56 | response, 57 | Err(PaymentError::LedgerWithdrawFromError { 58 | ledger: setup.ledger.canister_id(), 59 | error: ic_cycles_ledger_client::WithdrawFromError::InsufficientAllowance { 60 | allowance: Nat::from(0u32), 61 | } 62 | }), 63 | "Unapproved users should not be able to make calls", 64 | ); 65 | setup.assert_user_balance_eq( 66 | expected_user_balance, 67 | "The user==patron should not have been charged for unauthorized spending attempts" 68 | .to_string(), 69 | ); 70 | } 71 | // user2 should be able to make the call. 72 | { 73 | // Check the canister cycle balance beforehand 74 | let service_canister_cycles_before = 75 | setup.pic.cycle_balance(setup.paid_service.canister_id); 76 | // Call the API 77 | let response: Result = 78 | setup.call_paid_service(caller, method, &payment_arg); 79 | assert_eq!( 80 | response, 81 | Ok("Yes, you paid 1 billion cycles!".to_string()), 82 | "Should have succeeded for caller {} with patron {}.", 83 | caller.to_string(), 84 | patron.to_string(), 85 | ); 86 | let service_canister_cycles_after = setup.pic.cycle_balance(setup.paid_service.canister_id); 87 | assert!( 88 | service_canister_cycles_after > service_canister_cycles_before, 89 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 90 | service_canister_cycles_before - service_canister_cycles_after 91 | ); 92 | expected_user_balance -= method.cost() + LEDGER_FEE; 93 | setup.assert_user_balance_eq( 94 | expected_user_balance, 95 | "Expected the user==patron balance to be the initial balance minus the ledger and API fees" 96 | .to_string(), 97 | ); 98 | } 99 | } 100 | 101 | /// Verifies that the `PaymentType::PatronPaysIcrc2Tokens` payment type works as expected. 102 | /// 103 | /// Here `user` is a patron, and pays on behalf of `users[2..5]`. 104 | /// 105 | /// Only funded users should be able to make calls, and they should be able to make only as many calls as personally approved for them. 106 | #[test] 107 | fn user_pays_cycles_for_other_users() { 108 | let setup = TestSetup::default(); 109 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 110 | 111 | // Ok, now we should be able to make an API call with EITHER an ICRC-2 approve or attached cycles, by declaring the payment type. 112 | // In this test, we will exercise the ICRC-2 approve. 113 | let method = PaidMethods::Cost1b; 114 | let payment_arg = PaymentType::PatronPaysIcrc2Cycles(ic_papi_api::Account { 115 | owner: setup.user, 116 | subaccount: None, 117 | }); 118 | let repetitions = 3; 119 | // Pre-approve payments 120 | for caller in setup.users.iter() { 121 | setup 122 | .ledger 123 | .icrc_2_approve( 124 | setup.user, 125 | &ApproveArgs { 126 | spender: Account { 127 | owner: setup.paid_service.canister_id(), 128 | subaccount: Some(principal2account(caller)), 129 | }, 130 | amount: Nat::from((method.cost() + LEDGER_FEE) * repetitions), 131 | ..ApproveArgs::default() 132 | }, 133 | ) 134 | .expect("Failed to call the ledger to approve") 135 | .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); 136 | // Check that the user has been charged for the approve. 137 | expected_user_balance -= LEDGER_FEE; 138 | setup.assert_user_balance_eq( 139 | expected_user_balance, 140 | "Expected the user balance to be charged for the ICRC2 approve".to_string(), 141 | ); 142 | } 143 | // An unauthorized user should not be able to make a call. 144 | { 145 | let response: Result = setup 146 | .paid_service 147 | .update(setup.unauthorized_user, method.name(), &payment_arg) 148 | .expect("Failed to call the paid service"); 149 | assert_eq!( 150 | response, 151 | Err(PaymentError::LedgerWithdrawFromError { 152 | ledger: setup.ledger.canister_id(), 153 | error: ic_cycles_ledger_client::WithdrawFromError::InsufficientAllowance { 154 | allowance: Nat::from(0u32), 155 | } 156 | }), 157 | "Unapproved users should not be able to make calls", 158 | ); 159 | setup.assert_user_balance_eq( 160 | expected_user_balance, 161 | "The user should not have been charged for unauthorized spending attempts".to_string(), 162 | ); 163 | } 164 | // Approved users should be able to make several API calls, up to the budget. 165 | let active_users = &setup.users[2..5]; 166 | for repetition in 0..repetitions { 167 | // Check the balance beforehand 168 | let service_canister_cycles_before = 169 | setup.pic.cycle_balance(setup.paid_service.canister_id); 170 | // Call the API 171 | for caller in active_users.iter() { 172 | let response: Result = setup 173 | .paid_service 174 | .update(*caller, method.name(), &payment_arg) 175 | .expect("Failed to call the paid service"); 176 | assert_eq!( 177 | response, 178 | Ok("Yes, you paid 1 billion cycles!".to_string()), 179 | "Should have succeeded for user {} on repetition {repetition}", 180 | caller.to_string(), 181 | ); 182 | let service_canister_cycles_after = 183 | setup.pic.cycle_balance(setup.paid_service.canister_id); 184 | assert!( 185 | service_canister_cycles_after > service_canister_cycles_before, 186 | "The service canister needs to charge more to cover its cycle cost! Loss: {}", 187 | service_canister_cycles_before - service_canister_cycles_after 188 | ); 189 | expected_user_balance -= method.cost() + LEDGER_FEE; 190 | setup.assert_user_balance_eq( 191 | expected_user_balance, 192 | "Expected the user balance to be the initial balance minus the ledger and API fees" 193 | .to_string(), 194 | ); 195 | } 196 | } 197 | // Also, additional calls by approved users, beyond the funded amount, should fail, even though there are funds left from inactive users. 198 | for caller in active_users.iter() { 199 | let response: Result = setup 200 | .paid_service 201 | .update(*caller, method.name(), &payment_arg) 202 | .expect("Failed to call the paid service"); 203 | assert_eq!( 204 | response, 205 | Err(PaymentError::LedgerWithdrawFromError { 206 | ledger: setup.ledger.canister_id(), 207 | error: ic_cycles_ledger_client::WithdrawFromError::InsufficientAllowance { 208 | allowance: Nat::from(0u32), 209 | } 210 | }), 211 | "Should not be able to exceed the budget", 212 | ); 213 | setup.assert_user_balance_eq( 214 | expected_user_balance, 215 | "The user should not have been charged for additional spending attempts".to_string(), 216 | ); 217 | } 218 | } 219 | 220 | /// If the caller can set the vendor as patron, the caller may potentially succeed in getting free goods. 221 | #[test] 222 | fn user_cannot_specify_vendor_as_patron() { 223 | let setup = TestSetup::default(); 224 | 225 | // Here the caller will try to specify the vendor as the patron. 226 | let caller = setup.user; 227 | let patron = setup.paid_service.canister_id(); 228 | let method = PaidMethods::Cost1b; 229 | let payment_arg = PaymentType::PatronPaysIcrc2Cycles(ic_papi_api::Account { 230 | owner: patron, 231 | subaccount: None, 232 | }); 233 | // The call should fail: 234 | { 235 | let response: Result = 236 | setup.call_paid_service(caller, method, &payment_arg); 237 | assert_eq!( 238 | response, 239 | Err(PaymentError::InvalidPatron), 240 | "The caller should not be able to specify the vendor as patron.", 241 | ); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/patron_pays_icrc2_tokens.rs: -------------------------------------------------------------------------------- 1 | //! Tests for the `PaymentType::PatronPaysIcrc2Tokens` payment type. 2 | use crate::util::cycles_ledger::{Account, ApproveArgs}; 3 | use crate::util::pic_canister::PicCanisterTrait; 4 | use crate::util::test_environment::{PaidMethods, TestSetup, LEDGER_FEE}; 5 | use candid::Nat; 6 | use ic_papi_api::caller::PatronPaysIcrc2Tokens; 7 | use ic_papi_api::{principal2account, PaymentError, PaymentType}; 8 | 9 | /// Verifies that `user` can pay tokens for `user2`: 10 | /// 11 | /// - The patron needs to approve the API cost plus the ledger fee. 12 | /// - An unauthorized user should not be able to use that approval. 13 | /// - `user2` should be able to make the API call. 14 | #[test] 15 | fn user_pays_tokens_for_user2() { 16 | let setup = TestSetup::default(); 17 | let mut expected_user_balance = TestSetup::USER_INITIAL_BALANCE; 18 | 19 | // Here the user pays for user2. 20 | let patron = setup.user; 21 | let caller = setup.user2; 22 | let method = PaidMethods::Cost1b; 23 | let payment_arg = PaymentType::PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens { 24 | ledger: setup.ledger.canister_id(), 25 | patron: ic_papi_api::Account { 26 | owner: setup.user, 27 | subaccount: None, 28 | }, 29 | }); 30 | 31 | // Authorize payment 32 | { 33 | setup 34 | .ledger 35 | .icrc_2_approve( 36 | patron, 37 | &ApproveArgs { 38 | spender: Account { 39 | owner: setup.paid_service.canister_id(), 40 | subaccount: Some(principal2account(&caller)), 41 | }, 42 | amount: Nat::from(method.cost() + LEDGER_FEE), 43 | ..ApproveArgs::default() 44 | }, 45 | ) 46 | .expect("Failed to call the ledger to approve") 47 | .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); 48 | // Check that the user has been charged for the approve. 49 | expected_user_balance -= LEDGER_FEE; 50 | setup.assert_user_balance_eq( 51 | expected_user_balance, 52 | "Expected the user==patron balance to be charged for the ICRC2 approve".to_string(), 53 | ); 54 | } 55 | // `unauthorized_user` has not paid so should not be able to make a call. 56 | { 57 | let response: Result = 58 | setup.call_paid_service(setup.unauthorized_user, method, &payment_arg); 59 | assert_eq!( 60 | response, 61 | Err(PaymentError::LedgerTransferFromError { 62 | ledger: setup.ledger.canister_id(), 63 | error: ic_cycles_ledger_client::TransferFromError::InsufficientAllowance { 64 | allowance: Nat::from(0u32), 65 | } 66 | }), 67 | "Users sho have not paid should not be able to make calls", 68 | ); 69 | setup.assert_user_balance_eq( 70 | expected_user_balance, 71 | "The user==patron should not have been charged for unauthorized spending attempts" 72 | .to_string(), 73 | ); 74 | } 75 | // `user2` should be able to make the call. 76 | { 77 | // Call the API 78 | { 79 | let response: Result = 80 | setup.call_paid_service(caller, method, &payment_arg); 81 | assert_eq!( 82 | response, 83 | Ok("Yes, you paid 1 billion cycles!".to_string()), 84 | "Should have succeeded for caller {} with patron {}.", 85 | caller.to_string(), 86 | patron.to_string(), 87 | ); 88 | } 89 | // The patron's account should have been debited. 90 | { 91 | expected_user_balance -= method.cost() + LEDGER_FEE; 92 | setup.assert_user_balance_eq( 93 | expected_user_balance, 94 | "Expected the user==patron balance to be the initial balance minus the ledger and API fees" 95 | .to_string(), 96 | ); 97 | } 98 | // The canister's ledger account should have been credited. 99 | { 100 | let service_balance = setup 101 | .ledger 102 | .icrc_1_balance_of( 103 | setup.paid_service.canister_id(), 104 | &Account { 105 | owner: setup.paid_service.canister_id(), 106 | subaccount: None, 107 | }, 108 | ) 109 | .expect("Could not get service balance"); 110 | assert_eq!( 111 | service_balance, 112 | Nat::from(method.cost()), 113 | "Expected the service balance to be the cost of the API call" 114 | ); 115 | } 116 | } 117 | } 118 | 119 | /// If the caller can set the vendor as patron, the caller may potentially succeed in getting free goods. 120 | #[test] 121 | fn user_cannot_specify_vendor_as_patron() { 122 | let setup = TestSetup::default(); 123 | 124 | // Here the caller will try to specify the vendor as the patron. 125 | let caller = setup.user; 126 | let patron = setup.paid_service.canister_id(); 127 | let method = PaidMethods::Cost1b; 128 | let payment_arg = PaymentType::PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens { 129 | ledger: setup.ledger.canister_id(), 130 | patron: ic_papi_api::Account { 131 | owner: patron, 132 | subaccount: None, 133 | }, 134 | }); 135 | // The call should fail: 136 | { 137 | let response: Result = 138 | setup.call_paid_service(caller, method, &payment_arg); 139 | assert_eq!( 140 | response, 141 | Err(PaymentError::InvalidPatron), 142 | "The caller should not be able to specify the vendor as patron.", 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/pic_util.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/papi/d2b80cb235b30951b56588045dcfbf8eae2b226c/src/example/paid_service/tests/it/pic_util.rs -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/util/cycles_depositor.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code, unused_imports)] 2 | use std::sync::Arc; 3 | 4 | use candid::{self, CandidType, Deserialize, Principal}; 5 | use ic_cdk::api::call::CallResult as Result; 6 | use pocket_ic::PocketIc; 7 | 8 | use super::pic_canister::{PicCanister, PicCanisterTrait}; 9 | 10 | #[derive(CandidType, Deserialize, Debug)] 11 | pub(crate) struct InitArg { 12 | pub(crate) ledger_id: Principal, 13 | } 14 | #[derive(CandidType, Deserialize, Debug)] 15 | pub(crate) struct Account { 16 | pub(crate) owner: Principal, 17 | pub(crate) subaccount: Option, 18 | } 19 | #[derive(CandidType, Deserialize, Debug)] 20 | pub(crate) struct DepositArg { 21 | pub(crate) to: Account, 22 | pub(crate) memo: Option, 23 | pub(crate) cycles: candid::Nat, 24 | } 25 | #[derive(CandidType, Deserialize, Debug)] 26 | pub(crate) struct DepositResult { 27 | pub(crate) balance: candid::Nat, 28 | pub(crate) block_index: candid::Nat, 29 | } 30 | 31 | pub struct Service(pub Principal); 32 | impl Service { 33 | pub async fn deposit(&self, arg0: &DepositArg) -> Result<(DepositResult,)> { 34 | ic_cdk::call(self.0, "deposit", (arg0,)).await 35 | } 36 | } 37 | 38 | pub struct CyclesDepositorPic { 39 | pub pic: Arc, 40 | pub canister_id: Principal, 41 | } 42 | 43 | impl From for CyclesDepositorPic { 44 | fn from(pic: PicCanister) -> Self { 45 | Self { 46 | pic: pic.pic(), 47 | canister_id: pic.canister_id(), 48 | } 49 | } 50 | } 51 | 52 | impl PicCanisterTrait for CyclesDepositorPic { 53 | /// The shared PocketIc instance. 54 | fn pic(&self) -> Arc { 55 | self.pic.clone() 56 | } 57 | /// The ID of this canister. 58 | fn canister_id(&self) -> Principal { 59 | self.canister_id.clone() 60 | } 61 | } 62 | 63 | impl CyclesDepositorPic { 64 | pub fn deposit( 65 | &self, 66 | _caller: Principal, 67 | arg0: &DepositArg, 68 | ) -> std::result::Result { 69 | self.update(self.canister_id, "deposit", arg0) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/util/cycles_ledger.rs: -------------------------------------------------------------------------------- 1 | // This is an experimental feature to generate Rust binding from Candid. 2 | // You may want to manually adjust some of the types. 3 | #![allow(dead_code, unused_imports)] 4 | use std::sync::Arc; 5 | 6 | use candid::{self, decode_one, encode_args, encode_one, CandidType, Deserialize, Principal}; 7 | use ic_cdk::api::call::CallResult; 8 | use pocket_ic::{PocketIc, WasmResult}; 9 | 10 | use super::pic_canister::{PicCanister, PicCanisterTrait}; 11 | 12 | #[derive(CandidType, Deserialize, Debug)] 13 | pub enum ChangeIndexId { 14 | SetTo(Principal), 15 | Unset, 16 | } 17 | #[derive(CandidType, Deserialize, Debug)] 18 | pub struct UpgradeArgs { 19 | pub change_index_id: Option, 20 | pub max_blocks_per_request: Option, 21 | } 22 | #[derive(CandidType, Deserialize, Debug)] 23 | pub struct InitArgs { 24 | pub index_id: Option, 25 | pub max_blocks_per_request: u64, 26 | } 27 | #[derive(CandidType, Deserialize, Debug)] 28 | pub enum LedgerArgs { 29 | Upgrade(Option), 30 | Init(InitArgs), 31 | } 32 | #[derive(CandidType, Deserialize, Debug)] 33 | pub struct SubnetFilter { 34 | pub subnet_type: Option, 35 | } 36 | #[derive(CandidType, Deserialize, Debug)] 37 | pub enum SubnetSelection { 38 | Filter(SubnetFilter), 39 | Subnet { subnet: Principal }, 40 | } 41 | #[derive(CandidType, Deserialize, Debug)] 42 | pub struct CanisterSettings { 43 | pub freezing_threshold: Option, 44 | pub controllers: Option>, 45 | pub reserved_cycles_limit: Option, 46 | pub memory_allocation: Option, 47 | pub compute_allocation: Option, 48 | } 49 | #[derive(CandidType, Deserialize, Debug)] 50 | pub struct CmcCreateCanisterArgs { 51 | pub subnet_selection: Option, 52 | pub settings: Option, 53 | } 54 | #[derive(CandidType, Deserialize, Debug)] 55 | pub struct CreateCanisterArgs { 56 | pub from_subaccount: Option, 57 | pub created_at_time: Option, 58 | pub amount: candid::Nat, 59 | pub creation_args: Option, 60 | } 61 | pub type BlockIndex = candid::Nat; 62 | #[derive(CandidType, Deserialize, Debug)] 63 | pub struct CreateCanisterSuccess { 64 | pub block_id: BlockIndex, 65 | pub canister_id: Principal, 66 | } 67 | #[derive(CandidType, Deserialize, Debug)] 68 | pub enum CreateCanisterError { 69 | GenericError { 70 | message: String, 71 | error_code: candid::Nat, 72 | }, 73 | TemporarilyUnavailable, 74 | Duplicate { 75 | duplicate_of: candid::Nat, 76 | canister_id: Option, 77 | }, 78 | CreatedInFuture { 79 | ledger_time: u64, 80 | }, 81 | FailedToCreate { 82 | error: String, 83 | refund_block: Option, 84 | fee_block: Option, 85 | }, 86 | TooOld, 87 | InsufficientFunds { 88 | balance: candid::Nat, 89 | }, 90 | } 91 | #[derive(CandidType, Deserialize, Debug)] 92 | pub struct Account { 93 | pub owner: Principal, 94 | pub subaccount: Option, 95 | } 96 | #[derive(CandidType, Deserialize, Debug)] 97 | pub struct CreateCanisterFromArgs { 98 | pub spender_subaccount: Option, 99 | pub from: Account, 100 | pub created_at_time: Option, 101 | pub amount: candid::Nat, 102 | pub creation_args: Option, 103 | } 104 | #[derive(CandidType, Deserialize, Debug)] 105 | pub enum RejectionCode { 106 | NoError, 107 | CanisterError, 108 | SysTransient, 109 | DestinationInvalid, 110 | Unknown, 111 | SysFatal, 112 | CanisterReject, 113 | } 114 | #[derive(CandidType, Deserialize, Debug)] 115 | pub enum CreateCanisterFromError { 116 | FailedToCreateFrom { 117 | create_from_block: Option, 118 | rejection_code: RejectionCode, 119 | refund_block: Option, 120 | approval_refund_block: Option, 121 | rejection_reason: String, 122 | }, 123 | GenericError { 124 | message: String, 125 | error_code: candid::Nat, 126 | }, 127 | TemporarilyUnavailable, 128 | InsufficientAllowance { 129 | allowance: candid::Nat, 130 | }, 131 | Duplicate { 132 | duplicate_of: candid::Nat, 133 | canister_id: Option, 134 | }, 135 | CreatedInFuture { 136 | ledger_time: u64, 137 | }, 138 | TooOld, 139 | InsufficientFunds { 140 | balance: candid::Nat, 141 | }, 142 | } 143 | #[derive(CandidType, Deserialize, Debug)] 144 | pub struct DepositArgs { 145 | pub to: Account, 146 | pub memo: Option, 147 | } 148 | #[derive(CandidType, Deserialize, Debug)] 149 | pub struct DepositResult { 150 | pub balance: candid::Nat, 151 | pub block_index: BlockIndex, 152 | } 153 | #[derive(CandidType, Deserialize, Debug)] 154 | pub struct HttpRequest { 155 | pub url: String, 156 | pub method: String, 157 | pub body: serde_bytes::ByteBuf, 158 | pub headers: Vec<(String, String)>, 159 | } 160 | #[derive(CandidType, Deserialize, Debug)] 161 | pub struct HttpResponse { 162 | pub body: serde_bytes::ByteBuf, 163 | pub headers: Vec<(String, String)>, 164 | pub status_code: u16, 165 | } 166 | #[derive(CandidType, Deserialize, Debug)] 167 | pub enum MetadataValue { 168 | Int(candid::Int), 169 | Nat(candid::Nat), 170 | Blob(serde_bytes::ByteBuf), 171 | Text(String), 172 | } 173 | #[derive(CandidType, Deserialize, Debug)] 174 | pub struct SupportedStandard { 175 | pub url: String, 176 | pub name: String, 177 | } 178 | #[derive(CandidType, Deserialize, Debug)] 179 | pub struct TransferArgs { 180 | pub to: Account, 181 | pub fee: Option, 182 | pub memo: Option, 183 | pub from_subaccount: Option, 184 | pub created_at_time: Option, 185 | pub amount: candid::Nat, 186 | } 187 | #[derive(CandidType, Deserialize, Debug)] 188 | pub enum TransferError { 189 | GenericError { 190 | message: String, 191 | error_code: candid::Nat, 192 | }, 193 | TemporarilyUnavailable, 194 | BadBurn { 195 | min_burn_amount: candid::Nat, 196 | }, 197 | Duplicate { 198 | duplicate_of: candid::Nat, 199 | }, 200 | BadFee { 201 | expected_fee: candid::Nat, 202 | }, 203 | CreatedInFuture { 204 | ledger_time: u64, 205 | }, 206 | TooOld, 207 | InsufficientFunds { 208 | balance: candid::Nat, 209 | }, 210 | } 211 | #[derive(CandidType, Deserialize, Debug)] 212 | pub struct AllowanceArgs { 213 | pub account: Account, 214 | pub spender: Account, 215 | } 216 | #[derive(CandidType, Deserialize, Debug)] 217 | pub struct Allowance { 218 | pub allowance: candid::Nat, 219 | pub expires_at: Option, 220 | } 221 | #[derive(CandidType, Deserialize, Debug)] 222 | pub struct ApproveArgs { 223 | pub fee: Option, 224 | pub memo: Option, 225 | pub from_subaccount: Option, 226 | pub created_at_time: Option, 227 | pub amount: candid::Nat, 228 | pub expected_allowance: Option, 229 | pub expires_at: Option, 230 | pub spender: Account, 231 | } 232 | impl Default for ApproveArgs { 233 | fn default() -> Self { 234 | Self { 235 | fee: None, 236 | memo: None, 237 | from_subaccount: None, 238 | created_at_time: None, 239 | amount: candid::Nat::from(0u32), 240 | expected_allowance: None, 241 | expires_at: None, 242 | spender: Account { 243 | owner: Principal::anonymous(), 244 | subaccount: None, 245 | }, 246 | } 247 | } 248 | } 249 | #[derive(CandidType, Deserialize, Debug)] 250 | pub enum ApproveError { 251 | GenericError { 252 | message: String, 253 | error_code: candid::Nat, 254 | }, 255 | TemporarilyUnavailable, 256 | Duplicate { 257 | duplicate_of: candid::Nat, 258 | }, 259 | BadFee { 260 | expected_fee: candid::Nat, 261 | }, 262 | AllowanceChanged { 263 | current_allowance: candid::Nat, 264 | }, 265 | CreatedInFuture { 266 | ledger_time: u64, 267 | }, 268 | TooOld, 269 | Expired { 270 | ledger_time: u64, 271 | }, 272 | InsufficientFunds { 273 | balance: candid::Nat, 274 | }, 275 | } 276 | #[derive(CandidType, Deserialize, Debug)] 277 | pub struct TransferFromArgs { 278 | pub to: Account, 279 | pub fee: Option, 280 | pub spender_subaccount: Option, 281 | pub from: Account, 282 | pub memo: Option, 283 | pub created_at_time: Option, 284 | pub amount: candid::Nat, 285 | } 286 | #[derive(CandidType, Deserialize, Debug)] 287 | pub enum TransferFromError { 288 | GenericError { 289 | message: String, 290 | error_code: candid::Nat, 291 | }, 292 | TemporarilyUnavailable, 293 | InsufficientAllowance { 294 | allowance: candid::Nat, 295 | }, 296 | BadBurn { 297 | min_burn_amount: candid::Nat, 298 | }, 299 | Duplicate { 300 | duplicate_of: candid::Nat, 301 | }, 302 | BadFee { 303 | expected_fee: candid::Nat, 304 | }, 305 | CreatedInFuture { 306 | ledger_time: u64, 307 | }, 308 | TooOld, 309 | InsufficientFunds { 310 | balance: candid::Nat, 311 | }, 312 | } 313 | #[derive(CandidType, Deserialize, Debug)] 314 | pub struct GetArchivesArgs { 315 | pub from: Option, 316 | } 317 | #[derive(CandidType, Deserialize, Debug)] 318 | pub struct GetArchivesResultItem { 319 | pub end: candid::Nat, 320 | pub canister_id: Principal, 321 | pub start: candid::Nat, 322 | } 323 | pub type GetArchivesResult = Vec; 324 | #[derive(CandidType, Deserialize, Debug)] 325 | pub struct GetBlocksArgsItem { 326 | pub start: candid::Nat, 327 | pub length: candid::Nat, 328 | } 329 | pub type GetBlocksArgs = Vec; 330 | #[derive(CandidType, Deserialize, Debug)] 331 | pub enum Value { 332 | Int(candid::Int), 333 | Map(Vec<(String, Box)>), 334 | Nat(candid::Nat), 335 | Nat64(u64), 336 | Blob(serde_bytes::ByteBuf), 337 | Text(String), 338 | Array(Vec>), 339 | } 340 | #[derive(CandidType, Deserialize, Debug)] 341 | pub struct GetBlocksResultBlocksItem { 342 | pub id: candid::Nat, 343 | pub block: Box, 344 | } 345 | candid::define_function!(pub GetBlocksResultArchivedBlocksItemCallback : ( 346 | GetBlocksArgs, 347 | ) -> (GetBlocksResult) query); 348 | #[derive(CandidType, Deserialize, Debug)] 349 | pub struct GetBlocksResultArchivedBlocksItem { 350 | pub args: GetBlocksArgs, 351 | pub callback: GetBlocksResultArchivedBlocksItemCallback, 352 | } 353 | #[derive(CandidType, Deserialize, Debug)] 354 | pub struct GetBlocksResult { 355 | pub log_length: candid::Nat, 356 | pub blocks: Vec, 357 | pub archived_blocks: Vec, 358 | } 359 | #[derive(CandidType, Deserialize, Debug)] 360 | pub struct DataCertificate { 361 | pub certificate: serde_bytes::ByteBuf, 362 | pub hash_tree: serde_bytes::ByteBuf, 363 | } 364 | #[derive(CandidType, Deserialize, Debug)] 365 | pub struct SupportedBlockType { 366 | pub url: String, 367 | pub block_type: String, 368 | } 369 | #[derive(CandidType, Deserialize, Debug)] 370 | pub struct WithdrawArgs { 371 | pub to: Principal, 372 | pub from_subaccount: Option, 373 | pub created_at_time: Option, 374 | pub amount: candid::Nat, 375 | } 376 | #[derive(CandidType, Deserialize, Debug)] 377 | pub enum WithdrawError { 378 | FailedToWithdraw { 379 | rejection_code: RejectionCode, 380 | fee_block: Option, 381 | rejection_reason: String, 382 | }, 383 | GenericError { 384 | message: String, 385 | error_code: candid::Nat, 386 | }, 387 | TemporarilyUnavailable, 388 | Duplicate { 389 | duplicate_of: candid::Nat, 390 | }, 391 | BadFee { 392 | expected_fee: candid::Nat, 393 | }, 394 | InvalidReceiver { 395 | receiver: Principal, 396 | }, 397 | CreatedInFuture { 398 | ledger_time: u64, 399 | }, 400 | TooOld, 401 | InsufficientFunds { 402 | balance: candid::Nat, 403 | }, 404 | } 405 | #[derive(CandidType, Deserialize, Debug)] 406 | pub struct WithdrawFromArgs { 407 | pub to: Principal, 408 | pub spender_subaccount: Option, 409 | pub from: Account, 410 | pub created_at_time: Option, 411 | pub amount: candid::Nat, 412 | } 413 | #[derive(CandidType, Deserialize, Debug)] 414 | pub enum WithdrawFromError { 415 | GenericError { 416 | message: String, 417 | error_code: candid::Nat, 418 | }, 419 | TemporarilyUnavailable, 420 | InsufficientAllowance { 421 | allowance: candid::Nat, 422 | }, 423 | Duplicate { 424 | duplicate_of: BlockIndex, 425 | }, 426 | InvalidReceiver { 427 | receiver: Principal, 428 | }, 429 | CreatedInFuture { 430 | ledger_time: u64, 431 | }, 432 | TooOld, 433 | FailedToWithdrawFrom { 434 | withdraw_from_block: Option, 435 | rejection_code: RejectionCode, 436 | refund_block: Option, 437 | approval_refund_block: Option, 438 | rejection_reason: String, 439 | }, 440 | InsufficientFunds { 441 | balance: candid::Nat, 442 | }, 443 | } 444 | 445 | pub struct Service(pub Principal); 446 | impl Service { 447 | pub async fn create_canister( 448 | &self, 449 | arg0: &CreateCanisterArgs, 450 | ) -> CallResult<(std::result::Result,)> { 451 | ic_cdk::call(self.0, "create_canister", (arg0,)).await 452 | } 453 | pub async fn create_canister_from( 454 | &self, 455 | arg0: &CreateCanisterFromArgs, 456 | ) -> CallResult<(std::result::Result,)> { 457 | ic_cdk::call(self.0, "create_canister_from", (arg0,)).await 458 | } 459 | pub async fn deposit(&self, arg0: &DepositArgs) -> CallResult<(DepositResult,)> { 460 | ic_cdk::call(self.0, "deposit", (arg0,)).await 461 | } 462 | pub async fn http_request(&self, arg0: &HttpRequest) -> CallResult<(HttpResponse,)> { 463 | ic_cdk::call(self.0, "http_request", (arg0,)).await 464 | } 465 | pub async fn icrc_1_balance_of(&self, arg0: &Account) -> CallResult<(candid::Nat,)> { 466 | ic_cdk::call(self.0, "icrc1_balance_of", (arg0,)).await 467 | } 468 | pub async fn icrc_1_decimals(&self) -> CallResult<(u8,)> { 469 | ic_cdk::call(self.0, "icrc1_decimals", ()).await 470 | } 471 | pub async fn icrc_1_fee(&self) -> CallResult<(candid::Nat,)> { 472 | ic_cdk::call(self.0, "icrc1_fee", ()).await 473 | } 474 | pub async fn icrc_1_metadata(&self) -> CallResult<(Vec<(String, MetadataValue)>,)> { 475 | ic_cdk::call(self.0, "icrc1_metadata", ()).await 476 | } 477 | pub async fn icrc_1_minting_account(&self) -> CallResult<(Option,)> { 478 | ic_cdk::call(self.0, "icrc1_minting_account", ()).await 479 | } 480 | pub async fn icrc_1_name(&self) -> CallResult<(String,)> { 481 | ic_cdk::call(self.0, "icrc1_name", ()).await 482 | } 483 | pub async fn icrc_1_supported_standards(&self) -> CallResult<(Vec,)> { 484 | ic_cdk::call(self.0, "icrc1_supported_standards", ()).await 485 | } 486 | pub async fn icrc_1_symbol(&self) -> CallResult<(String,)> { 487 | ic_cdk::call(self.0, "icrc1_symbol", ()).await 488 | } 489 | pub async fn icrc_1_total_supply(&self) -> CallResult<(candid::Nat,)> { 490 | ic_cdk::call(self.0, "icrc1_total_supply", ()).await 491 | } 492 | pub async fn icrc_1_transfer( 493 | &self, 494 | arg0: &TransferArgs, 495 | ) -> CallResult<(std::result::Result,)> { 496 | ic_cdk::call(self.0, "icrc1_transfer", (arg0,)).await 497 | } 498 | pub async fn icrc_2_allowance(&self, arg0: &AllowanceArgs) -> CallResult<(Allowance,)> { 499 | ic_cdk::call(self.0, "icrc2_allowance", (arg0,)).await 500 | } 501 | pub async fn icrc_2_approve( 502 | &self, 503 | arg0: &ApproveArgs, 504 | ) -> CallResult<(std::result::Result,)> { 505 | ic_cdk::call(self.0, "icrc2_approve", (arg0,)).await 506 | } 507 | pub async fn icrc_2_transfer_from( 508 | &self, 509 | arg0: &TransferFromArgs, 510 | ) -> CallResult<(std::result::Result,)> { 511 | ic_cdk::call(self.0, "icrc2_transfer_from", (arg0,)).await 512 | } 513 | pub async fn icrc_3_get_archives( 514 | &self, 515 | arg0: &GetArchivesArgs, 516 | ) -> CallResult<(GetArchivesResult,)> { 517 | ic_cdk::call(self.0, "icrc3_get_archives", (arg0,)).await 518 | } 519 | pub async fn icrc_3_get_blocks(&self, arg0: &GetBlocksArgs) -> CallResult<(GetBlocksResult,)> { 520 | ic_cdk::call(self.0, "icrc3_get_blocks", (arg0,)).await 521 | } 522 | pub async fn icrc_3_get_tip_certificate(&self) -> CallResult<(Option,)> { 523 | ic_cdk::call(self.0, "icrc3_get_tip_certificate", ()).await 524 | } 525 | pub async fn icrc_3_supported_block_types(&self) -> CallResult<(Vec,)> { 526 | ic_cdk::call(self.0, "icrc3_supported_block_types", ()).await 527 | } 528 | pub async fn withdraw( 529 | &self, 530 | arg0: &WithdrawArgs, 531 | ) -> CallResult<(std::result::Result,)> { 532 | ic_cdk::call(self.0, "withdraw", (arg0,)).await 533 | } 534 | pub async fn withdraw_from( 535 | &self, 536 | arg0: &WithdrawFromArgs, 537 | ) -> CallResult<(std::result::Result,)> { 538 | ic_cdk::call(self.0, "withdraw_from", (arg0,)).await 539 | } 540 | } 541 | 542 | pub struct CyclesLedgerPic { 543 | pub pic: Arc, 544 | pub canister_id: Principal, 545 | } 546 | 547 | impl PicCanisterTrait for CyclesLedgerPic { 548 | /// The shared PocketIc instance. 549 | fn pic(&self) -> Arc { 550 | self.pic.clone() 551 | } 552 | /// The ID of this canister. 553 | fn canister_id(&self) -> Principal { 554 | self.canister_id.clone() 555 | } 556 | } 557 | 558 | impl From for CyclesLedgerPic { 559 | fn from(pic: PicCanister) -> Self { 560 | Self { 561 | pic: pic.pic, 562 | canister_id: pic.canister_id, 563 | } 564 | } 565 | } 566 | 567 | impl CyclesLedgerPic { 568 | /* 569 | pub fn create_canister(&self, caller: Principal, arg0: &CreateCanisterArgs) -> Result<(std::result::Result,)> { 570 | self.pic.update_call(self.canister_id, caller, "create_canister", (arg0,)) 571 | } 572 | pub fn create_canister_from(&self, caller: Principal, arg0: &CreateCanisterFromArgs) -> Result<(std::result::Result,)> { 573 | self.pic.update_call(self.canister_id, caller, "create_canister_from", (arg0,)) 574 | } 575 | */ 576 | pub fn deposit(&self, caller: Principal, arg0: &DepositArgs) -> Result { 577 | self.update(caller, "deposit", (arg0,)) 578 | } 579 | /* 580 | pub fn http_request(&self, caller: Principal, arg0: &HttpRequest) -> Result<(HttpResponse,)> { 581 | self.pic.update_call(self.canister_id, caller, "http_request", (arg0,)) 582 | } 583 | */ 584 | pub fn icrc_1_balance_of( 585 | &self, 586 | caller: Principal, 587 | arg0: &Account, 588 | ) -> Result { 589 | self.update(caller, "icrc1_balance_of", arg0) 590 | } 591 | /* 592 | pub fn icrc_1_decimals(&self, caller: Principal) -> Result<(u8,)> { 593 | self.pic.update_call(self.canister_id, caller, "icrc1_decimals", ()) 594 | } 595 | pub fn icrc_1_fee(&self, caller: Principal) -> Result<(candid::Nat,)> { 596 | self.pic.update_call(self.canister_id, caller, "icrc1_fee", ()) 597 | } 598 | pub fn icrc_1_metadata(&self, caller: Principal) -> Result<(Vec<(String,MetadataValue,)>,)> { 599 | self.pic.update_call(self.canister_id, caller, "icrc1_metadata", ()) 600 | } 601 | pub fn icrc_1_minting_account(&self, caller: Principal) -> Result<(Option,)> { 602 | self.pic.update_call(self.canister_id, caller, "icrc1_minting_account", ()) 603 | } 604 | pub fn icrc_1_name(&self, caller: Principal) -> Result<(String,)> { 605 | self.pic.update_call(self.canister_id, caller, "icrc1_name", ()) 606 | } 607 | pub fn icrc_1_supported_standards(&self, caller: Principal) -> Result<(Vec,)> { 608 | self.pic.update_call(self.canister_id, caller, "icrc1_supported_standards", ()) 609 | } 610 | pub fn icrc_1_symbol(&self, caller: Principal) -> Result<(String,)> { 611 | self.pic.update_call(self.canister_id, caller, "icrc1_symbol", ()) 612 | } 613 | pub fn icrc_1_total_supply(&self, caller: Principal) -> Result<(candid::Nat,)> { 614 | self.pic.update_call(self.canister_id, caller, "icrc1_total_supply", ()) 615 | } 616 | pub fn icrc_1_transfer(&self, caller: Principal, arg0: &TransferArgs) -> Result<(std::result::Result,)> { 617 | self.pic.update_call(self.canister_id, caller, "icrc1_transfer", (arg0,)) 618 | } 619 | pub fn icrc_2_allowance(&self, caller: Principal, arg0: &AllowanceArgs) -> Result<(Allowance,)> { 620 | self.pic.update_call(self.canister_id, caller, "icrc2_allowance", (arg0,)) 621 | } 622 | */ 623 | pub fn icrc_2_approve( 624 | &self, 625 | caller: Principal, 626 | arg0: &ApproveArgs, 627 | ) -> std::result::Result, String> { 628 | self.update(caller, "icrc2_approve", arg0) 629 | } 630 | /* 631 | pub fn icrc_2_transfer_from(&self, caller: Principal, arg0: &TransferFromArgs) -> Result<(std::result::Result,)> { 632 | self.pic.update_call(self.canister_id, caller, "icrc2_transfer_from", (arg0,)) 633 | } 634 | pub fn icrc_3_get_archives(&self, caller: Principal, arg0: &GetArchivesArgs) -> Result<(GetArchivesResult,)> { 635 | self.pic.update_call(self.canister_id, caller, "icrc3_get_archives", (arg0,)) 636 | } 637 | pub fn icrc_3_get_blocks(&self, caller: Principal, arg0: &GetBlocksArgs) -> Result<(GetBlocksResult,)> { 638 | self.pic.update_call(self.canister_id, caller, "icrc3_get_blocks", (arg0,)) 639 | } 640 | pub fn icrc_3_get_tip_certificate(&self, caller: Principal) -> Result<(Option,)> { 641 | self.pic.update_call(self.canister_id, caller, "icrc3_get_tip_certificate", ()) 642 | } 643 | pub fn icrc_3_supported_block_types(&self, caller: Principal) -> Result<(Vec,)> { 644 | self.pic.update_call(self.canister_id, caller, "icrc3_supported_block_types", encode_args(())) 645 | } 646 | pub fn withdraw(&self, caller: Principal, arg0: &WithdrawArgs) -> Result<(std::result::Result,)> { 647 | self.pic.update_call(self.canister_id, caller, "withdraw", encode_args((arg0,)).unwrap()) 648 | } 649 | */ 650 | pub fn withdraw_from( 651 | &self, 652 | caller: Principal, 653 | arg0: &WithdrawFromArgs, 654 | ) -> Result<(std::result::Result,), String> { 655 | self.update(caller, "withdraw_from", arg0) 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cycles_depositor; 2 | pub mod cycles_ledger; 3 | pub mod pic_canister; 4 | pub mod test_environment; 5 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/util/pic_canister.rs: -------------------------------------------------------------------------------- 1 | use candid::{decode_one, encode_one, CandidType, Deserialize, Principal}; 2 | use pocket_ic::{PocketIc, WasmResult}; 3 | use std::fs; 4 | use std::path::{Path, PathBuf}; 5 | use std::sync::Arc; 6 | 7 | /// Common methods for interacting with a canister using `PocketIc`. 8 | pub trait PicCanisterTrait { 9 | /// A shared PocketIc instance. 10 | /// 11 | /// Note: `PocketIc` uses interior mutability for query and update calls. No external mut annotation or locks appear to be necessary. 12 | fn pic(&self) -> Arc; 13 | 14 | /// The ID of this canister. 15 | fn canister_id(&self) -> Principal; 16 | 17 | /// Makes an update call to the canister. 18 | fn update(&self, caller: Principal, method: &str, arg: impl CandidType) -> Result 19 | where 20 | T: for<'a> Deserialize<'a> + CandidType, 21 | { 22 | self.pic() 23 | .update_call(self.canister_id(), caller, method, encode_one(arg).unwrap()) 24 | .map_err(|e| { 25 | format!( 26 | "Update call error. RejectionCode: {:?}, Error: {}", 27 | e.code, e.description 28 | ) 29 | }) 30 | .and_then(|reply| match reply { 31 | WasmResult::Reply(reply) => { 32 | decode_one(&reply).map_err(|e| format!("Decoding failed: {e}")) 33 | } 34 | WasmResult::Reject(error) => Err(error), 35 | }) 36 | } 37 | 38 | /// Makes a query call to the canister. 39 | #[allow(dead_code)] 40 | fn query(&self, caller: Principal, method: &str, arg: impl CandidType) -> Result 41 | where 42 | T: for<'a> Deserialize<'a> + CandidType, 43 | { 44 | self.pic() 45 | .query_call(self.canister_id(), caller, method, encode_one(arg).unwrap()) 46 | .map_err(|e| { 47 | format!( 48 | "Query call error. RejectionCode: {:?}, Error: {}", 49 | e.code, e.description 50 | ) 51 | }) 52 | .and_then(|reply| match reply { 53 | WasmResult::Reply(reply) => { 54 | decode_one(&reply).map_err(|_| "Decoding failed".to_string()) 55 | } 56 | WasmResult::Reject(error) => Err(error), 57 | }) 58 | } 59 | fn workspace_dir() -> PathBuf { 60 | let output = std::process::Command::new(env!("CARGO")) 61 | .arg("locate-project") 62 | .arg("--workspace") 63 | .arg("--message-format=plain") 64 | .output() 65 | .unwrap() 66 | .stdout; 67 | let cargo_path = Path::new(std::str::from_utf8(&output).unwrap().trim()); 68 | cargo_path.parent().unwrap().to_path_buf() 69 | } 70 | /// The path to a typical Cargo Wasm build. 71 | fn cargo_wasm_path(name: &str) -> String { 72 | let workspace_dir = Self::workspace_dir(); 73 | workspace_dir 74 | .join("target/wasm32-unknown-unknown/release") 75 | .join(name) 76 | .with_extension("wasm") 77 | .to_str() 78 | .unwrap() 79 | .to_string() 80 | } 81 | /// The path to the wasm after `dfx deploy`. Expects the Wasm to be gzipped. 82 | /// 83 | /// If not already gzipped, please add this to the canister declaration in `dfx.json`: `"gzip": true` 84 | fn dfx_wasm_path(name: &str) -> String { 85 | Self::workspace_dir() 86 | .join(format!(".dfx/local/canisters/{name}/{name}.wasm.gz")) 87 | .to_str() 88 | .unwrap() 89 | .to_string() 90 | } 91 | } 92 | 93 | /// A typical canister running on PocketIC. 94 | pub struct PicCanister { 95 | pub pic: Arc, 96 | pub canister_id: Principal, 97 | } 98 | 99 | impl PicCanisterTrait for PicCanister { 100 | /// The shared PocketIc instance. 101 | fn pic(&self) -> Arc { 102 | self.pic.clone() 103 | } 104 | /// The ID of this canister. 105 | fn canister_id(&self) -> Principal { 106 | self.canister_id.clone() 107 | } 108 | } 109 | 110 | impl PicCanister { 111 | /// Creates a new canister. 112 | pub fn new(pic: Arc, wasm_path: &str) -> Self { 113 | PicCanisterBuilder::default() 114 | .with_wasm(wasm_path) 115 | .deploy_to(pic) 116 | } 117 | } 118 | 119 | /// Canister installer, using the builder pattern, for use in test environmens using `PocketIC`. 120 | /// 121 | /// # Example 122 | /// For a default test environment: 123 | /// ``` 124 | /// let pic_canister = BackendBuilder::default().deploy(); 125 | /// ``` 126 | /// To add a backend canister to an existing `PocketIC`: 127 | /// ``` 128 | /// let pic = PocketIc::new(); 129 | /// let canister_id = BackendBuilder::default().deploy_to(&pic); 130 | /// ``` 131 | /// To redeploy an existing canister: 132 | /// ``` 133 | /// // First deployment: 134 | /// let (pic, canister_id) = BackendBuilder::default().deploy(); 135 | /// // Subsequent deployment: 136 | /// let canister_id = BackendBuilder::default().with_canister(canister_id).deploy_to(&pic); 137 | /// ``` 138 | /// To customise the deployment, use the `.with_*` modifiers. E.g.: 139 | /// ``` 140 | /// let (pic, canister_id) = BackendBuilder::default() 141 | /// .with_wasm("path/to/ic_chainfusion_signer.wasm") 142 | /// .with_arg(vec![1, 2, 3]) 143 | /// .with_controllers(vec![Principal::from_text("controller").unwrap()]) 144 | /// .with_cycles(1_000_000_000_000) 145 | /// .deploy(); 146 | /// ``` 147 | #[derive(Debug)] 148 | pub struct PicCanisterBuilder { 149 | /// Canister ID of the backend canister. If not set, a new canister will be created. 150 | canister_id: Option, 151 | /// Cycles to add to the backend canister. 152 | cycles: u128, 153 | /// Path to the backend wasm file. 154 | wasm_path: String, 155 | /// Argument to pass to the backend canister. 156 | arg: Vec, 157 | /// Controllers of the backend canister. 158 | /// 159 | /// If the list is not specified, controllers will be unchnaged from the PocketIc defaults. 160 | controllers: Option>, 161 | } 162 | // Defaults 163 | impl PicCanisterBuilder { 164 | /// The default number of cycles to add to the backend canister on deployment. 165 | /// 166 | /// To override, please use `with_cycles()`. 167 | pub const DEFAULT_CYCLES: u128 = 2_000_000_000_000; 168 | /// The default argument to pass to the backend canister: `()`. 169 | /// 170 | /// To override, please use `with_arg()`. 171 | pub fn default_arg() -> Vec { 172 | encode_one(()).unwrap() 173 | } 174 | } 175 | impl Default for PicCanisterBuilder { 176 | fn default() -> Self { 177 | Self { 178 | canister_id: None, 179 | cycles: Self::DEFAULT_CYCLES, 180 | wasm_path: "unspecified.wasm".to_string(), 181 | arg: Self::default_arg(), 182 | controllers: None, 183 | } 184 | } 185 | } 186 | // Customisation 187 | impl PicCanisterBuilder { 188 | /// Sets a custom argument for the backend canister. 189 | #[allow(dead_code)] 190 | pub fn with_arg(mut self, arg: Vec) -> Self { 191 | self.arg = arg; 192 | self 193 | } 194 | /// Deploys to an existing canister with the given ID. 195 | #[allow(dead_code)] 196 | pub fn with_canister(mut self, canister_id: Principal) -> Self { 197 | self.canister_id = Some(canister_id); 198 | self 199 | } 200 | /// Sets custom controllers for the backend canister. 201 | #[allow(dead_code)] 202 | pub fn with_controllers(mut self, controllers: Vec) -> Self { 203 | self.controllers = Some(controllers); 204 | self 205 | } 206 | /// Sets the cycles to add to the backend canister. 207 | #[allow(dead_code)] 208 | pub fn with_cycles(mut self, cycles: u128) -> Self { 209 | self.cycles = cycles; 210 | self 211 | } 212 | /// Configures the deployment to use a custom Wasm file. 213 | #[allow(dead_code)] 214 | pub fn with_wasm(mut self, wasm_path: &str) -> Self { 215 | self.wasm_path = wasm_path.to_string(); 216 | self 217 | } 218 | } 219 | // Get parameters 220 | impl PicCanisterBuilder { 221 | /// Reads the backend Wasm bytes from the configured path. 222 | fn wasm_bytes(&self) -> Vec { 223 | fs::read(self.wasm_path.clone()).expect(&format!( 224 | "Could not find the backend wasm: {}", 225 | self.wasm_path 226 | )) 227 | } 228 | } 229 | // Builder 230 | impl PicCanisterBuilder { 231 | /// Get or create canister. 232 | fn canister_id(&mut self, pic: &PocketIc) -> Principal { 233 | if let Some(canister_id) = self.canister_id { 234 | canister_id 235 | } else { 236 | let canister_id = pic.create_canister(); 237 | self.canister_id = Some(canister_id); 238 | canister_id 239 | } 240 | } 241 | /// Add cycles to the backend canister. 242 | fn add_cycles(&mut self, pic: &PocketIc) { 243 | if self.cycles > 0 { 244 | let canister_id = self.canister_id(pic); 245 | pic.add_cycles(canister_id, self.cycles); 246 | } 247 | } 248 | /// Install the backend canister. 249 | fn install(&mut self, pic: &PocketIc) { 250 | let wasm_bytes = self.wasm_bytes(); 251 | let canister_id = self.canister_id(pic); 252 | let arg = self.arg.clone(); 253 | pic.install_canister(canister_id, wasm_bytes, arg, None); 254 | } 255 | /// Set controllers of the backend canister. 256 | fn set_controllers(&mut self, pic: &PocketIc) { 257 | if let Some(controllers) = self.controllers.clone() { 258 | let canister_id = self.canister_id(pic); 259 | pic.set_controllers(canister_id.clone(), None, controllers) 260 | .expect("Test setup error: Failed to set controllers"); 261 | } 262 | } 263 | /// Setup the backend canister. 264 | pub fn deploy_to(&mut self, pic: Arc) -> PicCanister { 265 | let canister_id = self.canister_id(&pic); 266 | self.add_cycles(&pic); 267 | self.install(&pic); 268 | self.set_controllers(&pic); 269 | PicCanister { 270 | pic: pic.clone(), 271 | canister_id, 272 | } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/example/paid_service/tests/it/util/test_environment.rs: -------------------------------------------------------------------------------- 1 | use crate::util::cycles_depositor::{self, CyclesDepositorPic}; 2 | use crate::util::cycles_ledger::{ 3 | Account, ApproveArgs, CyclesLedgerPic, InitArgs as LedgerInitArgs, LedgerArgs, 4 | }; 5 | use crate::util::pic_canister::{PicCanister, PicCanisterBuilder, PicCanisterTrait}; 6 | use candid::{encode_one, CandidType, Nat, Principal}; 7 | use example_paid_service_api::InitArgs; 8 | use ic_papi_api::cycles::cycles_ledger_canister_id; 9 | use ic_papi_api::PaymentError; 10 | use pocket_ic::{PocketIc, PocketIcBuilder}; 11 | use std::sync::Arc; 12 | 13 | pub const LEDGER_FEE: u128 = 100_000_000; // The documented fee: https://internetcomputer.org/docs/current/developer-docs/defi/cycles/cycles-ledger#fees 14 | 15 | /// Methods protected by PAPI. 16 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 17 | pub enum PaidMethods { 18 | Cost1bIcrc2Cycles, 19 | CallerPays1bIcrc2Tokens, 20 | Cost1b, 21 | } 22 | impl PaidMethods { 23 | pub fn name(&self) -> &str { 24 | match self { 25 | Self::Cost1bIcrc2Cycles => "caller_pays_1b_icrc2_cycles", 26 | Self::CallerPays1bIcrc2Tokens => "caller_pays_1b_icrc2_tokens", 27 | Self::Cost1b => "cost_1b", 28 | } 29 | } 30 | pub fn cost(&self) -> u128 { 31 | match self { 32 | Self::Cost1bIcrc2Cycles => 1_000_000_000, 33 | Self::CallerPays1bIcrc2Tokens => 1_000_000_000, 34 | Self::Cost1b => 1_000_000_000, 35 | } 36 | } 37 | } 38 | 39 | pub struct TestSetup { 40 | /// The PocketIC instance. 41 | #[allow(dead_code)] 42 | // The Arc is used; this makes it accessible without having to refer to a specific canister. 43 | pub pic: Arc, 44 | /// The canister providing the API. 45 | pub paid_service: PicCanister, 46 | /// ICRC2 ledger 47 | pub ledger: CyclesLedgerPic, 48 | /// User 49 | pub user: Principal, 50 | /// Another user 51 | pub user2: Principal, 52 | /// A crowd 53 | pub users: [Principal; 5], 54 | /// Unauthorized user 55 | pub unauthorized_user: Principal, 56 | /// A canister used to deposit cycles into the ledger. 57 | pub cycles_depositor: CyclesDepositorPic, 58 | } 59 | impl Default for TestSetup { 60 | fn default() -> Self { 61 | let pic = Arc::new( 62 | PocketIcBuilder::new() 63 | .with_fiduciary_subnet() 64 | .with_system_subnet() 65 | .with_application_subnet() 66 | .with_ii_subnet() 67 | .with_nns_subnet() 68 | .build(), 69 | ); 70 | let cycles_ledger_canister_id = pic 71 | .create_canister_with_id(None, None, cycles_ledger_canister_id()) 72 | .unwrap(); 73 | 74 | // Would like to create this with the cycles ledger canister ID but currently this yields an error. 75 | let ledger = CyclesLedgerPic::from( 76 | PicCanisterBuilder::default() 77 | .with_canister(cycles_ledger_canister_id) 78 | .with_wasm(&PicCanister::dfx_wasm_path("cycles_ledger")) 79 | .with_arg( 80 | encode_one(LedgerArgs::Init(LedgerInitArgs { 81 | index_id: None, 82 | max_blocks_per_request: 999, 83 | })) 84 | .expect("Failed to encode ledger init arg"), 85 | ) 86 | .deploy_to(pic.clone()), 87 | ); 88 | let paid_service = PicCanisterBuilder::default() 89 | .with_wasm(&PicCanister::cargo_wasm_path("example_paid_service")) 90 | .with_arg( 91 | encode_one(Some(InitArgs { 92 | ledger: ledger.canister_id(), 93 | })) 94 | .unwrap(), 95 | ) 96 | .deploy_to(pic.clone()); 97 | let user = 98 | Principal::from_text("xzg7k-thc6c-idntg-knmtz-2fbhh-utt3e-snqw6-5xph3-54pbp-7axl5-tae") 99 | .unwrap(); 100 | let user2 = 101 | Principal::from_text("jwhyn-xieqy-drmun-h7uci-jzycw-vnqhj-s62vl-4upsg-cmub3-vakaq-rqe") 102 | .unwrap(); 103 | let users = [ 104 | Principal::from_text("s2xin-cwqnw-sjvht-gp553-an54g-2rhlc-z4c5d-xz5iq-irnbi-sadik-qae") 105 | .unwrap(), 106 | Principal::from_text("dmvof-2tilt-3xmvh-c7tbj-n3whk-k2i6b-2s2ge-xoo3d-wjuw3-ijpuw-eae") 107 | .unwrap(), 108 | Principal::from_text("kjerd-nj73t-u3hhp-jcj4d-g7w56-qlrvb-gguta-45yve-336zs-sunxa-zqe") 109 | .unwrap(), 110 | Principal::from_text("zxhav-yshtx-vhzs2-nvuu3-jrq66-bidn2-put3y-ulwcf-2gb2o-ykfco-sae") 111 | .unwrap(), 112 | Principal::from_text("nggqm-p5ozz-i5hfv-bejmq-2gtow-4dtqw-vjatn-4b4yw-s5mzs-i46su-6ae") 113 | .unwrap(), 114 | ]; 115 | let unauthorized_user = 116 | Principal::from_text("rg3gz-22tjp-jh7hl-migkq-vb7in-i2ylc-6umlc-dtbug-v6jgc-uo24d-nqe") 117 | .unwrap(); 118 | let cycles_depositor = PicCanisterBuilder::default() 119 | .with_wasm(&PicCanister::dfx_wasm_path("cycles_depositor")) 120 | .with_controllers(vec![user]) 121 | .with_arg( 122 | encode_one(cycles_depositor::InitArg { 123 | ledger_id: ledger.canister_id, 124 | }) 125 | .unwrap(), 126 | ) 127 | .deploy_to(pic.clone()) 128 | .into(); 129 | 130 | let ans = Self { 131 | pic, 132 | paid_service, 133 | ledger, 134 | user, 135 | user2, 136 | users, 137 | unauthorized_user, 138 | cycles_depositor, 139 | }; 140 | ans.fund_user(Self::USER_INITIAL_BALANCE); 141 | ans 142 | } 143 | } 144 | impl TestSetup { 145 | /// The user's initial balance. 146 | pub const USER_INITIAL_BALANCE: u128 = 100_000_000_000; 147 | /// Deposit 100 * the ledger fee in the user's ledger wallet. That should be enough to be getting on with. 148 | pub fn fund_user(&self, megasquigs: u128) { 149 | let initial_balance = self.user_balance(); 150 | // .. Magic cycles into existence (test only - not IRL). 151 | let deposit = megasquigs + LEDGER_FEE; 152 | self.pic 153 | .add_cycles(self.cycles_depositor.canister_id, deposit); 154 | // .. Send cycles to the cycles ledger. 155 | self.cycles_depositor 156 | .deposit( 157 | self.user, 158 | &cycles_depositor::DepositArg { 159 | to: cycles_depositor::Account { 160 | owner: self.user, 161 | subaccount: None, 162 | }, 163 | memo: None, 164 | cycles: candid::Nat::from(deposit), 165 | }, 166 | ) 167 | .expect("Failed to deposit funds in the ledger"); 168 | // .. That should have cost one fee. 169 | let expected_balance = initial_balance.clone() + megasquigs; 170 | self.assert_user_balance_eq(expected_balance.clone(), format!("Expected user balance to be the initial balance ({initial_balance}) plus the requested sum ({megasquigs}) = {expected_balance}")); 171 | } 172 | /// Gets the user balance 173 | pub fn user_balance(&self) -> Nat { 174 | self.ledger 175 | .icrc_1_balance_of( 176 | self.user, 177 | &Account { 178 | owner: self.user, 179 | subaccount: None, 180 | }, 181 | ) 182 | .expect("Could not get user balance") 183 | } 184 | /// Asserts that the user's ledger balance is a certain value. 185 | pub fn assert_user_balance_eq(&self, expected_balance: T, message: String) 186 | where 187 | T: Into, 188 | { 189 | assert_eq!(self.user_balance(), expected_balance.into(), "{}", message); 190 | } 191 | /// User sends an ICRC2 approval with teh paid service as spender. 192 | pub fn user_approves_payment_for_paid_service(&self, amount: T) 193 | where 194 | T: Into, 195 | { 196 | self.ledger 197 | .icrc_2_approve( 198 | self.user, 199 | &ApproveArgs { 200 | spender: Account { 201 | owner: self.paid_service.canister_id(), 202 | subaccount: None, 203 | }, 204 | amount: amount.into(), 205 | ..ApproveArgs::default() 206 | }, 207 | ) 208 | .expect("Failed to call the ledger to approve") 209 | .expect("Failed to approve the paid service to spend the user's ICRC-2 tokens"); 210 | } 211 | /// Calls a paid service. 212 | pub fn call_paid_service( 213 | &self, 214 | caller: Principal, 215 | method: PaidMethods, 216 | arg: impl CandidType, 217 | ) -> Result { 218 | self.paid_service 219 | .update(caller, method.name(), arg) 220 | .expect("Failed to call the paid service") 221 | } 222 | } 223 | 224 | #[test] 225 | fn icrc2_test_setup_works() { 226 | let _setup = TestSetup::default(); 227 | } 228 | -------------------------------------------------------------------------------- /src/example/paid_service_api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "example-paid-service-api" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | candid = { workspace = true } 8 | serde = { workspace = true } 9 | serde_bytes = { workspace = true } 10 | -------------------------------------------------------------------------------- /src/example/paid_service_api/src/lib.rs: -------------------------------------------------------------------------------- 1 | use candid::{CandidType, Deserialize, Principal}; 2 | 3 | #[derive(Clone, CandidType, Deserialize, Debug)] 4 | pub struct InitArgs { 5 | pub ledger: Principal, 6 | } 7 | -------------------------------------------------------------------------------- /src/guard/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ic-papi-guard" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | candid = { workspace = true } 8 | ic-cdk = "0.17.1" 9 | ic-cycles-ledger-client = { workspace = true } 10 | ic-papi-api = { workspace = true } 11 | serde = { workspace = true } 12 | serde_bytes = { workspace = true } 13 | -------------------------------------------------------------------------------- /src/guard/src/guards/any.rs: -------------------------------------------------------------------------------- 1 | //! Accepts any payment that the vendor accepts. 2 | 3 | use candid::{CandidType, Deserialize, Principal}; 4 | use ic_papi_api::{ 5 | caller::{CallerPaysIcrc2Tokens, PatronPaysIcrc2Cycles, PatronPaysIcrc2Tokens, TokenAmount}, 6 | PaymentError, PaymentType, 7 | }; 8 | 9 | use super::{ 10 | attached_cycles::AttachedCyclesPayment, 11 | caller_pays_icrc2_cycles::CallerPaysIcrc2CyclesPaymentGuard, 12 | caller_pays_icrc2_tokens::CallerPaysIcrc2TokensPaymentGuard, 13 | patron_pays_icrc2_cycles::PatronPaysIcrc2CyclesPaymentGuard, 14 | patron_pays_icrc2_tokens::PatronPaysIcrc2TokensPaymentGuard, PaymentGuardTrait, 15 | }; 16 | 17 | /// A guard that accepts a user-specified payment type, providing the vendor supports it. 18 | pub struct PaymentGuard { 19 | pub supported: [VendorPaymentConfig; CAP], 20 | } 21 | 22 | /// Vendor payment configuration, including details that may not necessarily be shared with the customer. 23 | #[derive(Debug, Clone, Eq, PartialEq)] 24 | pub enum VendorPaymentConfig { 25 | /// Cycles are received by the vendor canister. 26 | AttachedCycles, 27 | /// Cycles are received by the vendor canister. 28 | CallerPaysIcrc2Cycles, 29 | /// Cycles are received by the vendor canister. 30 | PatronPaysIcrc2Cycles, 31 | /// The caller pays tokens to the vendor's main account on the chosen ledger. 32 | CallerPaysIcrc2Tokens { ledger: Principal }, 33 | /// A patron pays tokens to a subaccount belonging to the vendor on the chosen ledger. 34 | /// - The vendor needs to move the tokens to their main account. 35 | PatronPaysIcrc2Tokens { ledger: Principal }, 36 | } 37 | 38 | /// A user's requested payment type paired with a vendor's configuration. 39 | #[derive(Debug, Clone, Eq, PartialEq, CandidType, Deserialize)] 40 | pub enum PaymentWithConfig { 41 | AttachedCycles, 42 | CallerPaysIcrc2Cycles, 43 | PatronPaysIcrc2Cycles(PatronPaysIcrc2Cycles), 44 | CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens), 45 | PatronPaysIcrc2Tokens(PatronPaysIcrc2Tokens), 46 | } 47 | 48 | impl PaymentGuard { 49 | pub async fn deduct(&self, payment: PaymentType, fee: TokenAmount) -> Result<(), PaymentError> { 50 | let payment_config = self 51 | .config(payment) 52 | .ok_or(PaymentError::UnsupportedPaymentType)?; 53 | match payment_config { 54 | PaymentWithConfig::AttachedCycles => AttachedCyclesPayment {}.deduct(fee).await, 55 | PaymentWithConfig::CallerPaysIcrc2Cycles => { 56 | CallerPaysIcrc2CyclesPaymentGuard {}.deduct(fee).await 57 | } 58 | PaymentWithConfig::PatronPaysIcrc2Cycles(patron) => { 59 | PatronPaysIcrc2CyclesPaymentGuard { patron } 60 | .deduct(fee) 61 | .await 62 | } 63 | PaymentWithConfig::CallerPaysIcrc2Tokens(CallerPaysIcrc2Tokens { ledger }) => { 64 | CallerPaysIcrc2TokensPaymentGuard { ledger } 65 | .deduct(fee) 66 | .await 67 | } 68 | PaymentWithConfig::PatronPaysIcrc2Tokens(payment_type) => { 69 | PatronPaysIcrc2TokensPaymentGuard { 70 | ledger: payment_type.ledger, 71 | patron: payment_type.patron, 72 | } 73 | .deduct(fee) 74 | .await 75 | } 76 | } 77 | } 78 | } 79 | impl PaymentGuard { 80 | /// Find the vendor configuration for the offered payment type. 81 | #[must_use] 82 | pub fn config(&self, payment: PaymentType) -> Option { 83 | match payment { 84 | PaymentType::AttachedCycles => self 85 | .supported 86 | .iter() 87 | .find(|&x| *x == VendorPaymentConfig::AttachedCycles) 88 | .map(|_| PaymentWithConfig::AttachedCycles), 89 | PaymentType::CallerPaysIcrc2Cycles => self 90 | .supported 91 | .iter() 92 | .find(|&x| *x == VendorPaymentConfig::CallerPaysIcrc2Cycles) 93 | .map(|_| PaymentWithConfig::CallerPaysIcrc2Cycles), 94 | PaymentType::PatronPaysIcrc2Cycles(patron) => self 95 | .supported 96 | .iter() 97 | .find(|&x| *x == VendorPaymentConfig::PatronPaysIcrc2Cycles) 98 | .map(|_| PaymentWithConfig::PatronPaysIcrc2Cycles(patron)), 99 | PaymentType::CallerPaysIcrc2Tokens(payment_type) => self 100 | .supported 101 | .iter() 102 | .find(|&x| { 103 | *x == VendorPaymentConfig::CallerPaysIcrc2Tokens { 104 | ledger: payment_type.ledger, 105 | } 106 | }) 107 | .map(|_| PaymentWithConfig::CallerPaysIcrc2Tokens(payment_type)), 108 | PaymentType::PatronPaysIcrc2Tokens(payment_type) => self 109 | .supported 110 | .iter() 111 | .find(|&x| { 112 | *x == VendorPaymentConfig::PatronPaysIcrc2Tokens { 113 | ledger: payment_type.ledger, 114 | } 115 | }) 116 | .map(|_| PaymentWithConfig::PatronPaysIcrc2Tokens(payment_type)), 117 | _ => None, 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/guard/src/guards/attached_cycles.rs: -------------------------------------------------------------------------------- 1 | use super::{PaymentError, PaymentGuardTrait}; 2 | use ic_cdk::api::call::{msg_cycles_accept, msg_cycles_available}; 3 | use ic_papi_api::caller::TokenAmount; 4 | 5 | /// The information required to charge attached cycles. 6 | #[derive(Default, Debug, Eq, PartialEq)] 7 | pub struct AttachedCyclesPayment {} 8 | 9 | impl PaymentGuardTrait for AttachedCyclesPayment { 10 | async fn deduct(&self, fee: TokenAmount) -> Result<(), PaymentError> { 11 | let available = msg_cycles_available(); 12 | if available < fee { 13 | return Err(PaymentError::InsufficientFunds { 14 | needed: fee, 15 | available, 16 | }); 17 | } 18 | msg_cycles_accept(fee); 19 | Ok(()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/guard/src/guards/caller_pays_icrc2_cycles.rs: -------------------------------------------------------------------------------- 1 | //! Code to receive cycles as payment, credited to the canister, using ICRC-2 and a cycles-ledger specific withdrawal method. 2 | use super::{PaymentError, PaymentGuardTrait}; 3 | use candid::Nat; 4 | use ic_cycles_ledger_client::WithdrawFromArgs; 5 | use ic_papi_api::{caller::TokenAmount, cycles::cycles_ledger_canister_id, Account}; 6 | 7 | /// Accepts cycles using an ICRC-2 approve followed by withdrawing the cycles to the current canister. Withdrawing 8 | /// cycles to the current canister is specific to the cycles ledger canister; it is not part of the ICRC-2 standard. 9 | #[derive(Default)] 10 | pub struct CallerPaysIcrc2CyclesPaymentGuard {} 11 | 12 | impl PaymentGuardTrait for CallerPaysIcrc2CyclesPaymentGuard { 13 | async fn deduct(&self, fee: TokenAmount) -> Result<(), PaymentError> { 14 | let caller = ic_cdk::caller(); 15 | let own_canister_id = ic_cdk::api::id(); 16 | let payer_account = Account { 17 | owner: caller, 18 | subaccount: None, 19 | }; 20 | // The patron must not be the vendor itself (this canister). 21 | if payer_account.owner == own_canister_id { 22 | return Err(PaymentError::InvalidPatron); 23 | } 24 | // The cycles ledger has a special `withdraw_from` method, similar to `transfer_from`, 25 | // but that adds the cycles to the canister rather than putting it into a ledger account. 26 | ic_cycles_ledger_client::Service(cycles_ledger_canister_id()) 27 | .withdraw_from(&WithdrawFromArgs { 28 | to: own_canister_id, 29 | amount: Nat::from(fee), 30 | from: payer_account, 31 | spender_subaccount: None, 32 | created_at_time: None, 33 | }) 34 | .await 35 | .map_err(|(rejection_code, string)| { 36 | eprintln!( 37 | "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", 38 | cycles_ledger_canister_id() 39 | ); 40 | PaymentError::LedgerUnreachable { 41 | ledger: cycles_ledger_canister_id(), 42 | } 43 | })? 44 | .0 45 | .map_err(|error| { 46 | eprintln!( 47 | "Failed to withdraw from ledger canister at {}: {error:?}", 48 | cycles_ledger_canister_id() 49 | ); 50 | PaymentError::LedgerWithdrawFromError { 51 | ledger: cycles_ledger_canister_id(), 52 | error, 53 | } 54 | }) 55 | .map(|_| ()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/guard/src/guards/caller_pays_icrc2_tokens.rs: -------------------------------------------------------------------------------- 1 | //! Code to receive any ICRC-2 token as payment. 2 | 3 | // Well known ICRC-2 tokens 4 | // TODO 5 | 6 | use super::{PaymentError, PaymentGuardTrait}; 7 | use candid::{Nat, Principal}; 8 | use ic_cycles_ledger_client::TransferFromArgs; 9 | use ic_papi_api::{caller::TokenAmount, Account}; 10 | 11 | pub struct CallerPaysIcrc2TokensPaymentGuard { 12 | /// The ledger for that specific token 13 | pub ledger: Principal, 14 | } 15 | 16 | impl PaymentGuardTrait for CallerPaysIcrc2TokensPaymentGuard { 17 | async fn deduct(&self, cost: TokenAmount) -> Result<(), PaymentError> { 18 | let caller = ic_cdk::api::caller(); 19 | ic_cycles_ledger_client::Service(self.ledger) 20 | .icrc_2_transfer_from(&TransferFromArgs { 21 | from: Account { 22 | owner: caller, 23 | subaccount: None, 24 | }, 25 | to: Account { 26 | owner: ic_cdk::api::id(), 27 | subaccount: None, 28 | }, 29 | amount: Nat::from(cost), 30 | spender_subaccount: None, 31 | created_at_time: None, 32 | memo: None, 33 | fee: None, 34 | }) 35 | .await 36 | .map_err(|(rejection_code, string)| { 37 | eprintln!( 38 | "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", 39 | self.ledger 40 | ); 41 | PaymentError::LedgerUnreachable { 42 | ledger: self.ledger, 43 | } 44 | })? 45 | .0 46 | .map_err(|error| { 47 | eprintln!( 48 | "Failed to withdraw from ledger canister at {}: {error:?}", 49 | self.ledger 50 | ); 51 | PaymentError::LedgerTransferFromError { 52 | ledger: self.ledger, 53 | error, 54 | } 55 | }) 56 | .map(|_| ()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/guard/src/guards/mod.rs: -------------------------------------------------------------------------------- 1 | //! Guards for specific flows 2 | 3 | use ic_papi_api::{caller::TokenAmount, PaymentError}; 4 | pub mod any; 5 | pub mod attached_cycles; 6 | pub mod caller_pays_icrc2_cycles; 7 | pub mod caller_pays_icrc2_tokens; 8 | pub mod patron_pays_icrc2_cycles; 9 | pub mod patron_pays_icrc2_tokens; 10 | 11 | #[allow(async_fn_in_trait)] 12 | pub trait PaymentGuardTrait { 13 | async fn deduct(&self, fee: TokenAmount) -> Result<(), PaymentError>; 14 | } 15 | -------------------------------------------------------------------------------- /src/guard/src/guards/patron_pays_icrc2_cycles.rs: -------------------------------------------------------------------------------- 1 | //! Code to receive cycles as payment, credited to the canister, using ICRC-2 and a cycles-ledger specific withdrawal method. 2 | use super::{PaymentError, PaymentGuardTrait}; 3 | use candid::Nat; 4 | use ic_cycles_ledger_client::WithdrawFromArgs; 5 | use ic_papi_api::{ 6 | caller::TokenAmount, cycles::cycles_ledger_canister_id, principal2account, Account, 7 | }; 8 | 9 | /// Accepts cycles using an ICRC-2 approve followed by withdrawing the cycles to the current canister. Withdrawing 10 | /// cycles to the current canister is specific to the cycles ledger canister; it is not part of the ICRC-2 standard. 11 | pub struct PatronPaysIcrc2CyclesPaymentGuard { 12 | /// The patron paying on behalf of the caller. 13 | pub patron: Account, 14 | } 15 | 16 | impl PaymentGuardTrait for PatronPaysIcrc2CyclesPaymentGuard { 17 | async fn deduct(&self, fee: TokenAmount) -> Result<(), PaymentError> { 18 | let own_canister_id = ic_cdk::api::id(); 19 | let caller = ic_cdk::caller(); 20 | let spender_subaccount = Some(principal2account(&caller)); 21 | // The patron must not be the vendor itself (this canister). 22 | if self.patron.owner == own_canister_id { 23 | return Err(PaymentError::InvalidPatron); 24 | } 25 | // The cycles ledger has a special `withdraw_from` method, similar to `transfer_from`, 26 | // but that adds the cycles to the canister rather than putting it into a ledger account. 27 | ic_cycles_ledger_client::Service(cycles_ledger_canister_id()) 28 | .withdraw_from(&WithdrawFromArgs { 29 | to: own_canister_id, 30 | amount: Nat::from(fee), 31 | from: self.patron.clone(), 32 | spender_subaccount, 33 | created_at_time: None, 34 | }) 35 | .await 36 | .map_err(|(rejection_code, string)| { 37 | eprintln!( 38 | "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", 39 | cycles_ledger_canister_id() 40 | ); 41 | PaymentError::LedgerUnreachable { 42 | ledger: cycles_ledger_canister_id(), 43 | } 44 | })? 45 | .0 46 | .map_err(|error| { 47 | eprintln!( 48 | "Failed to withdraw from ledger canister at {}: {error:?}", 49 | cycles_ledger_canister_id() 50 | ); 51 | PaymentError::LedgerWithdrawFromError { 52 | ledger: cycles_ledger_canister_id(), 53 | error, 54 | } 55 | }) 56 | .map(|_| ()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/guard/src/guards/patron_pays_icrc2_tokens.rs: -------------------------------------------------------------------------------- 1 | //! Code to receive cycles as payment, credited to the canister, using ICRC-2 and a cycles-ledger specific withdrawal method. 2 | use super::{PaymentError, PaymentGuardTrait}; 3 | use candid::{Nat, Principal}; 4 | use ic_cycles_ledger_client::TransferFromArgs; 5 | use ic_papi_api::{caller::TokenAmount, principal2account, Account}; 6 | 7 | /// Accepts cycles using an ICRC-2 approve followed by withdrawing the cycles to the current canister. Withdrawing 8 | /// cycles to the current canister is specific to the cycles ledger canister; it is not part of the ICRC-2 standard. 9 | pub struct PatronPaysIcrc2TokensPaymentGuard { 10 | /// The ledger for that specific token 11 | pub ledger: Principal, 12 | /// The patron paying on behalf of the caller 13 | pub patron: Account, 14 | } 15 | 16 | impl PaymentGuardTrait for PatronPaysIcrc2TokensPaymentGuard { 17 | async fn deduct(&self, cost: TokenAmount) -> Result<(), PaymentError> { 18 | let caller = ic_cdk::api::caller(); 19 | let own_canister_id = ic_cdk::api::id(); 20 | let spender_subaccount = principal2account(&caller); 21 | // The patron must not be the vendor itself (this canister). 22 | if self.patron.owner == own_canister_id { 23 | return Err(PaymentError::InvalidPatron); 24 | } 25 | // Note: The cycles ledger client is ICRC-2 compatible so can be used here. 26 | ic_cycles_ledger_client::Service(self.ledger) 27 | .icrc_2_transfer_from(&TransferFromArgs { 28 | from: self.patron.clone(), 29 | to: Account { 30 | owner: ic_cdk::api::id(), 31 | subaccount: None, 32 | }, 33 | amount: Nat::from(cost), 34 | spender_subaccount: Some(spender_subaccount), 35 | created_at_time: None, 36 | memo: None, 37 | fee: None, 38 | }) 39 | .await 40 | .map_err(|(rejection_code, string)| { 41 | eprintln!( 42 | "Failed to reach ledger canister at {}: {rejection_code:?}: {string}", 43 | self.ledger 44 | ); 45 | PaymentError::LedgerUnreachable { 46 | ledger: self.ledger, 47 | } 48 | })? 49 | .0 50 | .map_err(|error| { 51 | eprintln!( 52 | "Failed to withdraw from ledger canister at {}: {error:?}", 53 | self.ledger 54 | ); 55 | PaymentError::LedgerTransferFromError { 56 | ledger: self.ledger, 57 | error, 58 | } 59 | }) 60 | .map(|_| ()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/guard/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod guards; 2 | -------------------------------------------------------------------------------- /src/papi/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ic-papi" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Library for implementing Paid APIs on the Internet Computer" 6 | 7 | [dependencies] 8 | ic-papi-api = { workspace = true } 9 | ic-papi-guard = { workspace = true } 10 | -------------------------------------------------------------------------------- /src/papi/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use ic_papi_api as api; 2 | pub use ic_papi_guard as guard; 3 | --------------------------------------------------------------------------------