diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..82a71ae --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## Summary + +## Detail + +## Testing +- [ ] 100% test coverage is maintained + +## Documentation + +--- + +**Requested Reviewers:** @mention \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f0cf6b..f049d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,37 @@ on: branches: [master] pull_request: +permissions: read-all + jobs: run_ci_tests: runs-on: ubuntu-latest steps: - - run: exit 0 + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "20.14.0" + + - name: Setup CI Environment + run: bash setup.sh + + - name: Run static checks + run: bash run.sh static_checks + + - name: Run Move tests + run: bash run.sh test + + - name: Run Move tests on v2 + run: | + git apply packages/stablecoin/examples/v2_base.patch + bash run.sh test + git apply -R packages/stablecoin/examples/v2_base.patch + + scan: + if: github.event_name == 'pull_request' + uses: circlefin/circle-public-github-workflows/.github/workflows/pr-scan.yaml@v1 + with: + allow-reciprocal-licenses: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a20479 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +build/ +.coverage_map.mvcov +.trace +logs/ +*.log diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..cf6f221 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "damirka.move-syntax", + "mysten.move", + "ymotongpoo.licenser" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c241707 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.autoSave": "onFocusChange", + "licenser.license": "Custom", + "licenser.customHeader": "Copyright @YEAR@ Circle Internet Group, Inc. All rights reserved.\n\nSPDX-License-Identifier: Apache-2.0\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.", + "licenser.useSingleLineStyle": false, + "editor.tabSize": 2, + "[move]": { + "editor.tabSize": 4 + }, + "editor.insertSpaces": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba24830 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## 1.0.0 (2024-09-16) + +- Initial commit to this source repository for smart contracts used by Circle's stablecoins on Sui blockchain +- Create packages for usdc deployment on SUI diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index d4031ba..54ecbe4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ # stablecoin-sui + Source repository for smart contracts used by Circle's stablecoins on Sui blockchain + +## Getting Started + +### Prerequisites + +Before you can start working with the contracts in this repository, make sure to set up your local environment using the script below. + +```bash +bash setup.sh +``` + +### IDE + +- VSCode is recommended for developing Move contracts. +- [Move (Extension)](https://marketplace.visualstudio.com/items?itemName=mysten.move) is a language server extension for Move. + +### Build and Test Move contracts + +1. Compile Move contracts from project root: + + ```bash + bash run.sh build + ``` + +2. Run the tests: + + ```bash + bash run.sh test + ``` + +### Deploying Move packages + +#### Deploying with Sui CLI + +Packages in this repo can be published [via the Sui CLI](https://docs.sui.io/guides/developer/first-app/publish). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2a76a91 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not file public issues on Github for security vulnerabilities. All +security vulnerabilities should be reported to Circle privately, through +Circle's [Vulnerability Disclosure Program](https://hackerone.com/circle). +Please read through the program policy before submitting a report. diff --git a/packages/stablecoin/Move.lock b/packages/stablecoin/Move.lock new file mode 100644 index 0000000..c46f102 --- /dev/null +++ b/packages/stablecoin/Move.lock @@ -0,0 +1,35 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 2 +manifest_digest = "2D16DF7BBB460E26A4151FFCA9834972024289FF6DFDD87F005D543C741A929E" +deps_digest = "3C4103934B1E040BB6B23F1D610B4EF9F2F1166A50A104EADCF77467C004C600" +dependencies = [ + { name = "Sui" }, + { name = "sui_extensions" }, +] + +[[move.package]] +name = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +name = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { name = "MoveStdlib" }, +] + +[[move.package]] +name = "sui_extensions" +source = { local = "../sui_extensions" } + +dependencies = [ + { name = "Sui" }, +] + +[move.toolchain-version] +compiler-version = "1.32.2" +edition = "2024.beta" +flavor = "sui" diff --git a/packages/stablecoin/Move.toml b/packages/stablecoin/Move.toml new file mode 100644 index 0000000..c741cef --- /dev/null +++ b/packages/stablecoin/Move.toml @@ -0,0 +1,35 @@ +# Copyright 2024 Circle Internet Group, Inc. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "stablecoin" +edition = "2024.beta" +license = "Apache 2.0" + +[dependencies.Sui] +git = "https://github.com/MystenLabs/sui.git" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a" + +[dependencies.sui_extensions] +local = "../sui_extensions" + +[addresses] +stablecoin = "0x0" + +[dev-dependencies] + +[dev-addresses] diff --git a/packages/stablecoin/examples/v2_base.patch b/packages/stablecoin/examples/v2_base.patch new file mode 100644 index 0000000..3ca78f8 --- /dev/null +++ b/packages/stablecoin/examples/v2_base.patch @@ -0,0 +1,43 @@ +diff --git a/packages/stablecoin/sources/roles.move b/packages/stablecoin/sources/roles.move +index 5841027..5e7bb9c 100644 +--- a/packages/stablecoin/sources/roles.move ++++ b/packages/stablecoin/sources/roles.move +@@ -128,6 +128,11 @@ module stablecoin::roles { + } + } + ++ /// Initializes version 2 of Roles. ++ public(package) fun init_v2(_: &mut Roles) { ++ // == Add any logic related to initializating a v2 roles below this line == ++ } ++ + /// [Package private] Change the master minter address. + /// - Only callable by the owner. + public(package) fun update_master_minter(roles: &mut Roles, new_master_minter: address, ctx: &TxContext) { +diff --git a/packages/stablecoin/sources/treasury.move b/packages/stablecoin/sources/treasury.move +index 68e24b8..cdd1afb 100644 +--- a/packages/stablecoin/sources/treasury.move ++++ b/packages/stablecoin/sources/treasury.move +@@ -603,6 +603,9 @@ module stablecoin::treasury { + event::emit(MigrationCompleted { + compatible_versions: *treasury.compatible_versions.keys() + }); ++ ++ // == Add any additional migration logic below this line == ++ treasury.roles.init_v2(); + } + + // === Assertions === +diff --git a/packages/stablecoin/sources/version_control.move b/packages/stablecoin/sources/version_control.move +index 7fa2c47..e3e94a7 100644 +--- a/packages/stablecoin/sources/version_control.move ++++ b/packages/stablecoin/sources/version_control.move +@@ -18,7 +18,7 @@ module stablecoin::version_control { + use sui::vec_set::VecSet; + + /// The current version of the package. +- const VERSION: u64 = 1; ++ const VERSION: u64 = 2; + + // === Errors === + const EIncompatibleVersion: u64 = 0; diff --git a/packages/stablecoin/sources/entry.move b/packages/stablecoin/sources/entry.move new file mode 100644 index 0000000..cc2400d --- /dev/null +++ b/packages/stablecoin/sources/entry.move @@ -0,0 +1,73 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// This module exposes entry functions for the stablecoin module to support calling functions on +/// wrapped objects from PTBs. Currently PTBs are unable to chain a reference from the output of one +/// function to the input of another, making it difficult to call functions on wrapped objects. +/// This module exposes those functions directly using entry functions. +module stablecoin::entry { + use stablecoin::treasury::Treasury; + + // === Entry Functions === + + /// Start owner role transfer process. + /// - Only callable by the owner. + /// - Only callable if the Treasury object is compatible with this package. + entry fun transfer_ownership(treasury: &mut Treasury, new_owner: address, ctx: &TxContext) { + treasury.assert_is_compatible(); + treasury.roles_mut().owner_role_mut().begin_role_transfer(new_owner, ctx) + } + + /// Finalize owner role transfer process. + /// - Only callable by the pending owner. + /// - Only callable if the Treasury object is compatible with this package. + entry fun accept_ownership(treasury: &mut Treasury, ctx: &TxContext) { + treasury.assert_is_compatible(); + treasury.roles_mut().owner_role_mut().accept_role(ctx) + } + + /// Change the master minter address. + /// - Only callable by the owner. + /// - Only callable if the Treasury object is compatible with this package. + entry fun update_master_minter(treasury: &mut Treasury, new_master_minter: address, ctx: &TxContext) { + treasury.assert_is_compatible(); + treasury.roles_mut().update_master_minter(new_master_minter, ctx) + } + + /// Change the blocklister address. + /// - Only callable by the owner. + /// - Only callable if the Treasury object is compatible with this package. + entry fun update_blocklister(treasury: &mut Treasury, new_blocklister: address, ctx: &TxContext) { + treasury.assert_is_compatible(); + treasury.roles_mut().update_blocklister(new_blocklister, ctx) + } + + /// Change the pauser address. + /// - Only callable by the owner. + /// - Only callable if the Treasury object is compatible with this package. + entry fun update_pauser(treasury: &mut Treasury, new_pauser: address, ctx: &TxContext) { + treasury.assert_is_compatible(); + treasury.roles_mut().update_pauser(new_pauser, ctx) + } + + /// Change the metadata updater address. + /// - Only callable by the owner. + /// - Only callable if the Treasury object is compatible with this package. + entry fun update_metadata_updater(treasury: &mut Treasury, new_metadata_updater: address, ctx: &TxContext) { + treasury.assert_is_compatible(); + treasury.roles_mut().update_metadata_updater(new_metadata_updater, ctx) + } +} diff --git a/packages/stablecoin/sources/mint_allowance.move b/packages/stablecoin/sources/mint_allowance.move new file mode 100644 index 0000000..0a9e1f7 --- /dev/null +++ b/packages/stablecoin/sources/mint_allowance.move @@ -0,0 +1,59 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module stablecoin::mint_allowance { + + const EOverflow: u64 = 0; + const EInsufficientAllowance: u64 = 1; + + /// A MintAllowance for a coin of type T. + /// Used for minting and burning. + public struct MintAllowance has store { + value: u64 + } + + /// [Package private] Gets the current allowance of the MintAllowance object. + public(package) fun value(self: &MintAllowance): u64 { + self.value + } + + /// [Package private] Create a new MintAllowance for type T. + public(package) fun new(): MintAllowance { + MintAllowance { value: 0 } + } + + /// [Package private] Set allowance to `value` + public(package) fun set(self: &mut MintAllowance, value: u64) { + self.value = value; + } + + /// [Package private] Increase the allowance by `value` + public(package) fun increase(self: &mut MintAllowance, value: u64) { + assert!(value < (18446744073709551615u64 - self.value), EOverflow); + self.value = self.value + value; + } + + /// [Package private] Decrease the allowance by `value` + public(package) fun decrease(self: &mut MintAllowance, value: u64) { + assert!(self.value >= value, EInsufficientAllowance); + self.value = self.value - value; + } + + /// [Package private] Destroy object + public(package) fun destroy(self: MintAllowance) { + let MintAllowance { value: _ } = self; + } +} diff --git a/packages/stablecoin/sources/roles.move b/packages/stablecoin/sources/roles.move new file mode 100644 index 0000000..533d08d --- /dev/null +++ b/packages/stablecoin/sources/roles.move @@ -0,0 +1,212 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module stablecoin::roles { + use sui::bag::{Self, Bag}; + use sui::event; + use sui_extensions::two_step_role::{Self, TwoStepRole}; + + // === Structs === + + public struct Roles has store { + /// A bag that maintains the mapping of privileged roles and their addresses. + /// Keys are structs that are suffixed with _Key. + /// Values are either addresses or objects containing more complex logic. + data: Bag + } + + /// Type used to specify which TwoStepRole the owner role corresponds to. + public struct OwnerRole has drop {} + + /// Key used to map to the mutable TwoStepRole of the owner EOA + public struct OwnerKey {} has copy, store, drop; + /// Key used to map to the mutable address of the master minter EOA, controlled by owner + public struct MasterMinterKey {} has copy, store, drop; + /// Key used to map to the address of the blocklister EOA, controlled by owner + public struct BlocklisterKey {} has copy, store, drop; + /// Key used to map to the address of the pauser EOA, controlled by owner + public struct PauserKey {} has copy, store, drop; + /// Key used to map to the address of the metadata updater EOA, controlled by owner + public struct MetadataUpdaterKey {} has copy, store, drop; + + // === Events === + + public struct MasterMinterChanged has copy, drop { + old_master_minter: address, + new_master_minter: address, + } + + public struct BlocklisterChanged has copy, drop { + old_blocklister: address, + new_blocklister: address, + } + + public struct PauserChanged has copy, drop { + old_pauser: address, + new_pauser: address, + } + + public struct MetadataUpdaterChanged has copy, drop { + old_metadata_updater: address, + new_metadata_updater: address, + } + + // === View-only functions === + + /// [Package private] Gets a mutable reference to the owner's TwoStepRole object. + public(package) fun owner_role_mut(roles: &mut Roles): &mut TwoStepRole> { + roles.data.borrow_mut(OwnerKey {}) + } + + /// [Package private] Gets an immutable reference to the owner's TwoStepRole object. + public(package) fun owner_role(roles: &Roles): &TwoStepRole> { + roles.data.borrow(OwnerKey {}) + } + + /// Gets the current owner address. + public fun owner(roles: &Roles): address { + roles.owner_role().active_address() + } + + /// Gets the pending owner address. + public fun pending_owner(roles: &Roles): Option
{ + roles.owner_role().pending_address() + } + + /// Gets the master minter address. + public fun master_minter(roles: &Roles): address { + *roles.data.borrow(MasterMinterKey {}) + } + + /// Gets the blocklister address. + public fun blocklister(roles: &Roles): address { + *roles.data.borrow(BlocklisterKey {}) + } + + /// Gets the pauser address. + public fun pauser(roles: &Roles): address { + *roles.data.borrow(PauserKey {}) + } + + /// Gets the metadata updater address. + public fun metadata_updater(roles: &Roles): address { + *roles.data.borrow(MetadataUpdaterKey {}) + } + + // === Write functions === + + /// [Package private] Creates and initializes a Roles object. + public(package) fun new( + owner: address, + master_minter: address, + blocklister: address, + pauser: address, + metadata_updater: address, + ctx: &mut TxContext, + ): Roles { + let mut data = bag::new(ctx); + data.add(OwnerKey {}, two_step_role::new(OwnerRole {}, owner)); + data.add(MasterMinterKey {}, master_minter); + data.add(BlocklisterKey {}, blocklister); + data.add(PauserKey {}, pauser); + data.add(MetadataUpdaterKey {}, metadata_updater); + Roles { + data + } + } + + /// [Package private] Change the master minter address. + /// - Only callable by the owner. + public(package) fun update_master_minter(roles: &mut Roles, new_master_minter: address, ctx: &TxContext) { + roles.owner_role().assert_sender_is_active_role(ctx); + + let old_master_minter = roles.update_address(MasterMinterKey {}, new_master_minter); + + event::emit(MasterMinterChanged { + old_master_minter, + new_master_minter + }); + } + + /// [Package private] Change the blocklister address. + /// - Only callable by the owner. + public(package) fun update_blocklister(roles: &mut Roles, new_blocklister: address, ctx: &TxContext) { + roles.owner_role().assert_sender_is_active_role(ctx); + + let old_blocklister = roles.update_address(BlocklisterKey {}, new_blocklister); + + event::emit(BlocklisterChanged { + old_blocklister, + new_blocklister + }); + } + + /// [Package private] Change the pauser address. + /// - Only callable by the owner. + public(package) fun update_pauser(roles: &mut Roles, new_pauser: address, ctx: &TxContext) { + roles.owner_role().assert_sender_is_active_role(ctx); + + let old_pauser = roles.update_address(PauserKey {}, new_pauser); + + event::emit(PauserChanged { + old_pauser, + new_pauser + }); + } + + /// [Package private] Change the metadata updater address. + /// - Only callable by the owner. + public(package) fun update_metadata_updater(roles: &mut Roles, new_metadata_updater: address, ctx: &TxContext) { + roles.owner_role().assert_sender_is_active_role(ctx); + + let old_metadata_updater = roles.update_address(MetadataUpdaterKey {}, new_metadata_updater); + + event::emit(MetadataUpdaterChanged { + old_metadata_updater, + new_metadata_updater + }); + } + + /// Updates an existing simple address role and returns the previously set address. + /// Fails if the key does not exist, or if the previously set value is not an address. + fun update_address(roles: &mut Roles, key: K, new_address: address): address { + let old_address = roles.data.remove(key); + roles.data.add(key, new_address); + old_address + } + + // === Test Only === + + #[test_only] + public(package) fun create_master_minter_changed_event(old_master_minter: address, new_master_minter: address): MasterMinterChanged { + MasterMinterChanged { old_master_minter, new_master_minter } + } + + #[test_only] + public(package) fun create_blocklister_changed_event(old_blocklister: address, new_blocklister: address): BlocklisterChanged { + BlocklisterChanged { old_blocklister, new_blocklister } + } + + #[test_only] + public(package) fun create_pauser_changed_event(old_pauser: address, new_pauser: address): PauserChanged { + PauserChanged { old_pauser, new_pauser } + } + + #[test_only] + public(package) fun create_metadata_updater_changed_event(old_metadata_updater: address, new_metadata_updater: address): MetadataUpdaterChanged { + MetadataUpdaterChanged { old_metadata_updater, new_metadata_updater } + } +} diff --git a/packages/stablecoin/sources/stablecoin.move b/packages/stablecoin/sources/stablecoin.move new file mode 100644 index 0000000..67c3c19 --- /dev/null +++ b/packages/stablecoin/sources/stablecoin.move @@ -0,0 +1,38 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module stablecoin::stablecoin { + use sui_extensions::upgrade_service; + + public struct STABLECOIN has drop {} + + #[allow(lint(share_owned))] + /// Initializes a shared UpgradeService and sets the + /// transaction's sender as the initial admin. + fun init(witness: STABLECOIN, ctx: &mut TxContext) { + let (upgrade_service, _) = upgrade_service::new( + witness, + ctx.sender() /* admin */, + ctx + ); + transfer::public_share_object(upgrade_service); + } + + #[test_only] + public(package) fun init_for_testing(ctx: &mut TxContext) { + init(STABLECOIN {}, ctx) + } +} diff --git a/packages/stablecoin/sources/treasury.move b/packages/stablecoin/sources/treasury.move new file mode 100644 index 0000000..2552dd9 --- /dev/null +++ b/packages/stablecoin/sources/treasury.move @@ -0,0 +1,779 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module stablecoin::treasury { + use std::string; + use std::ascii; + use std::u64::{min, max}; + use sui::{ + coin::{ + Self, Coin, CoinMetadata, DenyCapV2, TreasuryCap, + + // returns if address is on the deny list based on the most recent update + deny_list_v2_contains_next_epoch as is_blocklisted, + // returns if the global pause is effective based on the most recent update + deny_list_v2_is_global_pause_enabled_next_epoch as is_paused, + }, + deny_list::{DenyList}, + event, + table::{Self, Table}, + dynamic_object_field as dof, + vec_set::{Self, VecSet} + }; + use stablecoin::mint_allowance::{Self, MintAllowance}; + use stablecoin::roles::{Self, Roles}; + use stablecoin::version_control::{Self, assert_object_version_is_compatible_with_package}; + + // === Errors === + + const EControllerAlreadyConfigured: u64 = 0; + const EDeniedAddress: u64 = 1; + const EDenyCapNotFound: u64 = 2; + const EInsufficientAllowance: u64 = 3; + const ENotBlocklister: u64 = 4; + const ENotController: u64 = 5; + const ENotMasterMinter: u64 = 6; + const ENotMetadataUpdater: u64 = 7; + const ENotPauser: u64 = 8; + const EPaused: u64 = 9; + const ETreasuryCapNotFound: u64 = 10; + const EUnauthorizedMintCap: u64 = 11; + const EZeroAmount: u64 = 12; + + /// Migration related error codes, starting at 100. + const EMigrationStarted: u64 = 100; + const EMigrationNotStarted: u64 = 101; + const EObjectMigrated: u64 = 102; + const ENotPendingVersion: u64 = 103; + + // === Structs === + + /// A versioned Treasury of type `T` that stores: + /// - a TreasuryCap object + /// - a DenyCap object + /// - a set of privileged roles that manages different parts of this object's data + /// - additional configurations related to minting and burning + public struct Treasury has key, store { + id: UID, + /// A map of { controller address => MintCap ID that it controls }. + controllers: Table, + /// A map of { authorized MintCap ID => its MintAllowance }. + mint_allowances: Table>, + /// Mutable privileged role addresses. + roles: Roles, + /// The set of package version numbers that object is compatible with. + compatible_versions: VecSet + } + + /// An object representing the ability to mint up to an allowance + /// specified in the Treasury. + /// The privilege can be revoked by the master minter. + public struct MintCap has key, store { + id: UID, + } + + /// Key for retrieving the `TreasuryCap` stored in a `Treasury` dynamic object field + public struct TreasuryCapKey has copy, store, drop {} + /// Key for retrieving `DenyCap` stored in a `Treasury` dynamic object field + public struct DenyCapKey has copy, store, drop {} + + // === Events === + + public struct MintCapCreated has copy, drop { + mint_cap: ID, + } + + public struct ControllerConfigured has copy, drop { + controller: address, + mint_cap: ID, + } + + public struct ControllerRemoved has copy, drop { + controller: address, + } + + public struct MinterConfigured has copy, drop { + controller: address, + mint_cap: ID, + allowance: u64, + } + + public struct MinterRemoved has copy, drop { + controller: address, + mint_cap: ID, + } + + public struct MinterAllowanceIncremented has copy, drop { + controller: address, + mint_cap: ID, + allowance_increment: u64, + new_allowance: u64, + } + + public struct Mint has copy, drop { + mint_cap: ID, + recipient: address, + amount: u64, + } + + public struct Burn has copy, drop { + mint_cap: ID, + amount: u64, + } + + public struct Blocklisted has copy, drop { + `address`: address + } + + public struct Unblocklisted has copy, drop { + `address`: address + } + + public struct Pause has copy, drop {} + + public struct Unpause has copy, drop {} + + public struct MetadataUpdated has copy, drop { + name: string::String, + symbol: ascii::String, + description: string::String, + icon_url: ascii::String + } + + public struct MigrationStarted has copy, drop { + compatible_versions: vector + } + + public struct MigrationAborted has copy, drop { + compatible_versions: vector + } + + public struct MigrationCompleted has copy, drop { + compatible_versions: vector + } + + // === View-only functions === + + /// Gets an immutable reference to the Roles object. + public fun roles(treasury: &Treasury): &Roles { + &treasury.roles + } + + /// [Package private] Gets a mutable reference to the Roles object. + public(package) fun roles_mut(treasury: &mut Treasury): &mut Roles { + &mut treasury.roles + } + + /// Gets the corresponding MintCap ID attached to a controller address. + /// Returns option::none() when input is not a valid controller + public fun get_mint_cap_id(treasury: &Treasury, controller: address): Option { + if (!treasury.controllers.contains(controller)) return option::none(); + option::some(*treasury.controllers.borrow(controller)) + } + + /// Gets the allowance of a MintCap object. + /// Returns 0 if the MintCap object is unauthorized. + public fun mint_allowance(treasury: &Treasury, mint_cap: ID): u64 { + if (!treasury.is_authorized_mint_cap(mint_cap)) return 0; + treasury.mint_allowances.borrow(mint_cap).value() + } + + /// Returns the total amount of Coin in circulation. + public fun total_supply(treasury: &Treasury): u64 { + treasury.borrow_treasury_cap().total_supply() + } + + /// Checks if a MintCap object is authorized to mint. + public fun is_authorized_mint_cap(treasury: &Treasury, id: ID): bool { + treasury.mint_allowances.contains(id) + } + + /// [Package private] Ensures that TreasuryCap exists. + public(package) fun assert_treasury_cap_exists(treasury: &Treasury) { + assert!(dof::exists_with_type<_, TreasuryCap>(&treasury.id, TreasuryCapKey {}), ETreasuryCapNotFound); + } + + /// [Package private] Ensures that DenyCap exists. + public(package) fun assert_deny_cap_exists(treasury: &Treasury) { + assert!(dof::exists_with_type<_, DenyCapV2>(&treasury.id, DenyCapKey {}), EDenyCapNotFound); + } + + /// Gets the set of package versions that the Treasury object is compatible with. + public fun compatible_versions(treasury: &Treasury): vector { + *treasury.compatible_versions.keys() + } + + /// Checks if an address is a mint controller. + fun is_controller(treasury: &Treasury, controller_addr: address): bool { + treasury.controllers.contains(controller_addr) + } + + // === Write functions === + + /// Creates and initializes a Treasury object of type T, wrapping a + /// TreasuryCap and DenyCapV2 of the same type into it. + public fun new( + treasury_cap: TreasuryCap, + deny_cap: DenyCapV2, + owner: address, + master_minter: address, + blocklister: address, + pauser: address, + metadata_updater: address, + ctx: &mut TxContext + ): Treasury { + let roles = roles::new(owner, master_minter, blocklister, pauser, metadata_updater, ctx); + let mut treasury = Treasury { + id: object::new(ctx), + controllers: table::new(ctx), + mint_allowances: table::new(ctx), + roles, + compatible_versions: vec_set::singleton(version_control::current_version()) + }; + dof::add(&mut treasury.id, TreasuryCapKey {}, treasury_cap); + dof::add(&mut treasury.id, DenyCapKey {}, deny_cap); + treasury + } + + /// Configures a controller of a MintCap object. + /// - Only callable by the master minter. + /// - Only callable if the Treasury object is compatible with this package. + entry fun configure_controller( + treasury: &mut Treasury, + controller: address, + mint_cap_id: ID, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + assert!(treasury.roles.master_minter() == ctx.sender(), ENotMasterMinter); + assert!(!treasury.is_controller(controller), EControllerAlreadyConfigured); + + treasury.controllers.add(controller, mint_cap_id); + event::emit(ControllerConfigured { + controller, + mint_cap: mint_cap_id + }); + } + + /// Creates a MintCap object. + /// - Only callable by the master minter. + /// - Only callable if the Treasury object is compatible with this package. + fun create_mint_cap( + treasury: &Treasury, + ctx: &mut TxContext + ): MintCap { + treasury.assert_is_compatible(); + assert!(treasury.roles.master_minter() == ctx.sender(), ENotMasterMinter); + let mint_cap = MintCap { id: object::new(ctx) }; + event::emit(MintCapCreated { + mint_cap: object::id(&mint_cap) + }); + mint_cap + } + + /// Convenience function that + /// 1. creates a MintCap + /// 2. configures the controller for this MintCap object + /// 3. transfers the MintCap object to a minter + /// + /// - Only callable by the master minter. + /// - Only callable if the Treasury object is compatible with this package. + entry fun configure_new_controller( + treasury: &mut Treasury, + controller: address, + minter: address, + ctx: &mut TxContext + ) { + let mint_cap = create_mint_cap(treasury, ctx); + configure_controller(treasury, controller, object::id(&mint_cap), ctx); + transfer::transfer(mint_cap, minter) + } + + /// Removes a controller. + /// - Only callable by the master minter. + /// - Only callable if the Treasury object is compatible with this package. + entry fun remove_controller( + treasury: &mut Treasury, + controller: address, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + assert!(treasury.roles.master_minter() == ctx.sender(), ENotMasterMinter); + assert!(treasury.is_controller(controller), ENotController); + + treasury.controllers.remove(controller); + + event::emit(ControllerRemoved { + controller + }); + } + + /// Authorizes a MintCap object to mint and burn, and sets its allowance. + /// - Only callable by the MintCap's controller. + /// - Only callable when not paused. + /// - Only callable if the Treasury object is compatible with this package. + entry fun configure_minter( + treasury: &mut Treasury, + deny_list: &DenyList, + new_allowance: u64, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + + assert!(!is_paused(deny_list), EPaused); + + let controller = ctx.sender(); + assert!(treasury.is_controller(controller), ENotController); + + let mint_cap_id = *get_mint_cap_id(treasury, controller).borrow(); + if (!treasury.mint_allowances.contains(mint_cap_id)) { + let mut allowance = mint_allowance::new(); + allowance.set(new_allowance); + treasury.mint_allowances.add(mint_cap_id, allowance); + } else { + treasury.mint_allowances.borrow_mut(mint_cap_id).set(new_allowance); + }; + event::emit(MinterConfigured { + controller, + mint_cap: mint_cap_id, + allowance: new_allowance + }); + } + + /// Increment allowance for a MintCap + /// - Only callable by the MintCap's controller. + /// - Only callable when not paused. + /// - Only callable if the Treasury object is compatible with this package. + entry fun increment_mint_allowance( + treasury: &mut Treasury, + deny_list: &DenyList, + allowance_increment: u64, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + + assert!(!is_paused(deny_list), EPaused); + assert!(allowance_increment > 0, EZeroAmount); + + let controller = ctx.sender(); + assert!(treasury.is_controller(controller), ENotController); + + let mint_cap_id = *get_mint_cap_id(treasury, controller).borrow(); + assert!(treasury.is_authorized_mint_cap(mint_cap_id), EUnauthorizedMintCap); + + treasury.mint_allowances.borrow_mut(mint_cap_id).increase(allowance_increment); + let new_allowance = treasury.mint_allowances.borrow(mint_cap_id).value(); + + event::emit(MinterAllowanceIncremented { + controller, + mint_cap: mint_cap_id, + allowance_increment, + new_allowance, + }); + } + + /// Deauthorizes a MintCap object. + /// - Only callable by the MintCap's controller. + /// - Only callable if the Treasury object is compatible with this package. + entry fun remove_minter( + treasury: &mut Treasury, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + + let controller = ctx.sender(); + assert!(treasury.is_controller(controller), ENotController); + + let mint_cap_id = *get_mint_cap_id(treasury, controller).borrow(); + let mint_allowance = treasury.mint_allowances.remove(mint_cap_id); + mint_allowance.destroy(); + event::emit(MinterRemoved { + controller, + mint_cap: mint_cap_id + }); + } + + /// Mints a Coin object with a specified amount (limited to the MintCap's allowance) + /// to a recipient address, increasing the total supply. + /// - Only callable by a minter. + /// - Only callable when not paused. + /// - Only callable if minter is not blocklisted. + /// - Only callable if recipient is not blocklisted. + /// - Only callable if the Treasury object is compatible with this package. + public fun mint( + treasury: &mut Treasury, + mint_cap: &MintCap, + deny_list: &DenyList, + amount: u64, + recipient: address, + ctx: &mut TxContext + ) { + treasury.assert_is_compatible(); + + assert!(!is_paused(deny_list), EPaused); + assert!(!is_blocklisted(deny_list, ctx.sender()), EDeniedAddress); + assert!(!is_blocklisted(deny_list, recipient), EDeniedAddress); + let mint_cap_id = object::id(mint_cap); + assert!(treasury.is_authorized_mint_cap(mint_cap_id), EUnauthorizedMintCap); + assert!(amount > 0, EZeroAmount); + + let mint_allowance = treasury.mint_allowances.borrow_mut(mint_cap_id); + assert!(mint_allowance.value() >= amount, EInsufficientAllowance); + + mint_allowance.decrease(amount); + + treasury.borrow_treasury_cap_mut().mint_and_transfer(amount, recipient, ctx); + + event::emit(Mint { + mint_cap: mint_cap_id, + recipient, + amount, + }); + } + + /// Burns a Coin object, decreasing the total supply. + /// - Only callable by a minter. + /// - Only callable when not paused. + /// - Only callable if minter is not blocklisted. + /// - Only callable if the Treasury object is compatible with this package. + public fun burn( + treasury: &mut Treasury, + mint_cap: &MintCap, + deny_list: &DenyList, + coin: Coin, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + + assert!(!is_paused(deny_list), EPaused); + assert!(!is_blocklisted(deny_list, ctx.sender()), EDeniedAddress); + let mint_cap_id = object::id(mint_cap); + assert!(treasury.is_authorized_mint_cap(mint_cap_id), EUnauthorizedMintCap); + + let amount = coin.value(); + assert!(amount > 0, EZeroAmount); + + treasury.borrow_treasury_cap_mut().burn(coin); + event::emit(Burn { + mint_cap: mint_cap_id, + amount + }); + } + + /// Blocklists an address. + /// - Only callable by the blocklister. + /// - Only callable if the Treasury object is compatible with this package. + entry fun blocklist( + treasury: &mut Treasury, + deny_list: &mut DenyList, + addr: address, + ctx: &mut TxContext + ) { + treasury.assert_is_compatible(); + assert!(treasury.roles.blocklister() == ctx.sender(), ENotBlocklister); + + if (!is_blocklisted(deny_list, addr)) { + coin::deny_list_v2_add(deny_list, treasury.borrow_deny_cap_mut(), addr, ctx); + }; + event::emit(Blocklisted { + `address`: addr + }) + } + + /// Unblocklists an address. + /// - Only callable by the blocklister. + /// - Only callable if the Treasury object is compatible with this package. + entry fun unblocklist( + treasury: &mut Treasury, + deny_list: &mut DenyList, + addr: address, + ctx: &mut TxContext + ) { + treasury.assert_is_compatible(); + assert!(treasury.roles.blocklister() == ctx.sender(), ENotBlocklister); + + if (is_blocklisted(deny_list, addr)) { + coin::deny_list_v2_remove(deny_list, treasury.borrow_deny_cap_mut(), addr, ctx); + }; + event::emit(Unblocklisted { + `address`: addr + }) + } + + /// Triggers stopped state; pause all transfers. + /// - Only callable by the pauser. + /// - Only callable if the Treasury object is compatible with this package. + entry fun pause( + treasury: &mut Treasury, + deny_list: &mut DenyList, + ctx: &mut TxContext + ) { + treasury.assert_is_compatible(); + + assert!(treasury.roles.pauser() == ctx.sender(), ENotPauser); + let deny_cap = treasury.borrow_deny_cap_mut(); + + if (!is_paused(deny_list)) { + coin::deny_list_v2_enable_global_pause(deny_list, deny_cap, ctx); + }; + event::emit(Pause {}); + } + + /// Restores normal state; unpause all transfers. + /// - Only callable by the pauser. + /// - Only callable if the Treasury object is compatible with this package. + entry fun unpause( + treasury: &mut Treasury, + deny_list: &mut DenyList, + ctx: &mut TxContext + ) { + treasury.assert_is_compatible(); + assert!(treasury.roles().pauser() == ctx.sender(), ENotPauser); + let deny_cap = treasury.borrow_deny_cap_mut(); + + if (is_paused(deny_list)) { + coin::deny_list_v2_disable_global_pause(deny_list, deny_cap, ctx); + }; + event::emit(Unpause {}); + } + + /// Returns an immutable reference of the TreasuryCap. + fun borrow_treasury_cap(treasury: &Treasury): &TreasuryCap { + treasury.assert_treasury_cap_exists(); + dof::borrow(&treasury.id, TreasuryCapKey {}) + } + + /// Returns a mutable reference of the TreasuryCap. + fun borrow_treasury_cap_mut(treasury: &mut Treasury): &mut TreasuryCap { + treasury.assert_treasury_cap_exists(); + dof::borrow_mut(&mut treasury.id, TreasuryCapKey {}) + } + + /// Returns a mutable reference of the DenyCap. + fun borrow_deny_cap_mut(treasury: &mut Treasury): &mut DenyCapV2 { + treasury.assert_deny_cap_exists(); + dof::borrow_mut(&mut treasury.id, DenyCapKey {}) + } + + /// Updates the CoinMetadata object of the same type as the Treasury. + /// - Only callable by the metadata updater. + /// - Only callable if the Treasury object is compatible with this package. + entry fun update_metadata( + treasury: &Treasury, + metadata: &mut CoinMetadata, + name: string::String, + symbol: ascii::String, + description: string::String, + icon_url: ascii::String, + ctx: &TxContext + ) { + treasury.assert_is_compatible(); + assert!(treasury.roles.metadata_updater() == ctx.sender(), ENotMetadataUpdater); + treasury.borrow_treasury_cap().update_name(metadata, name); + treasury.borrow_treasury_cap().update_symbol(metadata, symbol); + treasury.borrow_treasury_cap().update_description(metadata, description); + treasury.borrow_treasury_cap().update_icon_url(metadata, icon_url); + event::emit(MetadataUpdated { + name, + symbol, + description, + icon_url + }) + } + + /// Starts the migration process, making the Treasury object be + /// additionally compatible with this package's version. + entry fun start_migration(treasury: &mut Treasury, ctx: &TxContext) { + treasury.roles.owner_role().assert_sender_is_active_role(ctx); + assert!(treasury.compatible_versions.size() == 1, EMigrationStarted); + + let active_version = treasury.compatible_versions.keys()[0]; + assert!(active_version < version_control::current_version(), EObjectMigrated); + + treasury.compatible_versions.insert(version_control::current_version()); + + event::emit(MigrationStarted { + compatible_versions: *treasury.compatible_versions.keys() + }); + } + + /// Aborts the migration process, reverting the Treasury object's compatibility + /// to the previous version. + entry fun abort_migration(treasury: &mut Treasury, ctx: &TxContext) { + treasury.roles.owner_role().assert_sender_is_active_role(ctx); + assert!(treasury.compatible_versions.size() == 2, EMigrationNotStarted); + + let pending_version = max( + treasury.compatible_versions.keys()[0], + treasury.compatible_versions.keys()[1] + ); + assert!(pending_version == version_control::current_version(), ENotPendingVersion); + + treasury.compatible_versions.remove(&pending_version); + + event::emit(MigrationAborted { + compatible_versions: *treasury.compatible_versions.keys() + }); + } + + /// Completes the migration process, making the Treasury object be + /// only compatible with this package's version. + entry fun complete_migration(treasury: &mut Treasury, ctx: &TxContext) { + treasury.roles.owner_role().assert_sender_is_active_role(ctx); + assert!(treasury.compatible_versions.size() == 2, EMigrationNotStarted); + + let (version_a, version_b) = (treasury.compatible_versions.keys()[0], treasury.compatible_versions.keys()[1]); + let (active_version, pending_version) = (min(version_a, version_b), max(version_a, version_b)); + + assert!(pending_version == version_control::current_version(), ENotPendingVersion); + + treasury.compatible_versions.remove(&active_version); + + event::emit(MigrationCompleted { + compatible_versions: *treasury.compatible_versions.keys() + }); + } + + // === Assertions === + + /// [Package private] Asserts that the Treasury object + /// is compatible with the package's version. + public(package) fun assert_is_compatible(treasury: &Treasury) { + assert_object_version_is_compatible_with_package(treasury.compatible_versions); + } + + // === Test Only === + + #[test_only] + public(package) fun get_controllers_for_testing(treasury: &Treasury): &Table { + &treasury.controllers + } + + #[test_only] + public(package) fun get_mint_allowances_for_testing(treasury: &Treasury): &Table> { + &treasury.mint_allowances + } + + #[test_only] + public(package) fun get_deny_cap_for_testing(treasury: &mut Treasury): &mut DenyCapV2 { + treasury.borrow_deny_cap_mut() + } + + #[test_only] + public(package) fun remove_treasury_cap_for_testing(treasury: &mut Treasury): TreasuryCap { + dof::remove(&mut treasury.id, TreasuryCapKey {}) + } + + #[test_only] + public(package) fun remove_deny_cap_for_testing(treasury: &mut Treasury): DenyCapV2 { + dof::remove(&mut treasury.id, DenyCapKey {}) + } + + #[test_only] + public(package) fun create_mint_cap_for_testing(ctx: &mut TxContext): MintCap { + MintCap { id: object::new(ctx) } + } + + #[test_only] + public(package) fun set_compatible_versions_for_testing(treasury: &mut Treasury, compatible_versions: VecSet) { + treasury.compatible_versions = compatible_versions; + } + + #[test_only] + public(package) fun create_mint_cap_created_event(mint_cap: ID): MintCapCreated { + MintCapCreated { mint_cap } + } + + #[test_only] + public(package) fun create_controller_configured_event(controller: address, mint_cap: ID): ControllerConfigured { + ControllerConfigured { controller, mint_cap } + } + + #[test_only] + public(package) fun create_controller_removed_event(controller: address): ControllerRemoved { + ControllerRemoved { controller } + } + + #[test_only] + public(package) fun create_minter_configured_event(controller: address, mint_cap: ID, allowance: u64): MinterConfigured { + MinterConfigured { controller, mint_cap, allowance } + } + + #[test_only] + public(package) fun create_minter_allowance_incremented_event( + controller: address, + mint_cap: ID, + allowance_increment: u64, + new_allowance: u64 + ): MinterAllowanceIncremented { + MinterAllowanceIncremented { controller, mint_cap, allowance_increment, new_allowance } + } + + #[test_only] + public(package) fun create_minter_removed_event(controller: address, mint_cap: ID): MinterRemoved { + MinterRemoved { controller, mint_cap } + } + + #[test_only] + public(package) fun create_mint_event(mint_cap: ID, recipient: address, amount: u64): Mint { + Mint { mint_cap, recipient, amount } + } + + #[test_only] + public(package) fun create_burn_event(mint_cap: ID, amount: u64): Burn { + Burn { mint_cap, amount } + } + + #[test_only] + public(package) fun create_blocklisted_event(`address`: address): Blocklisted { + Blocklisted { `address` } + } + + #[test_only] + public(package) fun create_unblocklisted_event(`address`: address): Unblocklisted { + Unblocklisted { `address` } + } + + #[test_only] + public(package) fun create_migration_started_event(compatible_versions: vector): MigrationStarted { + MigrationStarted { compatible_versions } + } + + #[test_only] + public(package) fun create_migration_aborted_event(compatible_versions: vector): MigrationAborted { + MigrationAborted { compatible_versions } + } + + #[test_only] + public(package) fun create_migration_completed_event(compatible_versions: vector): MigrationCompleted { + MigrationCompleted { compatible_versions } + } + + #[test_only] + public(package) fun create_metadata_updated_event( + name: string::String, + symbol: ascii::String, + description: string::String, + icon_url: ascii::String + ): MetadataUpdated { + MetadataUpdated { + name, + symbol, + description, + icon_url + } + } +} diff --git a/packages/stablecoin/sources/version_control.move b/packages/stablecoin/sources/version_control.move new file mode 100644 index 0000000..41b4f6e --- /dev/null +++ b/packages/stablecoin/sources/version_control.move @@ -0,0 +1,38 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module stablecoin::version_control { + use sui::vec_set::VecSet; + + /// The current version of the package. + const VERSION: u64 = 1; + + // === Errors === + const EIncompatibleVersion: u64 = 0; + + // === Methods === + + /// Gets the current package's version. + public fun current_version(): u64 { + VERSION + } + + /// [Package private] Asserts that an object's compatible version set is + /// compatible with the current package's version. + public(package) fun assert_object_version_is_compatible_with_package(compatible_versions: VecSet) { + assert!(compatible_versions.contains(¤t_version()), EIncompatibleVersion); + } +} diff --git a/packages/stablecoin/tests/mint_allowance_tests.move b/packages/stablecoin/tests/mint_allowance_tests.move new file mode 100644 index 0000000..c00792a --- /dev/null +++ b/packages/stablecoin/tests/mint_allowance_tests.move @@ -0,0 +1,79 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module stablecoin::mint_allowance_tests { + use sui::test_utils::{assert_eq}; + use stablecoin::mint_allowance; + + public struct MINT_ALLOWANCE_TESTS has drop {} + + #[test] + fun create_and_mutate_mint_allowance__should_succeed() { + let mut allowance = mint_allowance::new(); + assert_eq(allowance.value(), 0); + + allowance.set(1); + assert_eq(allowance.value(), 1); + + allowance.decrease(1); + assert_eq(allowance.value(), 0); + + allowance.set(5); + assert_eq(allowance.value(), 5); + + allowance.increase(3); + assert_eq(allowance.value(), 8); + + allowance.destroy(); + } + + #[test, expected_failure(abort_code = ::stablecoin::mint_allowance::EOverflow)] + fun increase__should_fail_on_integer_overflow() { + let mut allowance = mint_allowance::new(); + allowance.set(1); + assert_eq(allowance.value(), 1); + + allowance.increase(18446744073709551615u64); + allowance.destroy(); + } + + #[test, expected_failure(abort_code = ::stablecoin::mint_allowance::EInsufficientAllowance)] + fun decrease__should_fail_if_allowance_is_insufficient() { + let mut allowance = mint_allowance::new(); + assert_eq(allowance.value(), 0); + + allowance.decrease(1); + allowance.destroy(); + } + + #[test] + fun increase_decrease__should_succeed_if_value_is_zero() { + let mut allowance = mint_allowance::new(); + assert_eq(allowance.value(), 0); + + allowance.set(100); + assert_eq(allowance.value(), 100); + + allowance.decrease(0); + assert_eq(allowance.value(), 100); + + allowance.increase(0); + assert_eq(allowance.value(), 100); + + allowance.destroy(); + } +} diff --git a/packages/stablecoin/tests/roles_tests.move b/packages/stablecoin/tests/roles_tests.move new file mode 100644 index 0000000..0405f1e --- /dev/null +++ b/packages/stablecoin/tests/roles_tests.move @@ -0,0 +1,195 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module stablecoin::roles_tests { + use sui::{ + event, + test_scenario::{Self, Scenario}, + test_utils::assert_eq, + test_utils::destroy, + }; + use stablecoin::roles::{Self, Roles, OwnerRole}; + use sui_extensions::{ + two_step_role, + test_utils::last_event_by_type + }; + + public struct ROLES_TEST has drop {} + + // test addresses + const DEPLOYER: address = @0x0; + const OWNER: address = @0x20; + const BLOCKLISTER: address = @0x30; + const PAUSER: address = @0x40; + const RANDOM_ADDRESS: address = @0x50; + const MASTER_MINTER: address = @0x60; + const METADATA_UPDATER: address = @0x70; + + #[test] + fun transfer_ownership_and_update_roles__should_succeed_and_pass_all_assertions() { + let (mut scenario, mut roles) = setup(); + + // transfer ownership to the DEPLOYER address + scenario.next_tx(OWNER); + test_transfer_ownership(DEPLOYER, &mut roles, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_accept_ownership(&mut roles, &mut scenario); + + // use the DEPLOYER address to modify the master minter, blocklister, pauser, and metadata updater + scenario.next_tx(DEPLOYER); + test_update_master_minter(MASTER_MINTER, &mut roles, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_update_blocklister(BLOCKLISTER, &mut roles, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_update_pauser(PAUSER, &mut roles, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_update_metadata_updater(METADATA_UPDATER, &mut roles, &mut scenario); + + scenario.end(); + destroy(roles); + } + + #[test, expected_failure(abort_code = two_step_role::ESenderNotActiveRole)] + fun update_master_minter__should_fail_if_not_sent_by_owner() { + let (mut scenario, mut roles) = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_update_master_minter(RANDOM_ADDRESS, &mut roles, &mut scenario); + + scenario.end(); + destroy(roles); + } + + #[test, expected_failure(abort_code = two_step_role::ESenderNotActiveRole)] + fun update_blocklister__should_fail_if_not_sent_by_owner() { + let (mut scenario, mut roles) = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_update_blocklister(RANDOM_ADDRESS, &mut roles, &mut scenario); + + scenario.end(); + destroy(roles); + } + + #[test, expected_failure(abort_code = two_step_role::ESenderNotActiveRole)] + fun update_pauser__should_fail_if_not_sent_by_owner() { + let (mut scenario, mut roles) = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_update_pauser(RANDOM_ADDRESS, &mut roles, &mut scenario); + + scenario.end(); + destroy(roles); + } + + #[test, expected_failure(abort_code = two_step_role::ESenderNotActiveRole)] + fun update_metadata_updater__should_fail_if_not_sent_by_owner() { + let (mut scenario, mut roles) = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_update_metadata_updater(RANDOM_ADDRESS, &mut roles, &mut scenario); + + scenario.end(); + destroy(roles); + } + + // === Helpers === + + /// Creates a Roles object and assigns all roles to OWNER + fun setup(): (Scenario, Roles) { + let mut scenario = test_scenario::begin(DEPLOYER); + let roles = roles::new(OWNER, OWNER, OWNER, OWNER, OWNER, scenario.ctx()); + assert_eq(roles.owner(), OWNER); + assert_eq(roles.pending_owner().is_none(), true); + assert_eq(roles.master_minter(), OWNER); + assert_eq(roles.pauser(), OWNER); + assert_eq(roles.blocklister(), OWNER); + + (scenario, roles) + } + + public(package) fun test_transfer_ownership(new_owner: address, roles: &mut Roles, scenario: &mut Scenario) { + let old_owner = roles.owner(); + roles.owner_role_mut().begin_role_transfer(new_owner, scenario.ctx()); + assert_eq(roles.owner(), old_owner); + assert_eq(*roles.pending_owner().borrow(), new_owner); + + let expected_event = two_step_role::create_role_transfer_started_event>( + old_owner, new_owner + ); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + public(package) fun test_accept_ownership(roles: &mut Roles, scenario: &mut Scenario) { + let old_owner = roles.owner(); + let pending_owner = roles.pending_owner(); + roles.owner_role_mut().accept_role(scenario.ctx()); + assert_eq(roles.owner(), *pending_owner.borrow()); + assert_eq(roles.pending_owner().is_none(), true); + + let expected_event = two_step_role::create_role_transferred_event>( + old_owner, *pending_owner.borrow() + ); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + public(package) fun test_update_master_minter(new_master_minter: address, roles: &mut Roles, scenario: &mut Scenario) { + let old_master_minter = roles.master_minter(); + roles.update_master_minter(new_master_minter, scenario.ctx()); + assert_eq(roles.master_minter(), new_master_minter); + + let expected_event = roles::create_master_minter_changed_event(old_master_minter, new_master_minter); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + public(package) fun test_update_blocklister(new_blocklister: address, roles: &mut Roles, scenario: &mut Scenario) { + let old_blocklister = roles.blocklister(); + roles.update_blocklister(new_blocklister, scenario.ctx()); + assert_eq(roles.blocklister(), new_blocklister); + + let expected_event = roles::create_blocklister_changed_event(old_blocklister, new_blocklister); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + public(package) fun test_update_pauser(new_pauser: address, roles: &mut Roles, scenario: &mut Scenario) { + let old_pauser = roles.pauser(); + roles.update_pauser(new_pauser, scenario.ctx()); + assert_eq(roles.pauser(), new_pauser); + + let expected_event = roles::create_pauser_changed_event(old_pauser, new_pauser); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + public(package) fun test_update_metadata_updater(new_metadata_updater: address, roles: &mut Roles, scenario: &mut Scenario) { + let old_metadata_updater = roles.metadata_updater(); + roles.update_metadata_updater(new_metadata_updater, scenario.ctx()); + assert_eq(roles.metadata_updater(), new_metadata_updater); + + let expected_event = roles::create_metadata_updater_changed_event(old_metadata_updater, new_metadata_updater); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } +} diff --git a/packages/stablecoin/tests/stablecoin_tests.move b/packages/stablecoin/tests/stablecoin_tests.move new file mode 100644 index 0000000..28f7fc1 --- /dev/null +++ b/packages/stablecoin/tests/stablecoin_tests.move @@ -0,0 +1,40 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module stablecoin::stablecoin_tests { + use sui::{ + test_scenario, + test_utils::{assert_eq} + }; + use stablecoin::stablecoin::{Self, STABLECOIN}; + use sui_extensions::upgrade_service::UpgradeService; + + const DEPLOYER: address = @0x10; + + #[test] + fun init__should_create_shared_upgrade_service() { + let mut scenario = test_scenario::begin(DEPLOYER); + stablecoin::init_for_testing(scenario.ctx()); + + scenario.next_tx(DEPLOYER); + let upgrade_service = scenario.take_shared>(); + assert_eq(upgrade_service.admin(), DEPLOYER); + test_scenario::return_shared(upgrade_service); + + scenario.end(); + } +} diff --git a/packages/stablecoin/tests/treasury_migration_tests.move b/packages/stablecoin/tests/treasury_migration_tests.move new file mode 100644 index 0000000..bd7901b --- /dev/null +++ b/packages/stablecoin/tests/treasury_migration_tests.move @@ -0,0 +1,308 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Tests in this module check that the migration functions work +/// as intended when given an outdated Treasury object. +#[test_only] +module stablecoin::treasury_migration_tests { + use sui::{ + coin, + event, + vec_set, + test_scenario::{Self, Scenario}, + test_utils::{assert_eq, destroy, create_one_time_witness}, + }; + use stablecoin::{ + treasury::{Self, Treasury}, + version_control + }; + use sui_extensions::test_utils::last_event_by_type; + + // Test addresses + const DEPLOYER: address = @0x0; + const OWNER: address = @0x10; + const RANDOM_ADDRESS: address = @0x1000; + + public struct TREASURY_MIGRATION_TESTS has drop {} + + #[test, expected_failure(abort_code = ::stablecoin::two_step_role::ESenderNotActiveRole)] + fun start_migration__should_fail_is_caller_is_not_owner() { + let mut scenario = setup(); + + // Some random address attempts to start a migration, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_start_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EMigrationStarted)] + fun start_migration__should_fail_if_migration_started() { + let mut scenario = setup(); + + // Start a migration to this package. + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + // Attempt to start another migration, should fail. + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EObjectMigrated)] + fun start_migration__should_fail_if_treasury_is_migrated() { + let mut scenario = setup(); + + // Complete a migration flow to this package. + { + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + scenario.next_tx(OWNER); + test_complete_migration(&mut scenario); + }; + + // Attempt to start a migration to this package again, should fail. + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + scenario.end(); + } + + #[test] + fun start_migration__should_succeed_and_pass_all_assertions() { + let mut scenario = setup(); + + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::two_step_role::ESenderNotActiveRole)] + fun abort_migration__should_fail_is_caller_is_not_owner() { + let mut scenario = setup(); + + // Some random address attempts to start a migration, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_abort_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EMigrationNotStarted)] + fun abort_migration__should_fail_if_migration_not_started() { + let mut scenario = setup(); + + // Attempt to abort a migration that has not started, should fail. + scenario.next_tx(OWNER); + test_abort_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotPendingVersion)] + fun abort_migration__should_fail_if_the_pending_version_is_not_this_package_version() { + let mut scenario = setup(); + + // Start a migration flow to a later package. + scenario.next_tx(OWNER); + start_migration_to_custom_version_for_testing(&scenario, version_control::current_version() + 100); + + // Attempt to abort the migration using this package, should fail. + scenario.next_tx(OWNER); + test_abort_migration(&mut scenario); + + scenario.end(); + } + + #[test] + fun abort_migration__should_succeed_and_pass_all_assertions() { + let mut scenario = setup(); + + // Start a migration. + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + // Abort the migration. + scenario.next_tx(OWNER); + test_abort_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::two_step_role::ESenderNotActiveRole)] + fun complete_migration__should_fail_is_caller_is_not_owner() { + let mut scenario = setup(); + + // Some random address attempts to start a migration, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_complete_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EMigrationNotStarted)] + fun complete_migration__should_fail_if_migration_not_started() { + let mut scenario = setup(); + + // Attempt to complete a migration that has not started, should fail. + scenario.next_tx(OWNER); + test_complete_migration(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotPendingVersion)] + fun complete_migration__should_fail_if_the_pending_version_is_not_this_package_version() { + let mut scenario = setup(); + + // Start a migration flow to a later package. + scenario.next_tx(OWNER); + start_migration_to_custom_version_for_testing(&scenario, version_control::current_version() + 100); + + // Attempt to complete the migration using this package, should fail. + scenario.next_tx(OWNER); + test_complete_migration(&mut scenario); + + scenario.end(); + } + + #[test] + fun complete_migration__should_succeed_and_pass_all_assertions() { + let mut scenario = setup(); + + // Start a migration. + scenario.next_tx(OWNER); + test_start_migration(&mut scenario); + + // Complete the migration. + scenario.next_tx(OWNER); + test_complete_migration(&mut scenario); + + scenario.end(); + } + + // === Helpers === + + /// Sets up an outdated Treasury object that is initialized with + /// (package's version - 1). + fun setup(): Scenario { + let mut scenario = test_scenario::begin(DEPLOYER); + + let (treasury_cap, deny_cap, metadata) = coin::create_regulated_currency_v2( + create_one_time_witness(), + 6, + b"SYMBOL", + b"NAME", + b"", + option::none(), + true, + scenario.ctx() + ); + destroy(metadata); + + let mut treasury = treasury::new( + treasury_cap, + deny_cap, + OWNER, + OWNER, + OWNER, + OWNER, + OWNER, + scenario.ctx() + ); + + let previous_version = version_control::current_version() - 1; + + treasury.set_compatible_versions_for_testing(vec_set::singleton(previous_version)); + assert_eq(treasury.compatible_versions(), vector[previous_version]); + + transfer::public_share_object(treasury); + + scenario + } + + fun test_start_migration(scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.start_migration(scenario.ctx()); + + let updated_compatible_versions = treasury.compatible_versions(); + assert_eq(updated_compatible_versions.length(), 2); + assert_eq(updated_compatible_versions.contains(&version_control::current_version()), true); + + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + treasury::create_migration_started_event(updated_compatible_versions) + ); + + test_scenario::return_shared(treasury); + } + + fun test_abort_migration(scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.abort_migration(scenario.ctx()); + + let updated_compatible_versions = treasury.compatible_versions(); + assert_eq(updated_compatible_versions.length(), 1); + assert_eq(updated_compatible_versions.contains(&version_control::current_version()), false); + + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + treasury::create_migration_aborted_event(updated_compatible_versions) + ); + + test_scenario::return_shared(treasury); + } + + fun test_complete_migration(scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.complete_migration(scenario.ctx()); + + let updated_compatible_versions = treasury.compatible_versions(); + assert_eq(updated_compatible_versions, vector[version_control::current_version()]); + + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + treasury::create_migration_completed_event(updated_compatible_versions) + ); + + test_scenario::return_shared(treasury); + } + + fun start_migration_to_custom_version_for_testing(scenario: &Scenario, version: u64) { + let mut treasury = scenario.take_shared>(); + + assert_eq(treasury.compatible_versions().length(), 1); + + let mut compatible_versions = vec_set::from_keys(treasury.compatible_versions()); + compatible_versions.insert(version); + treasury.set_compatible_versions_for_testing(compatible_versions); + + assert_eq(treasury.compatible_versions().length(), 2); + assert_eq(treasury.compatible_versions().contains(&version), true); + + test_scenario::return_shared(treasury); + } +} diff --git a/packages/stablecoin/tests/treasury_tests.move b/packages/stablecoin/tests/treasury_tests.move new file mode 100644 index 0000000..62a1e3d --- /dev/null +++ b/packages/stablecoin/tests/treasury_tests.move @@ -0,0 +1,1539 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module stablecoin::treasury_tests { + use std::string; + use std::ascii; + use sui::{ + coin::{Self, Coin, CoinMetadata}, + deny_list::{Self, DenyList}, + event, + vec_set, + test_scenario::{Self, Scenario}, + test_utils::{Self, assert_eq, destroy}, + }; + use stablecoin::{ + entry, + treasury::{Self, MintCap, Treasury}, + version_control + }; + use sui_extensions::test_utils::last_event_by_type; + + // test addresses + const DEPLOYER: address = @0x0; + const MASTER_MINTER: address = @0x20; + const CONTROLLER: address = @0x30; + const MINTER: address = @0x40; + const MINT_RECIPIENT: address = @0x50; + const MINT_CAP_ADDR: address = @0x60; + const OWNER: address = @0x70; + const BLOCKLISTER: address = @0x80; + const PAUSER: address = @0x01; + const METADATA_UPDATER: address = @0x11; + + const RANDOM_ADDRESS: address = @0x1000; + const RANDOM_ADDRESS_2: address = @0x1001; + const RANDOM_ADDRESS_3: address = @0x1002; + const RANDOM_ADDRESS_4: address = @0x1003; + + public struct TREASURY_TESTS has drop {} + + #[test] + fun e2e_flow__should_succeed_and_pass_all_assertions() { + // Transaction 1: create coin and treasury + let mut scenario = setup(); + + // Transaction 2: configure mint controller and worker + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + // Transaction 3: configure minter + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + // Transaction 4: mint to recipient address + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + // Transaction 5: transfer coin balance to minter to be burnt + scenario.next_tx(MINT_RECIPIENT); + { + let coin = scenario.take_from_sender>(); + assert_eq(coin.value(), 1000000); + transfer::public_transfer(coin, MINTER); + }; + + // Transaction 6: burn minted balance + scenario.next_tx(MINTER); + test_burn(&mut scenario); + + // Transaction 6: remove minter + scenario.next_tx(CONTROLLER); + test_remove_minter(&mut scenario); + + // Transaction 7: remove controller + scenario.next_tx(MASTER_MINTER); + test_remove_controller(CONTROLLER, &mut scenario); + + scenario.end(); + } + + #[test] + fun configure_controller__should_succeed_with_existing_mint_cap() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MASTER_MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(10, &mut scenario); + + scenario.next_tx(MASTER_MINTER); + { + let mut treasury = scenario.take_shared>(); + let mint_cap = scenario.take_from_sender>(); + + treasury.configure_controller(RANDOM_ADDRESS, object::id(&mint_cap), scenario.ctx()); + assert_eq(treasury.get_controllers_for_testing().contains(RANDOM_ADDRESS), true); + assert_eq(treasury.get_controllers_for_testing().contains(CONTROLLER), true); + let mint_cap_id = *treasury.get_mint_cap_id(RANDOM_ADDRESS).borrow(); + assert_eq(*treasury.get_mint_cap_id(CONTROLLER).borrow(), mint_cap_id); + assert_eq(treasury.mint_allowance(mint_cap_id), 10); + + scenario.return_to_sender(mint_cap); + test_scenario::return_shared(treasury); + }; + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EControllerAlreadyConfigured)] + fun configure_controller__should_fail_with_existing_controller() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_controller(CONTROLLER, object::id_from_address(MINT_CAP_ADDR), &mut scenario); + + // Configure the same controller - expect failure + scenario.next_tx(MASTER_MINTER); + test_configure_controller(CONTROLLER, object::id_from_address(MINT_CAP_ADDR), &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotMasterMinter)] + fun configure_controller__should_fail_if_caller_is_not_master_minter() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + { + let mut treasury = scenario.take_shared>(); + treasury.configure_controller(RANDOM_ADDRESS, object::id_from_address(MINT_CAP_ADDR), scenario.ctx()); + test_scenario::return_shared(treasury); + }; + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotMasterMinter)] + fun configure_new_controller__should_fail_if_caller_is_not_master_minter() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_configure_new_controller(CONTROLLER, RANDOM_ADDRESS_2, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EControllerAlreadyConfigured)] + fun configure_new_controller__should_fail_with_existing_controller() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, RANDOM_ADDRESS_2, &mut scenario); + + // Configure the same controller - expect failure + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, RANDOM_ADDRESS_2, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotController)] + fun remove_controller__should_fail_with_non_controller() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_remove_controller(RANDOM_ADDRESS, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotMasterMinter)] + fun remove_controller__should_fail_if_not_sent_by_master_minter() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_remove_controller(CONTROLLER, &mut scenario); + + scenario.end(); + } + + #[test] + fun configure_minter__should_reset_allowance() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(10, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotController)] + fun configure_minter__should_fail_from_non_controller() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_configure_minter(0, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EPaused)] + fun configure_minter__should_fail_when_paused() { + let mut scenario = setup(); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(10, &mut scenario); + + scenario.end(); + } + + #[test] + fun increment_mint_allowance__should_increment_allowance() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_increment_mint_allowance(2, 3 /* expected_allowance */, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EPaused)] + fun increment_mint_allowance__should_fail_when_paused() { + let mut scenario = setup(); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(CONTROLLER); + test_increment_mint_allowance(10, 10 /* expected_allowance */, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EZeroAmount)] + fun increment_mint_allowance__should_fail_when_incrementing_by_zero() { + let mut scenario = setup(); + + scenario.next_tx(CONTROLLER); + test_increment_mint_allowance(0, 0 /* expected_allowance */, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotController)] + fun increment_mint_allowance__should_fail_from_non_controller() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_increment_mint_allowance(10, 10 /* expected_allowance */, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EUnauthorizedMintCap)] + fun increment_mint_allowance__should_fail_with_unauthorized_mint_cap() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_increment_mint_allowance(10, 10 /* expected_allowance */, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::mint_allowance::EOverflow)] + fun increment_mint_allowance__should_fail_when_allowance_increment_causes_overflow() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_increment_mint_allowance(18446744073709551615u64, 0 /* expected_allowance */, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotController)] + fun remove_minter__should_fail_from_non_controller() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_remove_minter(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EZeroAmount)] + fun mint__should_fail_with_zero_amount() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(0, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EUnauthorizedMintCap)] + fun mint__should_fail_from_deauthorized_mint_cap() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_remove_minter(&mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EInsufficientAllowance)] + fun mint__should_fail_if_exceed_allowance() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EInsufficientAllowance)] + fun mint__should_fail_if_exceed_allowance_non_zero() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(900000, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDeniedAddress)] + fun mint__should_fail_from_denylisted_sender() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(BLOCKLISTER); + test_blocklist(MINTER, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDeniedAddress)] + fun mint__should_fail_given_denylisted_recipient() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(BLOCKLISTER); + test_blocklist(MINT_RECIPIENT, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ETreasuryCapNotFound)] + fun mint__should_fail_if_treasury_cap_not_found() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(MASTER_MINTER); + remove_treasury_cap(&scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EUnauthorizedMintCap)] + fun burn__should_fail_from_deauthorized_mint_cap() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_remove_minter(&mut scenario); + + scenario.next_tx(MINTER); + test_burn(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EPaused)] + fun mint__should_fail_when_paused() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINT_RECIPIENT, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDeniedAddress)] + fun burn__should_fail_from_denylisted_sender() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINTER, &mut scenario); + + scenario.next_tx(BLOCKLISTER); + test_blocklist(MINTER, &mut scenario); + + scenario.next_tx(MINTER); + test_burn(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EZeroAmount)] + fun burn__should_fail_with_zero_amount() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(MINTER); + let coin = coin::zero(scenario.ctx()); + transfer::public_transfer(coin, MINTER); + + scenario.next_tx(MINTER); + test_burn(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EPaused)] + fun burn__should_fail_when_paused() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(1000000, &mut scenario); + + scenario.next_tx(MINTER); + test_mint(1000000, MINTER, &mut scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(MINTER); + test_burn(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ETreasuryCapNotFound)] + fun burn__should_fail_if_treasury_cap_not_found() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(MASTER_MINTER); + remove_treasury_cap(&scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(0, &mut scenario); + + scenario.next_tx(MINTER); + let coin = coin::zero(scenario.ctx()); + transfer::public_transfer(coin, MINTER); + + scenario.next_tx(MINTER); + test_burn(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotBlocklister)] + fun blocklist__should_fail_if_caller_is_not_blocklister() { + let mut scenario = setup(); + + // Some random address tries to blocklist an address, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDenyCapNotFound)] + fun blocklist__should_fail_when_deny_cap_is_missing() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + remove_deny_cap(&scenario); + + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.end(); + } + + #[test] + fun blocklist__should_succeed_if_caller_is_blocklister() { + let mut scenario = setup(); + + // Blocklister blocklists an address. + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_blocklisted_current_epoch(&mut scenario, RANDOM_ADDRESS_2, true); + + scenario.end(); + } + + #[test] + fun blocklist__should_be_idempotent() { + let mut scenario = setup(); + + // Blocklister blocklists an address. + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Blocklisting the same address keeps the address in the blocklisted state. + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_blocklisted_current_epoch(&mut scenario, RANDOM_ADDRESS_2, true); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotBlocklister)] + fun unblocklist__should_fail_if_caller_is_not_blocklister() { + let mut scenario = setup(); + + // Blocklister blocklists an address. + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Some random address tries to unblocklist the address, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_unblocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDenyCapNotFound)] + fun unblocklist__should_fail_when_deny_cap_is_missing() { + let mut scenario = setup(); + + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.next_tx(RANDOM_ADDRESS); + remove_deny_cap(&scenario); + + scenario.next_tx(BLOCKLISTER); + test_unblocklist(RANDOM_ADDRESS_2, &mut scenario); + + scenario.end(); + } + + #[test] + fun unblocklist__should_succeed_if_caller_is_blocklister() { + let mut scenario = setup(); + + // Blocklister blocklists an address. + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Blocklister unblocklists the address. + scenario.next_tx(BLOCKLISTER); + test_unblocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Fast forward to next epoch, check blocklist state + scenario.next_epoch(RANDOM_ADDRESS); + test_is_blocklisted_current_epoch(&mut scenario, RANDOM_ADDRESS_2, false); + + scenario.end(); + } + + #[test] + fun unblocklist__should_be_idempotent() { + let mut scenario = setup(); + + // Blocklister blocklists an address. + scenario.next_tx(BLOCKLISTER); + test_blocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Blocklister unblocklists the address. + scenario.next_tx(BLOCKLISTER); + test_unblocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Unblocklisting the same address keeps the address in the unblocklisted state. + scenario.next_tx(BLOCKLISTER); + test_unblocklist(RANDOM_ADDRESS_2, &mut scenario); + + // Fast forward to next epoch, check blocklist state + scenario.next_epoch(RANDOM_ADDRESS); + test_is_blocklisted_current_epoch(&mut scenario, RANDOM_ADDRESS_2, false); + + scenario.end(); + } + + #[test] + fun update_roles__should_succeed_and_pass_all_assertions() { + let mut scenario = setup(); + + // transfer ownership to the DEPLOYER address + scenario.next_tx(OWNER); + test_transfer_ownership(DEPLOYER, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_accept_ownership(&mut scenario); + + // use the DEPLOYER address to modify the master minter, blocklister, pauser, and metadata updater + scenario.next_tx(DEPLOYER); + test_update_master_minter(RANDOM_ADDRESS, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_update_blocklister(RANDOM_ADDRESS_2, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_update_pauser(RANDOM_ADDRESS_3, &mut scenario); + + scenario.next_tx(DEPLOYER); + test_update_metadata_updater(RANDOM_ADDRESS_4, &mut scenario); + + scenario.end(); + } + + #[test] + fun update_metadata__should_succeed_and_pass_all_assertions() { + let mut scenario = setup(); + + scenario.next_tx(METADATA_UPDATER); + test_update_metadata( + string::utf8(b"new name"), + ascii::string(b"new symbol"), + string::utf8(b"new description"), + ascii::string(b"new url"), + &mut scenario + ); + + // try to unset the URL + scenario.next_tx(METADATA_UPDATER); + test_update_metadata( + string::utf8(b"new name"), + ascii::string(b"new symbol"), + string::utf8(b"new description"), + ascii::string(b""), + &mut scenario + ); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotMetadataUpdater)] + fun update_metadata__should_fail_if_not_metadata_updater() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_update_metadata( + string::utf8(b"new name"), + ascii::string(b"new symbol"), + string::utf8(b"new description"), + ascii::string(b"new url"), + &mut scenario + ); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ETreasuryCapNotFound)] + fun update_metadata__should_fail_if_not_treasury_cap_not_found() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + remove_treasury_cap(&scenario); + + scenario.next_tx(METADATA_UPDATER); + test_update_metadata( + string::utf8(b"new name"), + ascii::string(b"new symbol"), + string::utf8(b"new description"), + ascii::string(b"new url"), + &mut scenario + ); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotPauser)] + fun pause__should_fail_when_caller_is_not_pauser() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_pause(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDenyCapNotFound)] + fun pause__should_fail_when_deny_cap_is_missing() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + remove_deny_cap(&scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.end(); + } + + #[test] + fun pause__should_succeed() { + let mut scenario = setup(); + + scenario.next_tx(OWNER); + test_update_pauser(PAUSER, &mut scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_paused_current_epoch(&mut scenario, true); + + scenario.end(); + } + + #[test] + fun pause__should_be_idempotent() { + let mut scenario = setup(); + + scenario.next_tx(OWNER); + test_update_pauser(PAUSER, &mut scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_paused_current_epoch(&mut scenario, true); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ENotPauser)] + fun unpause__should_fail_when_caller_is_not_pauser() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + test_unpause(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EDenyCapNotFound)] + fun unpause__should_fail_when_deny_cap_is_missing() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + remove_deny_cap(&scenario); + + scenario.next_tx(PAUSER); + test_unpause(&mut scenario); + + scenario.end(); + } + + #[test] + fun unpause__should_succeed() { + let mut scenario = setup(); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(PAUSER); + test_unpause(&mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_paused_current_epoch(&mut scenario, false); + + scenario.end(); + } + + #[test] + fun unpause__should_be_idempotent() { + let mut scenario = setup(); + + scenario.next_tx(OWNER); + test_update_pauser(PAUSER, &mut scenario); + + scenario.next_tx(PAUSER); + test_pause(&mut scenario); + + scenario.next_tx(PAUSER); + test_unpause(&mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_paused_current_epoch(&mut scenario, false); + + scenario.next_tx(PAUSER); + test_unpause(&mut scenario); + + scenario.next_epoch(RANDOM_ADDRESS); + test_is_paused_current_epoch(&mut scenario, false); + + scenario.end(); + } + + #[test] + fun is_authorized_mint_cap__should_return_expected_state() { + let mut scenario = setup(); + + scenario.next_tx(MASTER_MINTER); + test_configure_new_controller(CONTROLLER, MINTER, &mut scenario); + + scenario.next_tx(CONTROLLER); + test_configure_minter(10, &mut scenario); + + scenario.next_tx(RANDOM_ADDRESS); + { + let treasury = scenario.take_shared>(); + let mint_cap = scenario.take_from_address>(MINTER); + + let random_object_id = object::new(scenario.ctx()); + assert_eq(treasury.is_authorized_mint_cap(object::id(&mint_cap)), true); + assert_eq(treasury.is_authorized_mint_cap(random_object_id.uid_to_inner()), false); + + object::delete(random_object_id); + test_scenario::return_to_address(MINTER, mint_cap); + test_scenario::return_shared(treasury); + }; + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::ETreasuryCapNotFound)] + fun total_supply__should_fail_when_treasury_cap_is_missing() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + remove_treasury_cap(&scenario); + + scenario.next_tx(RANDOM_ADDRESS); + { + let treasury = scenario.take_shared>(); + treasury.total_supply(); + test_scenario::return_shared(treasury); + }; + + scenario.end(); + } + + #[test] + fun get_mint_cap_id__should_return_empty_when_input_is_not_controller() { + let mut scenario = setup(); + + scenario.next_tx(RANDOM_ADDRESS); + { + let treasury = scenario.take_shared>(); + assert_eq(treasury.get_mint_cap_id(RANDOM_ADDRESS), option::none()); + test_scenario::return_shared(treasury); + }; + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::stablecoin::treasury::EObjectMigrated)] + fun start_migration__should_fail_when_calling_from_current_version() { + let mut scenario = setup(); + + scenario.next_tx(OWNER); + { + let mut treasury = scenario.take_shared>(); + treasury.start_migration(scenario.ctx()); + test_scenario::return_shared(treasury); + }; + + scenario.end(); + } + + // === Incompatible Treasury object tests === + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun configure_controller__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.configure_controller(RANDOM_ADDRESS, object::id_from_address(RANDOM_ADDRESS_2), scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun configure_new_controller__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.configure_new_controller(RANDOM_ADDRESS, RANDOM_ADDRESS_2, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun remove_controller__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.remove_controller(RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun configure_minter__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.configure_minter(&deny_list, 100000, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun increment_mint_allowance__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.increment_mint_allowance(&deny_list, 100000, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun remove_minter__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.remove_minter(scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun mint__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + let mint_cap = treasury::create_mint_cap_for_testing(scenario.ctx()); + treasury.mint( + &mint_cap, + &deny_list, + 100000, + RANDOM_ADDRESS, + scenario.ctx() + ); + destroy(mint_cap); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun burn__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + let mint_cap = treasury::create_mint_cap_for_testing(scenario.ctx()); + treasury.burn( + &mint_cap, + &deny_list, + coin::zero(scenario.ctx()), + scenario.ctx() + ); + destroy(mint_cap); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun blocklist__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, mut deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.blocklist(&mut deny_list, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun unblocklist__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, mut deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.unblocklist(&mut deny_list, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun pause__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, mut deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.pause(&mut deny_list, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun unpause__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, mut deny_list, metadata) = before_incompatible_treasury_object_scenario(); + treasury.unpause(&mut deny_list, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun update_metadata__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, treasury, deny_list, mut metadata) = before_incompatible_treasury_object_scenario(); + treasury.update_metadata( + &mut metadata, + string::utf8(b"new name"), + ascii::string(b"new symbol"), + string::utf8(b"new description"), + ascii::string(b"new url"), + scenario.ctx() + ); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun transfer_ownership__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + entry::transfer_ownership(&mut treasury, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun accept_ownership__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + entry::accept_ownership(&mut treasury, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun update_master_minter__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + entry::update_master_minter(&mut treasury, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun update_blocklister__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + entry::update_blocklister(&mut treasury, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun update_pauser__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + entry::update_pauser(&mut treasury, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + #[test, expected_failure(abort_code = ::stablecoin::version_control::EIncompatibleVersion)] + fun update_metadata_updater__should_fail_if_treasury_object_is_incompatible() { + let (mut scenario, mut treasury, deny_list, metadata) = before_incompatible_treasury_object_scenario(); + entry::update_metadata_updater(&mut treasury, RANDOM_ADDRESS, scenario.ctx()); + after_incompatible_treasury_object_scenario(scenario, treasury, deny_list, metadata); + } + + // === Helpers === + + fun setup(): Scenario { + let mut scenario = test_scenario::begin(DEPLOYER); + { + deny_list::create_for_test(scenario.ctx()); + let otw = test_utils::create_one_time_witness(); + let (treasury_cap, deny_cap, metadata) = coin::create_regulated_currency_v2( + otw, + 6, + b"SYMBOL", + b"NAME", + b"", + option::none(), + true, + scenario.ctx() + ); + + let treasury = treasury::new( + treasury_cap, + deny_cap, + OWNER, + MASTER_MINTER, + BLOCKLISTER, + PAUSER, + METADATA_UPDATER, + scenario.ctx() + ); + assert_eq(treasury.total_supply(), 0); + assert_eq(treasury.get_controllers_for_testing().length(), 0); + assert_eq(treasury.get_mint_allowances_for_testing().length(), 0); + assert_eq(treasury.roles().owner(), OWNER); + assert_eq(treasury.roles().master_minter(), MASTER_MINTER); + assert_eq(treasury.roles().blocklister(), BLOCKLISTER); + assert_eq(treasury.roles().pauser(), PAUSER); + assert_eq(treasury.roles().metadata_updater(), METADATA_UPDATER); + assert_eq(treasury.compatible_versions(), vector[version_control::current_version()]); + treasury.assert_treasury_cap_exists(); + treasury.assert_deny_cap_exists(); + + transfer::public_share_object(metadata); + transfer::public_share_object(treasury); + }; + + scenario + } + + fun before_incompatible_treasury_object_scenario(): (Scenario, Treasury, DenyList, CoinMetadata) { + let mut scenario = setup(); + + // Set compatible_versions to an invalid version. + scenario.next_tx(OWNER); + let mut treasury = scenario.take_shared>(); + treasury.set_compatible_versions_for_testing(vec_set::singleton(version_control::current_version() + 1)); + test_scenario::return_shared(treasury); + + scenario.next_tx(OWNER); + let treasury = scenario.take_shared>(); + let deny_list = scenario.take_shared(); + let metadata = scenario.take_shared>(); + (scenario, treasury, deny_list, metadata) + } + + fun after_incompatible_treasury_object_scenario(scenario: Scenario, treasury: Treasury, deny_list: DenyList, metadata: CoinMetadata) { + test_scenario::return_shared(treasury); + test_scenario::return_shared(deny_list); + test_scenario::return_shared(metadata); + scenario.end(); + } + + fun test_configure_new_controller(controller: address, minter: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.configure_new_controller(controller, minter, scenario.ctx()); + let mint_cap_id = *treasury.get_mint_cap_id(controller).borrow(); + assert_eq(treasury.get_controllers_for_testing().contains(controller), true); + assert_eq(treasury.mint_allowance(*treasury.get_mint_cap_id(controller).borrow()), 0); + + let expected_event1 = treasury::create_mint_cap_created_event(mint_cap_id); + let expected_event2 = treasury::create_controller_configured_event(controller, mint_cap_id); + assert_eq(event::num_events(), 2); + assert_eq(last_event_by_type(), expected_event1); + assert_eq(last_event_by_type(), expected_event2); + + test_scenario::return_shared(treasury); + + // Check new MintCap has been transferred to minter. + scenario.next_tx(minter); + let mint_cap = scenario.take_from_sender>(); + assert_eq(object::id(&mint_cap), mint_cap_id); + scenario.return_to_sender(mint_cap); + } + + fun test_configure_controller(controller: address, mint_cap_id: ID, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.configure_controller(controller, mint_cap_id, scenario.ctx()); + assert_eq(treasury.get_controllers_for_testing().contains(controller), true); + assert_eq(treasury.mint_allowance(*treasury.get_mint_cap_id(controller).borrow()), 0); + + let expected_event = treasury::create_controller_configured_event(controller, mint_cap_id); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(treasury); + } + + fun test_remove_controller(controller: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.remove_controller(controller, scenario.ctx()); + assert_eq(treasury.get_controllers_for_testing().contains(controller), false); + + let expected_event = treasury::create_controller_removed_event(controller); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(treasury); + } + + fun test_configure_minter(allowance: u64, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + let deny_list = scenario.take_shared(); + + treasury.configure_minter(&deny_list, allowance, scenario.ctx()); + + let mint_cap_id = *treasury.get_mint_cap_id(scenario.sender()).borrow(); + assert_eq(treasury.mint_allowance(mint_cap_id), allowance); + + let expected_event = treasury::create_minter_configured_event(scenario.sender(), mint_cap_id, allowance); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(treasury); + test_scenario::return_shared(deny_list); + } + + fun test_increment_mint_allowance(allowance_increment: u64, expected_allowance: u64, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + let deny_list = scenario.take_shared(); + + treasury.increment_mint_allowance(&deny_list, allowance_increment, scenario.ctx()); + + let mint_cap_id = *treasury.get_controllers_for_testing().borrow(scenario.sender()); + assert_eq(treasury.mint_allowance(mint_cap_id), expected_allowance); + + let expected_event = treasury::create_minter_allowance_incremented_event(scenario.sender(), mint_cap_id, allowance_increment, expected_allowance); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(treasury); + test_scenario::return_shared(deny_list); + } + + fun test_remove_minter(scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + + treasury.remove_minter(scenario.ctx()); + + let mint_cap_id = *treasury.get_mint_cap_id(scenario.sender()).borrow(); + assert_eq(treasury.mint_allowance(mint_cap_id), 0); + assert_eq(treasury.get_mint_allowances_for_testing().contains(mint_cap_id), false); + + let expected_event = treasury::create_minter_removed_event(scenario.sender(), mint_cap_id); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(treasury); + } + + fun test_mint(mint_amount: u64, recipient: address, scenario: &mut Scenario) { + let deny_list = scenario.take_shared(); + let mut treasury = scenario.take_shared>(); + let mint_cap = scenario.take_from_sender>(); + + let allowance_before = treasury.mint_allowance(object::id(&mint_cap)); + treasury.mint(&mint_cap, &deny_list, mint_amount, recipient, scenario.ctx()); + assert_eq(treasury.total_supply(), mint_amount); + assert_eq(treasury.mint_allowance(object::id(&mint_cap)), allowance_before - mint_amount); + + let expected_event = treasury::create_mint_event(object::id(&mint_cap), recipient, mint_amount); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + scenario.return_to_sender(mint_cap); + test_scenario::return_shared(treasury); + test_scenario::return_shared(deny_list); + + // Check new coin has been transferred to the recipient at the end of the previous transaction + scenario.next_tx(recipient); + let coin = scenario.take_from_sender>(); + assert_eq(coin.value(), mint_amount); + scenario.return_to_sender(coin); + } + + fun test_burn(scenario: &mut Scenario) { + let sender = scenario.sender(); + let deny_list = scenario.take_shared(); + let mut treasury = scenario.take_shared>(); + let mint_cap = scenario.take_from_sender>(); + let coin = scenario.take_from_sender>(); + let coin_id = object::id(&coin); + + let allowance_before = treasury.mint_allowance(object::id(&mint_cap)); + let amount_before = treasury.total_supply(); + let burn_amount = coin.value(); + treasury.burn(&mint_cap, &deny_list, coin, scenario.ctx()); + assert_eq(treasury.total_supply(), amount_before - burn_amount); + assert_eq(treasury.mint_allowance(object::id(&mint_cap)), allowance_before); + + let expected_event = treasury::create_burn_event(object::id(&mint_cap), burn_amount); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + scenario.return_to_sender(mint_cap); + test_scenario::return_shared(treasury); + test_scenario::return_shared(deny_list); + + // Check coin ID has been deleted at the end of the previous transaction + scenario.next_tx(sender); + assert_eq(scenario.ids_for_sender>().contains(&coin_id), false); + } + + fun test_blocklist(addr: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + let mut deny_list = scenario.take_shared(); + let blocklisted_before = coin::deny_list_v2_contains_current_epoch(&deny_list, addr, scenario.ctx()); + + treasury.blocklist(&mut deny_list, addr, scenario.ctx()); + assert_eq(coin::deny_list_v2_contains_next_epoch(&deny_list, addr), true); + assert_eq(coin::deny_list_v2_contains_current_epoch(&deny_list, addr, scenario.ctx()), blocklisted_before); + + let expected_event = treasury::create_blocklisted_event(addr); + let expected_event_count = 1 + event::events_by_type().length(); + assert_eq(event::num_events() as u64, expected_event_count); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + } + + fun test_unblocklist(addr: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + let mut deny_list = scenario.take_shared(); + let blocklisted_before = coin::deny_list_v2_contains_current_epoch(&deny_list, addr, scenario.ctx()); + + treasury.unblocklist(&mut deny_list, addr, scenario.ctx()); + assert_eq(coin::deny_list_v2_contains_next_epoch(&deny_list, addr), false); + assert_eq(coin::deny_list_v2_contains_current_epoch(&deny_list, addr, scenario.ctx()), blocklisted_before); + + let expected_event = treasury::create_unblocklisted_event(addr); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + } + + fun test_transfer_ownership(new_owner: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + entry::transfer_ownership(&mut treasury, new_owner, scenario.ctx()); + assert_eq(*treasury.roles().pending_owner().borrow(), new_owner); + test_scenario::return_shared(treasury); + } + + fun test_accept_ownership(scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + let pending_owner = treasury.roles().pending_owner(); + entry::accept_ownership(&mut treasury, scenario.ctx()); + assert_eq(treasury.roles().owner(), *pending_owner.borrow()); + assert_eq(treasury.roles().pending_owner().is_none(), true); + test_scenario::return_shared(treasury); + } + + fun test_update_master_minter(new_master_minter: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + entry::update_master_minter(&mut treasury, new_master_minter, scenario.ctx()); + assert_eq(treasury.roles().master_minter(), new_master_minter); + test_scenario::return_shared(treasury); + } + + fun test_update_blocklister(new_blocklister: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + entry::update_blocklister(&mut treasury, new_blocklister, scenario.ctx()); + assert_eq(treasury.roles().blocklister(), new_blocklister); + test_scenario::return_shared(treasury); + } + + fun test_update_pauser(new_pauser: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + entry::update_pauser(&mut treasury, new_pauser, scenario.ctx()); + assert_eq(treasury.roles().pauser(), new_pauser); + test_scenario::return_shared(treasury); + } + + fun test_update_metadata_updater(new_metadata_updater: address, scenario: &mut Scenario) { + let mut treasury = scenario.take_shared>(); + entry::update_metadata_updater(&mut treasury, new_metadata_updater, scenario.ctx()); + assert_eq(treasury.roles().metadata_updater(), new_metadata_updater); + test_scenario::return_shared(treasury); + } + + fun test_pause(scenario: &mut Scenario) { + let mut deny_list = scenario.take_shared(); + let mut treasury = scenario.take_shared>(); + let paused_before = coin::deny_list_v2_is_global_pause_enabled_current_epoch(&deny_list, scenario.ctx()); + + treasury.pause(&mut deny_list, scenario.ctx()); + assert_eq(coin::deny_list_v2_is_global_pause_enabled_next_epoch(&deny_list), true); + assert_eq(coin::deny_list_v2_is_global_pause_enabled_current_epoch(&deny_list, scenario.ctx()), paused_before); + + let expected_event_count = 1 + event::events_by_type().length(); + assert_eq(event::num_events() as u64, expected_event_count); + assert_eq(event::events_by_type>().length(), 1); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + } + + fun test_unpause(scenario: &mut Scenario) { + let mut deny_list = scenario.take_shared(); + let mut treasury = scenario.take_shared>(); + let paused_before = coin::deny_list_v2_is_global_pause_enabled_current_epoch(&deny_list, scenario.ctx()); + + treasury.unpause(&mut deny_list, scenario.ctx()); + + assert_eq(coin::deny_list_v2_is_global_pause_enabled_next_epoch(&deny_list), false); + assert_eq(coin::deny_list_v2_is_global_pause_enabled_current_epoch(&deny_list, scenario.ctx()), paused_before); + + assert_eq(event::num_events(), 1); + assert_eq(event::events_by_type>().length(), 1); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + } + + fun test_is_blocklisted_current_epoch(scenario: &mut Scenario, addr: address, expected: bool) { + let deny_list = scenario.take_shared(); + let treasury = scenario.take_shared>(); + + assert_eq(coin::deny_list_v2_contains_current_epoch(&deny_list, addr, scenario.ctx()), expected); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + } + + fun test_is_paused_current_epoch(scenario: &mut Scenario, expected: bool) { + let deny_list = scenario.take_shared(); + let treasury = scenario.take_shared>(); + + assert_eq(coin::deny_list_v2_is_global_pause_enabled_current_epoch(&deny_list, scenario.ctx()), expected); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + } + + fun test_update_metadata( + name: string::String, + symbol: ascii::String, + description: string::String, + url: ascii::String, + scenario: &mut Scenario + ) { + let treasury = scenario.take_shared>(); + let mut metadata = scenario.take_shared>(); + + treasury.update_metadata(&mut metadata, name, symbol, description, url, scenario.ctx()); + assert_eq(metadata.get_name(), name); + assert_eq(metadata.get_symbol(), symbol); + assert_eq(metadata.get_description(), description); + assert_eq(metadata.get_icon_url().borrow().inner_url(), url); + + let expected_event = treasury::create_metadata_updated_event(name, symbol, description, url); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + + test_scenario::return_shared(treasury); + test_scenario::return_shared(metadata); + } + + fun remove_treasury_cap(scenario: &Scenario) { + let mut treasury = scenario.take_shared>(); + + let treasury_cap = treasury.remove_treasury_cap_for_testing(); + transfer::public_transfer(treasury_cap, MASTER_MINTER); + + test_scenario::return_shared(treasury); + } + + fun remove_deny_cap(scenario: &Scenario) { + let mut treasury = scenario.take_shared>(); + + let treasury_cap = treasury.remove_deny_cap_for_testing(); + transfer::public_transfer(treasury_cap, MASTER_MINTER); + + test_scenario::return_shared(treasury); + } +} diff --git a/packages/stablecoin/tests/version_control_tests.move b/packages/stablecoin/tests/version_control_tests.move new file mode 100644 index 0000000..fcb18c4 --- /dev/null +++ b/packages/stablecoin/tests/version_control_tests.move @@ -0,0 +1,59 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module stablecoin::version_control_tests { + use sui::vec_set::{Self, VecSet}; + use stablecoin::version_control; + + #[test, expected_failure(abort_code = version_control::EIncompatibleVersion)] + fun assert_object_version_is_compatible_with_package__should_abort_if_not_compatible() { + let compatible_versions: VecSet = vec_set::empty(); + version_control::assert_object_version_is_compatible_with_package( + compatible_versions + ) + } + + #[test] + fun assert_object_version_is_compatible_with_package__should_succeed_if_compatible_version_is_contained() { + let compatible_versions: VecSet = vec_set::singleton(version_control::current_version()); + version_control::assert_object_version_is_compatible_with_package( + compatible_versions + ) + } + + #[test] + fun assert_object_version_is_compatible_with_package__should_succeed_if_one_of_multiple_compatible_versions() { + let mut compatible_versions: VecSet = vec_set::empty(); + compatible_versions.insert(version_control::current_version()); + compatible_versions.insert(version_control::current_version() + 1); + + version_control::assert_object_version_is_compatible_with_package( + compatible_versions + ) + } + + #[test, expected_failure(abort_code = version_control::EIncompatibleVersion)] + fun assert_object_version_is_compatible_with_package__should_fail_if_none_of_multiple_compatible_versions() { + let mut compatible_versions: VecSet = vec_set::empty(); + compatible_versions.insert(version_control::current_version() + 1); + compatible_versions.insert(version_control::current_version() + 2); + + version_control::assert_object_version_is_compatible_with_package( + compatible_versions + ) + } +} diff --git a/packages/sui_extensions/Move.lock b/packages/sui_extensions/Move.lock new file mode 100644 index 0000000..95cdbcc --- /dev/null +++ b/packages/sui_extensions/Move.lock @@ -0,0 +1,26 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 2 +manifest_digest = "9721447F29946674D6846478FAE936F6200E9FC1F713E8ED50833CB9CA7AF68B" +deps_digest = "F8BBB0CCB2491CA29A3DF03D6F92277A4F3574266507ACD77214D37ECA3F3082" +dependencies = [ + { name = "Sui" }, +] + +[[move.package]] +name = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +name = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { name = "MoveStdlib" }, +] + +[move.toolchain-version] +compiler-version = "1.32.2" +edition = "2024.beta" +flavor = "sui" diff --git a/packages/sui_extensions/Move.toml b/packages/sui_extensions/Move.toml new file mode 100644 index 0000000..cb3d78f --- /dev/null +++ b/packages/sui_extensions/Move.toml @@ -0,0 +1,32 @@ +# Copyright 2024 Circle Internet Group, Inc. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "sui_extensions" +edition = "2024.beta" +license = "Apache 2.0" + +[dependencies.Sui] +git = "https://github.com/MystenLabs/sui.git" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a" + +[addresses] +sui_extensions = "0x0" + +[dev-dependencies] + +[dev-addresses] diff --git a/packages/sui_extensions/sources/two_step_role.move b/packages/sui_extensions/sources/two_step_role.move new file mode 100644 index 0000000..a628626 --- /dev/null +++ b/packages/sui_extensions/sources/two_step_role.move @@ -0,0 +1,138 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Module containing basic role management functionality, +/// including a two-step transfer process. +/// This module controls the role via address storage (as opposed +/// to using Capabilities) for cases when using Capabilities are not desired. +/// The two-step transfer process ensures the role is never transferred to an +/// inaccesible address. +/// If access to the active_address EOA is lost, this role can not be transferred. +/// +/// Inspired by OpenZeppelin's Ownable2Step in Solidity: +/// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol. +module sui_extensions::two_step_role { + use sui::event; + + // === Errors === + + const ESenderNotActiveRole: u64 = 0; + const EPendingAddressNotSet: u64 = 1; + const ESenderNotPendingAddress: u64 = 2; + + // === Structs === + + public struct TwoStepRole has store { + active_address: address, + pending_address: Option
+ } + + // === Events === + + public struct RoleTransferStarted has copy, drop { + old_address: address, + new_address: address, + } + + public struct RoleTransferred has copy, drop { + old_address: address, + new_address: address, + } + + // === View-only functions === + + /// Gets the active address of the TwoStepRole. + public fun active_address(role: &TwoStepRole): address { + role.active_address + } + + /// Gets the optional, pending address of the TwoStepRole. May be + /// empty if there is no ongoing role transfer. + public fun pending_address(role: &TwoStepRole): Option
{ + role.pending_address + } + + /// Asserts that the transaction sender EOA is the active_address stored on the Role. + /// Aborts with error otherwise. + public fun assert_sender_is_active_role(role: &TwoStepRole, ctx: &TxContext) { + assert!(role.active_address == ctx.sender(), ESenderNotActiveRole); + } + + // === Write functions === + + /// Creates and initializes a TwoStepRole object. + public fun new(_witness: T, active_address: address): TwoStepRole { + TwoStepRole { + active_address, + pending_address: option::none(), + } + } + + /// Start the role transfer. Must be followed by an accept_role call by the new_address EOA. + /// A transfer can be aborted by starting another role transfer process + /// to the current active address and accepting the role. + /// + /// - Only callable by the active address. + public fun begin_role_transfer( + role: &mut TwoStepRole, + new_address: address, + ctx: &TxContext + ) { + role.assert_sender_is_active_role(ctx); + + role.pending_address = option::some(new_address); + + event::emit(RoleTransferStarted { + old_address: role.active_address, + new_address, + }); + } + + /// Complete the role transfer by accepting the role. + /// - Only callable by the pending address. + public fun accept_role( + role: &mut TwoStepRole, + ctx: &TxContext + ) { + assert!(role.pending_address.is_some(), EPendingAddressNotSet); + assert!(role.pending_address.contains(&ctx.sender()), ESenderNotPendingAddress); + + let old_address = role.active_address; + role.active_address = role.pending_address.extract(); + + event::emit(RoleTransferred { + old_address, + new_address: role.active_address + }); + } + + /// Destroys a TwoStepRole object. + public fun destroy(role: TwoStepRole) { + let TwoStepRole { active_address: _, pending_address: _ } = role; + } + + // === Test Only === + + #[test_only] + public fun create_role_transfer_started_event(old_address: address, new_address: address): RoleTransferStarted { + RoleTransferStarted { old_address, new_address } + } + + #[test_only] + public fun create_role_transferred_event(old_address: address, new_address: address): RoleTransferred { + RoleTransferred { old_address, new_address } + } +} diff --git a/packages/sui_extensions/sources/upgrade_service.move b/packages/sui_extensions/sources/upgrade_service.move new file mode 100644 index 0000000..cc1a3c2 --- /dev/null +++ b/packages/sui_extensions/sources/upgrade_service.move @@ -0,0 +1,302 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module sui_extensions::upgrade_service { + use sui::{ + address, + dynamic_object_field as dof, + event, + package::{ + UpgradeTicket, + UpgradeReceipt, + UpgradeCap + }, + types::is_one_time_witness + }; + use sui_extensions::two_step_role::{Self, TwoStepRole}; + use std::type_name; + + // === Errors === + + const ENotOneTimeWitness: u64 = 0; + const ETypeNotFromPackage: u64 = 1; + const EUpgradeCapDoesNotExist: u64 = 2; + const EUpgradeCapExists: u64 = 3; + + // === Structs === + + /// An `UpgradeService` that stores an `UpgradeCap` and delegates access of + /// the capability object to an `admin` address. + /// + /// The type must be a One-Time Witness type that comes + /// from the package that the `UpgradeCap` controls. + public struct UpgradeService has key, store { + id: UID, + /// A mutable `TwoStepRole` representing the admin address that is + /// able to perform upgrades using the `UpgradeCap` that this service custodies. + admin: TwoStepRole> + } + + /// Key for retrieving the `UpgradeCap` stored in an `UpgradeService` dynamic object field. + public struct UpgradeCapKey has copy, store, drop {} + + /// Type used to specify which `TwoStepRole` the admin role corresponds to. + public struct AdminRole has drop {} + + // === Events === + + public struct UpgradeCapDeposited has copy, drop { + upgrade_cap_id: ID + } + + public struct UpgradeCapExtracted has copy, drop { + upgrade_cap_id: ID + } + + public struct UpgradeServiceDestroyed has copy, drop {} + + public struct AuthorizeUpgrade has copy, drop { + package_id: ID, + policy: u8 + } + + public struct CommitUpgrade has copy, drop { + package_id: ID + } + + // === View-only functions === + + /// The ID of the package that the stored `UpgradeCap` authorizes upgrades for. + /// Can be `0x0` if the cap cannot currently authorize an upgrade because there is + /// already a pending upgrade in the transaction. Otherwise guaranteed to be the + /// latest version of any given package. + public fun upgrade_cap_package(upgrade_service: &UpgradeService): ID { + upgrade_service.assert_upgrade_cap_exists(); + upgrade_service.borrow_upgrade_cap().package() + } + + /// The most recent version of the package that the stored `UpgradeCap` manages, + /// increments by one for each successfully applied upgrade. + public fun upgrade_cap_version(upgrade_service: &UpgradeService): u64 { + upgrade_service.assert_upgrade_cap_exists(); + upgrade_service.borrow_upgrade_cap().version() + } + + /// The most permissive kind of upgrade currently supported by the stored `UpgradeCap`. + public fun upgrade_cap_policy(upgrade_service: &UpgradeService): u8 { + upgrade_service.assert_upgrade_cap_exists(); + upgrade_service.borrow_upgrade_cap().policy() + } + + /// Gets the current admin address. + public fun admin(upgrade_service: &UpgradeService): address { + upgrade_service.admin.active_address() + } + + /// Gets the pending admin address. + public fun pending_admin(upgrade_service: &UpgradeService): Option
{ + upgrade_service.admin.pending_address() + } + + // === Write functions === + + /// Creates an `UpgradeService` object, initializing the admin role to + /// the predefined admin address. + public fun new(witness: T, admin: address, ctx: &mut TxContext): (UpgradeService, T) { + assert!(is_one_time_witness(&witness), ENotOneTimeWitness); + let upgrade_service = UpgradeService { + id: object::new(ctx), + admin: two_step_role::new(AdminRole {}, admin) + }; + (upgrade_service, witness) + } + + /// Performs a one-time deposit of an `UpgradeCap` into an `UpgradeService`. + /// `UpgradeCap` must control the package that `T` is defined in. + /// - Only callable if the `UpgradeCap` has not been used for an upgrade. + entry fun deposit(upgrade_service: &mut UpgradeService, upgrade_cap: UpgradeCap) { + let package_address_of_type = address::from_ascii_bytes( + type_name::get_with_original_ids().get_address().as_bytes() + ); + let package_address_of_upgrade_cap = &upgrade_cap.package().to_address(); + assert!(package_address_of_type == package_address_of_upgrade_cap, ETypeNotFromPackage); + + upgrade_service.assert_upgrade_cap_does_not_exist(); + let upgrade_cap_id = object::id(&upgrade_cap); + upgrade_service.add_upgrade_cap(upgrade_cap); + + event::emit(UpgradeCapDeposited { + upgrade_cap_id + }); + } + + /// Extracts the stored `UpgradeCap` and transfers it to a recipient address. + /// - Only callable by the admin. + entry fun extract(upgrade_service: &mut UpgradeService, recipient: address, ctx: &TxContext) { + upgrade_service.admin.assert_sender_is_active_role(ctx); + upgrade_service.assert_upgrade_cap_exists(); + + let upgrade_cap = remove_upgrade_cap(upgrade_service); + let upgrade_cap_id = object::id(&upgrade_cap); + + transfer::public_transfer(upgrade_cap, recipient); + + event::emit(UpgradeCapExtracted { + upgrade_cap_id + }); + } + + /// Permanently destroys the `UpgradeService`. + /// - Only callable by the admin. + entry fun destroy_empty(upgrade_service: UpgradeService, ctx: &TxContext) { + upgrade_service.admin.assert_sender_is_active_role(ctx); + upgrade_service.assert_upgrade_cap_does_not_exist(); + + let UpgradeService { id, admin } = upgrade_service; + id.delete(); + admin.destroy(); + + event::emit(UpgradeServiceDestroyed {}); + } + + /// Start admin role transfer process. + /// - Only callable by the admin. + entry fun change_admin(upgrade_service: &mut UpgradeService, new_admin: address, ctx: &TxContext) { + upgrade_service.admin.begin_role_transfer(new_admin, ctx) + } + + /// Finalize admin role transfer process. + /// - Only callable by the pending admin. + entry fun accept_admin(upgrade_service: &mut UpgradeService, ctx: &TxContext) { + upgrade_service.admin.accept_role(ctx) + } + + /// Issues an `UpgradeTicket` that authorizes the upgrade to a package content with `digest` + /// for the package that the stored `UpgradeCap` manages. + /// - Only callable by the admin. + public fun authorize_upgrade( + upgrade_service: &mut UpgradeService, + policy: u8, + digest: vector, + ctx: &TxContext + ): UpgradeTicket { + upgrade_service.admin.assert_sender_is_active_role(ctx); + upgrade_service.assert_upgrade_cap_exists(); + + let package_id_before_authorization = upgrade_service.borrow_upgrade_cap().package(); + let upgrade_ticket = upgrade_service.borrow_upgrade_cap_mut().authorize(policy, digest); + + event::emit(AuthorizeUpgrade { + package_id: package_id_before_authorization, + policy + }); + + upgrade_ticket + } + + /// Consumes an `UpgradeReceipt` to update the stored `UpgradeCap`, + /// finalizing the upgrade. + /// - Only callable by the admin. + public fun commit_upgrade( + upgrade_service: &mut UpgradeService, + receipt: UpgradeReceipt, + ctx: &TxContext + ) { + upgrade_service.admin.assert_sender_is_active_role(ctx); + upgrade_service.assert_upgrade_cap_exists(); + + let new_package_id = receipt.package(); + upgrade_service.borrow_upgrade_cap_mut().commit(receipt); + + event::emit(CommitUpgrade { + package_id: new_package_id + }); + } + + // === Helper functions === + + /// Stores an `UpgradeCap` in a dynamic object field on an `UpgradeService`. + fun add_upgrade_cap(upgrade_service: &mut UpgradeService, upgrade_cap: UpgradeCap) { + dof::add(&mut upgrade_service.id, UpgradeCapKey {}, upgrade_cap); + } + + /// Returns an immutable reference to the `UpgradeCap` stored in a `UpgradeService`. + fun borrow_upgrade_cap(upgrade_service: &UpgradeService): &UpgradeCap { + dof::borrow(&upgrade_service.id, UpgradeCapKey {}) + } + + /// Returns a mutable reference to the `UpgradeCap` stored in a `UpgradeService`. + fun borrow_upgrade_cap_mut(upgrade_service: &mut UpgradeService): &mut UpgradeCap { + dof::borrow_mut(&mut upgrade_service.id, UpgradeCapKey {}) + } + + /// Removes an `UpgradeCap` that is stored in an `UpgradeService` + fun remove_upgrade_cap(upgrade_service: &mut UpgradeService): UpgradeCap { + dof::remove(&mut upgrade_service.id, UpgradeCapKey {}) + } + + /// Ensures that an `UpgradeCap` exists in an `UpgradeService`. + fun assert_upgrade_cap_exists(upgrade_service: &UpgradeService) { + assert!(upgrade_service.exists_upgrade_cap(), EUpgradeCapDoesNotExist); + } + + /// Ensures that an `UpgradeCap` does not exist in an `UpgradeService`. + fun assert_upgrade_cap_does_not_exist(upgrade_service: &UpgradeService) { + assert!(!upgrade_service.exists_upgrade_cap(), EUpgradeCapExists); + } + + /// Checks whether an `UpgradeCap` exists in an `UpgradeService`. + public(package) fun exists_upgrade_cap(upgrade_service: &UpgradeService): bool { + dof::exists_with_type<_, UpgradeCap>(&upgrade_service.id, UpgradeCapKey {}) + } + + // === Test Only === + + #[test_only] + public(package) fun add_upgrade_cap_for_testing(upgrade_service: &mut UpgradeService, upgrade_cap: UpgradeCap) { + add_upgrade_cap(upgrade_service, upgrade_cap) + } + + #[test_only] + public(package) fun borrow_upgrade_cap_for_testing(upgrade_service: &UpgradeService): &UpgradeCap { + upgrade_service.borrow_upgrade_cap() + } + + #[test_only] + public(package) fun borrow_upgrade_cap_mut_for_testing(upgrade_service: &mut UpgradeService): &mut UpgradeCap { + upgrade_service.borrow_upgrade_cap_mut() + } + + #[test_only] + public(package) fun create_upgrade_cap_deposited_event(upgrade_cap_id: ID): UpgradeCapDeposited { + UpgradeCapDeposited { upgrade_cap_id } + } + + #[test_only] + public(package) fun create_upgrade_cap_extracted_event(upgrade_cap_id: ID): UpgradeCapExtracted { + UpgradeCapExtracted { upgrade_cap_id } + } + + #[test_only] + public(package) fun create_authorize_upgrade_event(package_id: ID, policy: u8): AuthorizeUpgrade { + AuthorizeUpgrade { package_id, policy } + } + + #[test_only] + public(package) fun create_commit_upgrade_event(package_id: ID): CommitUpgrade { + CommitUpgrade { package_id } + } +} diff --git a/packages/sui_extensions/tests/test_utils.move b/packages/sui_extensions/tests/test_utils.move new file mode 100644 index 0000000..16d8d84 --- /dev/null +++ b/packages/sui_extensions/tests/test_utils.move @@ -0,0 +1,27 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module sui_extensions::test_utils { + use sui::event; + use sui::test_utils::assert_eq; + + public fun last_event_by_type(): T { + let events_by_type = event::events_by_type(); + assert_eq(events_by_type.is_empty(), false); + *events_by_type.borrow(events_by_type.length() - 1) + } +} diff --git a/packages/sui_extensions/tests/two_step_role_tests.move b/packages/sui_extensions/tests/two_step_role_tests.move new file mode 100644 index 0000000..0fed5d5 --- /dev/null +++ b/packages/sui_extensions/tests/two_step_role_tests.move @@ -0,0 +1,220 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module sui_extensions::two_step_role_tests { + use sui::{ + event, + test_scenario::{Self, Scenario}, + test_utils::{assert_eq} + }; + use sui_extensions::{ + test_utils::last_event_by_type, + two_step_role::{Self, TwoStepRole} + }; + + public struct TWO_STEP_ROLE_TESTS has drop {} + + // Test addresses + const ADMIN: address = @0xA; + const NEW_ADMIN: address = @0xB; + const INVALID_ADMIN: address = @0xC; + + // === Helper functions === + + fun setup(): (Scenario, TwoStepRole) { + let scenario = test_scenario::begin(ADMIN); + let role = two_step_role::new(TWO_STEP_ROLE_TESTS {}, ADMIN); + (scenario, role) + } + + fun test_begin_role_transfer(new_address: address, role: &mut TwoStepRole, scenario: &mut Scenario) { + let active_address = role.active_address(); + role.begin_role_transfer(new_address, scenario.ctx()); + + assert_eq(role.active_address(), active_address); + assert_eq(role.pending_address(), option::some(new_address)); + + let expected_event = two_step_role::create_role_transfer_started_event(active_address, new_address); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + fun test_accept_role(role: &mut TwoStepRole, scenario: &mut Scenario) { + let old_active_address = role.active_address(); + let new_active_address = role.pending_address(); + role.accept_role(scenario.ctx()); + + assert_eq(role.active_address(), *new_active_address.borrow()); + assert_eq(role.pending_address().is_none(), true); + + let expected_event = two_step_role::create_role_transferred_event( + old_active_address, *new_active_address.borrow() + ); + assert_eq(event::num_events(), 1); + assert_eq(last_event_by_type(), expected_event); + } + + // === Tests === + + // new tests + + #[test] + fun new__should_succeed() { + let (scenario, role) = setup(); + assert_eq(role.active_address(), ADMIN); + assert_eq(role.pending_address(), option::none()); + + role.destroy(); + scenario.end(); + } + + // begin_role_transfer tests + + #[test] + fun begin_role_transfer__should_succeed() { + let (mut scenario, mut role) = setup(); + + scenario.next_tx(ADMIN); + test_begin_role_transfer(NEW_ADMIN, &mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + #[test] + fun begin_role_transfer__should_succeed_if_pending_address_is_set() { + let (mut scenario, mut role) = setup(); + + // Transfer to INVALID_ADMIN + scenario.next_tx(ADMIN); + test_begin_role_transfer(INVALID_ADMIN, &mut role, &mut scenario); + + // Transfer to NEW_ADMIN before original transfer is accepted + scenario.next_tx(ADMIN); + test_begin_role_transfer(NEW_ADMIN, &mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + #[test] + fun begin_role_transfer__should_succeed_when_set_to_current_active_address() { + let (mut scenario, mut role) = setup(); + + // Transfer to current active address + scenario.next_tx(ADMIN); + test_begin_role_transfer(ADMIN, &mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + #[test] + #[expected_failure(abort_code = two_step_role::ESenderNotActiveRole)] + fun begin_role_transfer__should_fail_if_sender_is_not_active_address() { + let (mut scenario, mut role) = setup(); + + scenario.next_tx(INVALID_ADMIN); + role.begin_role_transfer(NEW_ADMIN, scenario.ctx()); + + role.destroy(); + scenario.end(); + } + + // accept_role tests + + #[test] + fun accept_role__should_succeed() { + let (mut scenario, mut role) = setup(); + + scenario.next_tx(ADMIN); + test_begin_role_transfer(NEW_ADMIN, &mut role, &mut scenario); + + scenario.next_tx(NEW_ADMIN); + test_accept_role(&mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + #[test] + fun accept_role__should_succeed_if_pending_address_is_active_address() { + let (mut scenario, mut role) = setup(); + + scenario.next_tx(ADMIN); + test_begin_role_transfer(ADMIN, &mut role, &mut scenario); + + scenario.next_tx(ADMIN); + test_accept_role(&mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + #[test] + #[expected_failure(abort_code = two_step_role::EPendingAddressNotSet)] + fun accept_role__should_fail_if_pending_address_not_set() { + let (mut scenario, mut role) = setup(); + + scenario.next_tx(NEW_ADMIN); + test_accept_role(&mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + #[test] + #[expected_failure(abort_code = two_step_role::ESenderNotPendingAddress)] + fun accept_role__should_fail_if_sender_is_not_pending_address() { + let (mut scenario, mut role) = setup(); + + scenario.next_tx(ADMIN); + test_begin_role_transfer(NEW_ADMIN, &mut role, &mut scenario); + + scenario.next_tx(INVALID_ADMIN); + test_accept_role(&mut role, &mut scenario); + + role.destroy(); + scenario.end(); + } + + // assert_sender_is_active_role tests + + #[test] + fun assert_sender_is_active_role__should_succeed() { + let (mut scenario, role) = setup(); + + scenario.next_tx(ADMIN); + role.assert_sender_is_active_role(scenario.ctx()); + + role.destroy(); + scenario.end(); + } + + #[test] + #[expected_failure] + fun assert_sender_is_active_role__should_fail_if_sender_not_active_address() { + let (mut scenario, role) = setup(); + + scenario.next_tx(INVALID_ADMIN); + role.assert_sender_is_active_role(scenario.ctx()); + + role.destroy(); + scenario.end(); + } + +} diff --git a/packages/sui_extensions/tests/upgrade_service_tests.move b/packages/sui_extensions/tests/upgrade_service_tests.move new file mode 100644 index 0000000..b41da27 --- /dev/null +++ b/packages/sui_extensions/tests/upgrade_service_tests.move @@ -0,0 +1,720 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module sui_extensions::upgrade_service_tests { + use sui::{ + event, + package::{Self, UpgradeTicket, UpgradeReceipt, UpgradeCap}, + test_scenario::{Self, Scenario}, + test_utils::{assert_eq, destroy, create_one_time_witness} + }; + use sui_extensions::{ + upgrade_service::{Self, UpgradeService, AdminRole}, + test_utils::last_event_by_type, + two_step_role + }; + + public struct UPGRADE_SERVICE_TESTS has drop {} + public struct NOT_ONE_TIME_WITNESS has drop {} + + const ZERO_ADDRESS: address = @0x0; + const DEPLOYER: address = @0x10; + const UPGRADE_SERVICE_ADMIN: address = @0x20; + const RANDOM_ADDRESS: address = @0x30; + const UPGRADE_CAP_RECIPIENT: address = @0x40; + + const UPGRADE_CAP_PACKAGE_ID: address = @0x1000; + const TEST_DIGEST: vector = vector[0, 1, 2]; + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::ENotOneTimeWitness)] + fun new__should_fail_if_type_is_not_one_time_witness() { + let mut scenario = test_scenario::begin(DEPLOYER); + + destroy(test_new(&mut scenario, NOT_ONE_TIME_WITNESS {}, UPGRADE_SERVICE_ADMIN)); + + scenario.end(); + } + + #[test] + fun new__should_succeed_and_pass_all_assertions() { + let mut scenario = test_scenario::begin(DEPLOYER); + + destroy(test_new(&mut scenario, create_one_time_witness(), UPGRADE_SERVICE_ADMIN)); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::ETypeNotFromPackage)] + fun deposit__should_fail_if_type_is_not_from_package() { + // Create an `UpgradeService`. + let mut scenario = test_scenario::begin(DEPLOYER); + { + let upgrade_service = test_new(&mut scenario, create_one_time_witness(), UPGRADE_SERVICE_ADMIN); + transfer::public_share_object(upgrade_service); + }; + + // Attempt to deposit an `UpgradeCap` that has a different package id from the package that + // defines `UPGRADE_SERVICE_TESTS`, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + assert_eq(RANDOM_ADDRESS != @sui_extensions, true); + let upgrade_cap_for_random_package = create_upgrade_cap(&mut scenario, RANDOM_ADDRESS.to_id()); + test_deposit(&scenario, upgrade_cap_for_random_package); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::EUpgradeCapExists)] + fun deposit__should_fail_if_upgrade_cap_exists() { + // Create an `UpgradeService`. + let mut scenario = test_scenario::begin(DEPLOYER); + { + let upgrade_service = test_new(&mut scenario, create_one_time_witness(), UPGRADE_SERVICE_ADMIN); + transfer::public_share_object(upgrade_service); + }; + + // Force add an `UpgradeCap`. In practice, this is not possible. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + { + let mut upgrade_service = scenario.take_shared>(); + + let upgrade_cap = create_upgrade_cap(&mut scenario, @sui_extensions.to_id()); + upgrade_service.add_upgrade_cap_for_testing(upgrade_cap); + + test_scenario::return_shared(upgrade_service); + }; + + // Attempt to deposit an `UpgradeCap`, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_cap = create_upgrade_cap(&mut scenario, @sui_extensions.to_id()); + test_deposit(&scenario, upgrade_cap); + + scenario.end(); + } + + #[test] + fun deposit__should_succeed_and_pass_all_assertions() { + // Create an `UpgradeService`. + let mut scenario = test_scenario::begin(DEPLOYER); + { + let upgrade_service = test_new(&mut scenario, create_one_time_witness(), UPGRADE_SERVICE_ADMIN); + transfer::public_share_object(upgrade_service); + }; + + // Deposit an `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_cap = create_upgrade_cap(&mut scenario, @sui_extensions.to_id()); + test_deposit(&scenario, upgrade_cap); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::ESenderNotActiveRole)] + fun extract__should_fail_if_sender_is_not_admin () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Random address attempts to extract the `UpgradeCap`, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_extract(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::EUpgradeCapDoesNotExist)] + fun extract__should_fail_if_upgrade_cap_is_missing () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Extract the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_extract(&mut scenario); + + // Extract the `UpgradeCap` again, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + { + let mut upgrade_service = scenario.take_shared>(); + upgrade_service.extract(UPGRADE_CAP_RECIPIENT, scenario.ctx()); + test_scenario::return_shared(upgrade_service); + }; + + scenario.end(); + } + + #[test] + fun extract__should_succeed_and_pass_all_assertions() { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_extract(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::ESenderNotActiveRole)] + fun destroy_empty__should_fail_if_sender_is_not_admin () { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_extract(&mut scenario); + + // Random address attempts to destroy the `UpgradeService`, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_destroy_empty(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::EUpgradeCapExists)] + fun destroy_empty__should_fail_if_upgrade_cap_exists () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Attempt to destroy the `UpgradeService` when the `UpgradeCap` has not + // been extracted, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_destroy_empty(&mut scenario); + + scenario.end(); + } + + #[test] + fun destroy_empty__should_succeed_and_pass_all_assertions() { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_extract(&mut scenario); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_destroy_empty(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::ESenderNotActiveRole)] + fun change_admin__should_fail_if_sender_is_not_admin () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Random address attempts to change the admin, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_change_admin(&mut scenario, RANDOM_ADDRESS); + + scenario.end(); + } + + #[test] + fun change_admin__should_succeed_and_pass_all_assertions () { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_change_admin(&mut scenario, RANDOM_ADDRESS); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::ESenderNotPendingAddress)] + fun accept_admin__should_fail_if_sender_is_not_pending_admin () { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_change_admin(&mut scenario, UPGRADE_SERVICE_ADMIN); + + // Random address attempts to accept the admin, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_accept_admin(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::EPendingAddressNotSet)] + fun accept_admin__should_fail_if_pending_admin_is_not_set () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Attempt to accept admin when the pending admin has not been set, should fail. + scenario.next_tx(RANDOM_ADDRESS); + test_accept_admin(&mut scenario); + + scenario.end(); + } + + #[test] + fun accept_admin__should_succeed_and_pass_all_assertions () { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_change_admin(&mut scenario, RANDOM_ADDRESS); + + scenario.next_tx(RANDOM_ADDRESS); + test_accept_admin(&mut scenario); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::ESenderNotActiveRole)] + fun authorize_upgrade__should_fail_if_sender_is_not_admin () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Random address attempts to authorize an upgrade, should fail. + scenario.next_tx(RANDOM_ADDRESS); + destroy(test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + )); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::EUpgradeCapDoesNotExist)] + fun authorize_upgrade__should_fail_if_upgrade_cap_is_missing () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Extract the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_extract(&mut scenario); + + // Attempt to authorize an upgrade after the `UpgradeCap` has been extracted, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + destroy(test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + )); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui::package::EAlreadyAuthorized)] + fun authorize_upgrade__should_fail_if_upgrade_cap_has_authorized_an_upgrade () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Authorize an upgrade. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + destroy(test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + )); + + // Attempt to authorize another upgrade, should fail as there is a pending + // upgrade. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + destroy(test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + )); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui::package::ETooPermissive)] + fun authorize_upgrade__should_fail_if_upgrade_is_too_permissive () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Restrict the underlying `UpgradeCap`'s upgrade policy. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + { + let mut upgrade_service = scenario.take_shared>(); + upgrade_service.borrow_upgrade_cap_mut_for_testing().only_dep_upgrades(); + test_scenario::return_shared(upgrade_service); + }; + + // Attempt to authorize an upgrade that has a more permissive policy, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + destroy(test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + )); + + scenario.end(); + } + + #[test] + fun authorize_upgrade__should_succeed_and_pass_all_assertions() { + let mut scenario = setup_with_shared_upgrade_service(); + + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + destroy(test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + )); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::two_step_role::ESenderNotActiveRole)] + fun commit_upgrade__should_fail_if_sender_is_not_admin () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Authorize an upgrade with the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_ticket = test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + ); + + // Perform the upgrade with the authorization ticket. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_receipt = package::test_upgrade(upgrade_ticket); + + // Random address attempts to commit an upgrade, should fail. + // In practice, this is not possible as the `UpgradeReceipt` must + // have been derived from an authorize_upgrade triggered by the + // admin. + scenario.next_tx(RANDOM_ADDRESS); + test_commit_upgrade( + &mut scenario, + upgrade_receipt + ); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui_extensions::upgrade_service::EUpgradeCapDoesNotExist)] + fun commit_upgrade__should_fail_if_upgrade_cap_is_missing () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Authorize an upgrade with the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_ticket = test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + ); + + // Perform the upgrade with the authorization ticket. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_receipt = package::test_upgrade(upgrade_ticket); + + // Extract the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_extract(&mut scenario); + + // Attempt to commit the upgrade after the `UpgradeCap` has been extracted, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_commit_upgrade( + &mut scenario, + upgrade_receipt + ); + + scenario.end(); + } + + #[test, expected_failure(abort_code = ::sui::package::EWrongUpgradeCap)] + fun commit_upgrade__should_fail_if_upgrade_cap_and_receipt_are_mismatched () { + let mut scenario = setup_with_shared_upgrade_service(); + + // Perform an upgrade cycle for a random package. + scenario.next_tx(DEPLOYER); + let mut upgrade_cap_for_random_package = create_upgrade_cap(&mut scenario, RANDOM_ADDRESS.to_id()); + let upgrade_ticket_for_random_package = upgrade_cap_for_random_package.authorize( + package::compatible_policy(), + TEST_DIGEST + ); + let upgrade_receipt_for_random_package = package::test_upgrade(upgrade_ticket_for_random_package); + destroy(upgrade_cap_for_random_package); + + // Attempt to commit the upgrade using an `UpgradeReceipt` that did not derive from the `UpgradeCap`, should fail. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_commit_upgrade( + &mut scenario, + upgrade_receipt_for_random_package + ); + + scenario.end(); + } + + #[test] + fun commit_upgrade__should_succeed_and_pass_all_assertions() { + let mut scenario = setup_with_shared_upgrade_service(); + + // Authorize an upgrade with the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_ticket = test_authorize_upgrade( + &mut scenario, + package::compatible_policy(), + TEST_DIGEST + ); + + // Perform the upgrade with the authorization ticket. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + let upgrade_receipt = package::test_upgrade(upgrade_ticket); + + // Commit the results of the upgrade to the `UpgradeCap`. + scenario.next_tx(UPGRADE_SERVICE_ADMIN); + test_commit_upgrade( + &mut scenario, + upgrade_receipt + ); + + scenario.end(); + } + + // === Helpers === + + #[allow(lint(share_owned))] + fun setup_with_shared_upgrade_service(): Scenario { + // Here, the package id that `UpgradeCap` controls does not match the id of the package + // that defines `UPGRADE_SERVICE_TESTS`. This is intentional as authorizing an upgrade + // requires that the `UpgradeCap`'s package is a non-0x0 address. Conversely, the package + // id of the test type `UPGRADE_SERVICE_TESTS` depends on the address set as the + // package's alias in the package manifest file, and for operational purposes, should be + // able to set to 0x0. + // + // This is a workaround to make the test environment isolated. + let mut scenario = test_scenario::begin(DEPLOYER); + + assert_eq(UPGRADE_CAP_PACKAGE_ID != @sui_extensions, true); + + let mut upgrade_service = test_new(&mut scenario, create_one_time_witness(), UPGRADE_SERVICE_ADMIN); + let upgrade_cap = create_upgrade_cap(&mut scenario, UPGRADE_CAP_PACKAGE_ID.to_id()); + upgrade_service.add_upgrade_cap_for_testing(upgrade_cap); + + transfer::public_share_object(upgrade_service); + + scenario + } + + fun test_new(scenario: &mut Scenario, witness: T, admin: address): UpgradeService { + let (upgrade_service, _) = upgrade_service::new(witness, admin, scenario.ctx()); + + assert_eq(upgrade_service.exists_upgrade_cap(), false); + assert_eq(upgrade_service.admin(), admin); + assert_eq(upgrade_service.pending_admin(), option::none()); + + upgrade_service + } + + fun test_deposit(scenario: &Scenario, upgrade_cap: UpgradeCap) { + let mut upgrade_service = scenario.take_shared>(); + + let expected_upgrade_cap_id = object::id(&upgrade_cap); + let expected_upgrade_cap_package = upgrade_cap.package(); + let expected_upgrade_cap_version = upgrade_cap.version(); + let expected_upgrade_cap_policy = upgrade_cap.policy(); + + upgrade_service.deposit(upgrade_cap); + + assert_eq(upgrade_service.exists_upgrade_cap(), true); + assert_eq(object::id(upgrade_service.borrow_upgrade_cap_for_testing()), expected_upgrade_cap_id); + check_upgrade_service_and_upgrade_cap( + &upgrade_service, + expected_upgrade_cap_package, + expected_upgrade_cap_version, + expected_upgrade_cap_policy + ); + + // Ensure that the correct event was emitted. + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + upgrade_service::create_upgrade_cap_deposited_event(expected_upgrade_cap_id) + ); + + test_scenario::return_shared(upgrade_service); + } + + fun test_extract(scenario: &mut Scenario) { + let mut upgrade_service = scenario.take_shared>(); + + let prev_upgrade_cap_id = object::id(upgrade_service.borrow_upgrade_cap_for_testing()); + let prev_upgrade_cap_package = upgrade_service.borrow_upgrade_cap_for_testing().package(); + let prev_upgrade_cap_version = upgrade_service.borrow_upgrade_cap_for_testing().version(); + let prev_upgrade_cap_policy = upgrade_service.borrow_upgrade_cap_for_testing().policy(); + + upgrade_service.extract(UPGRADE_CAP_RECIPIENT, scenario.ctx()); + assert_eq(upgrade_service.exists_upgrade_cap(), false); + + // Ensure that the correct event was emitted. + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + upgrade_service::create_upgrade_cap_extracted_event(prev_upgrade_cap_id) + ); + + // Ensure that the extracted `UpgradeCap` has the same fields. + scenario.next_tx(UPGRADE_CAP_RECIPIENT); + let upgrade_cap = scenario.take_from_sender(); + check_upgrade_cap( + &upgrade_cap, + prev_upgrade_cap_package, + prev_upgrade_cap_version, + prev_upgrade_cap_policy + ); + scenario.return_to_sender(upgrade_cap); + + test_scenario::return_shared(upgrade_service); + } + + fun test_destroy_empty(scenario: &mut Scenario){ + let upgrade_service = scenario.take_shared>(); + let upgrade_service_object_id = object::id(&upgrade_service); + + upgrade_service.destroy_empty(scenario.ctx()); + + // Ensure that the correct event was emitted. + assert_eq(event::num_events(), 1); + assert_eq(event::events_by_type>().length(), 1); + + // Ensure that the `UpgradeService` was destroyed. + let prev_tx_effects = scenario.next_tx(RANDOM_ADDRESS); + assert_eq(prev_tx_effects.deleted(), vector[upgrade_service_object_id]); + } + + fun test_change_admin(scenario: &mut Scenario, new_admin: address){ + let mut upgrade_service = scenario.take_shared>(); + + let current_admin = upgrade_service.admin(); + + upgrade_service.change_admin(new_admin, scenario.ctx()); + + // Ensure that the admin states are correctly set. + assert_eq(upgrade_service.admin(), current_admin); + assert_eq(upgrade_service.pending_admin(), option::some(new_admin)); + + // Ensure that the correct event was emitted. + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + two_step_role::create_role_transfer_started_event>(current_admin, new_admin) + ); + + test_scenario::return_shared(upgrade_service); + } + + fun test_accept_admin(scenario: &mut Scenario){ + let mut upgrade_service = scenario.take_shared>(); + + let current_admin = upgrade_service.admin(); + let pending_admin = upgrade_service.pending_admin().get_with_default(ZERO_ADDRESS); + + upgrade_service.accept_admin(scenario.ctx()); + + // Ensure that the admin states are correctly set. + assert_eq(upgrade_service.admin(), pending_admin); + assert_eq(upgrade_service.pending_admin(), option::none()); + + // Ensure that the correct event was emitted. + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + two_step_role::create_role_transferred_event>(current_admin, pending_admin) + ); + + test_scenario::return_shared(upgrade_service); + } + + fun test_authorize_upgrade(scenario: &mut Scenario, policy: u8, digest: vector): UpgradeTicket { + let mut upgrade_service = scenario.take_shared>(); + + let prev_upgrade_cap_package = upgrade_service.upgrade_cap_package(); + let prev_upgrade_cap_version = upgrade_service.upgrade_cap_version(); + let prev_upgrade_cap_policy = upgrade_service.upgrade_cap_policy(); + + let upgrade_ticket = upgrade_service.authorize_upgrade(policy, digest, scenario.ctx()); + + // Ensure that the `UpgradeTicket` is created correctly. + check_upgrade_ticket( + &upgrade_ticket, + prev_upgrade_cap_package, + policy, + digest + ); + + check_upgrade_service_and_upgrade_cap( + &upgrade_service, + @0x0.to_id(), + prev_upgrade_cap_version, + prev_upgrade_cap_policy + ); + + // Ensure that the correct events were emitted. + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + upgrade_service::create_authorize_upgrade_event(prev_upgrade_cap_package, policy) + ); + + test_scenario::return_shared(upgrade_service); + upgrade_ticket + } + + fun test_commit_upgrade(scenario: &mut Scenario, receipt: UpgradeReceipt) { + let mut upgrade_service = scenario.take_shared>(); + + let prev_upgrade_cap_version = upgrade_service.upgrade_cap_version(); + let prev_upgrade_cap_policy = upgrade_service.upgrade_cap_policy(); + let new_upgrade_cap_package = receipt.package(); + + upgrade_service.commit_upgrade(receipt, scenario.ctx()); + + check_upgrade_service_and_upgrade_cap( + &upgrade_service, + new_upgrade_cap_package, + prev_upgrade_cap_version + 1, + prev_upgrade_cap_policy + ); + + // Ensure that the correct events were emitted. + assert_eq(event::num_events(), 1); + assert_eq( + last_event_by_type(), + upgrade_service::create_commit_upgrade_event(new_upgrade_cap_package) + ); + + test_scenario::return_shared(upgrade_service); + } + + fun create_upgrade_cap( + scenario: &mut Scenario, + package_id: ID, + ): UpgradeCap { + let upgrade_cap = package::test_publish(package_id, scenario.ctx()); + check_upgrade_cap( + &upgrade_cap, + package_id, + 1, + package::compatible_policy() + ); + upgrade_cap + } + + fun check_upgrade_service_and_upgrade_cap(upgrade_service: &UpgradeService, package: ID, version: u64, policy: u8) { + assert_eq(upgrade_service.upgrade_cap_package(), package); + assert_eq(upgrade_service.upgrade_cap_version(), version); + assert_eq(upgrade_service.upgrade_cap_policy(), policy); + + check_upgrade_cap( + upgrade_service.borrow_upgrade_cap_for_testing(), + package, + version, + policy + ); + } + + fun check_upgrade_cap(upgrade_cap: &UpgradeCap, package: ID, version: u64, policy: u8) { + assert_eq(upgrade_cap.package(), package); + assert_eq(upgrade_cap.version(), version); + assert_eq(upgrade_cap.policy(), policy); + } + + fun check_upgrade_ticket(upgrade_ticket: &UpgradeTicket, package: ID, policy: u8, digest: vector) { + assert_eq(upgrade_ticket.package(), package); + assert_eq(upgrade_ticket.policy(), policy); + assert_eq(*upgrade_ticket.digest(), digest); + } +} diff --git a/packages/usdc/Move.lock b/packages/usdc/Move.lock new file mode 100644 index 0000000..78bc17c --- /dev/null +++ b/packages/usdc/Move.lock @@ -0,0 +1,45 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 2 +manifest_digest = "6241BCC3ECEB3F37DC54B2F9DFCAC10D0A1A604BD03E0DF4143F28D8D41CE638" +deps_digest = "060AD7E57DFB13104F21BE5F5C3759D03F0553FC3229247D9A7A6B45F50D03A3" +dependencies = [ + { name = "Sui" }, + { name = "stablecoin" }, + { name = "sui_extensions" }, +] + +[[move.package]] +name = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +name = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { name = "MoveStdlib" }, +] + +[[move.package]] +name = "stablecoin" +source = { local = "../stablecoin" } + +dependencies = [ + { name = "Sui" }, + { name = "sui_extensions" }, +] + +[[move.package]] +name = "sui_extensions" +source = { local = "../sui_extensions" } + +dependencies = [ + { name = "Sui" }, +] + +[move.toolchain-version] +compiler-version = "1.32.2" +edition = "2024.beta" +flavor = "sui" diff --git a/packages/usdc/Move.toml b/packages/usdc/Move.toml new file mode 100644 index 0000000..79afe98 --- /dev/null +++ b/packages/usdc/Move.toml @@ -0,0 +1,38 @@ +# Copyright 2024 Circle Internet Group, Inc. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "usdc" +edition = "2024.beta" +license = "Apache 2.0" + +[dependencies.Sui] +git = "https://github.com/MystenLabs/sui.git" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "a4185da5659d8d299d34e1bb2515ff1f7e32a20a" + +[dependencies.stablecoin] +local = "../stablecoin" + +[dependencies.sui_extensions] +local = "../sui_extensions" + +[addresses] +usdc = "0x0" + +[dev-dependencies] + +[dev-addresses] diff --git a/packages/usdc/sources/usdc.move b/packages/usdc/sources/usdc.move new file mode 100644 index 0000000..ea2da7c --- /dev/null +++ b/packages/usdc/sources/usdc.move @@ -0,0 +1,74 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module usdc::usdc { + use std::ascii::string; + use sui::coin; + use sui::url; + use stablecoin::treasury; + use sui_extensions::upgrade_service; + + /// The One-Time Witness struct for the USDC coin. + public struct USDC has drop {} + + // === Constants === + + const DESCRIPTION: vector = b"USDC is a US dollar-backed stablecoin issued by Circle. USDC is designed to provide a faster, safer, and more efficient way to send, spend, and exchange money around the world."; + const ICON_URL: vector = b"https://www.circle.com/hubfs/Brand/USDC/USDC_icon_32x32.png"; + + #[allow(lint(share_owned))] + /// Initializes + /// - A shared Treasury object + /// - A shared UpgradeService object + fun init(witness: USDC, ctx: &mut TxContext) { + let (upgrade_service, witness) = upgrade_service::new( + witness, + ctx.sender() /* admin */, + ctx + ); + + let (treasury_cap, deny_cap, metadata) = coin::create_regulated_currency_v2( + witness, + 6, // decimals + b"USDC", // symbol + b"USDC", // name + DESCRIPTION, + option::some(url::new_unsafe(string(ICON_URL))), + true, // allow global pause + ctx + ); + + let treasury = treasury::new( + treasury_cap, + deny_cap, + ctx.sender(), // owner + ctx.sender(), // master minter + ctx.sender(), // blocklister + ctx.sender(), // pauser + ctx.sender(), // metadata updater + ctx + ); + + transfer::public_share_object(metadata); + transfer::public_share_object(treasury); + transfer::public_share_object(upgrade_service); + } + + #[test_only] + public(package) fun init_for_testing(ctx: &mut TxContext) { + init(USDC {}, ctx) + } +} diff --git a/packages/usdc/tests/usdc_tests.move b/packages/usdc/tests/usdc_tests.move new file mode 100644 index 0000000..e81305c --- /dev/null +++ b/packages/usdc/tests/usdc_tests.move @@ -0,0 +1,121 @@ +// Copyright 2024 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[test_only] +module usdc::usdc_tests { + use std::{string, ascii}; + use sui::{ + test_scenario, + test_utils::{assert_eq}, + coin::{Self, CoinMetadata, RegulatedCoinMetadata}, + deny_list::{Self, DenyList}, + url + }; + use stablecoin::treasury::Treasury; + use sui_extensions::upgrade_service::UpgradeService; + use usdc::usdc::{Self, USDC}; + + const DEPLOYER: address = @0x0; + const RANDOM_ADDRESS: address = @0x10; + + #[test] + fun init__should_create_correct_number_of_objects() { + let mut scenario = test_scenario::begin(DEPLOYER); + usdc::init_for_testing(scenario.ctx()); + + let previous_tx_effects = scenario.next_tx(DEPLOYER); + assert_eq(previous_tx_effects.created().length(), 4); + assert_eq(previous_tx_effects.frozen().length(), 1); + assert_eq(previous_tx_effects.shared().length(), 3); // Shared metadata, treasury and upgrade service objects + + scenario.end(); + } + + #[test] + fun init__should_create_correct_coin_metadata() { + let mut scenario = test_scenario::begin(DEPLOYER); + usdc::init_for_testing(scenario.ctx()); + + scenario.next_tx(DEPLOYER); + let metadata = scenario.take_shared>(); + assert_eq(metadata.get_decimals(), 6); + assert_eq(metadata.get_name(), string::utf8(b"USDC")); + assert_eq(metadata.get_symbol(), ascii::string(b"USDC")); + assert_eq(metadata.get_description(), string::utf8(b"USDC is a US dollar-backed stablecoin issued by Circle. USDC is designed to provide a faster, safer, and more efficient way to send, spend, and exchange money around the world.")); + assert_eq(metadata.get_icon_url(), option::some(url::new_unsafe(ascii::string(b"https://www.circle.com/hubfs/Brand/USDC/USDC_icon_32x32.png")))); + test_scenario::return_shared(metadata); + + scenario.end(); + } + + #[test] + fun init__should_create_regulated_coin_metadata() { + let mut scenario = test_scenario::begin(DEPLOYER); + usdc::init_for_testing(scenario.ctx()); + + scenario.next_tx(DEPLOYER); + assert_eq(test_scenario::has_most_recent_immutable>(), true); + + scenario.end(); + } + + #[test] + fun init__should_create_shared_treasury_and_wrap_treasury_cap() { + let mut scenario = test_scenario::begin(DEPLOYER); + usdc::init_for_testing(scenario.ctx()); + + scenario.next_tx(DEPLOYER); + let treasury = scenario.take_shared>(); + assert_eq(treasury.total_supply(), 0); + test_scenario::return_shared(treasury); + + scenario.end(); + } + + #[test] + fun init__should_create_shared_treasury_and_wrap_deny_cap() { + let mut scenario = test_scenario::begin(DEPLOYER); + usdc::init_for_testing(scenario.ctx()); + deny_list::create_for_test(scenario.ctx()); + + scenario.next_tx(DEPLOYER); + + // Check that deny cap is working by adding an address to the deny list + let mut treasury = scenario.take_shared>(); + let mut deny_list = scenario.take_shared(); + + treasury.blocklist(&mut deny_list, RANDOM_ADDRESS, scenario.ctx()); + assert_eq(coin::deny_list_v2_contains_next_epoch(&deny_list, RANDOM_ADDRESS), true); + + test_scenario::return_shared(deny_list); + test_scenario::return_shared(treasury); + + scenario.end(); + } + + #[test] + fun init__should_create_shared_upgrade_service() { + let mut scenario = test_scenario::begin(DEPLOYER); + usdc::init_for_testing(scenario.ctx()); + + scenario.next_tx(DEPLOYER); + let upgrade_service = scenario.take_shared>(); + assert_eq(upgrade_service.admin(), DEPLOYER); + test_scenario::return_shared(upgrade_service); + + scenario.end(); + } +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..a28070f --- /dev/null +++ b/run.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# +# Copyright 2024 Circle Internet Group, Inc. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FULLNODE_PORT="9001" +FAUCET_PORT="9123" +FULLNODE_EPOCH_DURATION_MS="10000" +FULLNODE_URL=http://localhost:$FULLNODE_PORT +FAUCET_URL=http://localhost:$FAUCET_PORT + +function clean() { + for path in $(_get_packages); do + echo ">> Cleaning $path..." + rm -rf $path/build + rm -f $path/.coverage_map.mvcov + rm -f $path/.trace + done +} + +function build() { + for path in $(_get_packages); do + echo ">> Building $path..." + if ! sui move build --path $path --lint; then + exit 1 + fi + done +} + +function static_checks() { + # Fails if any files (eg Move.lock) was updated. + build + if ! git diff --quiet --exit-code "**/Move.lock"; then + echo ">> Did you forget to commit the Move.lock files?" + echo "" + git --no-pager diff "**/Move.lock" + exit 1 + fi +} + +function test() { + for path in $(_get_packages); do + echo ">> Testing $path..." + if ! sui-debug move test --path "$path" --statistics --coverage; then + exit 1 + fi + + if [ -f $path/.coverage_map.mvcov ] + then + echo ">> Printing coverage results for $path..." + sui move coverage summary --path "$path" + + if [ -z "$(sui move coverage summary --path "$path" | grep "% Move Coverage: 100.00")" ] + then + echo ">> Coverage is not at 100%!" + exit 1 + fi + fi + done +} + +function start_network() { + LOG_FILE="$PWD/sui-node.log" + + stop_network + + echo "Starting network in the background..." + echo ">> Fullnode: $FULLNODE_URL" + echo ">> Faucet: $FAUCET_URL" + echo ">> Epoch duration: $FULLNODE_EPOCH_DURATION_MS ms" + echo ">> Logs written to $LOG_FILE" + + # Starts an in-memory node by using the `--force-regenesis` flag. + sui start \ + --fullnode-rpc-port=$FULLNODE_PORT \ + --with-faucet=$FAUCET_PORT \ + --epoch-duration-ms=$FULLNODE_EPOCH_DURATION_MS \ + --force-regenesis &> $LOG_FILE & + + WAIT_TIME=30 + + echo ">> Waiting for Sui node to come online within $WAIT_TIME seconds..." + ELAPSED=0 + SECONDS=0 + while [[ "$ELAPSED" -lt "$WAIT_TIME" ]] + do + FULLNODE_HEALTHCHECK_STATUS_CODE="$(curl -k -s -o /dev/null -w %{http_code} -X POST -H 'Content-Type: application/json' -d "{\"jsonrpc\": \"2.0\", \"method\": \"suix_getLatestSuiSystemState\", \"params\": [], \"id\": 1}" $FULLNODE_URL)" + FAUCET_HEALTHCHECK_STATUS_CODE="$(curl -k -s -o /dev/null -w %{http_code} $FAUCET_URL)" + + if [[ "$FULLNODE_HEALTHCHECK_STATUS_CODE" -eq 200 ]] && [[ "$FAUCET_HEALTHCHECK_STATUS_CODE" -eq 200 ]]; then + echo ">> Sui node is started after $ELAPSED seconds!" + exit 0 + fi + + # Add a heartbeat every 5 seconds and show status + if [[ $(( ELAPSED % 5 )) == 0 && "$ELAPSED" > 0 ]]; then + echo ">> Waiting for Sui node for $ELAPSED seconds.." + echo ">> Fullnode status: $FULLNODE_HEALTHCHECK_STATUS_CODE, Faucet status: $FAUCET_HEALTHCHECK_STATUS_CODE" + fi + + # Ping every second + sleep 1 + ELAPSED=$SECONDS + done +} + +function stop_network() { + # Find the PID of the node using the lsof command + # -t = only return port number + # -c sui = where command name is 'sui' + # -a = + # -i:$FULLNODE_PORT = where the port is '$FULLNODE_PORT' + PID=$(lsof -t -c sui -a -i:$FULLNODE_PORT || true) + + if [ ! -z "$PID" ]; then + echo "Stopping network at pid: $PID..." + kill "$PID" &>/dev/null + rm "$PWD/sui-node.log" + fi +} + +function create_patch() { + GIT_DIFF=$(git diff) + echo "$GIT_DIFF" > $1 +} + +function _get_packages() { + find "packages" -type f -name "Move.toml" -exec dirname {} \; | sort | uniq +} + +# This script takes in a function name as the first argument, +# and runs it in the context of the script. +if [ -z $1 ]; then + echo "Usage: bash run.sh "; + exit 1; +elif declare -f "$1" > /dev/null; then + "$@"; +else + echo "Function '$1' does not exist"; + exit 1; +fi diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..512ecb6 --- /dev/null +++ b/setup.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# +# Copyright 2024 Circle Internet Group, Inc. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +if [[ "$CI" == true ]] +then + OS="ubuntu-x86_64" +else + OS="macos-arm64" +fi + +SUI_VERSION="1.32.2" +SUI_INSTALLATION_DIRECTORY="$HOME/.sui/bin" + +if ! command -v sui &> /dev/null || ! sui -V | grep -q "sui $SUI_VERSION-" +then + echo "Installing Sui binary from Github..." + echo ">> Version: '$SUI_VERSION'" + echo ">> OS: '$OS'" + + # Download and extract Sui binaries. + rm -rf "$SUI_INSTALLATION_DIRECTORY" + mkdir -p "$SUI_INSTALLATION_DIRECTORY" + curl -L -o "$SUI_INSTALLATION_DIRECTORY/sui-v$SUI_VERSION.tgz" "https://github.com/MystenLabs/sui/releases/download/mainnet-v$SUI_VERSION/sui-mainnet-v$SUI_VERSION-$OS.tgz" + tar -xvzf "$SUI_INSTALLATION_DIRECTORY/sui-v$SUI_VERSION.tgz" -C "$SUI_INSTALLATION_DIRECTORY" + rm "$SUI_INSTALLATION_DIRECTORY/sui-v$SUI_VERSION.tgz" + + # Sanity check that the Sui binary was installed correctly + echo "Checking sui installation..." + if ! "$SUI_INSTALLATION_DIRECTORY/sui" -V | grep -q "sui $SUI_VERSION-" + then + echo "Sui binary was not installed correctly" + exit 1 + fi + + if [[ "$CI" == true ]] + then + echo "$SUI_INSTALLATION_DIRECTORY" >> $GITHUB_PATH + else + echo " Sui binary installed successfully. Run the following command to add 'sui' to your shell" + echo " echo 'export PATH=\"$SUI_INSTALLATION_DIRECTORY:\$PATH\"' >> ~/.zshrc" + fi +fi + +echo "Setup completed!"