diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e734a1f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + +## Detail + +## Testing + +## Documentation + +--- + +**Requested Reviewers:** @mention diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88ad383 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +# 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. + +name: CI +on: + push: + branches: [master] + pull_request: + +permissions: + contents: write + +jobs: + run_ci_tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: "20.14.0" + + - name: Setup CI Environment + run: make setup + + - name: Run static checks + run: make static-checks + + - name: Run move tests + run: make test + + - name: Run move prover + run: | + export BOOGIE_EXE=/home/runner/.local/bin/boogie + export CVC5_EXE=/home/runner/.local/bin/cvc5 + export Z3_EXE=/home/runner/.local/bin/z3 + make prove + + - name: Start network + run: make start-network + + - name: Run Typescript tests + run: yarn test + + scan: + if: github.event_name == 'pull_request' + uses: circlefin/circle-public-github-workflows/.github/workflows/pr-scan.yaml@v1 + with: + allow-reciprocal-licenses: false + + release-sbom: + if: github.event_name == 'push' + uses: circlefin/circle-public-github-workflows/.github/workflows/attach-release-assets.yaml@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ea743a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.aptos/ +gas-profiling/ +*.log +bin/ +build/ +node_modules/ +logs/ +**/build-output* +**/.coverage_map.mvcov +**/.trace +**/boogie.bpl +scripts/typescript/resources/** +!scripts/typescript/resources/default_token.json +!scripts/typescript/resources/*.template.json + +# Intellij +.idea diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..227611d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +yarn format diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..42e31a0 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.14.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d085fb1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +build/* +node_modules/* +yarn.lock diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..36b3563 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "trailingComma": "none" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7cfefd6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "movebit.aptos-move-analyzer", + "ymotongpoo.licenser", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7139399 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "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, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "aptos-move-analyzer.movefmt.enable": false, + "aptos-move-analyzer.inlay.hints.enable": false +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7c45505 --- /dev/null +++ b/Makefile @@ -0,0 +1,145 @@ +.PHONY: setup static-checks fmt lint clean build-publish-payload build-dev prove test start-network stop-network create-local-account + +# === Move compiler settings === + +# Refer to https://github.com/aptos-labs/aptos-core/blob/687937182b30f32895d13e4384a96e03f569468c/third_party/move/move-model/src/metadata.rs#L80 +compiler_version = 1 + +# Refer to https://github.com/aptos-labs/aptos-core/blob/687937182b30f32895d13e4384a96e03f569468c/third_party/move/move-model/src/metadata.rs#L179 +# NOTE: The bytecode_version used during compilation is auto-inferred from this setting. Refer to https://github.com/aptos-labs/aptos-core/blob/687937182b30f32895d13e4384a96e03f569468c/third_party/move/move-model/src/metadata.rs#L235-L241 +# for more information. +language_version = 1 + +setup: + @bash scripts/shell/setup.sh + +static-checks: + @yarn format:check && yarn type-check && yarn lint && make lint + +fmt: + @packages=$$(find "packages" -type d -mindepth 1 -maxdepth 1); \ + for package in $$packages; do \ + aptos move fmt --package-path $$package --config max_width=120; \ + done; + +lint: + @packages=$$(find "packages" -type d -mindepth 1 -maxdepth 1); \ + for package in $$packages; do \ + echo ">> Running linter on $$package" && \ + LINT_RESULTS="$$(\ + aptos move lint \ + --package-dir $$package \ + --language-version "$(language_version)" \ + --dev 2>&1 \ + )" && \ + if $$(echo "$$LINT_RESULTS" | grep "warning" --quiet); then \ + echo ">> Linting failed for $$package\n"; \ + echo "$$LINT_RESULTS"; \ + exit 1; \ + fi; \ + done + +clean: + @packages=$$(find "packages" -type d -mindepth 1 -maxdepth 1); \ + for package in $$packages; do \ + echo ">> Cleaning $$package..."; \ + rm -rf $$package/build; \ + rm -f $$package/.coverage_map.mvcov; \ + rm -f $$package/.trace; \ + done; \ + echo ">> Cleaning TS script build output..."; \ + rm -rf scripts/typescript/build-output + +build-publish-payload: clean + @if [ -z "$(package)" ] || [ -z "$(output)" ] || [ -z "$(included_artifacts)" ]; then \ + echo "Usage: make build-publish-payload package=\"\" output=\"\" included_artifacts=\"\" [named_addresses=\"\"]"; \ + exit 1; \ + fi; \ + \ + mkdir -p "$$(dirname "$(output)")"; \ + echo ">> Building $$package..."; \ + aptos move build-publish-payload \ + --assume-yes \ + --package-dir "packages/$(package)" \ + --named-addresses "$(named_addresses)" \ + --language-version "$(language_version)" \ + --compiler-version "$(compiler_version)" \ + --json-output-file "$(output)" \ + --included-artifacts "$(included_artifacts)"; + +verify-metadata: + @if [ -z "$(package)" ] || [ -z "$(package_id)" ] || [ -z "$(url)" ] || [ -z "$(included_artifacts)" ]; then \ + echo "Usage: make verify-package package=\"\" package_id=\"\" included_artifacts=\"\" url=\"\" [named_addresses=\"\"]"; \ + exit 1; \ + fi; \ + \ + aptos move verify-package \ + --package-dir "packages/$(package)" \ + --account "$(package_id)" \ + --named-addresses "$(named_addresses)" \ + --language-version "$(language_version)" \ + --compiler-version "$(compiler_version)" \ + --included-artifacts "$(included_artifacts)" \ + --url "${url}"; + +build-dev: clean + @packages=$$(find "packages" -type d -mindepth 1 -maxdepth 1); \ + for package in $$packages; do \ + echo ">> Building $$package in dev mode..."; \ + aptos move compile \ + --dev \ + --package-dir $$package \ + --language-version "$(language_version)" \ + --compiler-version "$(compiler_version)"; \ + done + +prove: clean + @packages=$$(find "packages" -type d -mindepth 1 -maxdepth 1); \ + for package in $$packages; do \ + echo ">> Running Move Prover for $$package..."; \ + aptos move prove \ + --package-dir $$package \ + --dev \ + --language-version "$(language_version)" \ + --compiler-version "$(compiler_version)"; \ + done + +test: clean + @packages=$$(find "packages" -type d -mindepth 1 -maxdepth 1); \ + for package in $$packages; do \ + echo ">> Testing $$package..."; \ + aptos move test \ + --package-dir $$package \ + --coverage \ + --dev \ + --language-version "$(language_version)" \ + --compiler-version "$(compiler_version)"; \ + \ + COVERAGE_RESULTS=$$(\ + aptos move coverage summary \ + --package-dir $$package \ + --dev \ + --language-version "$(language_version)" \ + --compiler-version "$(compiler_version)" \ + ); \ + \ + if [ -z "$$(echo "$$COVERAGE_RESULTS" | grep "% Move Coverage: 100.00")" ]; then \ + echo ">> Coverage is not at 100%!"; \ + exit 1; \ + fi; \ + done + +log_file = $(shell pwd)/aptos-node.log + +start-network: stop-network + @bash scripts/shell/start_network.sh + +stop-network: + @bash scripts/shell/stop_network.sh + +create-local-account: + @mkdir -p .aptos/keys && \ + if [ ! -f .aptos/keys/deployer.key ]; then \ + aptos key generate --key-type ed25519 --output-file .aptos/keys/deployer.key; \ + fi && \ + aptos init --profile deployer --network local --private-key-file .aptos/keys/deployer.key --assume-yes diff --git a/README.md b/README.md index 6123899..e158237 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,152 @@ # stablecoin-aptos + Source repository for smart contracts used by Circle's stablecoins on Aptos blockchain + +## Getting Started + +### Prerequisites + +Before you can start working with the contracts in this repository, + +1. [Optional] Create a directory to store Aptos-related binaries: + + ```bash + mkdir -p $HOME/.aptos/bin/ + echo 'export APTOS_BIN="$HOME/.aptos/bin"' >> ~/.zshrc + echo 'export PATH="$APTOS_BIN:$PATH"' >> ~/.zshrc + ``` + +2. Install the necessary tools + + ```bash + make setup + ``` + +A guide on installing the CLI tools in other environments can be found [here](https://aptos.dev/en/build/cli). + +### IDE + +The recommended IDE for this repository is VSCode. To get your IDE set up: + +1. Install the recommended extensions in VSCode. Enter `@recommended` into the search bar in the Extensions panel and install each of the extensions +2. Install the [`aptos-move-analyzer`](https://github.com/movebit/aptos-move-analyzer) binary, ensuring that it can be found on your PATH + + ```sh + mkdir -p $HOME/.aptos/bin/ + curl -L -o "$HOME/.aptos/bin/aptos-move-analyzer" "https://github.com/movebit/aptos-move-analyzer/releases/download/v1.0.0/aptos-move-analyzer-mac-x86_64-v1.0.0" + chmod +x $HOME/.aptos/bin/aptos-move-analyzer + echo 'export PATH="$HOME/.aptos/bin:$PATH"' >> ~/.zshrc + ``` + +### Test Move contracts + +1. Run all Move tests: + + ```bash + make test + ``` + + Coverage info is printed by default with this command. + +2. Code formatting: + + ```bash + make fmt + ``` + +## Localnet testing + +To test the contracts on a localnet: + +1. Start the local network. + + ```sh + make start-network + ``` + +2. Create a local account and fund it with APT + + ```sh + make create-local-account + ``` + +3. Deploy `aptos_extensions` and `stablecoin` packages, and initialize stablecoin state + + ```sh + yarn scripts deploy-and-initialize-token \ + -r http://localhost:8080 \ + --deployer-key \ + --token-config-path ./scripts/typescript/resources/default_token.json + ``` + + [!NOTE] + + - The private key can be found inside `.aptos/keys/deployer.key`. + - The deployment uses default configurations inside "./scripts/typescript/resources/default_token.json". For a real deployment, please make a copy of `scripts/typescript/resources/usdc_deploy_template.json` and fill in all settings. + +4. [Optional] Upgrade the `stablecoin` packages + + ```sh + yarn scripts upgrade-stablecoin-package \ + -r http://localhost:8080 \ + --admin-key \ + --payload-file-path \ + --aptos-extensions-package-id
\ + --stablecoin-package-id
+ ``` + +## Deploying to a live network + +1. Create a deployer keypair and fund it with APT + + If deploying to a test environment (local/devnet/testnet), you can create and fund the account with the following CLI command + + ```sh + # Local + yarn scripts generate-keypair --prefund + + # Devnet + yarn scripts generate-keypair --prefund --rpc-url "https://api.devnet.aptoslabs.com" --faucet-url "https://faucet.devnet.aptoslabs.com" + + # Testnet + yarn scripts generate-keypair --prefund --rpc-url "https://api.testnet.aptoslabs.com" --faucet-url "https://faucet.testnet.aptoslabs.com" + ``` + + If deploying to mainnet, create a keypair and separately fund the account by purchasing APT. + + ```sh + yarn scripts generate-keypair + ``` + +2. Create token configuration by copying existing template. + + ```sh + cp scripts/typescript/resources/usdc_deploy_template.json scripts/typescript/resources/ + ``` + + Fill out all configuration parameters. + +3. Deploy `aptos_extensions` and `stablecoin` packages, and initialize stablecoin state + + ```sh + yarn scripts deploy-and-initialize-token \ + -r http://localhost:8080 \ + --deployer-key \ + --token-config-path + ``` + + Source code verification is disabled by default, but can be enabled via the `--verify-source` flag. + +## Interacting with a deployed token + +We have provided scripts that enable developers to interact with a deployed token. To view a list of available scripts and their options, use the following command: + +```sh +yarn scripts --help +``` + +If you want to see the specific options for a particular script, replace `` with the name of the script and run: + +```sh +yarn scripts --help +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e66e8b2 --- /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 [Bug Bounty Program](https://hackerone.com/circle-bbp). +Please read through the program policy before submitting a report. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b8220b6 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,65 @@ +/** + * 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. + */ + +const typescriptEslint = require("@typescript-eslint/eslint-plugin"); +const globals = require("globals"); +const tsParser = require("@typescript-eslint/parser"); +const js = require("@eslint/js"); + +const { FlatCompat } = require("@eslint/eslintrc"); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +module.exports = [ + { + ignores: ["build/*", "node_modules/*", "**/yarn.lock", "eslint.config.js"] + }, + ...compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ), + { + plugins: { + "@typescript-eslint": typescriptEslint + }, + + languageOptions: { + globals: { + ...globals.node + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module" + }, + + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off" + } + }, + { + rules: { + camelcase: ["error", { properties: "always" }] + } + } +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2936cad --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "stablecoin-aptos", + "version": "1.0.0", + "description": "Circle's Stablecoin Smart Contracts on Aptos blockchain", + "repository": { + "type": "git", + "url": "git+https://github.com/circlefin/stablecoin-aptos.git" + }, + "scripts": { + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "scripts": "ts-node scripts/typescript/index.ts", + "test": "env NODE_ENV=TESTING TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register -r dotenv/config -t 300000 'test/**/*.test.ts'", + "type-check": "tsc --noEmit", + "postinstall": "husky" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/circlefin/stablecoin-aptos#readme", + "devDependencies": { + "@aptos-labs/ts-sdk": "1.29.1", + "@types/mocha": "10.0.6", + "@types/sinon": "17.0.3", + "@typescript-eslint/eslint-plugin": "7.11.0", + "@typescript-eslint/parser": "7.17.0", + "commander": "12.1.0", + "dotenv": "16.4.5", + "eslint": "9.13.0", + "husky": "9.1.6", + "mocha": "10.7.3", + "prettier": "3.3.3", + "prettier-eslint": "16.3.0", + "sinon": "19.0.2", + "ts-node": "10.9.2", + "typescript": "5.6.3", + "yup": "1.4.0" + }, + "engines": { + "node": "20.14.0", + "yarn": "1.x.x" + } +} diff --git a/packages/aptos_extensions/Move.toml b/packages/aptos_extensions/Move.toml new file mode 100644 index 0000000..50c28e3 --- /dev/null +++ b/packages/aptos_extensions/Move.toml @@ -0,0 +1,19 @@ +[package] +name = "AptosExtensions" +version = "1.0.0" +upgrade_policy = "immutable" + +[addresses] +aptos_extensions = "_" +deployer = "_" + +[dev-addresses] +aptos_extensions = "0x87899b097b766da6838867c873185c90d7d5661ebfe2ce0e985b53a2b0ed9e28" +deployer = "0xaa03675e2e13043f3d591804b0d8a8ffb775d14b11ca0831866ecdf48df26713" + +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "mainnet" +subdir = "aptos-move/framework/aptos-framework" + +[dev-dependencies] diff --git a/packages/aptos_extensions/sources/aptos_extensions.move b/packages/aptos_extensions/sources/aptos_extensions.move new file mode 100644 index 0000000..2318b85 --- /dev/null +++ b/packages/aptos_extensions/sources/aptos_extensions.move @@ -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. + +/// This module contains initialization logic where the aptos_extensions resource account signer capability is retrieved and dropped. +module aptos_extensions::aptos_extensions { + use aptos_framework::resource_account; + + // === Write functions === + + /// This function consumes the signer capability and drops it because the package is deployed to a resource account + /// and we want to prevent future changes to the account after the deployment. + fun init_module(resource_signer: &signer) { + resource_account::retrieve_resource_account_cap(resource_signer, @deployer); + } + + // === Test-only === + + #[test_only] + public fun test_init_module(resource_acct: &signer) { + init_module(resource_acct); + } +} diff --git a/packages/aptos_extensions/sources/manageable.move b/packages/aptos_extensions/sources/manageable.move new file mode 100644 index 0000000..7d12886 --- /dev/null +++ b/packages/aptos_extensions/sources/manageable.move @@ -0,0 +1,184 @@ +// 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 defines logic for managing an admin role privilege with two-step role transfer restrictions. +/// The two-step transfer process ensures the role is never transferred to an +/// inaccesible address. +/// +/// Inspired by OpenZeppelin's Ownable2Step in Solidity: +/// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol. +module aptos_extensions::manageable { + use std::option; + use std::option::Option; + use std::signer; + use aptos_framework::event; + + // === Errors === + + /// Address is not the admin. + const ENOT_ADMIN: u64 = 1; + /// Address is not the pending admin. + const ENOT_PENDING_ADMIN: u64 = 2; + /// Pending admin is not set. + const EPENDING_ADMIN_NOT_SET: u64 = 3; + /// AdminRole resource is missing. + const EMISSING_ADMIN_RESOURCE: u64 = 4; + + // === Structs === + + /// The admin and pending admin addresses state. + struct AdminRole has key { + admin: address, + pending_admin: Option
+ } + + // === Events === + + #[event] + /// Emitted when the admin change is started. + struct AdminChangeStarted has drop, store { + resource_address: address, + old_admin: address, + new_admin: address + } + + #[event] + /// Emitted when the admin is changed to a new address. + struct AdminChanged has drop, store { + resource_address: address, + old_admin: address, + new_admin: address + } + + #[event] + /// Emitted when the AdminRole resource is destroyed. + struct AdminRoleDestroyed has drop, store { + resource_address: address + } + + // === View-only functions === + + #[view] + /// Returns the active admin address. + public fun admin(resource_address: address): address acquires AdminRole { + borrow_global(resource_address).admin + } + + #[view] + /// Returns the pending admin address. + public fun pending_admin(resource_address: address): Option
acquires AdminRole { + borrow_global(resource_address).pending_admin + } + + /// Aborts if the caller is not the admin of the input object + public fun assert_is_admin(caller: &signer, resource_address: address) acquires AdminRole { + assert!(admin(resource_address) == signer::address_of(caller), ENOT_ADMIN); + } + + /// Aborts if the AdminRole resource doesn't exist at the resource address. + public fun assert_admin_exists(resource_address: address) { + assert!(exists(resource_address), EMISSING_ADMIN_RESOURCE); + } + + // === Write functions === + + /// Creates and inits a new AdminRole resource. + public fun new(caller: &signer, admin: address) { + move_to(caller, AdminRole { admin, pending_admin: option::none() }); + } + + /// Starts the admin role change by setting the pending admin to the new_admin address. + entry fun change_admin(caller: &signer, resource_address: address, new_admin: address) acquires AdminRole { + let admin_role = borrow_global_mut(resource_address); + assert!(admin_role.admin == signer::address_of(caller), ENOT_ADMIN); + + admin_role.pending_admin = option::some(new_admin); + + event::emit(AdminChangeStarted { resource_address, old_admin: admin_role.admin, new_admin }); + } + + /// Changes the admin address to the pending admin address. + entry fun accept_admin(caller: &signer, resource_address: address) acquires AdminRole { + let admin_role = borrow_global_mut(resource_address); + assert!(option::is_some(&admin_role.pending_admin), EPENDING_ADMIN_NOT_SET); + assert!( + option::contains(&admin_role.pending_admin, &signer::address_of(caller)), + ENOT_PENDING_ADMIN + ); + + let old_admin = admin_role.admin; + let new_admin = option::extract(&mut admin_role.pending_admin); + + admin_role.admin = new_admin; + + event::emit(AdminChanged { resource_address, old_admin, new_admin }); + } + + /// Removes the AdminRole resource from the caller. + public fun destroy(caller: &signer) acquires AdminRole { + let AdminRole { admin: _, pending_admin: _ } = move_from(signer::address_of(caller)); + + event::emit(AdminRoleDestroyed { resource_address: signer::address_of(caller) }); + } + + // === Test-only === + + #[test_only] + public fun test_AdminChangeStarted_event( + resource_address: address, old_admin: address, new_admin: address + ): AdminChangeStarted { + AdminChangeStarted { resource_address, old_admin, new_admin } + } + + #[test_only] + public fun test_AdminChanged_event( + resource_address: address, old_admin: address, new_admin: address + ): AdminChanged { + AdminChanged { resource_address, old_admin, new_admin } + } + + #[test_only] + public fun test_AdminRoleDestroyed_event(resource_address: address): AdminRoleDestroyed { + AdminRoleDestroyed { resource_address } + } + + #[test_only] + public fun test_change_admin(caller: &signer, resource_address: address, new_admin: address) acquires AdminRole { + change_admin(caller, resource_address, new_admin); + } + + #[test_only] + public fun test_accept_admin(caller: &signer, resource_address: address) acquires AdminRole { + accept_admin(caller, resource_address); + } + + #[test_only] + public fun set_admin_for_testing(resource_address: address, admin: address) acquires AdminRole { + let role = borrow_global_mut(resource_address); + role.admin = admin; + } + + #[test_only] + public fun set_pending_admin_for_testing(resource_address: address, pending_admin: address) acquires AdminRole { + let role = borrow_global_mut(resource_address); + role.pending_admin = option::some(pending_admin); + } + + #[test_only] + public fun admin_role_exists_for_testing(resource_address: address): bool { + exists(resource_address) + } +} diff --git a/packages/aptos_extensions/sources/ownable.move b/packages/aptos_extensions/sources/ownable.move new file mode 100644 index 0000000..bb9b4b4 --- /dev/null +++ b/packages/aptos_extensions/sources/ownable.move @@ -0,0 +1,182 @@ +// 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 defines logic for managing an owner role privilege with two-step role transfer restrictions. +/// The module may only be used by Aptos Objects. +/// The two-step transfer process ensures the role is never transferred to an +/// inaccesible address. +/// +/// Inspired by OpenZeppelin's Ownable2Step in Solidity: +/// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol. +module aptos_extensions::ownable { + use std::option::{Self, Option}; + use std::signer; + use aptos_framework::event; + use aptos_framework::object::{Self, Object}; + + // === Errors === + + /// Non-existent object. + const ENON_EXISTENT_OBJECT: u64 = 1; + /// Address is not the owner. + const ENOT_OWNER: u64 = 2; + /// Address is not the pending owner. + const ENOT_PENDING_OWNER: u64 = 3; + /// Pending owner is not set. + const EPENDING_OWNER_NOT_SET: u64 = 4; + + // === Structs === + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + /// The current and pending owner addresses state. + struct OwnerRole has key { + owner: address, + pending_owner: Option
+ } + + // === Events === + + #[event] + /// Emitted when the ownership transfer is started. + struct OwnershipTransferStarted has drop, store { + obj_address: address, + old_owner: address, + new_owner: address + } + + #[event] + /// Emitted when the ownership is transferred to a new address. + struct OwnershipTransferred has drop, store { + obj_address: address, + old_owner: address, + new_owner: address + } + + #[event] + /// Emitted when the OwnerRole resource is destroyed. + struct OwnerRoleDestroyed has drop, store { + obj_address: address + } + + // === View-only functions === + + #[view] + /// Returns the active owner address. + public fun owner(obj: Object): address acquires OwnerRole { + borrow_global(object::object_address(&obj)).owner + } + + #[view] + /// Returns the pending owner address. + public fun pending_owner(obj: Object): Option
acquires OwnerRole { + borrow_global(object::object_address(&obj)).pending_owner + } + + /// Aborts if the caller is not the owner of the input object + public fun assert_is_owner(caller: &signer, obj_address: address) acquires OwnerRole { + let obj = object::address_to_object(obj_address); + assert!(owner(obj) == signer::address_of(caller), ENOT_OWNER); + } + + // === Write functions === + + /// Creates and inits a new OwnerRole object. + public fun new(obj_signer: &signer, owner: address) { + assert!(object::is_object(signer::address_of(obj_signer)), ENON_EXISTENT_OBJECT); + move_to(obj_signer, OwnerRole { owner, pending_owner: option::none() }); + } + + /// Starts the ownership transfer of the object by setting the pending owner to the new_owner address. + entry fun transfer_ownership(caller: &signer, obj: Object, new_owner: address) acquires OwnerRole { + let obj_address = object::object_address(&obj); + let owner_role = borrow_global_mut(obj_address); + assert!(owner_role.owner == signer::address_of(caller), ENOT_OWNER); + + owner_role.pending_owner = option::some(new_owner); + + event::emit(OwnershipTransferStarted { obj_address, old_owner: owner_role.owner, new_owner }); + } + + /// Transfers the ownership of the object by setting the owner to the pending owner address. + entry fun accept_ownership(caller: &signer, obj: Object) acquires OwnerRole { + let obj_address = object::object_address(&obj); + let owner_role = borrow_global_mut(obj_address); + assert!(option::is_some(&owner_role.pending_owner), EPENDING_OWNER_NOT_SET); + assert!( + option::contains(&owner_role.pending_owner, &signer::address_of(caller)), + ENOT_PENDING_OWNER + ); + + let old_owner = owner_role.owner; + let new_owner = option::extract(&mut owner_role.pending_owner); + + owner_role.owner = new_owner; + + event::emit(OwnershipTransferred { obj_address, old_owner, new_owner }); + } + + /// Removes the OwnerRole resource from the caller. + public fun destroy(caller: &signer) acquires OwnerRole { + let OwnerRole { owner: _, pending_owner: _ } = move_from(signer::address_of(caller)); + + event::emit(OwnerRoleDestroyed { obj_address: signer::address_of(caller) }); + } + + // === Test-only === + + #[test_only] + public fun test_OwnershipTransferStarted_event( + obj_address: address, old_owner: address, new_owner: address + ): OwnershipTransferStarted { + OwnershipTransferStarted { obj_address, old_owner, new_owner } + } + + #[test_only] + public fun test_OwnershipTransferred_event( + obj_address: address, old_owner: address, new_owner: address + ): OwnershipTransferred { + OwnershipTransferred { obj_address, old_owner, new_owner } + } + + #[test_only] + public fun test_OwnerRoleDestroyed_event(obj_address: address): OwnerRoleDestroyed { + OwnerRoleDestroyed { obj_address } + } + + #[test_only] + public fun test_transfer_ownership( + caller: &signer, obj: Object, new_owner: address + ) acquires OwnerRole { + transfer_ownership(caller, obj, new_owner); + } + + #[test_only] + public fun test_accept_ownership(caller: &signer, obj: Object) acquires OwnerRole { + accept_ownership(caller, obj); + } + + #[test_only] + public fun set_owner_for_testing(obj_address: address, owner: address) acquires OwnerRole { + let role = borrow_global_mut(obj_address); + role.owner = owner; + } + + #[test_only] + public fun set_pending_owner_for_testing(obj_address: address, pending_owner: address) acquires OwnerRole { + let role = borrow_global_mut(obj_address); + role.pending_owner = option::some(pending_owner); + } +} diff --git a/packages/aptos_extensions/sources/pausable.move b/packages/aptos_extensions/sources/pausable.move new file mode 100644 index 0000000..ec2669e --- /dev/null +++ b/packages/aptos_extensions/sources/pausable.move @@ -0,0 +1,192 @@ +// 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 defines logic for pausing and unpausing objects. +/// The pausable module depends on the ownable module for pauser role management. +module aptos_extensions::pausable { + use std::signer; + use aptos_framework::event; + use aptos_framework::object::{Self, Object}; + + use aptos_extensions::ownable::{Self, OwnerRole}; + + // === Errors === + + /// Non-existent object. + const ENON_EXISTENT_OBJECT: u64 = 1; + /// Non-existent OwnerRole object. + const ENON_EXISTENT_OWNER: u64 = 2; + /// Caller is not pauser. + const ENOT_PAUSER: u64 = 3; + /// Object is paused. + const EPAUSED: u64 = 4; + + // === Structs === + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + /// The paused and pauser address state. + struct PauseState has key { + paused: bool, + pauser: address + } + + // === Events === + + #[event] + /// Emitted when PauseState paused is set to true. + struct Pause has drop, store { + obj_address: address + } + + #[event] + /// Emitted when PauseState paused is set to false. + struct Unpause has drop, store { + obj_address: address + } + + #[event] + /// Emitted when the PauseState pauser address is changed. + struct PauserChanged has drop, store { + obj_address: address, + old_pauser: address, + new_pauser: address + } + + #[event] + /// Emitted when the PauseState resource is destroyed. + struct PauseStateDestroyed has drop, store { + obj_address: address + } + + // === View-only functions === + + #[view] + /// Returns the PauseState pauser address. + public fun pauser(obj: Object): address acquires PauseState { + borrow_global(object::object_address(&obj)).pauser + } + + #[view] + /// Returns the PauseState paused status. + public fun is_paused(obj: Object): bool acquires PauseState { + borrow_global(object::object_address(&obj)).paused + } + + /// Asserts that state is not paused. + public fun assert_not_paused(obj_address: address) acquires PauseState { + assert!(!is_paused(object::address_to_object(obj_address)), EPAUSED); + } + + // === Write functions === + + /// Creates and inits a new unpaused PauseState. + public fun new(obj_signer: &signer, pauser: address) { + let obj_address = signer::address_of(obj_signer); + assert!(object::is_object(obj_address), ENON_EXISTENT_OBJECT); + assert!(object::object_exists(obj_address), ENON_EXISTENT_OWNER); + move_to(obj_signer, PauseState { paused: false, pauser }); + } + + /// Changes the PauseState paused to true. + entry fun pause(caller: &signer, obj: Object) acquires PauseState { + let obj_address = object::object_address(&obj); + let pause_state = borrow_global_mut(obj_address); + assert!(pause_state.pauser == signer::address_of(caller), ENOT_PAUSER); + + pause_state.paused = true; + + event::emit(Pause { obj_address }); + } + + /// Changes the PauseState paused to false. + entry fun unpause(caller: &signer, obj: Object) acquires PauseState { + let obj_address = object::object_address(&obj); + let pause_state = borrow_global_mut(obj_address); + assert!(pause_state.pauser == signer::address_of(caller), ENOT_PAUSER); + + pause_state.paused = false; + + event::emit(Unpause { obj_address }); + } + + /// Changes the PauseState pauser address. + entry fun update_pauser(caller: &signer, obj: Object, new_pauser: address) acquires PauseState { + let obj_address = object::object_address(&obj); + ownable::assert_is_owner(caller, obj_address); + let pause_state = borrow_global_mut(obj_address); + let old_pauser = pause_state.pauser; + + pause_state.pauser = new_pauser; + + event::emit(PauserChanged { obj_address, old_pauser, new_pauser }) + } + + /// Removes the PauseState resource from the caller. + public fun destroy(caller: &signer) acquires PauseState { + let PauseState { paused: _, pauser: _ } = move_from(signer::address_of(caller)); + + event::emit(PauseStateDestroyed { obj_address: signer::address_of(caller) }); + } + + // === Test-only === + + #[test_only] + public fun test_Pause_event(obj_address: address): Pause { + Pause { obj_address } + } + + #[test_only] + public fun test_Unpause_event(obj_address: address): Unpause { + Unpause { obj_address } + } + + #[test_only] + public fun test_PauserChanged_event( + obj_address: address, new_pauser: address, old_pauser: address + ): PauserChanged { + PauserChanged { obj_address, new_pauser, old_pauser } + } + + #[test_only] + public fun test_PauseStateDestroyed_event(obj_address: address): PauseStateDestroyed { + PauseStateDestroyed { obj_address } + } + + #[test_only] + public fun test_pause(caller: &signer, obj: Object) acquires PauseState { + pause(caller, obj); + } + + #[test_only] + public fun test_unpause(caller: &signer, obj: Object) acquires PauseState { + unpause(caller, obj); + } + + #[test_only] + public fun test_update_pauser(caller: &signer, obj: Object, new_pauser: address) acquires PauseState { + update_pauser(caller, obj, new_pauser) + } + + #[test_only] + public fun set_paused_for_testing(obj_address: address, paused: bool) acquires PauseState { + borrow_global_mut(obj_address).paused = paused; + } + + #[test_only] + public fun set_pauser_for_testing(obj_address: address, pauser: address) acquires PauseState { + borrow_global_mut(obj_address).pauser = pauser; + } +} diff --git a/packages/aptos_extensions/sources/upgradable.move b/packages/aptos_extensions/sources/upgradable.move new file mode 100644 index 0000000..664a0bc --- /dev/null +++ b/packages/aptos_extensions/sources/upgradable.move @@ -0,0 +1,130 @@ +// 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 defines logic to perform a package upgrade on a resource account. +/// The module depends on the manageable module for admin role management. +module aptos_extensions::upgradable { + use std::signer; + use aptos_framework::account::{Self, SignerCapability}; + use aptos_framework::code; + use aptos_framework::event; + + use aptos_extensions::manageable; + + // === Errors === + + /// The SignerCapability is not the caller's signer cap. + const EMISMATCHED_SIGNER_CAP: u64 = 1; + + // === Structs === + + struct SignerCapStore has key { + signer_cap: SignerCapability + } + + // === Events === + + #[event] + /// Emitted when a package is upgraded. + struct PackageUpgraded has drop, store { + resource_acct: address + } + + #[event] + /// Emitted when the SignerCapability is extracted. + struct SignerCapExtracted has drop, store { + resource_acct: address + } + + // === Write functions === + + /// Creates and inits a new SignerCapStore resource. + /// Requires an AdminRole resource to exist. + public fun new(caller: &signer, signer_cap: SignerCapability) { + manageable::assert_admin_exists(signer::address_of(caller)); + assert!( + account::get_signer_capability_address(&signer_cap) == signer::address_of(caller), + EMISMATCHED_SIGNER_CAP + ); + move_to(caller, SignerCapStore { signer_cap }); + } + + /// Upgrades the package code at the resource account address. + entry fun upgrade_package( + caller: &signer, + resource_acct: address, + metadata_serialized: vector, + code: vector> + ) acquires SignerCapStore { + manageable::assert_is_admin(caller, resource_acct); + let signer_cap = &borrow_global(resource_acct).signer_cap; + + let resource_signer = account::create_signer_with_capability(signer_cap); + + code::publish_package_txn(&resource_signer, metadata_serialized, code); + + event::emit(PackageUpgraded { resource_acct }); + } + + /// Extracts the SignerCapability from the SignerCapStore and removes the SignerCapStore resource. + public fun extract_signer_cap(caller: &signer, resource_acct: address): SignerCapability acquires SignerCapStore { + manageable::assert_is_admin(caller, resource_acct); + + let SignerCapStore { signer_cap } = move_from(resource_acct); + + event::emit(SignerCapExtracted { resource_acct }); + + signer_cap + } + + // === Test-only === + + #[test_only] + public fun test_PackageUpgraded_event(resource_acct: address): PackageUpgraded { + PackageUpgraded { resource_acct } + } + + #[test_only] + public fun test_SignerCapExtracted_event(resource_acct: address): SignerCapExtracted { + SignerCapExtracted { resource_acct } + } + + #[test_only] + public fun extract_signer_cap_for_testing(resource_acct: address): SignerCapability acquires SignerCapStore { + let SignerCapStore { signer_cap } = move_from(resource_acct); + signer_cap + } + + #[test_only] + public fun set_signer_cap_for_testing(caller: &signer, signer_cap: SignerCapability) { + move_to(caller, SignerCapStore { signer_cap }); + } + + #[test_only] + public fun test_upgrade_package( + caller: &signer, + resource_acct: address, + metadata_serialized: vector, + code: vector> + ) acquires SignerCapStore { + upgrade_package(caller, resource_acct, metadata_serialized, code); + } + + #[test_only] + public fun signer_cap_store_exists_for_testing(resource_acct: address): bool { + exists(resource_acct) + } +} diff --git a/packages/aptos_extensions/tests/aptos_extensions_tests.move b/packages/aptos_extensions/tests/aptos_extensions_tests.move new file mode 100644 index 0000000..e3a1ff4 --- /dev/null +++ b/packages/aptos_extensions/tests/aptos_extensions_tests.move @@ -0,0 +1,56 @@ +// 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 aptos_extensions::aptos_extensions_tests { + use aptos_framework::account; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::resource_account; + + const ZERO_AUTH_KEY: vector = x"0000000000000000000000000000000000000000000000000000000000000000"; + const SEED: vector = b"1234"; + const SEED_2: vector = b"5678"; + + /// error::not_found(resource_account::ECONTAINER_NOT_PUBLISHED) + const ECONTAINER_NOT_PUBLISHED: u64 = 393217; + /// error::invalid_argument(EUNAUTHORIZED_NOT_OWNER) + const EUNAUTHORIZED_NOT_OWNER: u64 = 65538; + + #[test, expected_failure(abort_code = ECONTAINER_NOT_PUBLISHED, location = aptos_framework::resource_account)] + fun init_module__should_consume_the_resource_account_signer_cap() { + account::create_account_for_test(@deployer); + resource_account::create_resource_account(&create_signer_for_test(@deployer), SEED, ZERO_AUTH_KEY); + let resource_account_address = account::create_resource_address(&@deployer, SEED); + let resource_account_signer = &create_signer_for_test(resource_account_address); + + aptos_extensions::aptos_extensions::test_init_module(resource_account_signer); + + resource_account::retrieve_resource_account_cap(resource_account_signer, @deployer); + } + + #[test, expected_failure(abort_code = EUNAUTHORIZED_NOT_OWNER, location = aptos_framework::resource_account)] + fun init_module__should_prevent_signer_cap_from_being_extracted_more_than_once() { + account::create_account_for_test(@deployer); + resource_account::create_resource_account(&create_signer_for_test(@deployer), SEED, ZERO_AUTH_KEY); + resource_account::create_resource_account(&create_signer_for_test(@deployer), SEED_2, ZERO_AUTH_KEY); + let resource_account_address = account::create_resource_address(&@deployer, SEED); + let resource_account_signer = &create_signer_for_test(resource_account_address); + + aptos_extensions::aptos_extensions::test_init_module(resource_account_signer); + + resource_account::retrieve_resource_account_cap(resource_account_signer, @deployer); + } +} diff --git a/packages/aptos_extensions/tests/manageable.spec.move b/packages/aptos_extensions/tests/manageable.spec.move new file mode 100644 index 0000000..f0c6e4f --- /dev/null +++ b/packages/aptos_extensions/tests/manageable.spec.move @@ -0,0 +1,96 @@ +// 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. + +spec aptos_extensions::manageable { + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + } + + /// Abort condition: The AdminRole resource is missing. + /// Post condition: There are no changes to the AdminRole state. + /// Post condition: The admin address is always returned. + spec admin { + aborts_if !exists(resource_address); + ensures global(resource_address) == old(global(resource_address)); + ensures result == global(resource_address).admin; + } + + /// Abort condition: The AdminRole resource is missing. + /// Post condition: There are no changes to the AdminRole state. + /// Post condition: The pending admin address is always returned. + spec pending_admin { + aborts_if !exists(resource_address); + ensures global(resource_address) == old(global(resource_address)); + ensures result == global(resource_address).pending_admin; + } + + /// Abort condition: The AdminRole resource is missing. + /// Post condition: The caller is not the admin of the resource address. + /// Post condition: There are no changes to the AdminRole state. + spec assert_is_admin { + aborts_if !exists(resource_address); + aborts_if signer::address_of(caller) != global(resource_address).admin; + ensures global(resource_address) == old(global(resource_address)); + } + + /// Abort condition: The AdminRole resource is missing. + /// Post condition: There are no changes to the AdminRole state. + spec assert_admin_exists { + aborts_if !exists(resource_address); + ensures global(resource_address) == old(global(resource_address)); + } + + /// Abort condition: The AdminRole resource already exists at the resource address. + /// Post condition: The AdminRole resource is created properly. + spec new { + aborts_if exists(signer::address_of(caller)); + ensures global(signer::address_of(caller)) == AdminRole { admin, pending_admin: option::spec_none() }; + } + + /// Abort condition: The AdminRole resource is missing. + /// Abort condition: The caller is not the admin. + /// Post condition: The pending admin is always updated to new_admin. + /// Post condition: The admin does not change. + spec change_admin { + aborts_if !exists(resource_address); + aborts_if signer::address_of(caller) != global(resource_address).admin; + ensures global(resource_address).admin == old(global(resource_address).admin); + ensures option::spec_contains(global(resource_address).pending_admin, new_admin); + } + + /// Abort condition: The AdminRole resource is missing. + /// Abort condition: The pending admin is not set. + /// Post condition: The caller is not the pending admin. + /// Post condition: The admin address is always set to the pending admin. + /// Post condition: The pending admin is set to option::none. + spec accept_admin { + aborts_if !exists(resource_address); + aborts_if option::is_none(global(resource_address).pending_admin); + aborts_if !option::spec_contains( + global(resource_address).pending_admin, signer::address_of(caller) + ); + ensures global(resource_address).admin == signer::address_of(caller); + ensures global(resource_address).pending_admin == option::spec_none(); + } + + /// Abort condition: The AdminRole resource is missing. + /// Post condition: The AdminRole resource is always removed. + spec destroy { + aborts_if !exists(signer::address_of(caller)); + ensures !exists(signer::address_of(caller)); + } +} diff --git a/packages/aptos_extensions/tests/manageable_tests.move b/packages/aptos_extensions/tests/manageable_tests.move new file mode 100644 index 0000000..578f937 --- /dev/null +++ b/packages/aptos_extensions/tests/manageable_tests.move @@ -0,0 +1,263 @@ +// 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 aptos_extensions::manageable_tests { + use std::option; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::event; + + use aptos_extensions::manageable; + use aptos_extensions::test_utils::{assert_eq, create_and_move_custom_resource}; + + // Test addresses + const ADMIN_ADDRESS: address = @0x1111; + const ADMIN_ADDRESS_2: address = @0x2222; + const ADMIN_ADDRESS_3: address = @0x3333; + const RESOURCE_ADDRESS: address = @0x4444; + + #[test] + fun new__should_create_object_admin_role_state_correctly() { + let (signer, obj_address) = create_and_move_custom_resource(); + + manageable::new(&signer, ADMIN_ADDRESS); + + assert_eq(manageable::admin_role_exists_for_testing(obj_address), true); + + assert_eq( + manageable::admin(obj_address), + ADMIN_ADDRESS + ); + assert_eq( + manageable::pending_admin(obj_address), + option::none() + ); + } + + #[test] + fun new__should_create_account_admin_role_state_correctly() { + manageable::new(&create_signer_for_test(RESOURCE_ADDRESS), ADMIN_ADDRESS); + + assert_eq( + manageable::admin(RESOURCE_ADDRESS), + ADMIN_ADDRESS + ); + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::none() + ); + } + + #[test] + fun admin__should_return_admin_address() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + manageable::set_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + + assert_eq(manageable::admin(RESOURCE_ADDRESS), ADMIN_ADDRESS_2); + } + + #[test] + fun pending_admin__should_return_pending_admin_address() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + manageable::set_pending_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::some(ADMIN_ADDRESS_2) + ); + } + + #[test] + fun assert_is_admin__should_succeed_if_called_by_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + manageable::assert_is_admin(&create_signer_for_test(ADMIN_ADDRESS), RESOURCE_ADDRESS); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::ENOT_ADMIN)] + fun assert_is_admin__should_abort_if_not_called_by_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + manageable::assert_is_admin(&create_signer_for_test(ADMIN_ADDRESS_2), RESOURCE_ADDRESS); + } + + #[test] + fun assert_admin_exists__should_succeed_if_resource_exists() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + manageable::assert_admin_exists(RESOURCE_ADDRESS); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::EMISSING_ADMIN_RESOURCE)] + fun assert_admin_exists__should_abort_if_admin_resource_does_not_exist() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + manageable::assert_admin_exists(ADMIN_ADDRESS); + } + + #[test] + fun change_admin__should_set_pending_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let admin_signer = &create_signer_for_test(ADMIN_ADDRESS); + let admin_change_started_event = manageable::test_AdminChangeStarted_event( + RESOURCE_ADDRESS, + ADMIN_ADDRESS, + ADMIN_ADDRESS_2 + ); + + manageable::test_change_admin(admin_signer, RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::some(ADMIN_ADDRESS_2) + ); + assert_eq(event::was_event_emitted(&admin_change_started_event), true); + } + + #[test] + fun change_admin__should_reset_pending_admin_if_already_set() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let admin_signer = &create_signer_for_test(ADMIN_ADDRESS); + let admin_change_started_event = manageable::test_AdminChangeStarted_event( + RESOURCE_ADDRESS, + ADMIN_ADDRESS, + ADMIN_ADDRESS_2 + ); + + manageable::set_pending_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_3); + manageable::test_change_admin(admin_signer, RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::some(ADMIN_ADDRESS_2) + ); + assert_eq(event::was_event_emitted(&admin_change_started_event), true); + } + + #[test] + fun change_admin__should_set_same_pending_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let admin_signer = &create_signer_for_test(ADMIN_ADDRESS); + let admin_change_started_event = manageable::test_AdminChangeStarted_event( + RESOURCE_ADDRESS, + ADMIN_ADDRESS, + ADMIN_ADDRESS_2 + ); + + manageable::set_pending_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + manageable::test_change_admin(admin_signer, RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::some(ADMIN_ADDRESS_2) + ); + assert_eq(event::was_event_emitted(&admin_change_started_event), true); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::ENOT_ADMIN)] + fun change_admin__should_fail_if_caller_not_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let invalid_admin_signer = &create_signer_for_test(ADMIN_ADDRESS_2); + manageable::test_change_admin(invalid_admin_signer, RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + } + + #[test] + fun accept_admin__should_change_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let new_admin_signer = &create_signer_for_test(ADMIN_ADDRESS_2); + let admin_changed_event = manageable::test_AdminChanged_event( + RESOURCE_ADDRESS, + ADMIN_ADDRESS, + ADMIN_ADDRESS_2 + ); + + manageable::set_pending_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + manageable::test_accept_admin(new_admin_signer, RESOURCE_ADDRESS); + + assert_eq( + manageable::admin(RESOURCE_ADDRESS), + ADMIN_ADDRESS_2 + ); + assert_eq(event::was_event_emitted(&admin_changed_event), true); + } + + #[test] + fun accept_admin__should_reset_pending_admin() { + let new_admin_signer = &create_signer_for_test(ADMIN_ADDRESS_2); + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + manageable::set_pending_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + manageable::test_accept_admin(new_admin_signer, RESOURCE_ADDRESS); + + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::none() + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::EPENDING_ADMIN_NOT_SET)] + fun accept_admin__should_fail_if_pending_admin_is_not_set() { + let new_admin_signer = &create_signer_for_test(ADMIN_ADDRESS_2); + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + manageable::test_accept_admin(new_admin_signer, RESOURCE_ADDRESS); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::ENOT_PENDING_ADMIN)] + fun accept_admin__should_fail_if_caller_is_not_pending_admin() { + let new_admin_signer = &create_signer_for_test(ADMIN_ADDRESS_3); + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + manageable::set_pending_admin_for_testing(RESOURCE_ADDRESS, ADMIN_ADDRESS_2); + manageable::test_accept_admin(new_admin_signer, RESOURCE_ADDRESS); + } + + #[test] + fun accept_admin__should_pass_if_pending_admin_same_as_admin() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let admin_signer = &create_signer_for_test(ADMIN_ADDRESS); + + manageable::test_change_admin(admin_signer, RESOURCE_ADDRESS, ADMIN_ADDRESS); + manageable::test_accept_admin(admin_signer, RESOURCE_ADDRESS); + + assert_eq( + manageable::admin(RESOURCE_ADDRESS), + ADMIN_ADDRESS + ); + assert_eq( + manageable::pending_admin(RESOURCE_ADDRESS), + option::none() + ); + } + + #[test] + public fun destroy__should_remove_admin_role_resource() { + setup_manageable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let resource_signer = create_signer_for_test(RESOURCE_ADDRESS); + let admin_role_destroyed_event = manageable::test_AdminRoleDestroyed_event(RESOURCE_ADDRESS); + + assert_eq(manageable::admin_role_exists_for_testing(RESOURCE_ADDRESS), true); + + manageable::destroy(&resource_signer); + + assert_eq(manageable::admin_role_exists_for_testing(RESOURCE_ADDRESS), false); + assert_eq(event::was_event_emitted(&admin_role_destroyed_event), true); + } + + // === Test helpers === + + fun setup_manageable(resource_address: address, admin: address) { + manageable::new(&create_signer_for_test(resource_address), admin); + } +} diff --git a/packages/aptos_extensions/tests/ownable.spec.move b/packages/aptos_extensions/tests/ownable.spec.move new file mode 100644 index 0000000..6283221 --- /dev/null +++ b/packages/aptos_extensions/tests/ownable.spec.move @@ -0,0 +1,98 @@ +// 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. + +spec aptos_extensions::ownable { + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + } + + /// Abort condition: The OwnerRole resource is missing. + /// Post condition: There are no changes to the OwnerRole state. + /// Post condition: The owner address is always returned. + spec owner { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + ensures global(obj_address) == old(global(obj_address)); + ensures result == global(obj_address).owner; + } + + /// Abort condition: The OwnerRole resource is missing. + /// Post condition: There are no changes to the OwnerRole state. + /// Post condition: The pending owner address is always returned. + spec pending_owner { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + ensures global(obj_address) == old(global(obj_address)); + ensures result == global(obj_address).pending_owner; + } + + /// Abort condition: The address is not a valid object address. + /// Abort condition: The OwnerRole resource is missing. + /// Abort condition: The caller is not the owner. + /// Post condition: There are no changes to the OwnerRole state. + spec assert_is_owner { + aborts_if !exists(obj_address); + aborts_if !exists(obj_address) || !object::spec_exists_at(obj_address); + aborts_if signer::address_of(caller) != global(obj_address).owner; + ensures global(obj_address) == old(global(obj_address)); + } + + /// Abort condition: No object exists at the object address. + /// Abort condition: The OwnerRole resource already exists at the object address. + /// Post condition: The OwnerRole resource is created properly. + spec new { + let obj_address = signer::address_of(obj_signer); + aborts_if !exists(obj_address); + aborts_if exists(obj_address); + ensures global(obj_address) == OwnerRole { owner, pending_owner: option::spec_none() }; + } + + /// Abort condition: The OwnerRole resource is missing. + /// Abort condition: The caller is not the owner address. + /// Post condition: The pending owner address is always updated to the new_owner. + /// Post condition: The owner address does not change. + spec transfer_ownership { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + aborts_if signer::address_of(caller) != global(obj_address).owner; + ensures global(obj_address).owner == old(global(obj_address).owner); + ensures option::spec_contains(global(obj_address).pending_owner, new_owner); + } + + /// Abort condition: The OwnerRole resource is missing. + /// Abort condition: The pending owner address is not set. + /// Abort condition: The caller is not the pending owner. + /// Post condition: The owner address is always set to the previous pending owner. + /// Post condition: The pending owner is set to option::none. + spec accept_ownership { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + aborts_if option::is_none(global(obj_address).pending_owner); + aborts_if !option::spec_contains( + global(obj_address).pending_owner, signer::address_of(caller) + ); + ensures global(obj_address).owner == signer::address_of(caller); + ensures global(obj_address).pending_owner == option::spec_none(); + } + + /// Abort condition: The OwnerRole resource is missing. + /// Post condition: The OwnerRole resource is always removed. + spec destroy { + aborts_if !exists(signer::address_of(caller)); + ensures !exists(signer::address_of(caller)); + } +} diff --git a/packages/aptos_extensions/tests/ownable_tests.move b/packages/aptos_extensions/tests/ownable_tests.move new file mode 100644 index 0000000..347b7e8 --- /dev/null +++ b/packages/aptos_extensions/tests/ownable_tests.move @@ -0,0 +1,250 @@ +// 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 aptos_extensions::ownable_tests { + use std::option; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::event; + use aptos_framework::object::{Self, Object}; + + use aptos_extensions::ownable::{Self, OwnerRole}; + use aptos_extensions::test_utils::{assert_eq, create_and_move_custom_resource}; + + // Test addresses + const OWNER_ADDRESS: address = @0x1111; + const OWNER_ADDRESS_2: address = @0x2222; + const OWNER_ADDRESS_3: address = @0x3333; + const RANDOM_ADDRESS: address = @0x4444; + + #[test] + public fun new__should_set_ownable_state() { + let (signer, obj_address) = create_and_move_custom_resource(); + + ownable::new(&signer, OWNER_ADDRESS); + + let obj = object::address_to_object(obj_address); + assert_eq( + ownable::owner(obj), + OWNER_ADDRESS + ); + assert_eq( + ownable::pending_owner(obj), + option::none() + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENON_EXISTENT_OBJECT)] + public fun new__should_fail_if_non_existent_object() { + let signer = create_signer_for_test(RANDOM_ADDRESS); + + ownable::new(&signer, OWNER_ADDRESS); + } + + #[test] + public fun owner__should_return_owner_address() { + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + + ownable::set_owner_for_testing(obj_address, OWNER_ADDRESS_2); + + assert_eq( + ownable::owner(obj), + OWNER_ADDRESS_2 + ); + } + + #[test] + public fun pending_owner__should_return_pending_owner_address() { + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + + ownable::set_pending_owner_for_testing(obj_address, OWNER_ADDRESS_2); + + assert_eq( + ownable::pending_owner(obj), + option::some(OWNER_ADDRESS_2) + ); + } + + #[test] + public fun assert_is_owner__should_succeed_if_called_by_owner() { + let (_, obj_address) = setup_ownable(OWNER_ADDRESS); + ownable::assert_is_owner(&create_signer_for_test(OWNER_ADDRESS), obj_address); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_OWNER)] + public fun assert_is_owner__should_abort_if_not_called_by_owner() { + let (_, obj_address) = setup_ownable(OWNER_ADDRESS); + ownable::assert_is_owner(&create_signer_for_test(OWNER_ADDRESS_2), obj_address); + } + + #[test] + public fun transfer_ownership__should_set_pending_owner() { + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + let owner_signer = &create_signer_for_test(OWNER_ADDRESS); + let transfer_started_event = ownable::test_OwnershipTransferStarted_event( + obj_address, + OWNER_ADDRESS, + OWNER_ADDRESS_2 + ); + + ownable::test_transfer_ownership(owner_signer, obj, OWNER_ADDRESS_2); + + assert_eq( + ownable::pending_owner(obj), + option::some(OWNER_ADDRESS_2) + ); + assert_eq(event::was_event_emitted(&transfer_started_event), true); + } + + #[test] + public fun transfer_ownership__should_reset_pending_owner_if_already_set() { + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + let owner_signer = &create_signer_for_test(OWNER_ADDRESS); + let transfer_started_event = ownable::test_OwnershipTransferStarted_event( + obj_address, + OWNER_ADDRESS, + OWNER_ADDRESS_2 + ); + + ownable::set_pending_owner_for_testing(obj_address, OWNER_ADDRESS_3); + ownable::test_transfer_ownership(owner_signer, obj, OWNER_ADDRESS_2); + + assert_eq( + ownable::pending_owner(obj), + option::some(OWNER_ADDRESS_2) + ); + assert_eq(event::was_event_emitted(&transfer_started_event), true); + } + + #[test] + public fun transfer_ownership__should_set_same_pending_owner() { + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + let owner_signer = &create_signer_for_test(OWNER_ADDRESS); + let transfer_started_event = ownable::test_OwnershipTransferStarted_event( + obj_address, + OWNER_ADDRESS, + OWNER_ADDRESS_2 + ); + + ownable::set_pending_owner_for_testing(obj_address, OWNER_ADDRESS_2); + ownable::test_transfer_ownership(owner_signer, obj, OWNER_ADDRESS_2); + + assert_eq( + ownable::pending_owner(obj), + option::some(OWNER_ADDRESS_2) + ); + assert_eq(event::was_event_emitted(&transfer_started_event), true); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_OWNER)] + public fun transfer_ownership__should_fail_if_caller_not_owner() { + let (obj, _) = setup_ownable(OWNER_ADDRESS); + let invalid_owner_signer = &create_signer_for_test(OWNER_ADDRESS_2); + + ownable::test_transfer_ownership(invalid_owner_signer, obj, OWNER_ADDRESS_2); + } + + #[test] + public fun accept_ownership__should_change_owner() { + let new_owner_signer = &create_signer_for_test(OWNER_ADDRESS_2); + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + let ownership_transferred_event = ownable::test_OwnershipTransferred_event( + obj_address, + OWNER_ADDRESS, + OWNER_ADDRESS_2 + ); + + ownable::set_pending_owner_for_testing(obj_address, OWNER_ADDRESS_2); + ownable::test_accept_ownership(new_owner_signer, obj); + + assert_eq( + ownable::owner(obj), + OWNER_ADDRESS_2 + ); + assert_eq(event::was_event_emitted(&ownership_transferred_event), true); + } + + #[test] + public fun accept_ownership__should_reset_pending_owner() { + let new_owner_signer = &create_signer_for_test(OWNER_ADDRESS_2); + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + + ownable::set_pending_owner_for_testing(obj_address, OWNER_ADDRESS_2); + ownable::test_accept_ownership(new_owner_signer, obj); + + assert_eq( + ownable::pending_owner(obj), + option::none() + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::EPENDING_OWNER_NOT_SET)] + public fun accept_ownership__should_fail_if_pending_owner_is_not_set() { + let new_owner_signer = &create_signer_for_test(OWNER_ADDRESS_2); + let (obj, _) = setup_ownable(OWNER_ADDRESS); + + ownable::test_accept_ownership(new_owner_signer, obj); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_PENDING_OWNER)] + public fun accept_ownership__should_fail_if_caller_is_not_pending_owner() { + let invalid_pending_owner_signer = &create_signer_for_test(OWNER_ADDRESS_3); + let (obj, obj_address) = setup_ownable(OWNER_ADDRESS); + + ownable::set_pending_owner_for_testing(obj_address, OWNER_ADDRESS_2); + ownable::test_accept_ownership(invalid_pending_owner_signer, obj); + } + + #[test] + public fun accept_ownership__should_pass_if_pending_owner_same_as_owner() { + let (obj, _) = setup_ownable(OWNER_ADDRESS); + let owner_signer = &create_signer_for_test(OWNER_ADDRESS); + + ownable::test_transfer_ownership(owner_signer, obj, OWNER_ADDRESS); + ownable::test_accept_ownership(owner_signer, obj); + + assert_eq( + ownable::owner(obj), + OWNER_ADDRESS + ); + assert_eq( + ownable::pending_owner(obj), + option::none() + ); + } + + #[test] + public fun destroy__should_remove_owner_role_resource() { + let (_, obj_address) = setup_ownable(OWNER_ADDRESS); + let object_signer = create_signer_for_test(obj_address); + let owner_role_destroyed_event = ownable::test_OwnerRoleDestroyed_event(obj_address); + + assert_eq(object::object_exists(obj_address), true); + + ownable::destroy(&object_signer); + + assert_eq(object::object_exists(obj_address), false); + assert_eq(event::was_event_emitted(&owner_role_destroyed_event), true); + } + + // === Test helpers === + + fun setup_ownable(owner: address): (Object, address) { + let (signer, obj_address) = create_and_move_custom_resource(); + ownable::new(&signer, owner); + (object::address_to_object(obj_address), obj_address) + } +} diff --git a/packages/aptos_extensions/tests/pausable.spec.move b/packages/aptos_extensions/tests/pausable.spec.move new file mode 100644 index 0000000..91bec23 --- /dev/null +++ b/packages/aptos_extensions/tests/pausable.spec.move @@ -0,0 +1,112 @@ +// 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. + +spec aptos_extensions::pausable { + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + } + + /// Abort condition: The PauseState resource is missing. + /// Post condition: There are no changes to the PauseState. + /// Post condition: The pauser address is always returned. + spec pauser { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + ensures global(obj_address) == old(global(obj_address)); + ensures result == global(obj_address).pauser; + } + + /// Abort condition: The PauseState resource is missing. + /// Post condition: There are no changes to the PauseState. + /// Post condition: The paused state is always returned. + spec is_paused { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + ensures global(obj_address) == old(global(obj_address)); + ensures result == global(obj_address).paused; + } + + /// Abort condition: The address is not a valid object address. + /// Abort condition: The PauseState resource is missing + /// Abort condition: The PauseState paused is true. + /// Post condition: There are no changes to the PauseState. + spec assert_not_paused { + aborts_if !exists(obj_address); + aborts_if !exists(obj_address) || !object::spec_exists_at(obj_address); + aborts_if global(obj_address).paused; + ensures global(obj_address) == old(global(obj_address)); + } + + /// Abort condition: Object does not exist at the object address. + /// Abort condition: The OwnerRole resource is missing. + /// Abort condition: The PauseState resource already exists at the object address. + /// Post condition: The PauseState resource is created properly. + spec new { + let obj_address = signer::address_of(obj_signer); + aborts_if !exists(obj_address); + aborts_if !object::spec_exists_at(obj_address); + aborts_if exists(obj_address); + ensures global(obj_address) == PauseState { paused: false, pauser: pauser }; + } + + /// Abort condition: The PauseState resource is missing. + /// Abort condition: The caller is not the pauser address. + /// Post condition: The paused state is always set to true. + /// Post condition: The pauser address does not change. + spec pause { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + aborts_if global(obj_address).pauser != signer::address_of(caller); + ensures global(obj_address).paused; + ensures global(obj_address).pauser == old(global(obj_address).pauser); + } + + /// Abort condition: The PauseState resource is missing. + /// Abort condition: The caller is not the pauser address. + /// Post condition: The paused state is always set to false + /// Post condition: The pauser address does not change + spec unpause { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + aborts_if global(obj_address).pauser != signer::address_of(caller); + ensures !global(obj_address).paused; + ensures global(obj_address).pauser == old(global(obj_address).pauser); + } + + /// Abort condition: The input object does not contain a valid object address. + /// Abort condition: The PauseState resource is missing. + /// Abort condition: The OwnerRole resource is missing. + /// Abort condition: The caller is not the owner address. + /// Post condition: The pauser is always updated to new_pauser. + /// Post condition: The paused state does not change. + spec update_pauser { + let obj_address = object::object_address(obj); + aborts_if !exists(obj_address); + aborts_if !exists(obj_address); + aborts_if !exists(obj_address) || !object::spec_exists_at(obj_address); + aborts_if global(obj_address).owner != signer::address_of(caller); + ensures global(obj_address).pauser == new_pauser; + ensures global(obj_address).paused == old(global(obj_address).paused); + } + + /// Abort condition: The PauseState resource is missing. + /// Post condition: The PauseState resource is always removed. + spec destroy { + aborts_if !exists(signer::address_of(caller)); + ensures !exists(signer::address_of(caller)); + } +} diff --git a/packages/aptos_extensions/tests/pausable_tests.move b/packages/aptos_extensions/tests/pausable_tests.move new file mode 100644 index 0000000..e2de867 --- /dev/null +++ b/packages/aptos_extensions/tests/pausable_tests.move @@ -0,0 +1,255 @@ +// 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 aptos_extensions::pausable_tests { + use aptos_framework::account::create_signer_for_test; + use aptos_framework::event; + use aptos_framework::object::{Self, Object}; + + use aptos_extensions::ownable; + use aptos_extensions::pausable::{Self, PauseState}; + use aptos_extensions::test_utils::{assert_eq, create_and_move_custom_resource}; + + // Test addresses + const PAUSER_ADDRESS: address = @0x1111; + const PAUSER_ADDRESS_2: address = @0x2222; + const OWNER_ADDRESS: address = @0x3333; + const RANDOM_ADDRESS: address = @0x4444; + + #[test] + public fun new__should_set_pausable_address() { + let (signer, obj_address) = create_and_move_custom_resource(); + ownable::new(&signer, OWNER_ADDRESS); + pausable::new(&signer, PAUSER_ADDRESS); + + let obj = object::address_to_object(obj_address); + assert_eq( + pausable::pauser(obj), + PAUSER_ADDRESS + ); + } + + #[test] + public fun new__should_set_paused_to_false() { + let (signer, obj_address) = create_and_move_custom_resource(); + ownable::new(&signer, OWNER_ADDRESS); + + pausable::new(&signer, PAUSER_ADDRESS); + + let obj = object::address_to_object(obj_address); + assert_eq( + pausable::is_paused(obj), + false + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::ENON_EXISTENT_OBJECT)] + public fun new__should_fail_when_object_does_not_exist() { + let signer = create_signer_for_test(RANDOM_ADDRESS); + + pausable::new(&signer, PAUSER_ADDRESS); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::ENON_EXISTENT_OWNER)] + public fun new__should_fail_if_owner_role_not_created() { + let (signer, _) = create_and_move_custom_resource(); + + pausable::new(&signer, PAUSER_ADDRESS); + } + + #[test] + public fun is_paused__should_return_false_if_not_paused() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + + pausable::set_paused_for_testing(obj_address, false); + + assert_eq(pausable::is_paused(obj), false); + } + + #[test] + public fun is_paused__should_return_true_if_paused() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + + pausable::set_paused_for_testing(obj_address, true); + + assert_eq(pausable::is_paused(obj), true); + } + + #[test] + public fun pauser__should_return_pauser_address() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + + pausable::set_pauser_for_testing(obj_address, PAUSER_ADDRESS_2); + + assert_eq(pausable::pauser(obj), PAUSER_ADDRESS_2); + } + + #[test] + public fun assert_not_paused__should_succeed_if_not_paused() { + let (_, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + pausable::set_paused_for_testing(obj_address, false); + + pausable::assert_not_paused(obj_address); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + public fun assert_not_paused__should_abort_if_paused() { + let (_, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + pausable::set_paused_for_testing(obj_address, true); + + pausable::assert_not_paused(obj_address); + } + + #[test] + public fun pause__should_set_paused_to_true() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let pauser_signer = &create_signer_for_test(PAUSER_ADDRESS); + let pause_event = pausable::test_Pause_event(obj_address); + + pausable::test_pause(pauser_signer, obj); + + assert_eq(pausable::is_paused(obj), true); + assert_eq(event::was_event_emitted(&pause_event), true); + } + + #[test] + public fun pause__should_succeed_if_already_paused() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let pauser_signer = &create_signer_for_test(PAUSER_ADDRESS); + let pause_event = pausable::test_Pause_event(obj_address); + + pausable::set_paused_for_testing(obj_address, true); + pausable::test_pause(pauser_signer, obj); + + assert_eq(pausable::is_paused(obj), true); + assert_eq(event::was_event_emitted(&pause_event), true); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::ENOT_PAUSER)] + public fun pause__should_fail_if_caller_is_not_pauser() { + let (obj, _) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let invalid_signer = &create_signer_for_test(PAUSER_ADDRESS_2); + + pausable::test_pause(invalid_signer, obj); + } + + #[test] + public fun unpause__should_set_paused_to_false() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let pauser_signer = &create_signer_for_test(PAUSER_ADDRESS); + let unpause_event = pausable::test_Unpause_event(obj_address); + + pausable::set_paused_for_testing(obj_address, true); + pausable::test_unpause(pauser_signer, obj); + + assert_eq(pausable::is_paused(obj), false); + assert_eq(event::was_event_emitted(&unpause_event), true); + } + + #[test] + public fun unpause__should_succeed_if_already_unpaused() { + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let pauser_signer = &create_signer_for_test(PAUSER_ADDRESS); + let unpause_event = pausable::test_Unpause_event(obj_address); + + pausable::set_paused_for_testing(obj_address, false); + pausable::test_unpause(pauser_signer, obj); + + assert_eq(pausable::is_paused(obj), false); + assert_eq(event::was_event_emitted(&unpause_event), true); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::ENOT_PAUSER)] + public fun unpause__should_fail_if_caller_not_pauser() { + let (obj, _) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let invalid_signer = &create_signer_for_test(PAUSER_ADDRESS_2); + + pausable::test_unpause(invalid_signer, obj); + } + + #[test] + public fun update_pauser__should_set_new_pauser() { + let owner_signer = &create_signer_for_test(OWNER_ADDRESS); + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let pauser_changed_event = pausable::test_PauserChanged_event( + obj_address, + PAUSER_ADDRESS_2, + PAUSER_ADDRESS + ); + + assert_eq(pausable::pauser(obj), PAUSER_ADDRESS); + + pausable::test_update_pauser(owner_signer, obj, PAUSER_ADDRESS_2); + + assert_eq(pausable::pauser(obj), PAUSER_ADDRESS_2); + assert_eq(event::was_event_emitted(&pauser_changed_event), true); + } + + #[test] + public fun update_pauser__should_be_idempotent() { + let owner_signer = &create_signer_for_test(OWNER_ADDRESS); + let (obj, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let pauser_changed_event_1 = pausable::test_PauserChanged_event( + obj_address, + PAUSER_ADDRESS_2, + PAUSER_ADDRESS + ); + let pauser_changed_event_2 = pausable::test_PauserChanged_event( + obj_address, + PAUSER_ADDRESS_2, + PAUSER_ADDRESS_2 + ); + + pausable::test_update_pauser(owner_signer, obj, PAUSER_ADDRESS_2); + pausable::test_update_pauser(owner_signer, obj, PAUSER_ADDRESS_2); + + assert_eq(pausable::pauser(obj), PAUSER_ADDRESS_2); + assert_eq(event::was_event_emitted(&pauser_changed_event_1), true); + assert_eq(event::was_event_emitted(&pauser_changed_event_2), true); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_OWNER)] + public fun update_pauser__should_fail_if_caller_not_owner() { + let invalid_owner_signer = &create_signer_for_test(PAUSER_ADDRESS_2); + let (obj, _) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + + pausable::test_update_pauser(invalid_owner_signer, obj, PAUSER_ADDRESS_2); + } + + #[test] + public fun destroy__should_remove_pausable_state_resource() { + let (_, obj_address) = setup_pausable(OWNER_ADDRESS, PAUSER_ADDRESS); + let object_signer = create_signer_for_test(obj_address); + let pause_state_destroyed_event = pausable::test_PauseStateDestroyed_event(obj_address); + + assert_eq(object::object_exists(obj_address), true); + + pausable::destroy(&object_signer); + + assert_eq(object::object_exists(obj_address), false); + assert_eq(event::was_event_emitted(&pause_state_destroyed_event), true); + } + + // === Test helpers === + + fun setup_pausable(owner: address, pauser: address): (Object, address) { + let (signer, obj_address) = create_and_move_custom_resource(); + ownable::new(&signer, owner); + pausable::new(&signer, pauser); + (object::address_to_object(obj_address), obj_address) + } +} diff --git a/packages/aptos_extensions/tests/test_utils.move b/packages/aptos_extensions/tests/test_utils.move new file mode 100644 index 0000000..37800ac --- /dev/null +++ b/packages/aptos_extensions/tests/test_utils.move @@ -0,0 +1,168 @@ +// 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 aptos_extensions::test_utils { + use std::string; + use aptos_std::debug; + use aptos_std::string_utils::format2; + use aptos_framework::object; + + /// Error thrown when assertion fails. + const ERROR_FAILED_ASSERTION: u64 = 0; + + const RANDOM_ADDRESS: address = @0x10; + + struct CustomResource has key {} + + public fun assert_eq(a: T, b: T) { + internal_assert_eq(a, b, true /* debug */) + } + + public fun assert_neq(a: T, b: T) { + internal_assert_neq(a, b, true /* debug */) + } + + public fun create_and_move_custom_resource(): (signer, address) { + let constructor_ref = object::create_sticky_object(RANDOM_ADDRESS); + let obj_address = object::address_from_constructor_ref(&constructor_ref); + let signer = object::generate_signer(&constructor_ref); + move_to(&signer, CustomResource {}); + (signer, obj_address) + } + + fun internal_assert_eq(a: T, b: T, debug: bool) { + if (&a == &b) { + return + }; + if (debug) { + debug::print(&format2(&b"[FAILED_ASSERTION] assert_eq({}, {})", a, b)); + }; + abort ERROR_FAILED_ASSERTION + } + + fun internal_assert_neq(a: T, b: T, debug: bool) { + if (&a != &b) { + return + }; + if (debug) { + debug::print(&format2(&b"[FAILED_ASSERTION] assert_neq({}, {})", a, b)); + }; + abort ERROR_FAILED_ASSERTION + } + + #[test] + fun assert_eq__should_succeed_with_matching_values() { + assert_eq(1, 1); + assert_eq(true, true); + assert_eq(@0x123, @0x123); + assert_eq(vector[1, 2, 3], vector[1, 2, 3]); + assert_eq(string::utf8(b"string"), string::utf8(b"string")); + + let (_, object_address) = create_and_move_custom_resource(); + assert_eq( + object::address_to_object(object_address), + object::address_to_object(object_address) + ); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_eq__should_fail_with_mismatched_integer() { + internal_assert_eq(1, 2, false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_eq__should_fail_with_mismatched_bool() { + internal_assert_eq(true, false, false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_eq__should_fail_with_mismatched_address() { + internal_assert_eq(@0x123, @0x234, false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_eq__should_fail_with_mismatched_vector() { + internal_assert_eq(vector[1, 2, 3], vector[2, 3, 4], false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_eq__should_fail_with_mismatched_string() { + internal_assert_eq(string::utf8(b"string"), string::utf8(b"other"), false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_eq__should_fail_with_mismatched_object_address() { + let (_, object_address) = create_and_move_custom_resource(); + let (_, object_address2) = create_and_move_custom_resource(); + internal_assert_eq( + object::address_to_object(object_address), + object::address_to_object(object_address2), + false /* debug */ + ); + } + + #[test] + fun assert_neq__should_succeed_with_mismatched_values() { + assert_neq(1, 2); + assert_neq(true, false); + assert_neq(@0x123, @0x234); + assert_neq(vector[1, 2, 3], vector[2, 3, 4]); + assert_neq(string::utf8(b"string"), string::utf8(b"other")); + + let (_, object_address) = create_and_move_custom_resource(); + let (_, object_address2) = create_and_move_custom_resource(); + assert_neq( + object::address_to_object(object_address), + object::address_to_object(object_address2) + ); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_neq__should_fail_with_matching_integer() { + internal_assert_neq(1, 1, false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_neq__should_fail_with_matching_bool() { + internal_assert_neq(true, true, false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_neq__should_fail_with_matching_address() { + internal_assert_neq(@0x123, @0x123, false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_neq__should_fail_with_matching_vector() { + internal_assert_neq(vector[1, 2, 3], vector[1, 2, 3], false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_neq__should_fail_with_matching_string() { + internal_assert_neq(string::utf8(b"string"), string::utf8(b"string"), false /* debug */); + } + + #[test, expected_failure(abort_code = ERROR_FAILED_ASSERTION)] + fun internal_assert_neq__should_fail_with_matching_object_address() { + let (_, object_address) = create_and_move_custom_resource(); + internal_assert_neq( + object::address_to_object(object_address), + object::address_to_object(object_address), + false /* debug */ + ); + } +} diff --git a/packages/aptos_extensions/tests/upgradable.spec.move b/packages/aptos_extensions/tests/upgradable.spec.move new file mode 100644 index 0000000..ab28a23 --- /dev/null +++ b/packages/aptos_extensions/tests/upgradable.spec.move @@ -0,0 +1,57 @@ +// 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. + +spec aptos_extensions::upgradable { + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + } + + /// Abort condition: The AdminRole resource is missing. + /// Abort condition: The signer_cap is not the caller's signer cap. + /// Abort condition: The SignerCapStore resource already exists at the caller's address. + /// Post condition: The SignerCapStore resource is created properly. + spec new { + aborts_if !exists(signer::address_of(caller)); + aborts_if account::get_signer_capability_address(signer_cap) != signer::address_of(caller); + aborts_if exists(signer::address_of(caller)); + ensures global(signer::address_of(caller)).signer_cap == signer_cap; + } + + /// Abort condition: The AdminRole resource is missing. + /// Abort condition: The caller is not the resource_acct admin. + /// Abort condition: The SignerCapStore resource is missing. + /// [NOT PROVEN] Abort condition: The code::publish_package_txn fails. + spec upgrade_package { + pragma aborts_if_is_partial; + aborts_if !exists(resource_acct); + aborts_if signer::address_of(caller) != global(resource_acct).admin; + aborts_if !exists(resource_acct); + } + + /// Abort condition: The AdminRole is missing. + /// Abort condition: The caller is not the resource_acct admin. + /// Abort condition: The SignerCapStore resource is missing. + /// Post condition: The SignerCapStore resource is removed. + /// Post condition: The extracted signer_cap is returned. + spec extract_signer_cap { + aborts_if !exists(resource_acct); + aborts_if signer::address_of(caller) != global(resource_acct).admin; + aborts_if !exists(resource_acct); + ensures !exists(resource_acct); + ensures result == old(global(resource_acct).signer_cap); + } +} diff --git a/packages/aptos_extensions/tests/upgradeable_tests.move b/packages/aptos_extensions/tests/upgradeable_tests.move new file mode 100644 index 0000000..b56f4a0 --- /dev/null +++ b/packages/aptos_extensions/tests/upgradeable_tests.move @@ -0,0 +1,137 @@ +// 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 aptos_extensions::upgradeable_tests { + use std::vector; + use aptos_framework::account::{create_signer_for_test, create_test_signer_cap}; + use aptos_framework::event; + + use aptos_extensions::manageable; + use aptos_extensions::test_utils::assert_eq; + use aptos_extensions::upgradable; + + const RESOURCE_ADDRESS: address = @0x1111; + const ADMIN_ADDRESS: address = @0x2222; + const RANDOM_ADDRESS: address = @0x3333; + + const TEST_METADATA_SERIALIZED: vector = x"04746573740100000000000000000000000000"; // empty BCS serialized PackageMetadata + + #[test] + fun new__should_create_signer_cap_store_correctly() { + let resource_acct_signer = &create_signer_for_test(RESOURCE_ADDRESS); + let resource_acct_signer_cap = create_test_signer_cap(RESOURCE_ADDRESS); + + manageable::new(resource_acct_signer, ADMIN_ADDRESS); + upgradable::new(resource_acct_signer, resource_acct_signer_cap); + + assert_eq(upgradable::signer_cap_store_exists_for_testing(RESOURCE_ADDRESS), true); + + let extracted_signer_cap = upgradable::extract_signer_cap_for_testing(RESOURCE_ADDRESS); + assert_eq(extracted_signer_cap, create_test_signer_cap(RESOURCE_ADDRESS)); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::EMISSING_ADMIN_RESOURCE)] + fun new__should_fail_if_admin_resource_is_missing() { + upgradable::new( + &create_signer_for_test(RESOURCE_ADDRESS), + create_test_signer_cap(RESOURCE_ADDRESS) + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::upgradable::EMISMATCHED_SIGNER_CAP)] + fun new__should_fail_if_signer_cap_is_for_different_address() { + let resource_acct_signer = &create_signer_for_test(RESOURCE_ADDRESS); + manageable::new(resource_acct_signer, ADMIN_ADDRESS); + upgradable::new( + &create_signer_for_test(RESOURCE_ADDRESS), + create_test_signer_cap(RANDOM_ADDRESS) + ); + } + + #[test] + fun upgrade_package__should_succeed() { + setup_upgradeable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let package_upgraded_event = upgradable::test_PackageUpgraded_event( + RESOURCE_ADDRESS + ); + upgradable::test_upgrade_package( + &create_signer_for_test(ADMIN_ADDRESS), + RESOURCE_ADDRESS, + TEST_METADATA_SERIALIZED, + vector::empty() + ); + + assert_eq(event::was_event_emitted(&package_upgraded_event), true); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::ENOT_ADMIN)] + fun upgrade_package__should_fail_if_caller_not_admin() { + setup_upgradeable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + upgradable::test_upgrade_package( + &create_signer_for_test(RANDOM_ADDRESS), + RESOURCE_ADDRESS, + TEST_METADATA_SERIALIZED, + vector::empty() + ); + } + + #[test] + fun extract_signer_cap__should_extract_signer_capability() { + setup_upgradeable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + let cap_extracted_event = upgradable::test_SignerCapExtracted_event(RESOURCE_ADDRESS); + + let signer_cap = upgradable::extract_signer_cap( + &create_signer_for_test(ADMIN_ADDRESS), + RESOURCE_ADDRESS + ); + + assert_eq(create_test_signer_cap(RESOURCE_ADDRESS), signer_cap); + assert_eq(event::was_event_emitted(&cap_extracted_event), true); + } + + #[test] + fun extract_signer_cap__should_remove_signer_cap_store_resource() { + setup_upgradeable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + assert_eq(upgradable::signer_cap_store_exists_for_testing(RESOURCE_ADDRESS), true); + + upgradable::extract_signer_cap( + &create_signer_for_test(ADMIN_ADDRESS), + RESOURCE_ADDRESS + ); + + assert_eq(upgradable::signer_cap_store_exists_for_testing(RESOURCE_ADDRESS), false); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::ENOT_ADMIN)] + fun extract_signer_cap__should_fail_if_caller_not_admin() { + setup_upgradeable(RESOURCE_ADDRESS, ADMIN_ADDRESS); + + upgradable::extract_signer_cap(&create_signer_for_test(RANDOM_ADDRESS), RESOURCE_ADDRESS); + } + + // === Test helpers === + + fun setup_upgradeable(resource_address: address, admin: address) { + let resource_acct_signer = &create_signer_for_test(resource_address); + let resource_acct_signer_cap = create_test_signer_cap(resource_address); + + manageable::new(resource_acct_signer, admin); + upgradable::new(resource_acct_signer, resource_acct_signer_cap); + } +} diff --git a/packages/stablecoin/Move.toml b/packages/stablecoin/Move.toml new file mode 100644 index 0000000..ca0bd82 --- /dev/null +++ b/packages/stablecoin/Move.toml @@ -0,0 +1,24 @@ +[package] +name = "Stablecoin" +version = "1.0.0" +upgrade_policy = "compatible" + +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "mainnet" +subdir = "aptos-move/framework/aptos-framework" + +[dependencies.AptosExtensions] +local = "../aptos_extensions" + +[addresses] +stablecoin = "_" +aptos_extensions = "_" +deployer = "_" + +[dev-addresses] +stablecoin = "0x94ae22c4ecec81b458095a7ae2a5de2ac81d2bff9c8633e029194424e422db3b" +aptos_extensions = "0x87899b097b766da6838867c873185c90d7d5661ebfe2ce0e985b53a2b0ed9e28" +deployer = "0xaa03675e2e13043f3d591804b0d8a8ffb775d14b11ca0831866ecdf48df26713" + +[dev-dependencies] diff --git a/packages/stablecoin/sources/blocklistable.move b/packages/stablecoin/sources/blocklistable.move new file mode 100644 index 0000000..9415445 --- /dev/null +++ b/packages/stablecoin/sources/blocklistable.move @@ -0,0 +1,211 @@ +// 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 defines logic to blocklist/unblocklist accounts from interacting with a stablecoin. +module stablecoin::blocklistable { + use std::event; + use std::signer; + use std::table_with_length::{Self, TableWithLength}; + use aptos_framework::fungible_asset::{Self, TransferRef}; + use aptos_framework::object::{Self, ConstructorRef}; + + use aptos_extensions::ownable; + use stablecoin::stablecoin_utils::stablecoin_address; + + friend stablecoin::stablecoin; + + // === Errors === + + /// Address is blocklisted. + const EBLOCKLISTED: u64 = 1; + /// Address is not the blocklister. + const ENOT_BLOCKLISTER: u64 = 2; + + // === Structs === + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct BlocklistState has key { + /// Mapping containing blocked addresses. + blocklist: TableWithLength, + /// The address of the stablecoin's blocklister. + blocklister: address, + /// The capability to transfer and freeze units of a stablecoin. + transfer_ref: TransferRef + } + + // === Events === + + #[event] + struct Blocklisted has drop, store { + address: address + } + + #[event] + struct Unblocklisted has drop, store { + address: address + } + + #[event] + struct BlocklisterChanged has drop, store { + old_blocklister: address, + new_blocklister: address + } + + // === View-only functions === + + #[view] + /// Returns whether an address is blocklisted + public fun is_blocklisted(addr: address): bool acquires BlocklistState { + internal_is_blocklisted(borrow_global(stablecoin_address()), addr) + } + + #[view] + /// Gets the blocklister address of a stablecoin. + public fun blocklister(): address acquires BlocklistState { + borrow_global(stablecoin_address()).blocklister + } + + /// Aborts if the address is blocklisted. + public fun assert_not_blocklisted(addr: address) acquires BlocklistState { + assert!(!is_blocklisted(addr), EBLOCKLISTED); + } + + // === Write functions === + + /// Creates new blocklist state. + public(friend) fun new( + stablecoin_obj_constructor_ref: &ConstructorRef, blocklister: address + ) { + let stablecoin_obj_signer = &object::generate_signer(stablecoin_obj_constructor_ref); + move_to( + stablecoin_obj_signer, + BlocklistState { + blocklist: table_with_length::new(), + blocklister, + transfer_ref: fungible_asset::generate_transfer_ref(stablecoin_obj_constructor_ref) + } + ); + } + + /// Adds an account to the blocklist. + entry fun blocklist(caller: &signer, addr_to_block: address) acquires BlocklistState { + let blocklist_state = borrow_global_mut(stablecoin_address()); + assert!(signer::address_of(caller) == blocklist_state.blocklister, ENOT_BLOCKLISTER); + if (!internal_is_blocklisted(blocklist_state, addr_to_block)) { + table_with_length::add(&mut blocklist_state.blocklist, addr_to_block, true); + }; + event::emit(Blocklisted { address: addr_to_block }) + } + + /// Removes an account from the blocklist. + entry fun unblocklist(caller: &signer, addr_to_unblock: address) acquires BlocklistState { + let blocklist_state = borrow_global_mut(stablecoin_address()); + assert!(signer::address_of(caller) == blocklist_state.blocklister, ENOT_BLOCKLISTER); + if (internal_is_blocklisted(blocklist_state, addr_to_unblock)) { + table_with_length::remove(&mut blocklist_state.blocklist, addr_to_unblock); + }; + event::emit(Unblocklisted { address: addr_to_unblock }) + } + + /// Update blocklister role + entry fun update_blocklister(caller: &signer, new_blocklister: address) acquires BlocklistState { + let stablecoin_address = stablecoin_address(); + ownable::assert_is_owner(caller, stablecoin_address); + + let blocklist_state = borrow_global_mut(stablecoin_address); + let old_blocklister = blocklist_state.blocklister; + blocklist_state.blocklister = new_blocklister; + + event::emit(BlocklisterChanged { old_blocklister, new_blocklister }); + } + + // === Aliases === + + inline fun internal_is_blocklisted(blocklist_state: &BlocklistState, addr: address): bool { + table_with_length::contains(&blocklist_state.blocklist, addr) + } + + // === Test Only === + + #[test_only] + use aptos_framework::fungible_asset::Metadata; + #[test_only] + use aptos_framework::object::Object; + + #[test_only] + public fun new_for_testing( + stablecoin_obj_constructor_ref: &ConstructorRef, blocklister: address + ) { + new(stablecoin_obj_constructor_ref, blocklister); + } + + #[test_only] + public fun transfer_ref_metadata_for_testing(): Object acquires BlocklistState { + fungible_asset::transfer_ref_metadata( + &borrow_global(stablecoin_address()).transfer_ref + ) + } + + #[test_only] + public fun num_blocklisted_for_testing(): u64 acquires BlocklistState { + table_with_length::length(&borrow_global(stablecoin_address()).blocklist) + } + + #[test_only] + public fun set_blocklister_for_testing(blocklister: address) acquires BlocklistState { + borrow_global_mut(stablecoin_address()).blocklister = blocklister; + } + + #[test_only] + public fun set_blocklisted_for_testing(addr: address, blocklisted: bool) acquires BlocklistState { + let blocklist = &mut borrow_global_mut(stablecoin_address()).blocklist; + if (blocklisted) { + table_with_length::add(blocklist, addr, true); + } else if (table_with_length::contains(blocklist, addr)) { + table_with_length::remove(blocklist, addr); + } + } + + #[test_only] + public fun test_blocklist(caller: &signer, addr_to_block: address) acquires BlocklistState { + blocklist(caller, addr_to_block); + } + + #[test_only] + public fun test_unblocklist(caller: &signer, addr_to_unblock: address) acquires BlocklistState { + unblocklist(caller, addr_to_unblock); + } + + #[test_only] + public fun test_Blocklisted_event(addr_to_block: address): Blocklisted { + Blocklisted { address: addr_to_block } + } + + #[test_only] + public fun test_Unblocklisted_event(addr_to_unblock: address): Unblocklisted { + Unblocklisted { address: addr_to_unblock } + } + + #[test_only] + public fun test_update_blocklister(caller: &signer, new_blocklister: address) acquires BlocklistState { + update_blocklister(caller, new_blocklister); + } + + #[test_only] + public fun test_BlocklisterChanged_event(old_blocklister: address, new_blocklister: address): BlocklisterChanged { + BlocklisterChanged { old_blocklister, new_blocklister } + } +} diff --git a/packages/stablecoin/sources/metadata.move b/packages/stablecoin/sources/metadata.move new file mode 100644 index 0000000..c69a34b --- /dev/null +++ b/packages/stablecoin/sources/metadata.move @@ -0,0 +1,213 @@ +// 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 defines logic for managing the metadata of a stablecoin. +module stablecoin::metadata { + use std::event; + use std::option::{Self, Option}; + use std::signer; + use std::string::String; + use aptos_framework::fungible_asset::{Self, Metadata, MutateMetadataRef}; + use aptos_framework::object::{Self, ConstructorRef}; + + use aptos_extensions::ownable; + use stablecoin::stablecoin_utils::stablecoin_address; + + friend stablecoin::stablecoin; + + // === Errors === + + /// Address is not the metadata_updater. + const ENOT_METADATA_UPDATER: u64 = 1; + + // === Structs === + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct MetadataState has key { + /// The capability to mutate the metadata of a stablecoin. + mutate_metadata_ref: MutateMetadataRef, + /// The address of the stablecoin's metadata updater. + metadata_updater: address + } + + // === Events === + + #[event] + struct MetadataUpdated has drop, store { + name: String, + symbol: String, + decimals: u8, + icon_uri: String, + project_uri: String + } + + #[event] + struct MetadataUpdaterChanged has drop, store { + old_metadata_updater: address, + new_metadata_updater: address + } + + // === View-only functions === + + #[view] + /// Gets the metadata updater address of a stablecoin. + public fun metadata_updater(): address acquires MetadataState { + borrow_global(stablecoin_address()).metadata_updater + } + + // === Write functions === + + /// Creates new metadata state. + public(friend) fun new( + stablecoin_obj_constructor_ref: &ConstructorRef, metadata_updater: address + ) { + let stablecoin_obj_signer = &object::generate_signer(stablecoin_obj_constructor_ref); + move_to( + stablecoin_obj_signer, + MetadataState { + mutate_metadata_ref: fungible_asset::generate_mutate_metadata_ref(stablecoin_obj_constructor_ref), + metadata_updater + } + ); + } + + /// Updates the FungibleAsset metadata + entry fun update_metadata( + caller: &signer, + name: Option, + symbol: Option, + icon_uri: Option, + project_uri: Option + ) acquires MetadataState { + let metadata_state = borrow_global(stablecoin_address()); + assert!( + signer::address_of(caller) == metadata_state.metadata_updater, + ENOT_METADATA_UPDATER + ); + mutate_asset_metadata(name, symbol, option::none(), icon_uri, project_uri); + } + + /// Mutates the FungibleAsset metadata + public(friend) fun mutate_asset_metadata( + name: Option, + symbol: Option, + decimals: Option, + icon_uri: Option, + project_uri: Option + ) acquires MetadataState { + let stablecoin_address = stablecoin_address(); + let metadata_state = borrow_global(stablecoin_address); + fungible_asset::mutate_metadata( + &metadata_state.mutate_metadata_ref, + name, + symbol, + decimals, + icon_uri, + project_uri + ); + let metadata = object::address_to_object(stablecoin_address); + event::emit( + MetadataUpdated { + name: fungible_asset::name(metadata), + symbol: fungible_asset::symbol(metadata), + decimals: fungible_asset::decimals(metadata), + icon_uri: fungible_asset::icon_uri(metadata), + project_uri: fungible_asset::project_uri(metadata) + } + ); + } + + /// Update metadata updater role + entry fun update_metadata_updater(caller: &signer, new_metadata_updater: address) acquires MetadataState { + let stablecoin_address = stablecoin_address(); + ownable::assert_is_owner(caller, stablecoin_address); + + let metadata_state = borrow_global_mut(stablecoin_address); + let old_metadata_updater = metadata_state.metadata_updater; + metadata_state.metadata_updater = new_metadata_updater; + + event::emit(MetadataUpdaterChanged { old_metadata_updater, new_metadata_updater }); + } + + // === Test Only === + + #[test_only] + use aptos_framework::object::Object; + + #[test_only] + public fun new_for_testing( + stablecoin_obj_constructor_ref: &ConstructorRef, metadata_updater: address + ) { + new(stablecoin_obj_constructor_ref, metadata_updater); + } + + #[test_only] + public fun mutate_metadata_ref_metadata_for_testing(): Object acquires MetadataState { + fungible_asset::object_from_metadata_ref( + &borrow_global(stablecoin_address()).mutate_metadata_ref + ) + } + + #[test_only] + public fun set_metadata_updater_for_testing(metadata_updater: address) acquires MetadataState { + borrow_global_mut(stablecoin_address()).metadata_updater = metadata_updater; + } + + #[test_only] + public fun test_update_metadata( + caller: &signer, + name: Option, + symbol: Option, + icon_uri: Option, + project_uri: Option + ) acquires MetadataState { + update_metadata(caller, name, symbol, icon_uri, project_uri); + } + + #[test_only] + public fun test_mutate_asset_metadata( + name: Option, + symbol: Option, + decimals: Option, + icon_uri: Option, + project_uri: Option + ) acquires MetadataState { + mutate_asset_metadata(name, symbol, decimals, icon_uri, project_uri); + } + + #[test_only] + public fun test_MetadataUpdated_event( + name: String, + symbol: String, + decimals: u8, + icon_uri: String, + project_uri: String + ): MetadataUpdated { + MetadataUpdated { name, symbol, decimals, icon_uri, project_uri } + } + + #[test_only] + public fun test_update_metadata_updater(caller: &signer, new_metadata_updater: address) acquires MetadataState { + update_metadata_updater(caller, new_metadata_updater); + } + + #[test_only] + public fun test_MetadataUpdaterChanged_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..402662f --- /dev/null +++ b/packages/stablecoin/sources/stablecoin.move @@ -0,0 +1,269 @@ +// 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 defines core stablecoin logic, including stablecoin resource creation, +/// initialization, and dispatchable deposit and withdraw functions. +module stablecoin::stablecoin { + use std::event; + use std::option; + use std::string::{Self, String, utf8}; + use std::vector; + use aptos_framework::dispatchable_fungible_asset; + use aptos_framework::function_info; + use aptos_framework::fungible_asset::{Self, FungibleAsset, TransferRef}; + use aptos_framework::object::{Self, ExtendRef, Object}; + use aptos_framework::primary_fungible_store; + use aptos_framework::resource_account; + + use aptos_extensions::manageable; + use aptos_extensions::ownable; + use aptos_extensions::pausable; + use aptos_extensions::upgradable; + use stablecoin::blocklistable; + use stablecoin::metadata; + use stablecoin::stablecoin_utils; + use stablecoin::treasury; + + // === Errors === + + /// Input metadata does not match stablecoin metadata. + const ESTABLECOIN_METADATA_MISMATCH: u64 = 1; + /// The stablecoin version has been initialized previously. + const ESTABLECOIN_VERSION_INITIALIZED: u64 = 2; + + // === Structs === + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct StablecoinState has key { + /// The capability to update the stablecoin object's storage. + extend_ref: ExtendRef, + /// The stablecoin's initialized version. + initialized_version: u8 + } + + // === Events === + + #[event] + struct Deposit has drop, store { + store_owner: address, + store: address, + amount: u64 + } + + #[event] + struct Withdraw has drop, store { + store_owner: address, + store: address, + amount: u64 + } + + #[event] + /// Emitted when a stablecoin version is initialized. + struct StablecoinInitialized has drop, store { + initialized_version: u8 + } + + // === View functions === + + #[view] + public fun stablecoin_address(): address { + stablecoin_utils::stablecoin_address() + } + + // === Write functions === + + /// Creates the stablecoin fungible asset, resources and roles. + fun init_module(resource_acct_signer: &signer) { + // Create the stablecoin's object container. + let stablecoin_obj_constructor_ref = + &object::create_named_object(resource_acct_signer, stablecoin_utils::stablecoin_obj_seed()); + + // Create the fungible asset primary store resources with default values. + primary_fungible_store::create_primary_store_enabled_fungible_asset( + stablecoin_obj_constructor_ref, + option::none() /* maximum supply */, + utf8(vector::empty()), + utf8(vector::empty()), + 0, + utf8(vector::empty()), + utf8(vector::empty()) + ); + + // Ensure that stores derived from the stablecoin are untransferable. + fungible_asset::set_untransferable(stablecoin_obj_constructor_ref); + + // Create the StablecoinState resource. + let stablecoin_obj_signer = &object::generate_signer(stablecoin_obj_constructor_ref); + move_to( + stablecoin_obj_signer, + StablecoinState { + extend_ref: object::generate_extend_ref(stablecoin_obj_constructor_ref), + initialized_version: 0 + } + ); + + // Initialize the stablecoin's roles with default addresses. + ownable::new(stablecoin_obj_signer, @deployer); + pausable::new(stablecoin_obj_signer, @deployer); + blocklistable::new(stablecoin_obj_constructor_ref, @deployer); + metadata::new(stablecoin_obj_constructor_ref, @deployer); + treasury::new(stablecoin_obj_constructor_ref, @deployer); + + // Retrieve the resource account signer capability and initialize the managable::AdminRole and upgradable::SignerCapStore resources. + let signer_cap = resource_account::retrieve_resource_account_cap(resource_acct_signer, @deployer); + manageable::new(resource_acct_signer, @deployer); + upgradable::new(resource_acct_signer, signer_cap); + + // Create and register the custom deposit and withdraw dispatchable functions. + let withdraw_function = + function_info::new_function_info( + resource_acct_signer, + string::utf8(b"stablecoin"), + string::utf8(b"override_withdraw") + ); + let deposit_function = + function_info::new_function_info( + resource_acct_signer, + string::utf8(b"stablecoin"), + string::utf8(b"override_deposit") + ); + dispatchable_fungible_asset::register_dispatch_functions( + stablecoin_obj_constructor_ref, + option::some(withdraw_function), + option::some(deposit_function), + option::none() /* omit override for derived_balance */ + ); + } + + /// Initializes the stablecoin's metadata. + entry fun initialize_v1( + caller: &signer, + name: String, + symbol: String, + decimals: u8, + icon_uri: String, + project_uri: String + ) acquires StablecoinState { + manageable::assert_is_admin(caller, @stablecoin); + let stablecoin_address = stablecoin_utils::stablecoin_address(); + let stablecoin_state = borrow_global_mut(stablecoin_address); + assert!(stablecoin_state.initialized_version == 0, ESTABLECOIN_VERSION_INITIALIZED); + + stablecoin_state.initialized_version = 1; + + metadata::mutate_asset_metadata( + option::some(name), + option::some(symbol), + option::some(decimals), + option::some(icon_uri), + option::some(project_uri) + ); + + event::emit(StablecoinInitialized { initialized_version: 1 }); + } + + /// Dispatchable deposit implementation + public fun override_deposit( + store: Object, fa: FungibleAsset, transfer_ref: &TransferRef + ) { + let stablecoin_address = stablecoin_utils::stablecoin_address(); + assert!( + object::object_address(&fungible_asset::store_metadata(store)) == stablecoin_address, + ESTABLECOIN_METADATA_MISMATCH + ); + + let store_owner = object::owner(store); + let amount = fungible_asset::amount(&fa); + + pausable::assert_not_paused(stablecoin_address); + blocklistable::assert_not_blocklisted(store_owner); + fungible_asset::deposit_with_ref(transfer_ref, store, fa); + + event::emit(Deposit { store_owner, store: object::object_address(&store), amount }) + } + + /// Dispatchable withdraw implementation + public fun override_withdraw( + store: Object, amount: u64, transfer_ref: &TransferRef + ): FungibleAsset { + let stablecoin_address = stablecoin_utils::stablecoin_address(); + assert!( + object::object_address(&fungible_asset::store_metadata(store)) == stablecoin_address, + ESTABLECOIN_METADATA_MISMATCH + ); + + let store_owner = object::owner(store); + + pausable::assert_not_paused(stablecoin_address); + blocklistable::assert_not_blocklisted(store_owner); + let asset = fungible_asset::withdraw_with_ref(transfer_ref, store, amount); + + event::emit(Withdraw { store_owner, store: object::object_address(&store), amount }); + + asset + } + + // === Test Only === + + #[test_only] + public fun test_init_module(resource_acct_signer: &signer) { + init_module(resource_acct_signer); + } + + #[test_only] + public fun test_initialize_v1( + caller: &signer, + name: String, + symbol: String, + decimals: u8, + icon_uri: String, + project_uri: String + ) acquires StablecoinState { + initialize_v1(caller, name, symbol, decimals, icon_uri, project_uri); + } + + #[test_only] + public fun initialized_version_for_testing(): u8 acquires StablecoinState { + borrow_global(stablecoin_utils::stablecoin_address()).initialized_version + } + + #[test_only] + public fun set_initialized_version_for_testing(initialized_version: u8) acquires StablecoinState { + borrow_global_mut(stablecoin_utils::stablecoin_address()).initialized_version = initialized_version; + } + + #[test_only] + public fun extend_ref_address_for_testing(): address acquires StablecoinState { + object::address_from_extend_ref( + &borrow_global(stablecoin_utils::stablecoin_address()).extend_ref + ) + } + + #[test_only] + public fun test_Deposit_event(store_owner: address, store: address, amount: u64): Deposit { + Deposit { store_owner, store, amount } + } + + #[test_only] + public fun test_Withdraw_event(store_owner: address, store: address, amount: u64): Withdraw { + Withdraw { store_owner, store, amount } + } + + #[test_only] + public fun test_StablecoinInitialized_event(initialized_version: u8): StablecoinInitialized { + StablecoinInitialized { initialized_version } + } +} diff --git a/packages/stablecoin/sources/stablecoin_utils.move b/packages/stablecoin/sources/stablecoin_utils.move new file mode 100644 index 0000000..fe73374 --- /dev/null +++ b/packages/stablecoin/sources/stablecoin_utils.move @@ -0,0 +1,54 @@ +// 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 defines common utilities for the stablecoin module. +module stablecoin::stablecoin_utils { + use aptos_framework::object; + + friend stablecoin::blocklistable; + friend stablecoin::metadata; + friend stablecoin::stablecoin; + friend stablecoin::treasury; + + const STABLECOIN_OBJ_SEED: vector = b"stablecoin"; + + /// Returns the stablecoin's named object seed value. + public(friend) fun stablecoin_obj_seed(): vector { + STABLECOIN_OBJ_SEED + } + + /// Returns the stablecoin's object address. + public(friend) fun stablecoin_address(): address { + object::create_object_address(&@stablecoin, STABLECOIN_OBJ_SEED) + } + + // === Test Only === + + #[test_only] + friend stablecoin::stablecoin_utils_tests; + #[test_only] + friend stablecoin::stablecoin_tests; + #[test_only] + friend stablecoin::stablecoin_e2e_tests; + #[test_only] + friend stablecoin::blocklistable_tests; + #[test_only] + friend stablecoin::treasury_tests; + #[test_only] + friend stablecoin::metadata_tests; + #[test_only] + friend stablecoin::fungible_asset_tests; +} diff --git a/packages/stablecoin/sources/treasury.move b/packages/stablecoin/sources/treasury.move new file mode 100644 index 0000000..5d3f56b --- /dev/null +++ b/packages/stablecoin/sources/treasury.move @@ -0,0 +1,493 @@ +// 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 defines logic related to minting and burning units of a stablecoin, +/// as well as all role management and allowance configuration for privileged minters. +module stablecoin::treasury { + use std::event; + use std::option::{Self, Option}; + use std::signer; + use std::smart_table::{Self, SmartTable}; + use aptos_framework::fungible_asset::{Self, BurnRef, FungibleAsset, MintRef}; + use aptos_framework::object::{Self, ConstructorRef}; + + use aptos_extensions::ownable; + use aptos_extensions::pausable; + use stablecoin::blocklistable; + use stablecoin::stablecoin_utils::stablecoin_address; + + friend stablecoin::stablecoin; + + // === Errors === + + /// Address is not the master minter. + const ENOT_MASTER_MINTER: u64 = 1; + /// Address is not the controller. + const ENOT_CONTROLLER: u64 = 2; + /// Address is not a minter. + const ENOT_MINTER: u64 = 3; + /// Amount is zero. + const EZERO_AMOUNT: u64 = 4; + /// Insufficient minter allowance. + const EINSUFFICIENT_ALLOWANCE: u64 = 5; + + // === Structs === + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct TreasuryState has key { + /// The capability to mint units of a stablecoin. + mint_ref: MintRef, + /// The capability to burn units of a stablecoin. + burn_ref: BurnRef, + /// The address of the stablecoin's master minter. + master_minter: address, + /// Mapping containing controllers and the minter addresses they control. + controllers: SmartTable, + /// Mapping containing minters and their mint allowance. + mint_allowances: SmartTable + } + + // === Events === + + #[event] + struct ControllerConfigured has drop, store { + controller: address, + minter: address + } + + #[event] + struct ControllerRemoved has drop, store { + controller: address + } + + #[event] + struct MinterConfigured has drop, store { + controller: address, + minter: address, + allowance: u64 + } + + #[event] + struct MinterAllowanceIncremented has drop, store { + controller: address, + minter: address, + allowance_increment: u64, + new_allowance: u64 + } + + #[event] + struct MinterRemoved has drop, store { + controller: address, + minter: address + } + + #[event] + struct Mint has drop, store { + minter: address, + amount: u64 + } + + #[event] + struct Burn has drop, store { + burner: address, + amount: u64 + } + + #[event] + struct MasterMinterChanged has drop, store { + old_master_minter: address, + new_master_minter: address + } + + // === View-only functions === + + #[view] + /// Gets the master minter address of a stablecoin. + public fun master_minter(): address acquires TreasuryState { + borrow_global(stablecoin_address()).master_minter + } + + #[view] + /// Gets the minter address that a controller manages. + /// Defaults to none if the address is not a controller. + public fun get_minter(controller: address): Option
acquires TreasuryState { + let treasury_state = borrow_global(stablecoin_address()); + if (!internal_is_controller(treasury_state, controller)) return option::none(); + option::some(internal_get_minter(treasury_state, controller)) + } + + #[view] + /// Returns whether an address is a minter. + public fun is_minter(minter: address): bool acquires TreasuryState { + internal_is_minter(borrow_global(stablecoin_address()), minter) + } + + #[view] + /// Gets the mint allowance of a minter address. + /// Defaults to zero if the address is not a minter. + public fun mint_allowance(minter: address): u64 acquires TreasuryState { + let treasury_state = borrow_global(stablecoin_address()); + if (!internal_is_minter(treasury_state, minter)) return 0; + internal_get_mint_allowance(treasury_state, minter) + } + + // === Write functions === + + /// Creates new treasury state. + public(friend) fun new( + stablecoin_obj_constructor_ref: &ConstructorRef, master_minter: address + ) { + let stablecoin_obj_signer = &object::generate_signer(stablecoin_obj_constructor_ref); + move_to( + stablecoin_obj_signer, + TreasuryState { + mint_ref: fungible_asset::generate_mint_ref(stablecoin_obj_constructor_ref), + burn_ref: fungible_asset::generate_burn_ref(stablecoin_obj_constructor_ref), + master_minter, + controllers: smart_table::new(), + mint_allowances: smart_table::new() + } + ); + } + + /// Configures the controller for a minter. + /// Each unique controller may only control one minter, + /// but each minter may be controlled by multiple controllers. + entry fun configure_controller(caller: &signer, controller: address, minter: address) acquires TreasuryState { + let treasury_state = borrow_global_mut(stablecoin_address()); + assert!( + signer::address_of(caller) == treasury_state.master_minter, + ENOT_MASTER_MINTER + ); + + smart_table::upsert(&mut treasury_state.controllers, controller, minter); + + event::emit(ControllerConfigured { controller, minter }) + } + + /// Removes a controller. + entry fun remove_controller(caller: &signer, controller: address) acquires TreasuryState { + let treasury_state = borrow_global_mut(stablecoin_address()); + assert!( + signer::address_of(caller) == treasury_state.master_minter, + ENOT_MASTER_MINTER + ); + assert!(internal_is_controller(treasury_state, controller), ENOT_CONTROLLER); + + smart_table::remove(&mut treasury_state.controllers, controller); + + event::emit(ControllerRemoved { controller }) + } + + /// Authorizes a minter address to mint and burn, and sets its allowance. + /// Only callable by the minter's controller. + entry fun configure_minter(caller: &signer, allowance: u64) acquires TreasuryState { + let stablecoin_address = stablecoin_address(); + pausable::assert_not_paused(stablecoin_address); + + let treasury_state = borrow_global_mut(stablecoin_address); + + let controller = signer::address_of(caller); + assert!(internal_is_controller(treasury_state, controller), ENOT_CONTROLLER); + + let minter = internal_get_minter(treasury_state, controller); + internal_set_mint_allowance(treasury_state, minter, allowance); + + event::emit(MinterConfigured { controller, minter, allowance }) + } + + /// Increment the mint allowance for a minter. + /// Only callable by the minter's controller. + entry fun increment_minter_allowance(caller: &signer, allowance_increment: u64) acquires TreasuryState { + let stablecoin_address = stablecoin_address(); + pausable::assert_not_paused(stablecoin_address); + assert!(allowance_increment != 0, EZERO_AMOUNT); + + let treasury_state = borrow_global_mut(stablecoin_address); + + let controller = signer::address_of(caller); + assert!(internal_is_controller(treasury_state, controller), ENOT_CONTROLLER); + + let minter = internal_get_minter(treasury_state, controller); + assert!(internal_is_minter(treasury_state, minter), ENOT_MINTER); + + let new_allowance = internal_get_mint_allowance(treasury_state, minter) + allowance_increment; + internal_set_mint_allowance(treasury_state, minter, new_allowance); + + event::emit( + MinterAllowanceIncremented { controller, minter, allowance_increment, new_allowance } + ) + } + + /// Removes a minter. + /// Only callable by the minter's controller. + entry fun remove_minter(caller: &signer) acquires TreasuryState { + let treasury_state = borrow_global_mut(stablecoin_address()); + + let controller = signer::address_of(caller); + assert!(internal_is_controller(treasury_state, controller), ENOT_CONTROLLER); + + let minter = internal_get_minter(treasury_state, controller); + assert!(internal_is_minter(treasury_state, minter), ENOT_MINTER); + + smart_table::remove(&mut treasury_state.mint_allowances, minter); + + event::emit(MinterRemoved { controller, minter }) + } + + /// Mints an amount of Fungible Asset (limited to the minter's allowance) + /// and returns the minted asset, increasing the total supply + /// and decreasing the minter's allowance. + public fun mint(caller: &signer, amount: u64): FungibleAsset acquires TreasuryState { + let stablecoin_address = stablecoin_address(); + assert!(amount != 0, EZERO_AMOUNT); + pausable::assert_not_paused(stablecoin_address); + + let treasury_state = borrow_global_mut(stablecoin_address); + + let minter = signer::address_of(caller); + assert!(internal_is_minter(treasury_state, minter), ENOT_MINTER); + blocklistable::assert_not_blocklisted(minter); + + let mint_allowance = internal_get_mint_allowance(treasury_state, minter); + assert!(mint_allowance >= amount, EINSUFFICIENT_ALLOWANCE); + + let asset = fungible_asset::mint(&treasury_state.mint_ref, amount); + internal_set_mint_allowance(treasury_state, minter, mint_allowance - amount); + + event::emit(Mint { minter, amount }); + + asset + } + + /// Burns an amount of Fungible Asset, decreasing the total supply. + public fun burn(caller: &signer, asset: FungibleAsset) acquires TreasuryState { + let stablecoin_address = stablecoin_address(); + let amount = fungible_asset::amount(&asset); + assert!(amount != 0, EZERO_AMOUNT); + pausable::assert_not_paused(stablecoin_address); + + let treasury_state = borrow_global(stablecoin_address); + + let burner = signer::address_of(caller); + assert!(internal_is_minter(treasury_state, burner), ENOT_MINTER); + blocklistable::assert_not_blocklisted(burner); + + fungible_asset::burn(&treasury_state.burn_ref, asset); + + event::emit(Burn { burner, amount }) + } + + /// Update master minter role + entry fun update_master_minter(caller: &signer, new_master_minter: address) acquires TreasuryState { + let stablecoin_address = stablecoin_address(); + ownable::assert_is_owner(caller, stablecoin_address); + + let treasury_state = borrow_global_mut(stablecoin_address); + let old_master_minter = treasury_state.master_minter; + treasury_state.master_minter = new_master_minter; + + event::emit(MasterMinterChanged { old_master_minter, new_master_minter }); + } + + // === Aliases === + + inline fun internal_get_minter(treasury_state: &TreasuryState, controller: address): address { + *smart_table::borrow(&treasury_state.controllers, controller) + } + + inline fun internal_is_controller(treasury_state: &TreasuryState, controller: address): bool { + smart_table::contains(&treasury_state.controllers, controller) + } + + inline fun internal_is_minter(treasury_state: &TreasuryState, minter: address): bool { + smart_table::contains(&treasury_state.mint_allowances, minter) + } + + inline fun internal_get_mint_allowance(treasury_state: &TreasuryState, minter: address): u64 { + *smart_table::borrow(&treasury_state.mint_allowances, minter) + } + + inline fun internal_set_mint_allowance( + treasury_state: &mut TreasuryState, minter: address, mint_allowance: u64 + ) { + smart_table::upsert(&mut treasury_state.mint_allowances, minter, mint_allowance); + } + + // === Test Only === + + #[test_only] + use aptos_framework::object::Object; + + #[test_only] + use aptos_framework::fungible_asset::Metadata; + + #[test_only] + public fun new_for_testing( + stablecoin_obj_constructor_ref: &ConstructorRef, master_minter: address + ) { + new(stablecoin_obj_constructor_ref, master_minter); + } + + #[test_only] + public fun mint_ref_metadata_for_testing(): Object acquires TreasuryState { + fungible_asset::mint_ref_metadata( + &borrow_global(stablecoin_address()).mint_ref + ) + } + + #[test_only] + public fun test_mint(amount: u64): FungibleAsset acquires TreasuryState { + fungible_asset::mint(&borrow_global(stablecoin_address()).mint_ref, amount) + } + + #[test_only] + public fun burn_ref_metadata_for_testing(): Object acquires TreasuryState { + fungible_asset::burn_ref_metadata( + &borrow_global(stablecoin_address()).burn_ref + ) + } + + #[test_only] + public fun test_burn(asset: FungibleAsset) acquires TreasuryState { + fungible_asset::burn(&borrow_global(stablecoin_address()).burn_ref, asset) + } + + #[test_only] + public fun num_controllers_for_testing(): u64 acquires TreasuryState { + smart_table::length(&borrow_global(stablecoin_address()).controllers) + } + + #[test_only] + public fun num_mint_allowances_for_testing(): u64 acquires TreasuryState { + smart_table::length(&borrow_global(stablecoin_address()).mint_allowances) + } + + #[test_only] + public fun set_master_minter_for_testing(master_minter: address) acquires TreasuryState { + borrow_global_mut(stablecoin_address()).master_minter = master_minter; + } + + #[test_only] + public fun is_controller_for_testing(controller: address): bool acquires TreasuryState { + internal_is_controller(borrow_global(stablecoin_address()), controller) + } + + #[test_only] + public fun force_configure_controller_for_testing(controller: address, minter: address) acquires TreasuryState { + let controllers = &mut borrow_global_mut(stablecoin_address()).controllers; + smart_table::upsert(controllers, controller, minter); + } + + #[test_only] + public fun force_remove_controller_for_testing(controller: address) acquires TreasuryState { + let controllers = &mut borrow_global_mut(stablecoin_address()).controllers; + if (smart_table::contains(controllers, controller)) { + smart_table::remove(controllers, controller); + } + } + + #[test_only] + public fun force_configure_minter_for_testing(minter: address, mint_allowance: u64) acquires TreasuryState { + let mint_allowances = &mut borrow_global_mut(stablecoin_address()).mint_allowances; + smart_table::upsert(mint_allowances, minter, mint_allowance); + } + + #[test_only] + public fun force_remove_minter_for_testing(minter: address) acquires TreasuryState { + let mint_allowances = &mut borrow_global_mut(stablecoin_address()).mint_allowances; + if (smart_table::contains(mint_allowances, minter)) { + smart_table::remove(mint_allowances, minter); + } + } + + #[test_only] + public fun test_configure_controller(caller: &signer, controller: address, minter: address) acquires TreasuryState { + configure_controller(caller, controller, minter) + } + + #[test_only] + public fun test_remove_controller(caller: &signer, controller: address) acquires TreasuryState { + remove_controller(caller, controller) + } + + #[test_only] + public fun test_configure_minter(caller: &signer, allowance: u64) acquires TreasuryState { + configure_minter(caller, allowance) + } + + #[test_only] + public fun test_increment_minter_allowance(caller: &signer, allowance_increment: u64) acquires TreasuryState { + increment_minter_allowance(caller, allowance_increment) + } + + #[test_only] + public fun test_remove_minter(caller: &signer) acquires TreasuryState { + remove_minter(caller) + } + + #[test_only] + public fun test_ControllerConfigured_event(controller: address, minter: address): ControllerConfigured { + ControllerConfigured { controller, minter } + } + + #[test_only] + public fun test_ControllerRemoved_event(controller: address): ControllerRemoved { + ControllerRemoved { controller } + } + + #[test_only] + public fun test_MinterConfigured_event(controller: address, minter: address, allowance: u64): MinterConfigured { + MinterConfigured { controller, minter, allowance } + } + + #[test_only] + public fun test_MinterAllowanceIncremented_event( + controller: address, + minter: address, + allowance_increment: u64, + new_allowance: u64 + ): MinterAllowanceIncremented { + MinterAllowanceIncremented { controller, minter, allowance_increment, new_allowance } + } + + #[test_only] + public fun test_MinterRemoved_event(controller: address, minter: address): MinterRemoved { + MinterRemoved { controller, minter } + } + + #[test_only] + public fun test_Mint_event(minter: address, amount: u64): Mint { + Mint { minter, amount } + } + + #[test_only] + public fun test_Burn_event(burner: address, amount: u64): Burn { + Burn { burner, amount } + } + + #[test_only] + public fun test_update_master_minter(caller: &signer, new_master_minter: address) acquires TreasuryState { + update_master_minter(caller, new_master_minter); + } + + #[test_only] + public fun test_MasterMinterChanged_event( + old_master_minter: address, new_master_minter: address + ): MasterMinterChanged { + MasterMinterChanged { old_master_minter, new_master_minter } + } +} diff --git a/packages/stablecoin/tests/blocklistable.spec.move b/packages/stablecoin/tests/blocklistable.spec.move new file mode 100644 index 0000000..fd39ef4 --- /dev/null +++ b/packages/stablecoin/tests/blocklistable.spec.move @@ -0,0 +1,148 @@ +// 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. + +spec stablecoin::blocklistable { + use aptos_framework::object::ObjectCore; + use aptos_extensions::ownable::OwnerRole; + use stablecoin::stablecoin_utils::spec_stablecoin_address; + + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + + invariant forall addr: address where exists(addr): + object::object_address(global(addr).transfer_ref.metadata) == addr; + } + + /// Abort condition: The BlocklistState resource is missing. + /// Post condition: Return whether address is blocklisted. + /// Post condition: The BlocklistState resource is unchanged. + spec is_blocklisted { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + ensures result + == table_with_length::spec_contains(global(stablecoin_address).blocklist, addr); + ensures global(stablecoin_address) == old(global(stablecoin_address)); + } + + /// Abort condition: The BlocklistState resource is missing. + /// Post condition: The blocklister address is always returned. + /// Post condition: The BlocklistState resource is unchanged. + spec blocklister { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + ensures result == global(stablecoin_address).blocklister; + ensures global(stablecoin_address) == old(global(stablecoin_address)); + } + + /// Abort condition: The BlocklistState resource is missing. + /// Abort condition: The input address is blocklisted. + /// Post condition: The BlocklistState resource is unchanged. + spec assert_not_blocklisted { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + aborts_if table_with_length::spec_contains(global(stablecoin_address).blocklist, addr); + ensures global(stablecoin_address) == old(global(stablecoin_address)); + } + + /// Abort condition: The stablecoin_obj_constructor_ref does not refer to a valid object. + /// Abort condition: The BlocklistState resource already exists at the object address. + /// Abort condition: The fungible asset Metadata resource is missing. + /// Post condition: The BlocklistState resource is created properly. + spec new { + let stablecoin_address = object::address_from_constructor_ref(stablecoin_obj_constructor_ref); + aborts_if !exists(stablecoin_address); + aborts_if !object::spec_exists_at(stablecoin_address); + aborts_if exists(stablecoin_address); + ensures table_with_length::spec_len(global(stablecoin_address).blocklist) == 0; + ensures global(stablecoin_address).blocklister == blocklister; + ensures global(stablecoin_address).transfer_ref + == TransferRef { + metadata: object::address_to_object(stablecoin_address) + }; + } + + /// Abort condition: The BlocklistState resource is missing. + /// Abort condition: The caller is not the blocklister address. + /// Post condition: The address is always added to the blocklist if not already blocklisted. + /// Post condition: The BlocklistState resource is unchanged if address is already blocklisted. + /// Post condition: The blocklist should be updated with the input address, while all other addresses stay the same. + spec blocklist { + let stablecoin_address = spec_stablecoin_address(); + + aborts_if !exists(stablecoin_address); + aborts_if signer::address_of(caller) != global(stablecoin_address).blocklister; + + ensures table_with_length::spec_contains(global(stablecoin_address).blocklist, addr_to_block) + == true; + ensures table_with_length::spec_contains( + old(global(stablecoin_address)).blocklist, addr_to_block + ) == true ==> + global(stablecoin_address).blocklist + == old(global(stablecoin_address).blocklist); + ensures table_with_length::spec_contains( + old(global(stablecoin_address)).blocklist, addr_to_block + ) == false ==> + global(stablecoin_address).blocklist + == table_with_length::spec_set( + old(global(stablecoin_address).blocklist), addr_to_block, true + ); + } + + /// Abort condition: The BlocklistState resource is missing. + /// Abort condition: The caller is not blocklister address. + /// Post condition: The address is always removed from the blocklist. + /// Post condition: The BlocklistState resource is unchanged if address is not blocklisted. + /// Post condition: The blocklist should be updated to remove the input address, while all other addresses stay the same. + spec unblocklist { + let stablecoin_address = spec_stablecoin_address(); + + aborts_if !exists(stablecoin_address); + aborts_if signer::address_of(caller) != global(stablecoin_address).blocklister; + + ensures table_with_length::spec_contains(global(stablecoin_address).blocklist, addr_to_unblock) + == false; + ensures table_with_length::spec_contains( + old(global(stablecoin_address)).blocklist, addr_to_unblock + ) == true ==> + global(stablecoin_address).blocklist + == table_with_length::spec_remove( + old(global(stablecoin_address).blocklist), addr_to_unblock + ); + ensures table_with_length::spec_contains( + old(global(stablecoin_address)).blocklist, addr_to_unblock + ) == false ==> + global(stablecoin_address).blocklist + == old(global(stablecoin_address).blocklist); + } + + /// Abort condition: The object does not exist at the stablecoin address. + /// Abort condition: The OwnerRole resource is missing. + /// Abort condition: The BlocklistState resource is missing. + /// Abort condition: The caller is not owner address. + /// Post condition: The blocklister address is always updated to new_blocklister. + /// Post condition: The blocklist remains unchanged. + spec update_blocklister { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if signer::address_of(caller) != global(stablecoin_address).owner; + ensures global(stablecoin_address).blocklister == new_blocklister; + ensures global(stablecoin_address).blocklist + == old(global(stablecoin_address).blocklist); + } +} diff --git a/packages/stablecoin/tests/blocklistable_tests.move b/packages/stablecoin/tests/blocklistable_tests.move new file mode 100644 index 0000000..9df0860 --- /dev/null +++ b/packages/stablecoin/tests/blocklistable_tests.move @@ -0,0 +1,219 @@ +// 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::blocklistable_tests { + use std::event; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::fungible_asset::Metadata; + use aptos_framework::object::{Self, ConstructorRef}; + + use aptos_extensions::ownable; + use aptos_extensions::test_utils::assert_eq; + use stablecoin::blocklistable; + use stablecoin::fungible_asset_tests::setup_fa; + + friend stablecoin::stablecoin_tests; + + const OWNER: address = @0x10; + const BLOCKLISTER: address = @0x20; + const RANDOM_ADDRESS: address = @0x30; + + #[test] + fun is_blocklisted__should_return_true_if_blocklisted() { + setup(); + blocklistable::set_blocklisted_for_testing(RANDOM_ADDRESS, true); + + assert_eq(blocklistable::is_blocklisted(RANDOM_ADDRESS), true); + } + + #[test] + fun is_blocklisted__should_return_false_by_default() { + setup(); + + assert_eq(blocklistable::is_blocklisted(RANDOM_ADDRESS), false); + } + + #[test] + fun is_blocklisted__should_return_false_if_not_blocklisted() { + setup(); + blocklistable::set_blocklisted_for_testing(RANDOM_ADDRESS, false); + + assert_eq(blocklistable::is_blocklisted(RANDOM_ADDRESS), false); + } + + #[test] + fun blocklister__should_return_blocklister_address() { + setup(); + blocklistable::set_blocklister_for_testing(BLOCKLISTER); + + assert_eq(blocklistable::blocklister(), BLOCKLISTER); + } + + #[test] + fun new__should_succeed() { + let (stablecoin_obj_constructor_ref, _, _) = setup_fa(@stablecoin); + test_new( + &stablecoin_obj_constructor_ref, + BLOCKLISTER, + ); + } + + #[test] + fun blocklist__should_succeed_with_unblocked_address() { + setup(); + let blocklister = + &create_signer_for_test(blocklistable::blocklister()); + + test_blocklist(blocklister, RANDOM_ADDRESS); + } + + #[test] + fun blocklist__should_be_idempotent() { + setup(); + let blocklister = + &create_signer_for_test(blocklistable::blocklister()); + blocklistable::set_blocklisted_for_testing(RANDOM_ADDRESS, true); + + test_blocklist(blocklister, RANDOM_ADDRESS); + } + + #[test, expected_failure(abort_code = stablecoin::blocklistable::ENOT_BLOCKLISTER)] + fun blocklist__should_fail_if_caller_is_not_blocklister() { + setup(); + let caller = &create_signer_for_test(RANDOM_ADDRESS); + + test_blocklist(caller, RANDOM_ADDRESS); + } + + #[test] + fun unblocklist__should_unblock_blocked_address() { + setup(); + let blocklister = + &create_signer_for_test(blocklistable::blocklister()); + blocklistable::set_blocklisted_for_testing(RANDOM_ADDRESS, true); + + test_unblocklist(blocklister, RANDOM_ADDRESS); + } + + #[test] + fun unblocklist__should_succeed_on_unblocklisted_address() { + setup(); + let blocklister = + &create_signer_for_test(blocklistable::blocklister()); + + test_unblocklist(blocklister, RANDOM_ADDRESS); + } + + #[test] + fun unblocklist__should_be_idempotent() { + setup(); + let blocklister = + &create_signer_for_test(blocklistable::blocklister()); + blocklistable::set_blocklisted_for_testing(RANDOM_ADDRESS, false); + + test_unblocklist(blocklister, RANDOM_ADDRESS); + } + + #[test, expected_failure(abort_code = stablecoin::blocklistable::ENOT_BLOCKLISTER)] + fun unblocklist__should_fail_if_caller_is_not_blocklister() { + setup(); + let caller = &create_signer_for_test(RANDOM_ADDRESS); + + test_unblocklist(caller, RANDOM_ADDRESS); + } + + #[test] + fun update_blocklister__should_update_role_to_different_address() { + setup(); + let caller = &create_signer_for_test(OWNER); + + test_update_blocklister(caller, OWNER, RANDOM_ADDRESS); + } + + #[test] + fun update_blocklister__should_succeed_with_same_address() { + setup(); + let caller = &create_signer_for_test(OWNER); + + test_update_blocklister(caller, OWNER, OWNER); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_OWNER)] + fun update_blocklister__should_fail_if_caller_is_not_owner() { + setup(); + let caller = &create_signer_for_test(RANDOM_ADDRESS); + + test_update_blocklister(caller, OWNER, RANDOM_ADDRESS); + } + + // === Helpers === + + fun setup() { + let (stablecoin_obj_constructor_ref, _, _) = setup_fa(@stablecoin); + test_new(&stablecoin_obj_constructor_ref, BLOCKLISTER); + } + + fun test_new( + stablecoin_obj_constructor_ref: &ConstructorRef, + blocklister: address, + ) { + let stablecoin_address = object::address_from_constructor_ref(stablecoin_obj_constructor_ref); + let stablecoin_signer = object::generate_signer(stablecoin_obj_constructor_ref); + let stablecoin_metadata = object::address_to_object(stablecoin_address); + + ownable::new(&stablecoin_signer, OWNER); + blocklistable::new_for_testing(stablecoin_obj_constructor_ref, blocklister); + + assert_eq(blocklistable::transfer_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(blocklistable::num_blocklisted_for_testing(), 0); + assert_eq(blocklistable::blocklister(), blocklister); + } + + fun test_blocklist(caller: &signer, addr: address) { + let expected_event = blocklistable::test_Blocklisted_event(addr); + + blocklistable::test_blocklist(caller, addr); + + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(blocklistable::is_blocklisted(addr), true); + } + + fun test_unblocklist(caller: &signer, addr: address) { + let expected_event = blocklistable::test_Unblocklisted_event(addr); + + blocklistable::test_unblocklist(caller, addr); + + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(blocklistable::is_blocklisted(addr), false); + } + + fun test_update_blocklister( + caller: &signer, + old_blocklister: address, + new_blocklister: address + ) { + blocklistable::set_blocklister_for_testing(old_blocklister); + let expected_event = blocklistable::test_BlocklisterChanged_event( + old_blocklister, new_blocklister + ); + + blocklistable::test_update_blocklister(caller, new_blocklister); + + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(blocklistable::blocklister(), new_blocklister); + } +} diff --git a/packages/stablecoin/tests/framework/dispatchable_fungible_asset_test_utils.move b/packages/stablecoin/tests/framework/dispatchable_fungible_asset_test_utils.move new file mode 100644 index 0000000..a51bf12 --- /dev/null +++ b/packages/stablecoin/tests/framework/dispatchable_fungible_asset_test_utils.move @@ -0,0 +1,297 @@ +// 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::dispatchable_fungible_asset_test_utils { + use std::option; + use std::string; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::dispatchable_fungible_asset; + use aptos_framework::function_info::{Self, FunctionInfo}; + use aptos_framework::fungible_asset::{Self, FungibleAsset, Metadata, TransferRef}; + use aptos_framework::object::{Self, ConstructorRef, Object}; + use aptos_framework::primary_fungible_store; + + use aptos_extensions::test_utils::assert_eq; + use stablecoin::fungible_asset_tests::{Self, setup_fa}; + + const NAME: vector = b"name"; + const SYMBOL: vector = b"symbol"; + const DECIMALS: u8 = 6; + const ICON_URI: vector = b"icon uri"; + const PROJECT_URI: vector = b"project uri"; + + const RANDOM_ADDRESS: address = @0x10; + + // === Errors === + + const ENO_DEPOSIT: u64 = 0; + const ENO_WITHDRAW: u64 = 1; + + // === Tests === + + #[test] + fun setup_with_failing_deposit__should_succeed_and_register_failing_deposit_function() { + let (_, metadata) = setup_dfa_with_failing_deposit(RANDOM_ADDRESS); + + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + assert_eq( + fungible_asset::deposit_dispatch_function(store), + option::some(create_function_info(b"failing_deposit")) + ); + } + + #[test] + fun setup_with_failing_deposit__should_succeed_and_register_succeeding_withdraw_function() { + let (_, metadata) = setup_dfa_with_failing_deposit(RANDOM_ADDRESS); + + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + assert_eq( + fungible_asset::withdraw_dispatch_function(store), + option::some(create_function_info(b"succeeding_withdraw")) + ); + } + + #[test] + fun setup_with_failing_withdraw__should_succeed_and_register_succeeding_deposit_function() { + let (_, metadata) = setup_dfa_with_failing_withdraw(RANDOM_ADDRESS); + + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + assert_eq( + fungible_asset::deposit_dispatch_function(store), + option::some(create_function_info(b"succeeding_deposit")) + ); + } + + #[test] + fun setup_with_failing_withdraw__should_succeed_and_register_failing_withdraw_function() { + let (_, metadata) = setup_dfa_with_failing_withdraw(RANDOM_ADDRESS); + + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + assert_eq( + fungible_asset::withdraw_dispatch_function(store), + option::some(create_function_info(b"failing_withdraw")) + ); + } + + #[test] + fun setup__should_succeed_and_register_succeeding_deposit_function() { + let (_, metadata) = setup_dfa(RANDOM_ADDRESS); + + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + assert_eq( + fungible_asset::deposit_dispatch_function(store), + option::some(create_function_info(b"succeeding_deposit")) + ); + } + + #[test] + fun setup__should_succeed_and_register_succeeding_withdraw_function() { + let (_, metadata) = setup_dfa(RANDOM_ADDRESS); + + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + assert_eq( + fungible_asset::withdraw_dispatch_function(store), + option::some(create_function_info(b"succeeding_withdraw")) + ); + } + + #[test, expected_failure( + abort_code = ENO_DEPOSIT + )] + fun failing_deposit__should_fail() { + // Setup a Fungible Asset + let (constructor_ref, metadata, _) = setup_fa(RANDOM_ADDRESS); + + // Get transfer ref + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref); + + // Deposit should fail + failing_deposit( + primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata), + fungible_asset::zero(metadata), + &transfer_ref + ); + } + + #[test] + fun succeeding_deposit__should_succeed() { + // Setup a Fungible Asset + let (constructor_ref, metadata, _) = setup_fa(RANDOM_ADDRESS); + + // Get transfer ref + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Deposit should succeed + let deposit_amount: u64 = 100; + succeeding_deposit( + store, + fungible_asset_tests::mint(&constructor_ref, deposit_amount), + &transfer_ref + ); + assert_eq(fungible_asset::balance(store), deposit_amount); + } + + #[test, expected_failure( + abort_code = ENO_WITHDRAW + )] + fun failing_withdraw__should_fail() { + // Setup a Fungible Asset + let (constructor_ref, metadata, _) = setup_fa(RANDOM_ADDRESS); + + // Get transfer ref + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Withdraw should fail + let withdrawn_asset = failing_withdraw( + store, + 1, + &transfer_ref + ); + + // Redeposit asset; this will never be reached + fungible_asset::deposit(store, withdrawn_asset); + } + + #[test] + fun succeeding_withdraw__should_succeed() { + // Setup a Fungible Asset + let (constructor_ref, metadata, _) = setup_fa(RANDOM_ADDRESS); + + // Get transfer ref + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Deposit into it + fungible_asset::deposit(store, fungible_asset_tests::mint(&constructor_ref, 100)); + + // Withdraw should succeed + let withdrawn_asset = succeeding_withdraw( + store, + 1, + &transfer_ref + ); + assert_eq(fungible_asset::balance(store), 99); + + // Redeposit asset + fungible_asset::deposit(store, withdrawn_asset); + } + + // === Helpers === + + public fun failing_deposit( + _store: Object, + _fa: FungibleAsset, + _transfer_ref: &TransferRef + ) { + abort ENO_DEPOSIT + } + + public fun succeeding_deposit( + store: Object, + fa: FungibleAsset, + transfer_ref: &TransferRef + ) { + fungible_asset::deposit_with_ref(transfer_ref, store, fa); + } + + public fun failing_withdraw( + _store: Object, + _amount: u64, + _transfer_ref: &TransferRef, + ): FungibleAsset { + abort ENO_WITHDRAW + } + + public fun succeeding_withdraw( + store: Object, + amount: u64, + transfer_ref: &TransferRef, + ): FungibleAsset { + fungible_asset::withdraw_with_ref(transfer_ref, store, amount) + } + + public fun setup_dfa_with_failing_deposit(owner: address): (ConstructorRef, Object) { + create_dfa(owner, true, false) + } + + public fun setup_dfa_with_failing_withdraw(owner: address): (ConstructorRef, Object) { + create_dfa(owner, false, true) + } + + public fun setup_dfa(owner: address): (ConstructorRef, Object) { + create_dfa(owner, false, false) + } + + fun create_dfa( + owner: address, + fail_deposit: bool, + fail_withdraw: bool + ): (ConstructorRef, Object) { + let constructor_ref = object::create_sticky_object(owner); + primary_fungible_store::create_primary_store_enabled_fungible_asset( + &constructor_ref, + option::none(), + string::utf8(NAME), + string::utf8(SYMBOL), + DECIMALS, + string::utf8(ICON_URI), + string::utf8(PROJECT_URI), + ); + + let fungible_asset_address = object::address_from_constructor_ref(&constructor_ref); + let metadata = object::address_to_object(fungible_asset_address); + + let deposit_function: FunctionInfo; + if (fail_deposit) { + deposit_function = create_function_info(b"failing_deposit"); + } else { + deposit_function = create_function_info(b"succeeding_deposit") + }; + + let withdraw_function: FunctionInfo; + if (fail_withdraw) { + withdraw_function = create_function_info(b"failing_withdraw"); + } else { + withdraw_function = create_function_info(b"succeeding_withdraw"); + }; + + dispatchable_fungible_asset::register_dispatch_functions( + &constructor_ref, + option::some(withdraw_function), + option::some(deposit_function), + option::none(), + ); + + (constructor_ref, metadata) + } + + fun create_function_info(selector: vector): FunctionInfo { + function_info::new_function_info( + &create_signer_for_test(@stablecoin), + string::utf8(b"dispatchable_fungible_asset_test_utils"), + string::utf8(selector) + ) + } +} diff --git a/packages/stablecoin/tests/framework/dispatchable_fungible_asset_tests.move b/packages/stablecoin/tests/framework/dispatchable_fungible_asset_tests.move new file mode 100644 index 0000000..1556dc6 --- /dev/null +++ b/packages/stablecoin/tests/framework/dispatchable_fungible_asset_tests.move @@ -0,0 +1,180 @@ +// 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::dispatchable_fungible_asset_tests { + use aptos_framework::account::create_signer_for_test; + use aptos_framework::dispatchable_fungible_asset; + use aptos_framework::fungible_asset; + use aptos_framework::primary_fungible_store; + + use aptos_extensions::test_utils::assert_eq; + use stablecoin::dispatchable_fungible_asset_test_utils::{ + Self, + setup_dfa, + setup_dfa_with_failing_deposit, + setup_dfa_with_failing_withdraw + }; + + const DEPOSIT_AMOUNT: u64 = 100; + + const OWNER: address = @0x10; + const RANDOM_ADDRESS: address = @0x20; + + // === Framework Errors === + + /// error::invalid_argument(fungible_asset::EINVALID_DISPATCHABLE_OPERATIONS) + const ERR_INVALID_ARGUMENT_INVALID_DISPATCHABLE_OPERATIONS: u64 = 65564; // 1 * 2^16 + 28 + + // === Tests === + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_INVALID_DISPATCHABLE_OPERATIONS, + location = aptos_framework::fungible_asset + )] + fun deposit__should_fail_if_bypassing_dispatchable_function() { + // Create DFA + let (constructor_ref, metadata) = setup_dfa(OWNER); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Create asset to deposit + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let minted_asset = fungible_asset::mint(&mint_ref, DEPOSIT_AMOUNT); + + // Try to deposit outside of dispatchable_fungible_asset::deposit + fungible_asset::deposit(store, minted_asset); + } + + #[test, expected_failure( + abort_code = dispatchable_fungible_asset_test_utils::ENO_DEPOSIT + )] + fun deposit__should_fail_if_dispatchable_function_fails() { + // Create DFA + let (constructor_ref, metadata) = setup_dfa_with_failing_deposit(OWNER); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Create asset to deposit + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let minted_asset = fungible_asset::mint(&mint_ref, DEPOSIT_AMOUNT); + + // Invoke dispatchable deposit + dispatchable_fungible_asset::deposit(store, minted_asset); + } + + #[test] + fun deposit__should_succeed_if_dispatchable_function_succeeds() { + // Create DFA + let (constructor_ref, metadata) = setup_dfa(OWNER); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Create asset to deposit + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let minted_asset = fungible_asset::mint(&mint_ref, DEPOSIT_AMOUNT); + + // Invoke dispatchable deposit + assert_eq(fungible_asset::balance(store), 0); + dispatchable_fungible_asset::deposit(store, minted_asset); + assert_eq(fungible_asset::balance(store), DEPOSIT_AMOUNT); + } + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_INVALID_DISPATCHABLE_OPERATIONS, + location = aptos_framework::fungible_asset + )] + fun withdraw__should_fail_if_bypassing_dispatchable_function() { + // Create DFA + let (constructor_ref, metadata) = setup_dfa(OWNER); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Deposit an asset first + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let minted_asset = fungible_asset::mint(&mint_ref, DEPOSIT_AMOUNT); + dispatchable_fungible_asset::deposit(store, minted_asset); + assert_eq(fungible_asset::balance(store), DEPOSIT_AMOUNT); + + // Try to withdraw outside of dispatchable_fungible_asset::withdraw + let withdrawn_asset = fungible_asset::withdraw( + &create_signer_for_test(RANDOM_ADDRESS), + store, + DEPOSIT_AMOUNT + ); + + // Redeposit, even though this won't be reached + dispatchable_fungible_asset::deposit(store, withdrawn_asset); + } + + #[test, expected_failure( + abort_code = dispatchable_fungible_asset_test_utils::ENO_WITHDRAW + )] + fun withdraw__should_fail_if_dispatchable_function_fails() { + // Create DFA + let (constructor_ref, metadata) = setup_dfa_with_failing_withdraw(OWNER); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Deposit an asset first + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let minted_asset = fungible_asset::mint(&mint_ref, DEPOSIT_AMOUNT); + dispatchable_fungible_asset::deposit(store, minted_asset); + assert_eq(fungible_asset::balance(store), DEPOSIT_AMOUNT); + + // Invoke dispatchable withdraw + let withdrawn_asset = dispatchable_fungible_asset::withdraw( + &create_signer_for_test(RANDOM_ADDRESS), + store, + DEPOSIT_AMOUNT + ); + + // Redeposit, even though this won't be reached + dispatchable_fungible_asset::deposit(store, withdrawn_asset); + } + + #[test] + fun withdraw__should_succeed_if_dispatchable_function_succeeds() { + // Create DFA + let (constructor_ref, metadata) = setup_dfa(OWNER); + + // Create a store + let store = primary_fungible_store::create_primary_store(RANDOM_ADDRESS, metadata); + + // Deposit an asset first + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let minted_asset = fungible_asset::mint(&mint_ref, DEPOSIT_AMOUNT); + dispatchable_fungible_asset::deposit(store, minted_asset); + assert_eq(fungible_asset::balance(store), DEPOSIT_AMOUNT); + + // Invoke dispatchable withdraw + assert_eq(fungible_asset::balance(store), DEPOSIT_AMOUNT); + let withdrawn_asset = dispatchable_fungible_asset::withdraw( + &create_signer_for_test(RANDOM_ADDRESS), + store, + DEPOSIT_AMOUNT + ); + assert_eq(fungible_asset::balance(store), 0); + + // Redeposit + dispatchable_fungible_asset::deposit(store, withdrawn_asset); + } +} diff --git a/packages/stablecoin/tests/framework/fungible_asset_tests.move b/packages/stablecoin/tests/framework/fungible_asset_tests.move new file mode 100644 index 0000000..625c3af --- /dev/null +++ b/packages/stablecoin/tests/framework/fungible_asset_tests.move @@ -0,0 +1,385 @@ +// 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::fungible_asset_tests { + use std::option::{Self, Option}; + use std::string; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::fungible_asset::{Self, FungibleAsset, Metadata}; + use aptos_framework::object::{Self, ConstructorRef, Object}; + use aptos_framework::primary_fungible_store; + use stablecoin::stablecoin_utils::stablecoin_obj_seed; + + use aptos_extensions::test_utils::assert_eq; + + const NAME: vector = b"name"; + const SYMBOL: vector = b"symbol"; + const DECIMALS: u8 = 6; + const ICON_URI: vector = b"icon uri"; + const PROJECT_URI: vector = b"project uri"; + + const DEPOSIT_AMOUNT: u64 = 100; + const U64_MAX: u64 = (1 << 63) | ((1 << 63) - 1); + + const OWNER_ONE: address = @0x10; + const OWNER_TWO: address = @0x20; + + const HOLDER_ONE: address = @0x30; + const HOLDER_TWO: address = @0x40; + + // === Framework Errors === + + // error::invalid_argument(fungible_asset::EFUNGIBLE_ASSET_AND_STORE_MISMATCH)) + const ERR_INVALID_ARGUMENT_ASSET_STORE_MISMATCH: u64 = 65547; + // error::invalid_argument(fungible_asset::ETRANSFER_REF_AND_FUNGIBLE_ASSET_MISMATCH) + const ERR_INVALID_ARGUMENT_TRANSFER_REF_ASSET_MISMATCH: u64 = 65538; + // error::invalid_argument(fungible_asset::ETRANSFER_REF_AND_STORE_MISMATCH) + const ERR_INVALID_ARGUMENT_TRANSFER_REF_STORE_MISMATCH: u64 = 65545; + /// error::invalid_argument(fungible_asset::EINSUFFICIENT_BALANCE) + const ERR_INVALID_ARGUMENT_INSUFFICIENT_BALANCE: u64 = 65540; // 1 * 2^16 + 4 + /// error::invalid_argument(fungible_asset::EBURN_REF_AND_FUNGIBLE_ASSET_MISMATCH) + const ERR_INVALID_ARGUMENT_BURN_REF_AND_FUNGIBLE_ASSET_MISMATCH: u64 = 65549; // 1 * 2^16 + 13 + /// error::permission_denied(ENOT_STORE_OWNER)) + const ERR_PERMISSION_DENIED_NOT_STORE_OWNER: u64 = 327688; + /// error::permission_denied(ENO_UNGATED_TRANSFERS) + const ERR_PERMISSION_DENIED_NO_UNGATED_TRANSFERS: u64 = 327683; + /// error::out_of_range(aggregator_v2::EAGGREGATOR_OVERFLOW) + const ERR_AGGREGATOR_V2_EAGGREGATOR_OVERFLOW: u64 = 131073; // 2 * 2^16 + 1 + /// error::out_of_range(fungible_asset::EMAX_SUPPLY_EXCEEDED) + const ERR_FUNGIBLE_ASSET_EMAX_SUPPLY_EXCEEDED: u64 = 131077; // 2 * 2^16 + 5 + + // === Tests === + + #[test] + fun setup__should_create_fungible_asset_with_metadata() { + let (_, metadata, fungible_asset_address) = setup_fa(OWNER_ONE); + + // Validate resources were created + assert_eq(object::object_exists(fungible_asset_address), true); + assert_eq(object::object_exists(fungible_asset_address), true); + assert_eq(object::object_exists(fungible_asset_address), true); + assert_eq(object::object_exists(fungible_asset_address), true); + + // Validate fungible asset state + assert_eq(fungible_asset::name(metadata), string::utf8(NAME)); + assert_eq(fungible_asset::symbol(metadata), string::utf8(SYMBOL)); + assert_eq(fungible_asset::decimals(metadata), DECIMALS); + assert_eq(fungible_asset::icon_uri(metadata), string::utf8(ICON_URI)); + assert_eq(fungible_asset::project_uri(metadata), string::utf8(PROJECT_URI)); + assert_eq(fungible_asset::supply(metadata), option::some(0)); + assert_eq(fungible_asset::maximum(metadata), option::none()); + assert_eq(fungible_asset::is_untransferable(metadata), true); + } + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_ASSET_STORE_MISMATCH, + location = aptos_framework::fungible_asset + )] + fun deposit__should_fail_for_mismatched_fungible_assets() { + // Create two distinct fungible assets + let (_, metadata_one, _) = setup_fa(OWNER_ONE); + let (constructor_ref_two, _, _) = setup_fa(OWNER_TWO); + + // Create a store for the first one + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata_one); + + // Mint an amount of the second one + let deposit_asset = mint(&constructor_ref_two, DEPOSIT_AMOUNT); + + // Try to deposit + fungible_asset::deposit(store, deposit_asset); + } + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_TRANSFER_REF_ASSET_MISMATCH, + location = aptos_framework::fungible_asset + )] + fun deposit_with_ref__should_fail_if_ref_does_not_match_asset() { + // Create two distinct fungible assets + let (constructor_ref_one, metadata_one, _) = setup_fa(OWNER_ONE); + let (constructor_ref_two, _, _) = setup_fa(OWNER_TWO); + + // Create a store for the first asset + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata_one); + + // Mint an amount of the first asset + let deposit_asset = mint(&constructor_ref_one, DEPOSIT_AMOUNT); + + // Generate a TransferRef for the SECOND asset + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref_two); + + // Attempt to use second TransferRef to deposit directly + // Note that deposit_asset.metadata == store.metadata; sanity-check + assert_eq(fungible_asset::store_metadata(store), fungible_asset::metadata_from_asset(&deposit_asset)); + + // Attempt to transfer + fungible_asset::deposit_with_ref(&transfer_ref, store, deposit_asset); + } + + #[test, expected_failure(arithmetic_error, location = aptos_framework::fungible_asset)] + fun deposit_with_ref__concurrent_fungible_balance_disabled__should_fail_if_store_balance_overflow() { + // Disable the ConcurrentFungibleBalance feature. + std::features::change_feature_flags_for_testing( + &create_signer_for_test(@std), + vector[], /* enable */ + vector[std::features::get_default_to_concurrent_fungible_balance_feature()], /* disable */ + ); + + let (constructor_ref, metadata, _) = setup_fa(OWNER_ONE); + + // Create a store with u64_max balance. + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + primary_fungible_store::mint(&fungible_asset::generate_mint_ref(&constructor_ref), HOLDER_ONE, U64_MAX); + assert_eq(primary_fungible_store::balance(HOLDER_ONE, metadata), U64_MAX); + + // Attempt to deposit another unit of asset to the same store, expect failure. + let deposit_asset = mint(&constructor_ref, 1); + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref); + fungible_asset::deposit_with_ref(&transfer_ref, store, deposit_asset); + } + + #[test, expected_failure(abort_code = ERR_AGGREGATOR_V2_EAGGREGATOR_OVERFLOW, location = aptos_framework::aggregator_v2)] + fun deposit_with_ref__concurrent_fungible_balance_enabled__should_fail_if_store_balance_overflow() { + // Enable the ConcurrentFungibleBalance feature. + std::features::change_feature_flags_for_testing( + &create_signer_for_test(@std), + vector[std::features::get_default_to_concurrent_fungible_balance_feature()], /* enable */ + vector[], /* disable */ + ); + + let (constructor_ref, metadata, _) = setup_fa(OWNER_ONE); + + // Create a store with u64_max balance. + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + primary_fungible_store::mint(&fungible_asset::generate_mint_ref(&constructor_ref), HOLDER_ONE, U64_MAX); + assert_eq(primary_fungible_store::balance(HOLDER_ONE, metadata), U64_MAX); + + // Attempt to deposit another unit of asset to the same store, expect failure. + let deposit_asset = mint(&constructor_ref, 1); + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref); + fungible_asset::deposit_with_ref(&transfer_ref, store, deposit_asset); + } + + #[test] + fun withdraw__should_succeed_if_caller_is_indirect_owner() { + // Create a fungible asset + let (fa_constructor_ref, metadata, _) = setup_fa(OWNER_ONE); + + // Create an intermediary object that an EOA owns. + let object_constructor_ref = object::create_object(HOLDER_ONE); + let object_address = object::address_from_constructor_ref(&object_constructor_ref); + + // Create a store which the object owns. + let store = primary_fungible_store::create_primary_store(object_address, metadata); + + // Sanity check that the store is empty, is directly owned by the object + // and is indirectly owned by the EOA. + assert_eq(fungible_asset::balance(store), 0); + assert_eq(object::owner(store), object_address); + assert_eq(object::owns(store, HOLDER_ONE), true); + + // Deposit into the store + let deposit_asset = mint(&fa_constructor_ref, DEPOSIT_AMOUNT); + fungible_asset::deposit(store, deposit_asset); + + // EOA attempts to withdraw any amount, should succeed. + let withdrawn_asset = fungible_asset::withdraw(&create_signer_for_test(HOLDER_ONE), store, 10); + + // Must redeposit it so it is consumed, even though this won't be reached + fungible_asset::deposit(store, withdrawn_asset); + } + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_INSUFFICIENT_BALANCE, + location = aptos_framework::fungible_asset + )] + fun withdraw__should_fail_if_store_is_empty() { + // Create a fungible asset + let (_, metadata, _) = setup_fa(OWNER_ONE); + + // Create a store + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + // Sanity check store is empty + assert_eq(fungible_asset::balance(store), 0); + + // Withdraw any amount + let owner = create_signer_for_test(HOLDER_ONE); + let withdrawn_asset = fungible_asset::withdraw(&owner, store, 10); + + // Must redeposit it so it is consumed, even though this won't be reached + fungible_asset::deposit(store, withdrawn_asset); + } + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_INSUFFICIENT_BALANCE, + location = aptos_framework::fungible_asset + )] + fun withdraw__should_fail_if_amount_exceeds_store_balance() { + // Create a fungible asset + let (constructor_ref, metadata, _) = setup_fa(OWNER_ONE); + + // Create a store + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + + // Deposit into the store + let deposit_asset = mint(&constructor_ref, DEPOSIT_AMOUNT); + fungible_asset::deposit(store, deposit_asset); + + // Withdraw more than deposit amount + let owner = create_signer_for_test(HOLDER_ONE); + let withdrawn_asset = fungible_asset::withdraw(&owner, store, DEPOSIT_AMOUNT + 1); + + // Must redeposit it so it is consumed, even though this won't be reached + fungible_asset::deposit(store, withdrawn_asset); + } + + #[test, expected_failure( + abort_code = ERR_PERMISSION_DENIED_NOT_STORE_OWNER, + location = aptos_framework::fungible_asset + )] + fun withdraw__should_fail_if_not_owner() { + // Create a fungible asset + let (constructor_ref, metadata, _) = setup_fa(OWNER_ONE); + + // Create a store + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + + // Deposit into the store + let deposit_asset = mint(&constructor_ref, DEPOSIT_AMOUNT); + fungible_asset::deposit(store, deposit_asset); + + // Withdraw as different owner + let not_owner = create_signer_for_test(HOLDER_TWO); + let withdrawn_asset = fungible_asset::withdraw(¬_owner, store, 1); + + // Must redeposit it so it is consumed, even though this won't be reached + fungible_asset::deposit(store, withdrawn_asset); + } + + #[test, expected_failure( + abort_code = ERR_INVALID_ARGUMENT_TRANSFER_REF_STORE_MISMATCH, + location = aptos_framework::fungible_asset + )] + fun withdraw_with_ref__should_fail_if_ref_does_not_match_asset() { + // Create two distinct fungible assets + let (constructor_ref_one, metadata_one, _) = setup_fa(OWNER_ONE); + let (constructor_ref_two, _, _) = setup_fa(OWNER_TWO); + + // Create a store for the first asset, and deposit into it + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata_one); + let deposit_asset = mint(&constructor_ref_one, DEPOSIT_AMOUNT); + fungible_asset::deposit(store, deposit_asset); + + // Generate a TransferRef for the SECOND asset + let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref_two); + + // Attempt to use second TransferRef to withdraw from the store + let withdrawn_asset = fungible_asset::withdraw_with_ref(&transfer_ref, store, DEPOSIT_AMOUNT); + + // Must redeposit it so it is consumed, even though this won't be reached + fungible_asset::deposit(store, withdrawn_asset); + } + + #[test, expected_failure( + abort_code = ERR_PERMISSION_DENIED_NO_UNGATED_TRANSFERS, + location = aptos_framework::object + )] + fun transfer_store__fails_if_asset_is_untransferable() { + // Create a fungible asset + let (_, metadata, _) = setup_fa(OWNER_ONE); + + // Create a store + let store = primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + + // Sanity check it's untransferable + assert_eq(object::is_untransferable(store), true); + + // Transfer store + let owner = create_signer_for_test(HOLDER_ONE); + object::transfer(&owner, store, HOLDER_TWO); + } + + #[test, expected_failure(abort_code = ERR_FUNGIBLE_ASSET_EMAX_SUPPLY_EXCEEDED, location = aptos_framework::fungible_asset)] + fun mint__should_fail_if_exceed_max_total_supply() { + let max_supply: u128 = ((U64_MAX as u128) + 100_000); + let (constructor_ref, metadata, _) = setup_fa_with_max_supply(OWNER_ONE, option::some(max_supply)); + + // Set total supply to u64_max. + primary_fungible_store::create_primary_store(HOLDER_ONE, metadata); + primary_fungible_store::mint(&fungible_asset::generate_mint_ref(&constructor_ref), HOLDER_ONE, U64_MAX); + assert_eq(option::extract(&mut fungible_asset::supply(metadata)), (U64_MAX as u128)); + + // Attempt to mint more units of asset than the max supply allows for, expect failure. + let asset = fungible_asset::mint(&fungible_asset::generate_mint_ref(&constructor_ref), 100_001); + + // Must redeposit it so it is consumed, even though this won't be reached + destroy_fungible_asset(&constructor_ref, asset); + } + + #[test, expected_failure(abort_code = ERR_INVALID_ARGUMENT_BURN_REF_AND_FUNGIBLE_ASSET_MISMATCH, location = aptos_framework::fungible_asset)] + fun burn__should_fail_if_burn_ref_metadata_does_not_match_fa_metadata() { + // Create a fungible asset + let (constructor_ref_1, _, _) = setup_fa(OWNER_ONE); + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref_1); + let fa = fungible_asset::mint(&mint_ref, 100); + + // Create BurnRef of a different fungible asset + let (constructor_ref_2, _, _) = setup_fa(OWNER_TWO); + let burn_ref = fungible_asset::generate_burn_ref(&constructor_ref_2); + + fungible_asset::burn(&burn_ref, fa); + } + + // === Helpers === + + public fun setup_fa(owner: address): (ConstructorRef, Object, address) { + setup_fa_with_max_supply(owner, option::none()) + } + + fun setup_fa_with_max_supply(owner: address, max_supply: Option): (ConstructorRef, Object, address) { + let constructor_ref = object::create_named_object(&create_signer_for_test(owner), stablecoin_obj_seed()); + primary_fungible_store::create_primary_store_enabled_fungible_asset( + &constructor_ref, + max_supply, + string::utf8(NAME), + string::utf8(SYMBOL), + DECIMALS, + string::utf8(ICON_URI), + string::utf8(PROJECT_URI), + ); + + fungible_asset::set_untransferable(&constructor_ref); + + let fungible_asset_address = object::address_from_constructor_ref(&constructor_ref); + let metadata = object::address_to_object(fungible_asset_address); + + (constructor_ref, metadata, fungible_asset_address) + } + + public fun mint( + constructor_ref: &ConstructorRef, + amount: u64 + ): FungibleAsset { + let mint_ref = fungible_asset::generate_mint_ref(constructor_ref); + fungible_asset::mint(&mint_ref, amount) + } + + fun destroy_fungible_asset(constructor_ref: &ConstructorRef, asset: FungibleAsset) { + let burn_ref = fungible_asset::generate_burn_ref(constructor_ref); + fungible_asset::burn(&burn_ref, asset); + } +} diff --git a/packages/stablecoin/tests/metadata.spec.move b/packages/stablecoin/tests/metadata.spec.move new file mode 100644 index 0000000..0b04355 --- /dev/null +++ b/packages/stablecoin/tests/metadata.spec.move @@ -0,0 +1,147 @@ +// 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. + +spec stablecoin::metadata { + use std::string; + use aptos_framework::object::ObjectCore; + use aptos_extensions::ownable::OwnerRole; + use stablecoin::stablecoin_utils::spec_stablecoin_address; + + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + + invariant forall addr: address where exists(addr): + object::object_address(global(addr).mutate_metadata_ref.metadata) == addr; + } + + /// Abort condition: The MetadataState resource is missing. + /// Post condition: The metadata updater address is always returned. + /// Post condition: The MetadataState resource is unchanged. + spec metadata_updater { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + ensures result == global(stablecoin_address).metadata_updater; + ensures global(stablecoin_address) == old(global(stablecoin_address)); + } + + /// Abort condition: The object does not exist at the stablecoin address. + /// Abort condition: The Metadata resource is missing. + /// Abort condition: The MetadataState resource already exists at the stablecoin address. + /// Post condition: The MetadataState resource is created properly. + spec new { + let stablecoin_address = object::address_from_constructor_ref(stablecoin_obj_constructor_ref); + aborts_if !exists(stablecoin_address); + aborts_if !object::spec_exists_at(stablecoin_address); + aborts_if exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures global(stablecoin_address).metadata_updater == metadata_updater; + ensures global(stablecoin_address).mutate_metadata_ref + == MutateMetadataRef { + metadata: object::address_to_object(stablecoin_address) + }; + } + + /// Abort condition: The object does not exist at the stablecoin address. + /// Abort condition: The MetadataState resource is missing. + /// Abort condition: The fungible asset Metadata resource is missing. + /// Abort condition: The caller is not metadata updater address. + /// Abort condition: The metadata fields are invalid. + /// Post condition: Metadata fields are updated for specified fields. + spec update_metadata { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if signer::address_of(caller) != global(stablecoin_address).metadata_updater; + include ValidateMetadataMutation { name, symbol, decimals: option::none(), icon_uri, project_uri }; + } + + /// Abort condition: The object does not exist at the stablecoin address. + /// Abort condition: The MetadataState resource is missing. + /// Abort condition: The fungible asset Metadata resource is missing. + /// Abort condition: The metadata fields are invalid. + /// Post condition: The metadata fields are always updated for specified fields. + spec mutate_asset_metadata { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + include ValidateMetadataMutation; + } + + /// Abort condition: The object does not exist at the stablecoin address. + /// Abort condition: The OwnerRole resources is missing. + /// Abort condition: The MetadataState resources is missing. + /// Abort condition: The caller is not the owner address. + /// Post condition: The metadata updater is always updated. + /// Post condition: The MetadataState mutate_metadata_ref is unchanged. + spec update_metadata_updater { + let stablecoin_address = spec_stablecoin_address(); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if signer::address_of(caller) != global(stablecoin_address).owner; + ensures global(stablecoin_address).metadata_updater == new_metadata_updater; + ensures global(stablecoin_address).mutate_metadata_ref + == old(global(stablecoin_address).mutate_metadata_ref); + } + + /// Helper function to check metadata fields + spec schema ValidateMetadataMutation { + name: Option; + symbol: Option; + decimals: Option; + icon_uri: Option; + project_uri: Option; + + let max_name_length = 32; + let max_symbol_length = 10; + let max_decimals = 32; + let max_uri_length = 512; + + aborts_if name != option::spec_none() && string::length(option::borrow(name)) > max_name_length; + aborts_if symbol != option::spec_none() && string::length(option::borrow(symbol)) > max_symbol_length; + aborts_if decimals != option::spec_none() && option::borrow(decimals) > max_decimals; + aborts_if icon_uri != option::spec_none() && string::length(option::borrow(icon_uri)) > max_uri_length; + aborts_if project_uri != option::spec_none() && string::length(option::borrow(project_uri)) > max_uri_length; + + let stablecoin_address = spec_stablecoin_address(); + + // Ensures metadata fields that are specified are updated + + ensures option::spec_is_some(name) ==> + global(stablecoin_address).name == option::borrow(name); + ensures option::spec_is_none(name) ==> + global(stablecoin_address).name == old(global(stablecoin_address).name); + ensures option::spec_is_some(symbol) ==> + global(stablecoin_address).symbol == option::borrow(symbol); + ensures option::spec_is_none(symbol) ==> + global(stablecoin_address).symbol == old(global(stablecoin_address).symbol); + ensures option::spec_is_some(decimals) ==> + global(stablecoin_address).decimals == option::borrow(decimals); + ensures option::spec_is_none(decimals) ==> + global(stablecoin_address).decimals == old(global(stablecoin_address).decimals); + ensures option::spec_is_some(icon_uri) ==> + global(stablecoin_address).icon_uri == option::borrow(icon_uri); + ensures option::spec_is_none(icon_uri) ==> + global(stablecoin_address).icon_uri == old(global(stablecoin_address).icon_uri); + ensures option::spec_is_some(project_uri) ==> + global(stablecoin_address).project_uri == option::borrow(project_uri); + ensures option::spec_is_none(project_uri) ==> + global(stablecoin_address).project_uri == old(global(stablecoin_address).project_uri); + } +} diff --git a/packages/stablecoin/tests/metadata_tests.move b/packages/stablecoin/tests/metadata_tests.move new file mode 100644 index 0000000..6965fc7 --- /dev/null +++ b/packages/stablecoin/tests/metadata_tests.move @@ -0,0 +1,489 @@ +// 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::metadata_tests { + use std::event; + use std::option::{Self, Option}; + use std::string::{String, utf8}; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::fungible_asset::{Self, Metadata}; + use aptos_framework::object::{Self, ConstructorRef}; + + use aptos_extensions::ownable; + use aptos_extensions::test_utils::assert_eq; + use stablecoin::fungible_asset_tests::setup_fa; + use stablecoin::metadata; + use stablecoin::stablecoin_utils::stablecoin_address; + + const OWNER: address = @0x10; + const METADATA_UPDATER: address = @0x20; + const RANDOM_ADDRESS: address = @0x30; + const RANDOM_ADDRESS_2: address = @0x40; + + const NAME: vector = b"name"; + const SYMBOL: vector = b"symbol"; + const DECIMALS: u8 = 6; + const ICON_URI: vector = b"icon uri"; + const PROJECT_URI: vector = b"project uri"; + + #[test] + fun metadata_updater__should_return_metadata_updater_address() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + + assert_eq(metadata::metadata_updater(), METADATA_UPDATER); + } + + #[test] + fun new__should_succeed() { + let (stablecoin_obj_constructor_ref, _, _) = setup_fa(@stablecoin); + test_new( + &stablecoin_obj_constructor_ref, + METADATA_UPDATER, + ); + } + + #[test] + fun update_metadata_updater__should_update_role_to_different_address() { + setup(); + let caller = &create_signer_for_test(OWNER); + + test_update_metadata_updater(caller, OWNER, RANDOM_ADDRESS); + } + + #[test] + fun update_metadata_updater__should_succeed_with_same_address() { + setup(); + let caller = &create_signer_for_test(OWNER); + + test_update_metadata_updater(caller, OWNER, OWNER); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_OWNER)] + fun update_metadata_updater__should_fail_if_caller_is_not_owner() { + setup(); + let caller = &create_signer_for_test(RANDOM_ADDRESS); + + test_update_metadata_updater(caller, OWNER, RANDOM_ADDRESS); + } + + #[test] + fun update_metadata__should_update_the_token_metadata() { + setup(); + metadata::set_metadata_updater_for_testing(RANDOM_ADDRESS); + let metadata_updater_signer = &create_signer_for_test(RANDOM_ADDRESS); + + let name = option::some(utf8(b"test_name")); + let symbol = option::some(utf8(b"symbol")); + let icon_uri = option::some(utf8(b"test_icon_uri")); + let project_uri = option::some(utf8(b"test_project_uri")); + + test_update_metadata( + metadata_updater_signer, + name, + symbol, + icon_uri, + project_uri + ); + } + + #[test] + fun update_metadata__should_not_update_if_metadata_did_not_change() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let metadata_updater_signer = &create_signer_for_test(METADATA_UPDATER); + + let name = option::none(); + let symbol = option::none(); + let icon_uri = option::none(); + let project_uri = option::none(); + + test_update_metadata( + metadata_updater_signer, + name, + symbol, + icon_uri, + project_uri + ); + } + + #[test] + fun update_metadata__should_update_name_if_name_is_changed() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let metadata_updater_signer = &create_signer_for_test(METADATA_UPDATER); + + let name = option::some(utf8(b"test_name")); + + test_update_metadata( + metadata_updater_signer, + name, + option::none(), + option::none(), + option::none() + ); + } + + #[test] + fun update_metadata__should_update_symbol_if_symbol_is_changed() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let metadata_updater_signer = &create_signer_for_test(METADATA_UPDATER); + + let symbol = option::some(utf8(b"symbol")); + + test_update_metadata( + metadata_updater_signer, + option::none(), + symbol, + option::none(), + option::none() + ); + } + + #[test] + fun update_metadata__should_update_icon_uri_if_icon_uri_is_changed() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let metadata_updater_signer = &create_signer_for_test(METADATA_UPDATER); + + let icon_uri = option::some(utf8(b"test_icon_uri")); + + test_update_metadata( + metadata_updater_signer, + option::none(), + option::none(), + icon_uri, + option::none() + ); + } + + #[test] + fun update_metadata__should_update_project_uri_if_project_uri_is_changed() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let metadata_updater_signer = &create_signer_for_test(METADATA_UPDATER); + + let project_uri = option::some(utf8(b"test_project_uri")); + + test_update_metadata( + metadata_updater_signer, + option::none(), + option::none(), + option::none(), + project_uri + ); + } + + #[test, expected_failure(abort_code = stablecoin::metadata::ENOT_METADATA_UPDATER)] + fun update_metadata__should_fail_if_caller_not_metadata_updater() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let invalid_metadata_updater_signer = &create_signer_for_test(RANDOM_ADDRESS_2); + + let name = option::some(utf8(b"test_name")); + let symbol = option::some(utf8(b"symbol")); + let icon_uri = option::some(utf8(b"test_icon_uri")); + let project_uri = option::some(utf8(b"test_project_uri")); + + test_update_metadata( + invalid_metadata_updater_signer, + name, + symbol, + icon_uri, + project_uri + ); + } + + #[test] + fun update_metadata__should_be_idempotent() { + setup(); + metadata::set_metadata_updater_for_testing(METADATA_UPDATER); + let metadata_updater_signer = &create_signer_for_test(METADATA_UPDATER); + + test_update_metadata( + metadata_updater_signer, + option::some(utf8(NAME)), + option::some(utf8(SYMBOL)), + option::some(utf8(ICON_URI)), + option::some(utf8(PROJECT_URI)) + ); + test_update_metadata( + metadata_updater_signer, + option::some(utf8(NAME)), + option::some(utf8(SYMBOL)), + option::some(utf8(ICON_URI)), + option::some(utf8(PROJECT_URI)) + ); + } + + #[test] + fun mutate_asset_metadata__should_update_the_metadata() { + setup(); + + let name = option::some(utf8(b"test_name")); + let symbol = option::some(utf8(b"symbol")); + let decimals = option::some(8); + let icon_uri = option::some(utf8(b"test_icon_uri")); + let project_uri = option::some(utf8(b"test_project_uri")); + + test_mutate_asset_metadata( + name, + symbol, + decimals, + icon_uri, + project_uri + ); + } + + #[test] + fun mutate_asset_metadata__should_be_idempotent() { + setup(); + + test_mutate_asset_metadata( + option::some(utf8(NAME)), + option::some(utf8(SYMBOL)), + option::some(DECIMALS), + option::some(utf8(ICON_URI)), + option::some(utf8(PROJECT_URI)) + ); + test_mutate_asset_metadata( + option::some(utf8(NAME)), + option::some(utf8(SYMBOL)), + option::some(DECIMALS), + option::some(utf8(ICON_URI)), + option::some(utf8(PROJECT_URI)) + ); + } + + #[test] + fun mutate_asset_metadata__should_not_update_if_metadata_did_not_change() { + setup(); + + let name = option::none(); + let symbol = option::none(); + let decimals = option::none(); + let icon_uri = option::none(); + let project_uri = option::none(); + + test_mutate_asset_metadata( + name, + symbol, + decimals, + icon_uri, + project_uri + ); + } + + #[test] + fun mutate_asset_metadata__should_update_name_if_name_is_changed() { + setup(); + + let name = option::some(utf8(b"test_name")); + + test_mutate_asset_metadata( + name, + option::none(), + option::none(), + option::none(), + option::none() + ); + } + + #[test] + fun mutate_asset_metadata__should_update_symbol_if_symbol_is_changed() { + setup(); + + let symbol = option::some(utf8(b"symbol")); + + test_mutate_asset_metadata( + option::none(), + symbol, + option::none(), + option::none(), + option::none() + ); + } + + #[test] + fun mutate_asset_metadata__should_update_decimals_if_decimals_is_changed() { + setup(); + + let decimals = option::some(10); + + test_mutate_asset_metadata( + option::none(), + option::none(), + decimals, + option::none(), + option::none() + ); + } + + #[test] + fun mutate_asset_metadata__should_update_icon_uri_if_icon_uri_is_changed() { + setup(); + + let icon_uri = option::some(utf8(b"test_icon_uri")); + + test_mutate_asset_metadata( + option::none(), + option::none(), + option::none(), + icon_uri, + option::none() + ); + } + + #[test] + fun mutate_asset_metadata__should_update_project_uri_if_project_uri_is_changed() { + setup(); + + let project_uri = option::some(utf8(b"test_project_uri")); + + test_mutate_asset_metadata( + option::none(), + option::none(), + option::none(), + option::none(), + project_uri + ); + } + + // === Helpers === + + fun setup() { + let (stablecoin_obj_constructor_ref, _, _) = setup_fa(@stablecoin); + test_new(&stablecoin_obj_constructor_ref, METADATA_UPDATER); + } + + fun test_new( + stablecoin_obj_constructor_ref: &ConstructorRef, + metadata_updater: address, + ) { + let stablecoin_signer = object::generate_signer(stablecoin_obj_constructor_ref); + let stablecoin_address = object::address_from_constructor_ref(stablecoin_obj_constructor_ref); + let stablecoin_metadata = object::address_to_object(stablecoin_address); + + ownable::new(&stablecoin_signer, OWNER); + metadata::new_for_testing(stablecoin_obj_constructor_ref, metadata_updater); + + assert_eq(metadata::mutate_metadata_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(metadata::metadata_updater(), metadata_updater); + } + + fun test_update_metadata( + metadata_updater: &signer, + name: Option, + symbol: Option, + icon_uri: Option, + project_uri: Option + ) { + metadata::test_update_metadata( + metadata_updater, + name, + symbol, + icon_uri, + project_uri + ); + + verify_asset_metadata_updated(name, symbol, option::none(), icon_uri, project_uri); + } + + fun test_mutate_asset_metadata( + name: Option, + symbol: Option, + decimals: Option, + icon_uri: Option, + project_uri: Option + ) { + metadata::test_mutate_asset_metadata( + name, + symbol, + decimals, + icon_uri, + project_uri + ); + + verify_asset_metadata_updated(name, symbol, decimals, icon_uri, project_uri); + } + + fun verify_asset_metadata_updated(name: Option, + symbol: Option, + decimals: Option, + icon_uri: Option, + project_uri: Option) { + let expected_name: String = { + if (option::is_some(&name)) *option::borrow(&name) + else utf8(NAME) + }; + let expected_symbol: String = { + if (option::is_some(&symbol))*option::borrow(&symbol) + else utf8(SYMBOL) + }; + let expected_decimals: u8 = { + if (option::is_some(&decimals))*option::borrow(&decimals) + else DECIMALS + }; + let expected_icon_uri: String = { + if (option::is_some(&icon_uri)) *option::borrow(&icon_uri) + else utf8(ICON_URI) + }; + let expected_project_uri: String = { + if (option::is_some(&project_uri)) *option::borrow(&project_uri) + else utf8(PROJECT_URI) + }; + + let stablecoin_address = stablecoin_address(); + + assert_eq(fungible_asset::name(object::address_to_object(stablecoin_address)), expected_name); + assert_eq(fungible_asset::symbol(object::address_to_object(stablecoin_address)), expected_symbol); + assert_eq(fungible_asset::decimals(object::address_to_object(stablecoin_address)), expected_decimals); + assert_eq(fungible_asset::icon_uri(object::address_to_object(stablecoin_address)), expected_icon_uri); + assert_eq( + fungible_asset::project_uri(object::address_to_object(stablecoin_address)), + expected_project_uri + ); + + let expected_event = metadata::test_MetadataUpdated_event( + expected_name, + expected_symbol, + expected_decimals, + expected_icon_uri, + expected_project_uri + ); + assert_eq(event::was_event_emitted(&expected_event), true); + } + + fun test_update_metadata_updater( + caller: &signer, + old_metadata_updater: address, + new_metadata_updater: address + ) { + metadata::set_metadata_updater_for_testing(old_metadata_updater); + let expected_event = metadata::test_MetadataUpdaterChanged_event( + old_metadata_updater, new_metadata_updater + ); + + metadata::test_update_metadata_updater( + caller, new_metadata_updater + ); + + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(metadata::metadata_updater(), new_metadata_updater); + } +} diff --git a/packages/stablecoin/tests/stablecoin.spec.move b/packages/stablecoin/tests/stablecoin.spec.move new file mode 100644 index 0000000..44800f1 --- /dev/null +++ b/packages/stablecoin/tests/stablecoin.spec.move @@ -0,0 +1,201 @@ +// 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. + +spec stablecoin::stablecoin { + use std::signer; + use std::table_with_length; + use aptos_framework::object::ObjectCore; + use aptos_framework::fungible_asset::{ConcurrentFungibleBalance, FungibleStore, Metadata, Untransferable}; + use aptos_framework::primary_fungible_store::DeriveRefPod; + use aptos_extensions::ownable::OwnerRole; + use aptos_extensions::pausable::PauseState; + use aptos_extensions::manageable::AdminRole; + use stablecoin::blocklistable::BlocklistState; + use stablecoin::metadata::{MetadataState, ValidateMetadataMutation}; + use stablecoin::stablecoin_utils::spec_stablecoin_address; + use stablecoin::treasury::TreasuryState; + + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + } + + /// Abort condition: Never aborts. + /// Post condition: The stablecoin address is returned as defined by the stablecoin_utils module. + spec stablecoin_address(): address { + aborts_if false; + ensures result == spec_stablecoin_address(); + } + + /// Abort condition: Any of the resources to initialize already exists at the stablecoin address. + /// Abort condition: Any of the resources to initialize already exists at the package address. + /// Post condition: The stablecoin address is always initialized with the expected resources. + /// Post condition: The package address is always initialized with the expected resources. + spec init_module { + /// The list of abort conditions here intentionally does not enumerate through the + /// abort conditions that happens from calling the underlying framework functions + /// to avoid an unnecessary long proof. Only the key conditions are proved here. + pragma aborts_if_is_partial; + + requires signer::address_of(resource_acct_signer) == @stablecoin; + + let stablecoin_address = spec_stablecoin_address(); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(stablecoin_address); + aborts_if exists(@stablecoin); + aborts_if exists(@stablecoin); + + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(stablecoin_address); + ensures exists(@stablecoin); + ensures exists(@stablecoin); + } + + /// Abort condition: The AdminRole resource is missing. + /// Abort condition: The caller is not the stablecoin admin address. + /// Abort condition: The stablecoin address is not a valid object address. + /// Abort condition: The StablecoinState resource is missing. + /// Abort condition: The StablecoinState resource has already been initialized. + /// Abort condition: The MetadataState resource is missing. + /// Abort condition: The Metadata resource is missing. + /// Abort condition: The metadata inputs are invalid. + /// Post condition: The Metadata resource is always updated with the expected fields. + /// Post condition: The StablecoinState resource is always initialized with the expected version. + spec initialize_v1 { + let stablecoin_address = spec_stablecoin_address(); + + aborts_if !exists(@stablecoin); + aborts_if signer::address_of(caller) != global(@stablecoin).admin; + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if global(stablecoin_address).initialized_version != 0; + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + + include ValidateMetadataMutation { + name: option::spec_some(name), + symbol: option::spec_some(symbol), + decimals: option::spec_some(decimals), + icon_uri: option::spec_some(icon_uri), + project_uri: option::spec_some(project_uri) + }; + + ensures global(stablecoin_address).initialized_version == 1; + } + + /// Pre-condition: The stablecoin address is a valid object address. + /// Pre-condition: FungibleStore already exists at the stablecoin address. + /// Abort condition: The PauseState resource is missing. + /// Abort condition: The BlocklistState is missing. + /// Abort condition: The asset is paused. + /// Abort condition: The fungible store's owner is blocklisted. + /// Abort condition: The transfer ref metadata does not match store metadata. + /// Abort condition: The transfer ref metadata does not match fungible asset metadata. + /// Abort condition: The concurrent balance is not used, and balance + deposit amount exceeds u64 max. + /// [NOT PROVEN] Abort condition: The concurrent balance is used, and balance + deposit amount exceeds u64 max. + /// Post condition: The fungible store balance is always increased by the deposit amount if the concurrent balance feature is not used by the store. + /// [NOT PROVEN] Post condition: The fungible store balance is always increased by the deposit amount if the concurrent balance feature is used by the store. + spec override_deposit { + /// There are some abort conditions that are unspecified due to technical + /// limitations. + pragma aborts_if_is_partial; + + let store_address = object::object_address(store); + let metadata = fungible_asset::store_metadata(store); + let stablecoin_address = spec_stablecoin_address(); + let deposit_amount = fungible_asset::amount(fa); + + requires exists(store_address); + requires exists(store_address); + + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if global(stablecoin_address).paused == true; + aborts_if table_with_length::spec_contains(global(stablecoin_address).blocklist, object::owner(store)); + aborts_if transfer_ref.metadata != fungible_asset::store_metadata(store); + aborts_if transfer_ref.metadata != fungible_asset::asset_metadata(fa); + aborts_if global(store_address).balance > MAX_U64 - deposit_amount; + + // Cannot be proved - If the concurrent balance feature is enabled, balance is always increased by the deposit amount. + + // If store balance is not zero and the concurrent balance feature is not enabled, balance is always increased by the deposit amount. + ensures (global(store_address).balance != 0 + || !exists(store_address)) ==> + global(store_address).balance + == old(global(store_address).balance) + deposit_amount; + } + + /// Pre-condition: The stablecoin address is a valid object address. + /// Pre-condition: FungibleStore already exists at the stablecoin address. + /// Abort condition: The PauseState resource is missing. + /// Abort condition: The BlocklistState resource is missing. + /// Abort condition: The asset is paused. + /// Abort condition: The fungible store's owner is blocklisted. + /// Abort condition: The transfer ref metadata does not match store metadata. + /// Abort condition: The concurrent balance is not used, amount to withdraw exceeds store balance. + /// [NOT PROVEN] Abort condition: The concurrent balance is used, amount to withdraw exceeds store balance. + /// Post condition: The fungible store balance is always decreased by the withdrawal amount if the concurrent balance feature is not used by the store. + /// [NOT PROVEN]: The fungible store balance is always decreased by the withdrawal amount if the concurrent balance feature is used by the store. + /// Post condition: The fungible asset is always returned with the expected metadata and amount. + spec override_withdraw { + /// There are some abort conditions that are unspecified due to technical + /// limitations. + pragma aborts_if_is_partial; + + let store_address = object::object_address(store); + let metadata = fungible_asset::store_metadata(store); + let stablecoin_address = spec_stablecoin_address(); + + requires exists(store_address); + requires exists(store_address); + + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if global(stablecoin_address).paused == true; + aborts_if table_with_length::spec_contains(global(stablecoin_address).blocklist, object::owner(store)); + aborts_if transfer_ref.metadata != fungible_asset::store_metadata(store); + aborts_if ( + global(store_address).balance != 0 || !exists(store_address) + ) && global(store_address).balance < amount; + + // Cannot be proved - if the concurrent balance feature is enabled, amount that exceeds current balance will trigger abort due to underflow. + // Cannot be proved - if the concurrent balance feature is enabled, balance is always decreased by the amount. + + // If store balance is not zero and the concurrent balance feature is not enabled, balance is always decreased by the amount. + ensures (global(store_address).balance == 0 + && exists(store_address)) + || global(store_address).balance + == old(global(store_address).balance) - amount; + + ensures result == FungibleAsset { metadata: transfer_ref.metadata, amount }; + } +} diff --git a/packages/stablecoin/tests/stablecoin_e2e_tests.move b/packages/stablecoin/tests/stablecoin_e2e_tests.move new file mode 100644 index 0000000..9140a4c --- /dev/null +++ b/packages/stablecoin/tests/stablecoin_e2e_tests.move @@ -0,0 +1,186 @@ +// 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_e2e_tests { + use std::event; + use std::vector; + use std::option; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::dispatchable_fungible_asset; + use aptos_framework::fungible_asset; + use aptos_framework::object; + use aptos_framework::transaction_context::generate_auid_address; + + use aptos_extensions::test_utils::assert_eq; + use stablecoin::stablecoin; + use stablecoin::stablecoin_tests::setup; + use stablecoin::treasury; + + /// Address definition must match ones in `stablecoin_tests.move`. + const MASTER_MINTER: address = @0x50; + const CONTROLLER: address = @0x60; + const MINTER: address = @0x70; + const RANDOM_ADDRESS: address = @0x80; + + #[test] + fun mint_and_deposit__should_succeed() { + let stablecoin_metadata = setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + + // Set up controller and minter. + treasury::test_configure_controller(&create_signer_for_test(MASTER_MINTER), CONTROLLER, MINTER); + treasury::test_configure_minter(&create_signer_for_test(CONTROLLER), 1_000_000); + + // Set up store for recipient. + let store = fungible_asset::create_store( + &object::create_object(RANDOM_ADDRESS), + stablecoin_metadata + ); + + // Mint a FungibleAsset and deposit it. + { + let asset = treasury::mint(&create_signer_for_test(MINTER), 1_000_000); + dispatchable_fungible_asset::deposit(store, asset); + + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some((1_000_000 as u128))); + assert_eq(fungible_asset::balance(store), 1_000_000); + + let mint_event = treasury::test_Mint_event(MINTER, 1_000_000); + let deposit_event = stablecoin::test_Deposit_event( + RANDOM_ADDRESS, /* store owner */ + object::object_address(&store), + 1_000_000 + ); + assert_eq(event::was_event_emitted(&mint_event), true); + assert_eq(event::was_event_emitted(&deposit_event), true); + }; + } + + #[test] + fun batch_mint_and_deposit__should_succeed() { + let stablecoin_metadata = setup(); + + // Set up test scenario. + let mint_allowance = 6_000_000; + let recipients = vector::empty
(); + let mint_amounts = vector::empty(); + + vector::push_back(&mut recipients, generate_auid_address()); + vector::push_back(&mut mint_amounts, 1_000_000); + + vector::push_back(&mut recipients, generate_auid_address()); + vector::push_back(&mut mint_amounts, 2_000_000); + + vector::push_back(&mut recipients, generate_auid_address()); + vector::push_back(&mut mint_amounts, 3_000_000); + + // Set up controller and minter. + treasury::set_master_minter_for_testing(MASTER_MINTER); + treasury::test_configure_controller(&create_signer_for_test(MASTER_MINTER), CONTROLLER, MINTER); + treasury::test_configure_minter( + &create_signer_for_test(CONTROLLER), + mint_allowance + ); + + // Mint a large amount of FungibleAsset. + let minted_asset = treasury::mint( + &create_signer_for_test(MINTER), + mint_allowance + ); + let expected_mint_events = vector::singleton(treasury::test_Mint_event(MINTER, mint_allowance)); + + // Batch mint to each recipient's fungible store. + let expected_deposit_events = vector::empty(); + { + let i = 0; + while (i < vector::length(&recipients)) { + let recipient = *vector::borrow(&recipients, i); + let mint_amount = *vector::borrow(&mint_amounts, i); + + let store = fungible_asset::create_store( + &object::create_object(recipient), + stablecoin_metadata + ); + let asset = fungible_asset::extract(&mut minted_asset, mint_amount); + dispatchable_fungible_asset::deposit(store, asset); + + vector::push_back( + &mut expected_deposit_events, + stablecoin::test_Deposit_event( + recipient, + object::object_address(&store), + mint_amount + ) + ); + + i = i + 1; + } + }; + + fungible_asset::destroy_zero(minted_asset); + + // Ensure that the correct events were emitted. + assert_eq(vector::length(&expected_mint_events), 1); + assert_eq(vector::length(&expected_deposit_events), 3); + assert_eq(event::emitted_events(), expected_mint_events); + assert_eq(event::emitted_events(), expected_deposit_events); + } + + #[test] + fun withdraw_and_burn__should_succeed() { + let stablecoin_metadata = setup(); + + // Set up controller and minter. + treasury::set_master_minter_for_testing(MASTER_MINTER); + treasury::test_configure_controller(&create_signer_for_test(MASTER_MINTER), CONTROLLER, MINTER); + treasury::test_configure_minter(&create_signer_for_test(CONTROLLER), 10_000_000); + + // Set up store for recipient. + let store = fungible_asset::create_store( + &object::create_object(RANDOM_ADDRESS), + stablecoin_metadata + ); + + // Mint and deposit to store. + { + let asset = treasury::mint(&create_signer_for_test(MINTER), 10_000_000); + dispatchable_fungible_asset::deposit(store, asset); + + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some((10_000_000 as u128))); + assert_eq(fungible_asset::balance(store), 10_000_000); + }; + + // Withdraw from store and burn. + { + let asset = dispatchable_fungible_asset::withdraw(&create_signer_for_test(RANDOM_ADDRESS), store, 1_000_000); + treasury::burn(&create_signer_for_test(MINTER), asset); + + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some((9_000_000 as u128))); + assert_eq(fungible_asset::balance(store), 9_000_000); + + let withdraw_event = stablecoin::test_Withdraw_event( + RANDOM_ADDRESS, /* store owner */ + object::object_address(&store), + 1_000_000 + ); + let burn_event = treasury::test_Burn_event(MINTER, 1_000_000); + assert_eq(event::was_event_emitted(&withdraw_event), true); + assert_eq(event::was_event_emitted(&burn_event), true); + }; + } +} diff --git a/packages/stablecoin/tests/stablecoin_tests.move b/packages/stablecoin/tests/stablecoin_tests.move new file mode 100644 index 0000000..81e3536 --- /dev/null +++ b/packages/stablecoin/tests/stablecoin_tests.move @@ -0,0 +1,544 @@ +// 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 std::event; + use std::option; + use std::string::{Self, String, utf8}; + use std::vector; + use aptos_framework::account; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::dispatchable_fungible_asset; + use aptos_framework::function_info::new_function_info; + use aptos_framework::fungible_asset::{Self, FungibleAsset, FungibleStore, Metadata}; + use aptos_framework::object::{Self, ConstructorRef, Object}; + use aptos_framework::primary_fungible_store; + use aptos_framework::resource_account; + + use aptos_extensions::manageable; + use aptos_extensions::ownable::{Self, OwnerRole}; + use aptos_extensions::pausable::{Self, PauseState}; + use aptos_extensions::test_utils::assert_eq; + use aptos_extensions::upgradable; + use stablecoin::blocklistable::{Self, test_blocklist}; + use stablecoin::metadata; + use stablecoin::stablecoin; + use stablecoin::stablecoin::test_StablecoinInitialized_event; + use stablecoin::stablecoin_utils; + use stablecoin::treasury; + + const RANDOM_ADDRESS: address = @0x10; + + const NAME: vector = b"name"; + const SYMBOL: vector = b"symbol"; + const DECIMALS: u8 = 6; + const ICON_URI: vector = b"icon uri"; + const PROJECT_URI: vector = b"project uri"; + const TEST_SEED: vector = b"test_seed"; + + /// error::not_found(object::EOBJECT_DOES_NOT_EXIST) + const ERR_OBJ_OBJECT_DOES_NOT_EXIST: u64 = 393218; // 6 * 2^16 + 2 + + #[test] + fun init_module__should_create_stablecoin_correctly() { + test_init_module(); + } + + #[test] + fun initialize_v1__should_succeed() { + test_init_module(); + + test_initialize_v1( + &create_signer_for_test(@deployer), + string::utf8(NAME), + string::utf8(SYMBOL), + DECIMALS, + string::utf8(ICON_URI), + string::utf8(PROJECT_URI) + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::manageable::ENOT_ADMIN)] + fun initialize_v1__should_fail_if_caller_not_admin() { + test_init_module(); + + test_initialize_v1( + &create_signer_for_test(RANDOM_ADDRESS), + string::utf8(NAME), + string::utf8(SYMBOL), + DECIMALS, + string::utf8(ICON_URI), + string::utf8(PROJECT_URI) + ); + } + + #[test, expected_failure(abort_code = stablecoin::stablecoin::ESTABLECOIN_VERSION_INITIALIZED)] + fun initialize_v1__should_fail_if_already_initialized() { + test_init_module(); + + stablecoin::set_initialized_version_for_testing(1); + + test_initialize_v1( + &create_signer_for_test(@deployer), + string::utf8(NAME), + string::utf8(SYMBOL), + DECIMALS, + string::utf8(ICON_URI), + string::utf8(PROJECT_URI) + ); + } + + #[test] + fun stablecoin_address__should_return_stablecoin_address() { + setup(); + + assert_eq(stablecoin::stablecoin_address(), stablecoin_utils::stablecoin_address()); + } + + #[test] + fun deposit__should_succeed_and_pass_all_assertions() { + let stablecoin_metadata = setup(); + let owner = create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(&owner, stablecoin_metadata); + + test_deposit(fungible_store, 100); + } + + #[test] + fun deposit__should_succeed_and_pass_all_assertions_for_zero_amount() { + let stablecoin_metadata = setup(); + let owner = create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(&owner, stablecoin_metadata); + + test_deposit(fungible_store, 0); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + fun deposit__should_fail_when_paused() { + let stablecoin_metadata = setup(); + let owner = create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(&owner, stablecoin_metadata); + + pausable::set_paused_for_testing(stablecoin_utils::stablecoin_address(), true); + + test_deposit(fungible_store, 100); + } + + #[test, expected_failure(abort_code = stablecoin::blocklistable::EBLOCKLISTED)] + fun deposit__should_fail_if_store_owner_is_blocklisted() { + let stablecoin_metadata = setup(); + let owner = create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(&owner, stablecoin_metadata); + let blocklister = + create_signer_for_test(blocklistable::blocklister()); + + test_blocklist(&blocklister, RANDOM_ADDRESS); + + test_deposit(fungible_store, 100); + } + + #[test, expected_failure(abort_code = ERR_OBJ_OBJECT_DOES_NOT_EXIST, location = aptos_framework::object)] + fun deposit__should_fail_if_store_does_not_have_owner() { + let stablecoin_metadata = setup(); + let secondary_store_constructor_ref = object::create_object(RANDOM_ADDRESS); + let secondary_store_addr = + object::address_from_constructor_ref(&secondary_store_constructor_ref); + let secondary_store_delete_ref = object::generate_delete_ref(&secondary_store_constructor_ref); + let fungible_store = + fungible_asset::create_store(&secondary_store_constructor_ref, stablecoin_metadata); + + object::delete(secondary_store_delete_ref); + assert_eq(object::is_object(secondary_store_addr), false); + + test_deposit(fungible_store, 100); + } + + #[test, expected_failure(abort_code = stablecoin::stablecoin::ESTABLECOIN_METADATA_MISMATCH)] + fun deposit__should_fail_if_depositing_other_assets() { + setup(); + // create alternative fungible asset + let owner = &create_signer_for_test(RANDOM_ADDRESS); + let (mint_ref, transfer_ref, _, _, metadata) = fungible_asset::create_fungible_asset(owner); + let store = fungible_asset::create_test_store(owner, metadata); + let fa = fungible_asset::mint(&mint_ref, 100); + + stablecoin::override_deposit(store, fa, &transfer_ref); + } + + #[test] + fun withdraw__should_succeed_and_pass_all_assertions() { + let stablecoin_metadata = setup(); + let owner = &create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(owner, stablecoin_metadata); + + // Deposit first + test_deposit(fungible_store, 100); + + // Withdraw + test_withdraw( + owner, + fungible_store, + 50, + ) + } + + #[test] + fun withdraw__should_succeed_and_pass_all_assertions_when_called_by_indirect_owner() { + let stablecoin_metadata = setup(); + let indirect_owner = &create_signer_for_test(RANDOM_ADDRESS); + let owner = &object::generate_signer(&object::create_object(RANDOM_ADDRESS)); + let fungible_store = fungible_asset::create_test_store(owner, stablecoin_metadata); + + // Deposit first + test_deposit(fungible_store, 100); + + // Withdraw + test_withdraw( + indirect_owner, + fungible_store, + 50, + ) + } + + #[test] + fun withdraw__should_succeed_and_pass_all_assertions_for_zero_amount() { + let stablecoin_metadata = setup(); + let owner = &create_signer_for_test(RANDOM_ADDRESS); + + test_withdraw( + owner, + fungible_asset::create_test_store(owner, stablecoin_metadata), + 0, + ) + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + fun withdraw__should_fail_when_paused() { + let stablecoin_metadata = setup(); + let owner = &create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(owner, stablecoin_metadata); + + // Deposit first + test_deposit(fungible_store, 100); + + // Pause + pausable::set_paused_for_testing(stablecoin_utils::stablecoin_address(), true); + + // Withdraw + test_withdraw( + owner, + fungible_store, + 100, + ) + } + + #[test, expected_failure(abort_code = stablecoin::blocklistable::EBLOCKLISTED)] + fun withdraw__should_fail_if_store_owner_is_blocklisted() { + let stablecoin_metadata = setup(); + let owner = &create_signer_for_test(RANDOM_ADDRESS); + let fungible_store = fungible_asset::create_test_store(owner, stablecoin_metadata); + let blocklister = + create_signer_for_test(blocklistable::blocklister()); + + test_blocklist(&blocklister, RANDOM_ADDRESS); + + test_withdraw( + owner, + fungible_store, + 100, + ) + } + + #[test, expected_failure(abort_code = ERR_OBJ_OBJECT_DOES_NOT_EXIST, location = aptos_framework::object)] + fun withdraw__should_fail_if_store_does_not_have_owner() { + let stablecoin_metadata = setup(); + let owner = &create_signer_for_test(RANDOM_ADDRESS); + let secondary_store_constructor_ref = object::create_object(RANDOM_ADDRESS); + let secondary_store_addr = + object::address_from_constructor_ref(&secondary_store_constructor_ref); + let secondary_store_delete_ref = object::generate_delete_ref(&secondary_store_constructor_ref); + let fungible_store = + fungible_asset::create_store(&secondary_store_constructor_ref, stablecoin_metadata); + + object::delete(secondary_store_delete_ref); + assert_eq(object::is_object(secondary_store_addr), false); + + test_withdraw( + owner, + fungible_store, + 100, + ) + } + + #[test, expected_failure(abort_code = stablecoin::stablecoin::ESTABLECOIN_METADATA_MISMATCH)] + fun withdraw__should_fail_if_withdrawing_other_assets() { + setup(); + // create alternative fungible asset + let owner = &create_signer_for_test(RANDOM_ADDRESS); + let (mint_ref, transfer_ref, burn_ref, _, metadata) = fungible_asset::create_fungible_asset(owner); + let store = fungible_asset::create_test_store(owner, metadata); + let amount = 100; + fungible_asset::mint_to(&mint_ref, store, amount); + + let fa = stablecoin::override_withdraw(store, amount, &transfer_ref); + + fungible_asset::burn(&burn_ref, fa); + } + + // === Helpers === + + public fun setup(): (Object) { + test_init_module(); + + let stablecoin_metadata = test_initialize_v1( + &create_signer_for_test(@deployer), + string::utf8(NAME), + string::utf8(SYMBOL), + DECIMALS, + string::utf8(ICON_URI), + string::utf8(PROJECT_URI) + ); + + stablecoin_metadata + } + + fun test_init_module() { + let resource_acct_signer = deploy_package(); + + stablecoin::test_init_module(&resource_acct_signer); + + validate_stablecoin_object_state(stablecoin_utils::stablecoin_address(), + utf8(vector[]), + utf8(vector[]), + 0, + utf8(vector[]), + utf8(vector[]), + @deployer, + @deployer, + @deployer, + @deployer, + @deployer, + @deployer + ); + } + + fun test_initialize_v1( + caller: &signer, + name: String, + symbol: String, + decimals: u8, + icon_uri: String, + project_uri: String + ): Object { + stablecoin::test_initialize_v1( + caller, + name, + symbol, + decimals, + icon_uri, + project_uri + ); + + let stablecoin_metadata = object::address_to_object(stablecoin_utils::stablecoin_address()); + + // verify the fungible asset metadata is updated correctly + assert_eq(fungible_asset::name(stablecoin_metadata), name); + assert_eq(fungible_asset::symbol(stablecoin_metadata), symbol); + assert_eq(fungible_asset::decimals(stablecoin_metadata), decimals); + assert_eq(fungible_asset::icon_uri(stablecoin_metadata), icon_uri); + assert_eq(fungible_asset::project_uri(stablecoin_metadata), project_uri); + + // verify the initialized state is updated correctly + assert_eq(stablecoin::initialized_version_for_testing(), 1); + + // verify the StablecoinInitialized event is emitted + let stablecoin_initialized_event = test_StablecoinInitialized_event(1); + assert_eq(event::was_event_emitted(&stablecoin_initialized_event), true); + + stablecoin_metadata + } + + public fun validate_stablecoin_object_state( + stablecoin_address: address, + name: String, + symbol: String, + decimals: u8, + icon_uri: String, + project_uri: String, + admin: address, + owner: address, + pauser: address, + blocklister: address, + master_minter: address, + metadata_updater: address + ) { + let stablecoin_metadata = object::address_to_object(stablecoin_address); + + // Ensure that all the expected resources exists. + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(object::object_exists(stablecoin_address), true); + assert_eq(manageable::admin_role_exists_for_testing(@stablecoin), true); + assert_eq(upgradable::signer_cap_store_exists_for_testing(@stablecoin), true); + + // Ensure that the fungible asset has been configured correctly. + assert_eq(fungible_asset::name(stablecoin_metadata), name); + assert_eq(fungible_asset::symbol(stablecoin_metadata), symbol); + assert_eq(fungible_asset::decimals(stablecoin_metadata), decimals); + assert_eq(fungible_asset::icon_uri(stablecoin_metadata), icon_uri); + assert_eq(fungible_asset::project_uri(stablecoin_metadata), project_uri); + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some(0)); + assert_eq(fungible_asset::maximum(stablecoin_metadata), option::none()); + assert_eq(fungible_asset::is_untransferable(stablecoin_metadata), true); + + // Ensure that stablecoin state is correct + assert_eq(stablecoin::initialized_version_for_testing(), 0); + assert_eq(stablecoin::extend_ref_address_for_testing(), stablecoin_address); + assert_eq(treasury::mint_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(treasury::burn_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(treasury::master_minter(), master_minter); + assert_eq(treasury::num_controllers_for_testing(), 0); + assert_eq(treasury::num_mint_allowances_for_testing(), 0); + assert_eq(blocklistable::transfer_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(blocklistable::num_blocklisted_for_testing(), 0); + assert_eq(blocklistable::blocklister(), blocklister); + assert_eq(metadata::mutate_metadata_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(metadata::metadata_updater(), metadata_updater); + + // Ensure the upgradable signer capability is setup correctly + let capability = &upgradable::extract_signer_cap_for_testing(@stablecoin); + assert_eq(account::get_signer_capability_address(capability), @stablecoin); + + // Ensure the stablecoin admin, owner and pauser are setup correctly + let owner_role = object::address_to_object(stablecoin_address); + let pausable_state = object::address_to_object(stablecoin_address); + assert_eq(manageable::admin(@stablecoin), admin); + assert_eq(manageable::pending_admin(@stablecoin), option::none()); + assert_eq(ownable::owner(owner_role), owner); + assert_eq(ownable::pending_owner(owner_role), option::none()); + assert_eq(pausable::pauser(pausable_state), pauser); + assert_eq(pausable::is_paused(pausable_state), false); + + // Ensure that the dispatchable functions have been correctly registered. + let withdraw_function_info = + new_function_info( + &create_signer_for_test(@stablecoin), + string::utf8(b"stablecoin"), + string::utf8(b"override_withdraw"), + ); + let deposit_function_info = + new_function_info( + &create_signer_for_test(@stablecoin), + string::utf8(b"stablecoin"), + string::utf8(b"override_deposit"), + ); + + let store = fungible_asset::create_test_store(&create_signer_for_test(RANDOM_ADDRESS), stablecoin_metadata); + assert_eq( + fungible_asset::deposit_dispatch_function(store), + option::some(deposit_function_info), + ); + assert_eq( + fungible_asset::withdraw_dispatch_function(store), + option::some(withdraw_function_info), + ); + } + + fun test_deposit( + fungible_store: Object, + amount: u64 + ) { + let minted_asset = treasury::test_mint(amount); + dispatchable_fungible_asset::deposit(fungible_store, minted_asset); + + // Event emission + let store_owner = object::owner(fungible_store); + let store_address = object::object_address(&fungible_store); + let expected_event = stablecoin::test_Deposit_event( + store_owner, + store_address, + amount + ); + assert_eq(event::was_event_emitted(&expected_event), true); + + // Balance check + assert_eq(fungible_asset::balance(fungible_store), amount); + } + + fun test_withdraw( + owner: &signer, + fungible_store: Object, + amount: u64 + ) { + let balance_before = fungible_asset::balance(fungible_store); + let withdrawn_asset = + dispatchable_fungible_asset::withdraw(owner, fungible_store, amount); + + // Event emission + let store_owner = object::owner(fungible_store); + let store_address = object::object_address(&fungible_store); + let expected_event = stablecoin::test_Withdraw_event( + store_owner, + store_address, + amount + ); + assert_eq(event::was_event_emitted(&expected_event), true); + + // Balance check + assert_eq(balance_before - amount, fungible_asset::balance(fungible_store)); + assert_eq(fungible_asset::amount(&withdrawn_asset), amount); + + // Clean up the assets + treasury::test_burn(withdrawn_asset); + } + + fun destroy_fungible_asset(constructor_ref: &ConstructorRef, asset: FungibleAsset) { + let burn_ref = fungible_asset::generate_burn_ref(constructor_ref); + fungible_asset::burn(&burn_ref, asset); + } + + fun deploy_package(): signer { + account::create_account_for_test(@deployer); + + // deploy an empty package to a new resource account + resource_account::create_resource_account_and_publish_package( + &create_signer_for_test(@deployer), + TEST_SEED, + x"04746573740100000000000000000000000000", // empty BCS serialized PackageMetadata + vector::empty() + ); + + // compute the resource account address + let resource_account_address = account::create_resource_address(&@deployer, TEST_SEED); + + // verify the resource account address is the same as the configured test package address + assert_eq(@stablecoin, resource_account_address); + + // return a resource account signer + let resource_account_signer = create_signer_for_test(resource_account_address); + resource_account_signer + } +} diff --git a/packages/stablecoin/tests/stablecoin_utils.spec.move b/packages/stablecoin/tests/stablecoin_utils.spec.move new file mode 100644 index 0000000..9af99d8 --- /dev/null +++ b/packages/stablecoin/tests/stablecoin_utils.spec.move @@ -0,0 +1,39 @@ +// 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. + +spec stablecoin::stablecoin_utils { + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + } + + /// Abort condition: Never aborts. + spec stablecoin_obj_seed(): vector { + aborts_if false; + ensures result == b"stablecoin"; + } + + /// Abort condition: Never aborts. + spec stablecoin_address(): address { + aborts_if false; + ensures result == spec_stablecoin_address(); + } + + /// Helper function to calculate stablecoin object address + spec fun spec_stablecoin_address(): address { + object::spec_create_object_address(@stablecoin, stablecoin_obj_seed()) + } +} diff --git a/packages/stablecoin/tests/stablecoin_utils_tests.move b/packages/stablecoin/tests/stablecoin_utils_tests.move new file mode 100644 index 0000000..8df7f3d --- /dev/null +++ b/packages/stablecoin/tests/stablecoin_utils_tests.move @@ -0,0 +1,43 @@ +// 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_utils_tests { + use aptos_framework::object; + + use aptos_extensions::test_utils::assert_eq; + use stablecoin::stablecoin_utils; + + const EXPECTED_STABLECOIN_OBJ_SEED: vector = b"stablecoin"; + + #[test] + fun stablecoin_obj_seed__should_return_expected_seed_value() { + assert_eq(stablecoin_utils::stablecoin_obj_seed(), EXPECTED_STABLECOIN_OBJ_SEED); + } + + #[test] + fun stablecoin_address__should_return_expected_stablecoin_object_address() { + // first verify that the configured stablecoin package address is the expected value (configured in the move.toml file) + assert_eq(@stablecoin, @0x94ae22c4ecec81b458095a7ae2a5de2ac81d2bff9c8633e029194424e422db3b); + + // then compare the expected stablecoin object address with a pre-computed value. + let expected_address = object::create_object_address(&@stablecoin, EXPECTED_STABLECOIN_OBJ_SEED); + assert_eq(expected_address, @0xc6a3f2ea3a7abd98aebd8c2290648a4973ab6022cad2e88efd64fd3fb3bda245); + + // lastly, compare the expected stablecoin object address with the stablecoin_address function return value. + assert_eq(stablecoin_utils::stablecoin_address(), expected_address); + } +} diff --git a/packages/stablecoin/tests/treasury.spec.move b/packages/stablecoin/tests/treasury.spec.move new file mode 100644 index 0000000..3b163fe --- /dev/null +++ b/packages/stablecoin/tests/treasury.spec.move @@ -0,0 +1,441 @@ +// 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. + +spec stablecoin::treasury { + use std::table_with_length; + use aptos_framework::object::ObjectCore; + use aptos_framework::fungible_asset::{ConcurrentSupply, Supply}; + use aptos_extensions::ownable::OwnerRole; + use aptos_extensions::pausable::PauseState; + use stablecoin::blocklistable::BlocklistState; + + /// Invariant: All addresses that owns a TreasuryState resource only stores a MintRef + /// and a BurnRef that belong to itself. + /// Invariant: Once a TreasuryState is initialized, the MintRef and the BurnRef stored in + /// it is not updated. + spec module { + pragma verify = true; + pragma aborts_if_is_strict; + + invariant forall addr: address where exists(addr): + object::object_address(global(addr).mint_ref.metadata) == addr + && object::object_address(global(addr).burn_ref.metadata) == addr; + + invariant update forall addr: address where old(exists(addr)) && exists(addr): + global(addr).mint_ref == old(global(addr).mint_ref) + && global(addr).burn_ref == old(global(addr).burn_ref); + } + + /// Invariant: The max allowance of a minter does not exceed MAX_U64. + spec TreasuryState { + invariant forall minter: address where smart_table::spec_contains(mint_allowances, minter): + smart_table::spec_get(mint_allowances, minter) <= MAX_U64; + } + + /// Abort condition: The required resources are missing. + /// Post condition: There are no changes to TreasuryState. + /// Post condition: The master minter address is always returned. + spec master_minter { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + + aborts_if !exists(stablecoin_address); + + ensures global(stablecoin_address) == old(global(stablecoin_address)); + ensures result == global(stablecoin_address).master_minter; + } + + /// Abort condition: The required resources are missing. + /// Post condition: There are no changes to TreasuryState. + /// Post condition: If the input address is a controller, then return an Option with the minter's address in it. + /// Else return an empty Option. + spec get_minter { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + + aborts_if !exists(stablecoin_address); + + ensures global(stablecoin_address) == old(global(stablecoin_address)); + + let is_controller = smart_table::spec_contains( + global(stablecoin_address).controllers, controller + ); + ensures is_controller ==> + result + == option::spec_some( + smart_table::spec_get(global(stablecoin_address).controllers, controller) + ); + ensures !is_controller ==> + result == option::spec_none(); + } + + /// Abort condition: The required resources are missing. + /// Post condition: There are no changes to TreasuryState. + /// Post condition: Returns true if the input address is a minter, false otherwise. + spec is_minter { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + + aborts_if !exists(stablecoin_address); + + ensures global(stablecoin_address) == old(global(stablecoin_address)); + ensures result + == smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter); + } + + /// Abort condition: The required resources are missing. + /// Post condition: There are no changes to TreasuryState. + /// Post condition: If the input address is a minter, then return the minter's mint allowance. + /// Else return zero. + spec mint_allowance { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + + aborts_if !exists(stablecoin_address); + + ensures global(stablecoin_address) == old(global(stablecoin_address)); + + let is_minter = smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter); + ensures is_minter ==> + result == smart_table::spec_get(global(stablecoin_address).mint_allowances, minter); + ensures !is_minter ==> result == 0; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: A TreasuryState resource already exist at the address. + /// Post condition: A TreasuryState resource is created at the address. + spec new { + let address_to_instantiate = object::address_from_constructor_ref(stablecoin_obj_constructor_ref); + + aborts_if !exists(address_to_instantiate); + aborts_if !object::spec_exists_at(address_to_instantiate); + + aborts_if exists(address_to_instantiate); + + ensures exists(address_to_instantiate); + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The caller is not the master minter. + /// Post condition: If the controller did not exist, then only one controller is added. + /// Post condition: If the controller already exists, then no controllers are added. + /// Post condition: The TreasuryState's controllers table contain the (controller, minter) pair. + /// Post condition: All irrelevant states are unchanged. + spec configure_controller { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let caller = signer::address_of(caller); + + aborts_if !exists(stablecoin_address); + + aborts_if caller != global(stablecoin_address).master_minter; + + ensures !old(smart_table::spec_contains(global(stablecoin_address).controllers, controller)) ==> + smart_table::spec_len(global(stablecoin_address).controllers) + == old(smart_table::spec_len(global(stablecoin_address).controllers)) + 1; + + ensures old(smart_table::spec_contains(global(stablecoin_address).controllers, controller)) ==> + smart_table::spec_len(global(stablecoin_address).controllers) + == old(smart_table::spec_len(global(stablecoin_address).controllers)); + + ensures smart_table::spec_get(global(stablecoin_address).controllers, controller) == minter; + + ensures global(stablecoin_address).master_minter + == old(global(stablecoin_address)).master_minter; + ensures global(stablecoin_address).mint_allowances + == old(global(stablecoin_address)).mint_allowances; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The caller is not the master minter. + /// Abort condition: The controller address is not a controller. + /// Post condition: Only one controller is removed. + /// Post condition: The controller address specified in the input is removed. + /// Post condition: All irrelevant states are unchanged. + spec remove_controller { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let caller = signer::address_of(caller); + + aborts_if !exists(stablecoin_address); + + aborts_if caller != global(stablecoin_address).master_minter; + + aborts_if !smart_table::spec_contains(global(stablecoin_address).controllers, controller); + + ensures old(smart_table::spec_len(global(stablecoin_address).controllers)) + - smart_table::spec_len(global(stablecoin_address).controllers) == 1; + + ensures !smart_table::spec_contains(global(stablecoin_address).controllers, controller); + + ensures global(stablecoin_address).master_minter + == old(global(stablecoin_address)).master_minter; + ensures global(stablecoin_address).mint_allowances + == old(global(stablecoin_address)).mint_allowances; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The caller is not a controller. + /// Abort condition: The asset is paused. + /// Post condition: If the minter did not exist, then only one minter is added. + /// Post condition: If the minter already exists, then no minters are added. + /// Post condition: The minter has the expected allowance. + /// Post condition: All irrelevant states are unchanged. + spec configure_minter { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let caller = signer::address_of(caller); + let minter = smart_table::spec_get(global(stablecoin_address).controllers, caller); + + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + + aborts_if !smart_table::spec_contains(global(stablecoin_address).controllers, caller); + + aborts_if global(stablecoin_address).paused; + + ensures !old(smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter)) ==> + smart_table::spec_len(global(stablecoin_address).mint_allowances) + == old(smart_table::spec_len(global(stablecoin_address).mint_allowances)) + 1; + + ensures old(smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter)) ==> + smart_table::spec_len(global(stablecoin_address).mint_allowances) + == old(smart_table::spec_len(global(stablecoin_address).mint_allowances)); + + ensures smart_table::spec_get(global(stablecoin_address).mint_allowances, minter) == allowance; + + ensures global(stablecoin_address).master_minter + == old(global(stablecoin_address)).master_minter; + ensures global(stablecoin_address).controllers + == old(global(stablecoin_address)).controllers; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The increment is zero. + /// Abort condition: The caller is not a controller. + /// Abort condition: The controller does not control a minter. + /// Abort condition: The allowance increment will cause an overflow. + /// Abort condition: The asset is paused. + /// Post condition: The list of minters remains unchanged. + /// Post condition: The minter's allowance is correctly updated. + /// Post condition: All irrelevant states are unchanged. + spec increment_minter_allowance { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let caller = signer::address_of(caller); + let minter = smart_table::spec_get(global(stablecoin_address).controllers, caller); + + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + + aborts_if allowance_increment == 0; + + aborts_if !smart_table::spec_contains(global(stablecoin_address).controllers, caller); + + aborts_if !smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter); + + aborts_if smart_table::spec_get(global(stablecoin_address).mint_allowances, minter) + + allowance_increment > MAX_U64; + + aborts_if global(stablecoin_address).paused; + + ensures smart_table::spec_len(global(stablecoin_address).mint_allowances) + == old(smart_table::spec_len(global(stablecoin_address).mint_allowances)); + + ensures smart_table::spec_get(global(stablecoin_address).mint_allowances, minter) + == old(smart_table::spec_get(global(stablecoin_address).mint_allowances, minter)) + + allowance_increment; + + ensures global(stablecoin_address).master_minter + == old(global(stablecoin_address)).master_minter; + ensures global(stablecoin_address).controllers + == old(global(stablecoin_address)).controllers; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The caller is not a controller. + /// Abort condition: The controller does not control a minter. + /// Post condition: Only one minter is removed. + /// Post condition: The minter address is removed. + /// Post condition: All irrelevant states are unchanged. + spec remove_minter { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let caller = signer::address_of(caller); + let minter = smart_table::spec_get(global(stablecoin_address).controllers, caller); + + aborts_if !exists(stablecoin_address); + + aborts_if !smart_table::spec_contains(global(stablecoin_address).controllers, caller); + + aborts_if !smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter); + + ensures old(smart_table::spec_len(global(stablecoin_address).mint_allowances)) + - smart_table::spec_len(global(stablecoin_address).mint_allowances) == 1; + + ensures !smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter); + + ensures global(stablecoin_address).master_minter + == old(global(stablecoin_address)).master_minter; + ensures global(stablecoin_address).controllers + == old(global(stablecoin_address)).controllers; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The asset is paused. + /// Abort condition: The caller is not a minter. + /// Abort condition: The caller is blocklisted. + /// Abort condition: Amount is not between [0, min(, MAX_U64)]. + /// [NOT PROVEN] Abort condition: The ConcurrentSupply feature is enabled, and aggregator_v2::try_add fails (overflow). + /// Abort condition: The ConcurrentSupply feature is disabled, there is no max supply, and minting causes integer overflow. + /// Abort condition: The ConcurrentSupply feature is disabled, there is a max supply, and minting exceeds the max supply. + /// Post condition: A FungibleAsset with the correct data is returned. + /// [PARTIAL] Post condition: Supply increases. + /// Post condition: The minter's allowance decreased by the mint amount. + /// Post condition: All other minters' allowances did not change. + /// Post condition: All irrelevant states are unchanged. + spec mint { + // There are some abort conditions that are unspecified due to technical + // limitations. + pragma aborts_if_is_partial; + + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let minter = signer::address_of(caller); + + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) && !exists(stablecoin_address); + + aborts_if global(stablecoin_address).paused; + + aborts_if !smart_table::spec_contains(global(stablecoin_address).mint_allowances, minter); + + aborts_if table_with_length::spec_contains(global(stablecoin_address).blocklist, minter); + + aborts_if amount <= 0; + aborts_if amount > smart_table::spec_get(global(stablecoin_address).mint_allowances, minter) + || amount > MAX_U64; + + // This abort condition cannot be specified due to technical limitations, but + // supply overflows have been generally unit tested. + // aborts_if exists(stablecoin_address) && aggregator_v2::try_add() == false; + + aborts_if !exists(stablecoin_address) + && exists(stablecoin_address) + && option::spec_is_none(global(stablecoin_address).maximum) + && (amount + global(stablecoin_address).current) > MAX_U128; + + aborts_if !exists(stablecoin_address) + && exists(stablecoin_address) + && option::spec_is_some(global(stablecoin_address).maximum) + && (amount + global(stablecoin_address).current) + > option::spec_borrow(global(stablecoin_address).maximum); + + ensures result + == FungibleAsset { + metadata: object::address_to_object(stablecoin_address), + amount + }; + + // This does not work, but supply updates have been generally unit tested. + // ensures exists(stablecoin_address) ==> aggregator_v2::read(global(stablecoin_address).current) + // == old(aggregator_v2::read(global(stablecoin_address).current)) + amount; + ensures !exists(stablecoin_address) && exists(stablecoin_address) ==> + global(stablecoin_address).current == old(global(stablecoin_address).current) + amount; + + ensures old(smart_table::spec_get(global(stablecoin_address).mint_allowances, minter)) + - smart_table::spec_get(global(stablecoin_address).mint_allowances, minter) == amount; + + ensures forall addr: address where addr != minter + && smart_table::spec_contains(global(stablecoin_address).mint_allowances, addr): + old(smart_table::spec_get(global(stablecoin_address).mint_allowances, addr)) + == smart_table::spec_get(global(stablecoin_address).mint_allowances, addr); + + ensures global(stablecoin_address).master_minter + == old(global(stablecoin_address)).master_minter; + ensures global(stablecoin_address).controllers + == old(global(stablecoin_address)).controllers; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The asset is paused. + /// Abort condition: The caller is not a burner. + /// Abort condition: The caller is blocklisted. + /// Abort condition: The amount to burn is <= 0. + /// Abort condition: The ConcurrentSupply feature is disabled, and the amount to burn exceed the current supply. + /// [NOT PROVEN] Abort condition: The ConcurrentSupply feature is enabled, and aggregator_v2::try_sub fails (underflow). + /// Abort condition: The BurnRef's metadata does not match the metadata of the FungibleAsset to burn. + /// Post condition: There are no changes to TreasuryState. + /// [PARTIAL] Post condition: Supply decreases. + spec burn { + // There are some abort conditions that are unspecified due to technical + // limitations. + pragma aborts_if_is_partial; + + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let burner = signer::address_of(caller); + let amount = fungible_asset::amount(asset); + + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) && !exists(stablecoin_address); + + aborts_if global(stablecoin_address).paused; + + aborts_if !smart_table::spec_contains(global(stablecoin_address).mint_allowances, burner); + + aborts_if table_with_length::spec_contains(global(stablecoin_address).blocklist, burner); + + aborts_if amount <= 0; + aborts_if !exists(stablecoin_address) + && exists(stablecoin_address) + && amount > global(stablecoin_address).current; + + // This abort condition cannot be specified due to technical limitations, but + // supply underflows have been generally unit tested. + // aborts_if exists(stablecoin_address) && aggregator_v2::try_sub() == false; + + aborts_if fungible_asset::burn_ref_metadata(global(stablecoin_address).burn_ref) + != fungible_asset::metadata_from_asset(asset); + + ensures global(stablecoin_address) == old(global(stablecoin_address)); + + // This does not work, but supply updates have been generally unit tested. + // ensures exists(stablecoin_address) ==> aggregator_v2::read(global(stablecoin_address).current) + // == old(aggregator_v2::read(global(stablecoin_address).current)) - amount; + ensures !exists(stablecoin_address) && exists(stablecoin_address) ==> + global(stablecoin_address).current == old(global(stablecoin_address).current) - amount; + } + + /// Abort condition: The required resources are missing. + /// Abort condition: The caller is not the owner. + /// Post condition: The master minter is updated. + /// Post condition: All irrelevant states are unchanged. + spec update_master_minter { + let stablecoin_address = stablecoin::stablecoin_utils::spec_stablecoin_address(); + let caller = signer::address_of(caller); + + aborts_if !exists(stablecoin_address); + aborts_if !exists(stablecoin_address) || !object::spec_exists_at(stablecoin_address); + aborts_if !exists(stablecoin_address); + + aborts_if caller != global(stablecoin_address).owner; + + ensures global(stablecoin_address).master_minter == new_master_minter; + + ensures global(stablecoin_address).controllers + == old(global(stablecoin_address)).controllers; + ensures global(stablecoin_address).mint_allowances + == old(global(stablecoin_address)).mint_allowances; + } +} diff --git a/packages/stablecoin/tests/treasury_tests.move b/packages/stablecoin/tests/treasury_tests.move new file mode 100644 index 0000000..636686f --- /dev/null +++ b/packages/stablecoin/tests/treasury_tests.move @@ -0,0 +1,683 @@ +// 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::event; + use std::option; + use aptos_framework::account::create_signer_for_test; + use aptos_framework::fungible_asset::{Self, FungibleAsset, Metadata}; + use aptos_framework::object::{Self, ConstructorRef, Object}; + + use aptos_extensions::ownable; + use aptos_extensions::pausable; + use aptos_extensions::test_utils::assert_eq; + use stablecoin::blocklistable; + use stablecoin::fungible_asset_tests::setup_fa; + use stablecoin::stablecoin_utils::stablecoin_address; + use stablecoin::treasury; + + const OWNER: address = @0x10; + const PAUSER: address = @0x20; + const BLOCKLISTER: address = @0x30; + const MASTER_MINTER: address = @0x40; + const CONTROLLER: address = @0x50; + const MINTER: address = @0x60; + const RANDOM_ADDRESS: address = @0x70; + + const U64_MAX: u64 = (1 << 63) | ((1 << 63) - 1); + + #[test] + fun master_minter__should_return_master_minter_address() { + setup(); + treasury::set_master_minter_for_testing(MASTER_MINTER); + + assert_eq(treasury::master_minter(), MASTER_MINTER); + } + + #[test] + fun get_minter__should_return_none_if_address_is_not_a_controller() { + setup(); + treasury::force_remove_controller_for_testing(CONTROLLER); + + assert_eq(treasury::get_minter(CONTROLLER), option::none()); + } + + #[test] + fun get_minter__should_return_controlled_minter() { + setup(); + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + + assert_eq(treasury::get_minter(CONTROLLER), option::some(MINTER)); + } + + #[test] + fun is_minter__should_return_false_if_not_minter() { + setup(); + treasury::force_remove_minter_for_testing(MINTER); + + assert_eq(treasury::is_minter(MINTER), false); + } + + #[test] + fun is_minter__should_return_true_if_minter() { + setup(); + treasury::force_configure_minter_for_testing(MINTER, 1_000_000); + + assert_eq(treasury::is_minter(MINTER), true); + } + + #[test] + fun mint_allowance__should_return_zero_if_address_is_not_a_minter() { + setup(); + treasury::force_remove_minter_for_testing(MINTER); + + assert_eq(treasury::mint_allowance(MINTER), 0); + } + + #[test] + fun mint_allowance__should_return_mint_allowance() { + setup(); + treasury::force_configure_minter_for_testing(MINTER, 1_000_000); + + assert_eq(treasury::mint_allowance(MINTER), 1_000_000); + } + + #[test] + fun new__should_succeed() { + let (stablecoin_obj_constructor_ref, _, _) = setup_fa(@stablecoin); + test_new(&stablecoin_obj_constructor_ref, MASTER_MINTER); + } + + #[test] + fun configure_controller__should_succeed_and_configure_new_controller() { + setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + assert_eq(treasury::is_controller_for_testing(CONTROLLER), false); + + test_configure_controller(MASTER_MINTER, CONTROLLER, MINTER); + } + + #[test] + fun configure_controller__should_succeed_and_update_existing_controller() { + setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + treasury::force_configure_controller_for_testing(CONTROLLER, RANDOM_ADDRESS); + assert_eq(treasury::get_minter(CONTROLLER), option::some(RANDOM_ADDRESS)); + + test_configure_controller(MASTER_MINTER, CONTROLLER, MINTER); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_MASTER_MINTER)] + fun configure_controller__should_fail_if_caller_is_not_master_minter() { + setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + + treasury::test_configure_controller( + &create_signer_for_test(RANDOM_ADDRESS), + CONTROLLER, + MINTER, + ); + } + + #[test] + fun remove_controller__should_succeed() { + setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + + treasury::test_remove_controller( + &create_signer_for_test(MASTER_MINTER), + CONTROLLER, + ); + + let expected_event = + treasury::test_ControllerRemoved_event(CONTROLLER); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(treasury::get_minter(CONTROLLER), option::none()); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_MASTER_MINTER)] + fun remove_controller__should_fail_if_caller_is_not_master_minter() { + setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + + treasury::test_remove_controller( + &create_signer_for_test(RANDOM_ADDRESS), + CONTROLLER, + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_CONTROLLER)] + fun remove_controller__should_fail_if_controller_is_unset() { + setup(); + + treasury::set_master_minter_for_testing(MASTER_MINTER); + assert_eq(treasury::is_controller_for_testing(CONTROLLER), false); + + treasury::test_remove_controller( + &create_signer_for_test(MASTER_MINTER), + CONTROLLER, + ); + } + + #[test] + fun configure_minter__should_succeed_and_configure_new_minter() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + assert_eq(treasury::is_minter(MINTER), false); + + test_configure_minter(CONTROLLER, MINTER, 1_000_000); + } + + #[test] + fun configure_minter__should_succeed_and_update_existing_minter() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + test_configure_minter(CONTROLLER, MINTER, 1_000_000); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + fun configure_minter__should_fail_when_paused() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + pausable::set_paused_for_testing(stablecoin_address(), true); + + treasury::test_configure_minter( + &create_signer_for_test(CONTROLLER), + 1_000_000, + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_CONTROLLER)] + fun configure_minter__should_fail_if_caller_is_not_controller() { + setup(); + + assert_eq(treasury::is_controller_for_testing(CONTROLLER), false); + + treasury::test_configure_minter( + &create_signer_for_test(CONTROLLER), + 1_000_000, + ); + } + + #[test] + fun increment_minter_allowance__should_succeed() { + setup(); + let initial_allowance = 100_000_000; + let allowance_increment = 1_000_000; + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + treasury::force_configure_minter_for_testing(MINTER, initial_allowance); + + treasury::test_increment_minter_allowance( + &create_signer_for_test(CONTROLLER), + allowance_increment, + ); + + let new_allowance = initial_allowance + allowance_increment; + let expected_event = + treasury::test_MinterAllowanceIncremented_event( + CONTROLLER, + MINTER, + allowance_increment, + new_allowance, + ); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(treasury::mint_allowance(MINTER), new_allowance); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + fun increment_minter_allowance__should_fail_when_paused() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + pausable::set_paused_for_testing(stablecoin_address(), true); + + treasury::test_increment_minter_allowance( + &create_signer_for_test(CONTROLLER), + 1_000_000, + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::EZERO_AMOUNT)] + fun increment_minter_allowance__should_fail_if_increment_is_zero() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + treasury::test_increment_minter_allowance( + &create_signer_for_test(CONTROLLER), 0 + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_CONTROLLER)] + fun increment_minter_allowance__should_fail_if_caller_is_not_controller() { + setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + assert_eq(treasury::is_controller_for_testing(CONTROLLER), false); + + treasury::test_increment_minter_allowance( + &create_signer_for_test(CONTROLLER), + 1_000_000, + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_MINTER)] + fun increment_minter_allowance__should_fail_if_minter_is_not_configured() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + assert_eq(treasury::is_minter(MINTER), false); + + treasury::test_increment_minter_allowance( + &create_signer_for_test(CONTROLLER), + 1_000_000, + ); + } + + #[test, expected_failure(arithmetic_error, location = stablecoin::treasury)] + fun increment_minter_allowance__should_fail_if_overflow() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + treasury::force_configure_minter_for_testing(MINTER, U64_MAX); + + treasury::test_increment_minter_allowance( + &create_signer_for_test(CONTROLLER), 1 + ); + } + + #[test] + fun remove_minter__should_succeed() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + treasury::test_remove_minter( + &create_signer_for_test(CONTROLLER) + ); + + let expected_event = + treasury::test_MinterRemoved_event(CONTROLLER, MINTER); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(treasury::is_minter(MINTER), false); + assert_eq(treasury::mint_allowance(MINTER), 0); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_CONTROLLER)] + fun remove_minter__should_fail_if_caller_is_not_controller() { + setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + assert_eq(treasury::is_controller_for_testing(CONTROLLER), false); + + treasury::test_remove_minter( + &create_signer_for_test(CONTROLLER) + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_MINTER)] + fun remove_minter__should_fail_if_minter_is_unset() { + setup(); + + treasury::force_configure_controller_for_testing(CONTROLLER, MINTER); + assert_eq(treasury::is_minter(MINTER), false); + + treasury::test_remove_minter( + &create_signer_for_test(CONTROLLER) + ); + } + + #[test] + fun mint__should_succeed_and_return_minted_asset() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + let asset = + test_mint( + MINTER, + 1_000_000, /* mint amount */ + 1_000_000, /* expected total supply */ + 100_000_000 - 1_000_000, /* expected mint allowance */ + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test] + fun mint__should_succeed_given_mint_amount_is_equal_to_allowance() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + let asset = + test_mint( + MINTER, + 100_000_000, /* mint amount */ + 100_000_000, /* expected total supply */ + 0, /* expected mint allowance */ + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::EZERO_AMOUNT)] + fun mint__should_fail_if_mint_amount_is_zero() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + let asset = + treasury::mint( + &create_signer_for_test(MINTER), 0 + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + fun mint__should_fail_when_paused() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + pausable::set_paused_for_testing(stablecoin_address(), true); + + let asset = + treasury::mint( + &create_signer_for_test(MINTER), + 1_000_000, + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_MINTER)] + fun mint__should_fail_if_caller_is_not_minter() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + assert_eq(treasury::is_minter(MINTER), false); + + let asset = + treasury::mint( + &create_signer_for_test(MINTER), + 1_000_000, + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test, expected_failure(abort_code = stablecoin::blocklistable::EBLOCKLISTED)] + fun mint__should_fail_if_caller_is_blocklisted() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + blocklistable::set_blocklisted_for_testing(MINTER, true); + + let asset = + treasury::mint( + &create_signer_for_test(MINTER), + 1_000_000, + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::EINSUFFICIENT_ALLOWANCE)] + fun mint__should_fail_if_minter_has_insufficient_allowance() { + let (stablecoin_obj_constructor_ref, _) = setup(); + + treasury::force_configure_minter_for_testing(MINTER, 100_000_000); + + let asset = + treasury::mint( + &create_signer_for_test(MINTER), + 100_000_001, + ); + destroy_fungible_asset(&stablecoin_obj_constructor_ref, asset); + } + + #[test] + fun burn__should_succeed() { + let (stablecoin_obj_constructor_ref, stablecoin_metadata) = setup(); + let mint_ref = + fungible_asset::generate_mint_ref(&stablecoin_obj_constructor_ref); + + treasury::force_configure_minter_for_testing(MINTER, 0); + let asset = fungible_asset::mint(&mint_ref, 1_000_000); + + assert_eq(fungible_asset::amount(&asset), 1_000_000); + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some((1_000_000 as u128))); + + treasury::burn( + &create_signer_for_test(MINTER), asset + ); + + let expected_event = treasury::test_Burn_event(MINTER, 1_000_000); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some((0 as u128))); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::EZERO_AMOUNT)] + fun burn__should_fail_if_burn_amount_is_zero() { + let (stablecoin_obj_constructor_ref, _) = setup(); + let mint_ref = + fungible_asset::generate_mint_ref(&stablecoin_obj_constructor_ref); + + treasury::force_configure_minter_for_testing(MINTER, 0); + let asset = fungible_asset::mint(&mint_ref, 0); + assert_eq(fungible_asset::amount(&asset), 0); + + treasury::burn( + &create_signer_for_test(MINTER), asset + ); + } + + #[test, expected_failure(abort_code = aptos_extensions::pausable::EPAUSED)] + fun burn__should_fail_when_paused() { + let (stablecoin_obj_constructor_ref, _) = setup(); + let mint_ref = + fungible_asset::generate_mint_ref(&stablecoin_obj_constructor_ref); + + treasury::force_configure_minter_for_testing(MINTER, 0); + let asset = fungible_asset::mint(&mint_ref, 1_000_000); + assert_eq(fungible_asset::amount(&asset), 1_000_000); + pausable::set_paused_for_testing(stablecoin_address(), true); + + treasury::burn( + &create_signer_for_test(MINTER), asset + ); + } + + #[test, expected_failure(abort_code = stablecoin::treasury::ENOT_MINTER)] + fun burn__should_fail_if_caller_is_not_minter() { + let (stablecoin_obj_constructor_ref, _) = setup(); + let mint_ref = + fungible_asset::generate_mint_ref(&stablecoin_obj_constructor_ref); + + let asset = fungible_asset::mint(&mint_ref, 1_000_000); + assert_eq(fungible_asset::amount(&asset), 1_000_000); + assert_eq(treasury::is_minter(MINTER), false); + + treasury::burn( + &create_signer_for_test(MINTER), asset + ); + } + + #[test, expected_failure(abort_code = stablecoin::blocklistable::EBLOCKLISTED)] + fun burn__should_fail_if_caller_is_blocklisted() { + let (stablecoin_obj_constructor_ref, _) = setup(); + let mint_ref = + fungible_asset::generate_mint_ref(&stablecoin_obj_constructor_ref); + + treasury::force_configure_minter_for_testing(MINTER, 0); + let asset = fungible_asset::mint(&mint_ref, 1_000_000); + assert_eq(fungible_asset::amount(&asset), 1_000_000); + blocklistable::set_blocklisted_for_testing(MINTER, true); + + treasury::burn( + &create_signer_for_test(MINTER), asset + ); + } + + #[test] + fun update_master_minter__should_update_role_to_different_address() { + setup(); + let caller = &create_signer_for_test(OWNER); + + test_update_master_minter(caller, OWNER, RANDOM_ADDRESS); + } + + #[test] + fun update_master_minter__should_succeed_with_same_address() { + setup(); + let caller = &create_signer_for_test(OWNER); + + test_update_master_minter(caller, OWNER, OWNER); + } + + #[test, expected_failure(abort_code = aptos_extensions::ownable::ENOT_OWNER)] + fun update_master_minter__should_fail_if_caller_is_not_owner() { + setup(); + let caller = &create_signer_for_test(RANDOM_ADDRESS); + + test_update_master_minter(caller, OWNER, RANDOM_ADDRESS); + } + + // === Helpers === + + fun setup(): (ConstructorRef, Object) { + let (stablecoin_obj_constructor_ref, stablecoin_metadata, _) = setup_fa(@stablecoin); + test_new(&stablecoin_obj_constructor_ref, MASTER_MINTER); + (stablecoin_obj_constructor_ref, stablecoin_metadata) + } + + fun test_new( + stablecoin_obj_constructor_ref: &ConstructorRef, master_minter: address, + ) { + let stablecoin_signer = object::generate_signer(stablecoin_obj_constructor_ref); + let stablecoin_address = + object::address_from_constructor_ref(stablecoin_obj_constructor_ref); + let stablecoin_metadata = object::address_to_object(stablecoin_address); + + ownable::new(&stablecoin_signer, OWNER); + pausable::new(&stablecoin_signer, PAUSER); + blocklistable::new_for_testing(stablecoin_obj_constructor_ref, BLOCKLISTER); + treasury::new_for_testing(stablecoin_obj_constructor_ref, master_minter); + + assert_eq(treasury::mint_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(treasury::burn_ref_metadata_for_testing(), stablecoin_metadata); + assert_eq(treasury::master_minter(), master_minter); + assert_eq(treasury::num_controllers_for_testing(), 0); + assert_eq(treasury::num_mint_allowances_for_testing(), 0); + } + + fun test_configure_controller( + master_minter: address, + controller: address, + minter: address + ) { + treasury::test_configure_controller( + &create_signer_for_test(master_minter), + controller, + minter, + ); + + let expected_event = + treasury::test_ControllerConfigured_event( + controller, minter + ); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(treasury::get_minter(controller), option::some(minter)); + } + + fun test_configure_minter( + controller: address, + minter: address, + allowance: u64 + ) { + treasury::test_configure_minter( + &create_signer_for_test(controller), + allowance, + ); + + let expected_event = + treasury::test_MinterConfigured_event( + controller, + minter, + allowance, + ); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(treasury::is_minter(minter), true); + assert_eq(treasury::mint_allowance(minter), allowance); + } + + fun test_mint( + minter: address, + amount: u64, + expected_total_supply: u128, + expected_mint_allowance: u64 + ): FungibleAsset { + let asset = + treasury::mint( + &create_signer_for_test(minter), amount + ); + + let stablecoin_metadata = object::address_to_object(stablecoin_address()); + + assert_eq(fungible_asset::amount(&asset), amount); + assert_eq(fungible_asset::metadata_from_asset(&asset), stablecoin_metadata); + + let expected_event = treasury::test_Mint_event(minter, amount); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(fungible_asset::supply(stablecoin_metadata), option::some(expected_total_supply)); + assert_eq(treasury::mint_allowance(minter), expected_mint_allowance); + + asset + } + + fun test_update_master_minter( + caller: &signer, + old_master_minter: address, + new_master_minter: address + ) { + treasury::set_master_minter_for_testing(old_master_minter); + + treasury::test_update_master_minter(caller, new_master_minter); + + let expected_event = + treasury::test_MasterMinterChanged_event( + old_master_minter, new_master_minter + ); + assert_eq(event::was_event_emitted(&expected_event), true); + assert_eq(treasury::master_minter(), new_master_minter); + } + + fun destroy_fungible_asset(constructor_ref: &ConstructorRef, asset: FungibleAsset) { + let burn_ref = fungible_asset::generate_burn_ref(constructor_ref); + fungible_asset::burn(&burn_ref, asset); + } +} diff --git a/scripts/shell/setup.sh b/scripts/shell/setup.sh new file mode 100644 index 0000000..e16d809 --- /dev/null +++ b/scripts/shell/setup.sh @@ -0,0 +1,69 @@ +#!/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. + +echo ">> Setting up environment" + +# ==== Aptos installation ==== +APTOS_CLI_VERSION="4.2.6" + +if [ "$CI" == true ]; then + curl -sSfL -o /tmp/aptos.zip "https://github.com/aptos-labs/aptos-core/releases/download/aptos-cli-v$APTOS_CLI_VERSION/aptos-cli-$APTOS_CLI_VERSION-Ubuntu-22.04-x86_64.zip" + sudo unzip /tmp/aptos.zip -d /usr/local/bin + sudo chmod +x /usr/local/bin/* +else + if [ "$(brew ls --versions aptos)" != "aptos $APTOS_CLI_VERSION" ]; then + brew uninstall --force aptos && \ + # aptos 4.2.6's formula + curl -s -o aptos.rb https://raw.githubusercontent.com/Homebrew/homebrew-core/ef458cb0a2574eb7d451090cbedc3942b77a7284/Formula/a/aptos.rb + brew install --formula aptos.rb + brew pin aptos + rm aptos.rb + fi +fi + +# ==== Movefmt & Move prover installation ==== +if [ -z $APTOS_BIN ] +then + aptos update movefmt + aptos update prover-dependencies +else + aptos update movefmt --install-dir $APTOS_BIN + aptos update prover-dependencies --install-dir $APTOS_BIN +fi + + +# ==== Yarn Installation ==== +YARN_VERSION="^1.x.x" +YARN_VERSION_REGEX="^1\..*\..*" + +if ! command -v yarn &> /dev/null || ! yarn --version | grep -q "$YARN_VERSION_REGEX" +then + echo "Installing yarn..." + npm install -g "yarn@$YARN_VERSION" + + # Sanity check that yarn was installed correctly + echo "Checking yarn installation..." + if ! yarn --version | grep -q "$YARN_VERSION_REGEX" + then + echo "Yarn was not installed correctly" + exit 1 + fi +fi + +# ==== NPM Packages Installation ==== +yarn install --frozen-lockfile -s diff --git a/scripts/shell/start_network.sh b/scripts/shell/start_network.sh new file mode 100644 index 0000000..c770dfb --- /dev/null +++ b/scripts/shell/start_network.sh @@ -0,0 +1,47 @@ +#!/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. + +LOG_FILE="$PWD/aptos-node.log" + +aptos node run-local-testnet --force-restart --with-indexer-api --assume-yes &> $LOG_FILE & + +WAIT_TIME=120 +echo ">> Waiting for Aptos node to come online within $WAIT_TIME seconds..." + +ELAPSED=0 +SECONDS=0 +while [[ "$ELAPSED" -lt "$WAIT_TIME" ]] +do + HEALTHCHECK_STATUS_CODE="$(curl -k -s -o /dev/null -w %{http_code} http://localhost:8070)" + if [[ "$HEALTHCHECK_STATUS_CODE" -eq 200 ]] + then + echo ">> Aptos node is started after $ELAPSED seconds!" + cat $LOG_FILE + echo ">> Opening explorer at https://explorer.aptoslabs.com/?network=local" + open https://explorer.aptoslabs.com/?network=local + exit 0 + fi + + if [[ $(( ELAPSED % 5 )) == 0 && "$ELAPSED" > 0 ]] + then + echo ">> Waiting for Aptos node for $ELAPSED seconds.." + fi + + sleep 1 + ELAPSED=$SECONDS +done diff --git a/scripts/shell/stop_network.sh b/scripts/shell/stop_network.sh new file mode 100644 index 0000000..260f9b6 --- /dev/null +++ b/scripts/shell/stop_network.sh @@ -0,0 +1,36 @@ +#!/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. + +LOG_FILE="$PWD/aptos-node.log" +if [ -f "$LOG_FILE" ] +then + rm "$LOG_FILE" +fi + +# Find the PID of the node using the lsof command +# -t = only return port number +# -c aptos = where command name is 'aptos' +# -a = +# -i:8080 = where the port is '8080' +PID=$(lsof -t -c aptos -a -i:8080 || true) + +if [ ! -z "$PID" ] +then + echo "Stopping network at pid: $PID..." + kill "$PID" &>/dev/null +fi diff --git a/scripts/typescript/acceptAdmin.ts b/scripts/typescript/acceptAdmin.ts new file mode 100644 index 0000000..1821a5d --- /dev/null +++ b/scripts/typescript/acceptAdmin.ts @@ -0,0 +1,70 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; +import { getAptosClient, validateAddresses } from "./utils"; + +export default program + .createCommand("accept-admin") + .description("Completes the two-step admin transfer for the package") + .requiredOption( + "--aptos-extensions-package-id ", + "The address where the aptos_extensions package is located." + ) + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--new-admin-key ", "New admin's private key") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(acceptAdmin); + +export async function acceptAdmin({ + aptosExtensionsPackageId, + stablecoinPackageId, + newAdminKey, + rpcUrl +}: { + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + newAdminKey: string; + rpcUrl: string; +}) { + validateAddresses(aptosExtensionsPackageId, stablecoinPackageId); + + const aptos = getAptosClient(rpcUrl); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + + const newAdmin = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(newAdminKey) + }); + + console.log( + `Accepting the Admin role transfer to ${newAdmin.accountAddress.toString()}...` + ); + + await aptosExtensionsPackage.manageable.acceptAdmin( + newAdmin, + stablecoinPackageId + ); +} diff --git a/scripts/typescript/acceptOwnership.ts b/scripts/typescript/acceptOwnership.ts new file mode 100644 index 0000000..d7512ba --- /dev/null +++ b/scripts/typescript/acceptOwnership.ts @@ -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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { getAptosClient, validateAddresses } from "./utils"; + +export default program + .createCommand("accept-ownership") + .description("Completes the two-step ownership transfer for the stablecoin") + .requiredOption( + "--aptos-extensions-package-id ", + "The address where the aptos_extensions package is located." + ) + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--new-owner-key ", "New owner's private key") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(acceptOwnership); + +export async function acceptOwnership({ + aptosExtensionsPackageId, + stablecoinPackageId, + newOwnerKey, + rpcUrl +}: { + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + newOwnerKey: string; + rpcUrl: string; +}) { + validateAddresses(aptosExtensionsPackageId, stablecoinPackageId); + + const aptos = getAptosClient(rpcUrl); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const newOwner = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(newOwnerKey) + }); + + console.log( + `Accepting the Owner role transfer to ${newOwner.accountAddress.toString()}...` + ); + + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + await aptosExtensionsPackage.ownable.acceptOwnership( + newOwner, + stablecoinAddress + ); +} diff --git a/scripts/typescript/calculateDeploymentAddresses.ts b/scripts/typescript/calculateDeploymentAddresses.ts new file mode 100644 index 0000000..97dc726 --- /dev/null +++ b/scripts/typescript/calculateDeploymentAddresses.ts @@ -0,0 +1,78 @@ +/** + * 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. + */ + +import { + AccountAddress, + createObjectAddress, + createResourceAddress +} from "@aptos-labs/ts-sdk"; +import { program } from "commander"; + +export default program + .createCommand("calculate-deployment-addresses") + .description( + "Calculate the addresses that the packages will be deployed to given some provided seed" + ) + .requiredOption("--deployer ", "Deployer address") + .requiredOption( + "--aptos-extensions-seed ", + "The deployment seed for the aptos_extensions package" + ) + .requiredOption( + "--stablecoin-seed ", + "The deployment seed for the stablecoin package" + ) + .action((options) => { + const { aptosExtensionsPackageId, stablecoinPackageId, stablecoinAddress } = + calculateDeploymentAddresses(options); + + console.log(`aptos_extensions package ID: ${aptosExtensionsPackageId}`); + console.log(`stablecoin package ID: ${stablecoinPackageId}`); + console.log(`stablecoin address: ${stablecoinAddress}`); + }); + +export function calculateDeploymentAddresses({ + deployer, + aptosExtensionsSeed, + stablecoinSeed +}: { + deployer: string; + aptosExtensionsSeed: string; + stablecoinSeed: string; +}) { + const aptosExtensionsPackageId = createResourceAddress( + AccountAddress.fromStrict(deployer), + new Uint8Array(Buffer.from(aptosExtensionsSeed)) + ); + + const stablecoinPackageId = createResourceAddress( + AccountAddress.fromStrict(deployer), + new Uint8Array(Buffer.from(stablecoinSeed)) + ); + + const stablecoinAddress = createObjectAddress( + stablecoinPackageId, + new Uint8Array(Buffer.from("stablecoin")) + ); + + return { + aptosExtensionsPackageId: aptosExtensionsPackageId.toString(), + stablecoinPackageId: stablecoinPackageId.toString(), + stablecoinAddress: stablecoinAddress.toString() + }; +} diff --git a/scripts/typescript/changeAdmin.ts b/scripts/typescript/changeAdmin.ts new file mode 100644 index 0000000..e17e111 --- /dev/null +++ b/scripts/typescript/changeAdmin.ts @@ -0,0 +1,81 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("change-admin") + .description("Starts a two-step admin transfer for the package") + .requiredOption( + "--aptos-extensions-package-id ", + "The address where the aptos_extensions package is located." + ) + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--admin-key ", "Admin's private key") + .requiredOption("--new-admin ", "The new admin's address") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(changeAdmin); + +export async function changeAdmin({ + aptosExtensionsPackageId, + stablecoinPackageId, + adminKey, + newAdmin, + rpcUrl +}: { + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + adminKey: string; + newAdmin: string; + rpcUrl: string; +}) { + validateAddresses(aptosExtensionsPackageId, stablecoinPackageId, newAdmin); + + const aptos = getAptosClient(rpcUrl); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + + const admin = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(adminKey) + }); + + console.log( + `Starting the Admin role transfer from ${admin.accountAddress.toString()} to ${newAdmin}...` + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await aptosExtensionsPackage.manageable.changeAdmin( + admin, + stablecoinPackageId, + newAdmin + ); +} diff --git a/scripts/typescript/configureController.ts b/scripts/typescript/configureController.ts new file mode 100644 index 0000000..6c075ad --- /dev/null +++ b/scripts/typescript/configureController.ts @@ -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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; + +export default program + .createCommand("configure-controller") + .description("Configures a controller") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--master-minter-key ", "Master Minter's private key") + .requiredOption("--controller ", "The controller's address") + .requiredOption("--minter ", "The minter's address") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(configureController); + +export async function configureController({ + stablecoinPackageId, + masterMinterKey, + controller, + minter, + rpcUrl +}: { + stablecoinPackageId: string; + masterMinterKey: string; + controller: string; + minter: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId, controller, minter); + + const aptos = getAptosClient(rpcUrl); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const masterMinter = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(masterMinterKey) + }); + + console.log(`Configuring controller ${controller} for minter ${minter}...`); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.treasury.configureController( + masterMinter, + controller, + minter + ); +} diff --git a/scripts/typescript/configureMinter.ts b/scripts/typescript/configureMinter.ts new file mode 100644 index 0000000..3770858 --- /dev/null +++ b/scripts/typescript/configureMinter.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { AptosFrameworkPackage } from "./packages/aptosFrameworkPackage"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + getAptosClient, + MAX_U64, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("configure-minter") + .description("Configures a minter") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption( + "--controller-key ", + "Minter's controller private key" + ) + .requiredOption( + "--mint-allowance ", + "The mint allowance (in subunits) to set" + ) + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(configureMinter); + +export async function configureMinter({ + stablecoinPackageId, + controllerKey, + mintAllowance, + rpcUrl +}: { + stablecoinPackageId: string; + controllerKey: string; + mintAllowance: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId); + if (BigInt(mintAllowance) > MAX_U64) { + throw new Error("Mint allowance exceeds MAX_U64"); + } + + const aptos = getAptosClient(rpcUrl); + const aptosFrameworkPackage = new AptosFrameworkPackage(aptos); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const controller = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(controllerKey) + }); + const minter = await stablecoinPackage.treasury.getMinter( + controller.accountAddress + ); + + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + const decimals = + await aptosFrameworkPackage.fungibleAsset.getDecimals(stablecoinAddress); + + console.log( + `Setting minter ${minter} allowance to $${BigInt(mintAllowance) / BigInt(10 ** decimals)}...` + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.treasury.configureMinter( + controller, + BigInt(mintAllowance) + ); +} diff --git a/scripts/typescript/deployAndInitializeToken.ts b/scripts/typescript/deployAndInitializeToken.ts new file mode 100644 index 0000000..e985c04 --- /dev/null +++ b/scripts/typescript/deployAndInitializeToken.ts @@ -0,0 +1,229 @@ +/** + * 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. + */ + +import { + Account, + Aptos, + Ed25519Account, + Ed25519PrivateKey +} from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { inspect } from "util"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { getAptosClient, waitForUserConfirmation } from "./utils"; +import { publishPackageToResourceAccount } from "./utils/deployUtils"; +import { readTokenConfig, TokenConfig } from "./utils/tokenConfig"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; + +export default program + .createCommand("deploy-and-initialize-token") + .description("Deploy all packages and initialize the token") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .requiredOption("--deployer-key ", "Deployer private key") + .requiredOption("--token-config-path ", "Path to token config file") + .option( + "--verify-source", + "Whether source code verification is enabled", + false + ) + .action(async (options) => { + await deployAndInitializeToken(options); + }); + +export async function deployAndInitializeToken({ + rpcUrl, + deployerKey, + tokenConfigPath, + verifySource +}: { + rpcUrl: string; + deployerKey: string; + tokenConfigPath: string; + verifySource?: boolean; +}): Promise<{ + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + stablecoinAddress: string; +}> { + const aptos = getAptosClient(rpcUrl); + + const deployer = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(deployerKey) + }); + console.log(`Deployer account: ${deployer.accountAddress}`); + + const tokenConfig = readTokenConfig(tokenConfigPath); + console.log( + `Creating stablecoin with config`, + inspect(tokenConfig, false, 8, true) + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + console.log("Publishing packages..."); + const { aptosExtensionsPackageId, stablecoinPackageId } = + await publishPackages(aptos, deployer, !!verifySource); + + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + + console.log( + `Deployed aptos_extensions package at ${aptosExtensionsPackageId}` + ); + console.log(`Deployed stablecoin package at ${stablecoinPackageId}`); + console.log( + `Stablecoin object for ${tokenConfig.symbol} created at ${stablecoinAddress}` + ); + + console.log("Initializing stablecoin"); + await initializeStablecoin( + aptosExtensionsPackage, + stablecoinPackage, + stablecoinAddress, + deployer, + tokenConfig + ); + console.log(`Stablecoin initialized for ${tokenConfig.symbol}`); + + return { + aptosExtensionsPackageId, + stablecoinPackageId, + stablecoinAddress + }; +} + +async function publishPackages( + aptos: Aptos, + deployer: Ed25519Account, + verifySource: boolean +) { + const [aptosExtensionsPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed: new Uint8Array(Buffer.from("aptos_extensions")), + namedDeps: [ + { name: "deployer", address: deployer.accountAddress.toString() } + ], + verifySource + }); + + const [stablecoinPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "stablecoin", + namedDeps: [ + { name: "aptos_extensions", address: aptosExtensionsPackageId }, + { name: "deployer", address: deployer.accountAddress.toString() } + ], + seed: new Uint8Array(Buffer.from("stablecoin")), + verifySource + }); + + return { aptosExtensionsPackageId, stablecoinPackageId }; +} + +async function initializeStablecoin( + aptosExtensionsPackage: AptosExtensionsPackage, + stablecoinPackage: StablecoinPackage, + stablecoinAddress: string, + deployer: Ed25519Account, + tokenConfig: TokenConfig +) { + // Initialize the stablecoin. + await stablecoinPackage.stablecoin.initializeV1( + deployer, + tokenConfig.name, + tokenConfig.symbol, + tokenConfig.decimals, + tokenConfig.iconUri, + tokenConfig.projectUri + ); + + // Configure the minters. + for (const [minter, mintAllowance] of Object.entries(tokenConfig.minters)) { + // Configure deployer as the temporary controller for the minter. + await stablecoinPackage.treasury.configureController( + deployer, + deployer.accountAddress, + minter + ); + + // Configure the minter. + await stablecoinPackage.treasury.configureMinter( + deployer, + BigInt(mintAllowance) + ); + } + + // Remove the deployer from the controllers list, if it was configured as a temporary controller. + if (Object.keys(tokenConfig.minters).length > 0) { + await stablecoinPackage.treasury.removeController( + deployer, + deployer.accountAddress + ); + } + + // Configure the controllers. + for (const [controller, minter] of Object.entries(tokenConfig.controllers)) { + await stablecoinPackage.treasury.configureController( + deployer, + controller, + minter + ); + } + + // Rotate privileged roles to the addresses defined in the config. + await stablecoinPackage.treasury.updateMasterMinter( + deployer, + tokenConfig.masterMinter + ); + await stablecoinPackage.blocklistable.updateBlocklister( + deployer, + tokenConfig.blocklister + ); + await stablecoinPackage.metadata.updateMetadataUpdater( + deployer, + tokenConfig.metadataUpdater + ); + await aptosExtensionsPackage.pausable.updatePauser( + deployer, + stablecoinAddress, + tokenConfig.pauser + ); + + // Start the two-step ownership and admin transfer. + // Note that the recipients of these roles will need to separately + // submit a transaction that accepts these roles. + await aptosExtensionsPackage.ownable.transferOwnership( + deployer, + stablecoinAddress, + tokenConfig.owner + ); + await aptosExtensionsPackage.manageable.changeAdmin( + deployer, + stablecoinPackage.id, + tokenConfig.admin + ); +} diff --git a/scripts/typescript/generateKeypair.ts b/scripts/typescript/generateKeypair.ts new file mode 100644 index 0000000..2e31b99 --- /dev/null +++ b/scripts/typescript/generateKeypair.ts @@ -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. + */ + +import { Account, Aptos, AptosApiType } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { getAptosClient } from "./utils"; + +export default program + .createCommand("generate-keypair") + .description("Generate a new Aptos keypair") + .option("--prefund", "Fund generated signer with some test Aptos token") + .option( + "-r, --rpc-url ", + "Network RPC URL, required when prefund is enabled" + ) + .option( + "--faucet-url ", + "Faucet URL, required when prefund is enabled" + ) + .action(async (options) => { + const keypair = await generateKeypair(options); + console.log("Account address:", keypair.accountAddress.toString()); + console.log("Public key:", keypair.publicKey.toString()); + console.log("Secret key:", keypair.privateKey.toString()); + }); + +export async function generateKeypair(options: { + rpcUrl?: string; + faucetUrl?: string; + prefund?: boolean; +}) { + const keypair = Account.generate(); + + if (options.prefund) { + let aptos: Aptos; + + if (!options.rpcUrl || !options.faucetUrl) { + console.log("Defaulting to local environment..."); + aptos = getAptosClient(); + } else { + aptos = getAptosClient(options.rpcUrl, options.faucetUrl); + } + + console.log( + `Requesting test tokens from ${aptos.config.getRequestUrl(AptosApiType.FAUCET)}...` + ); + + await aptos.fundAccount({ + accountAddress: keypair.accountAddress, + amount: 10 * 10 ** 8, // Max. 10 APT, actual amount received depends on the environment. + options: { waitForIndexer: false } + }); + + const accountBalance = await aptos.getAccountAPTAmount({ + accountAddress: keypair.accountAddress + }); + + console.log( + `Funded address ${keypair.accountAddress.toString()} with ${accountBalance / 10 ** 8} APT` + ); + } + + return keypair; +} diff --git a/scripts/typescript/index.ts b/scripts/typescript/index.ts new file mode 100644 index 0000000..5bf443b --- /dev/null +++ b/scripts/typescript/index.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +import { program } from "commander"; + +import acceptAdmin from "./acceptAdmin"; +import acceptOwnership from "./acceptOwnership"; +import calculateDeploymentAddresses from "./calculateDeploymentAddresses"; +import changeAdmin from "./changeAdmin"; +import configureController from "./configureController"; +import configureMinter from "./configureMinter"; +import deployAndInitializeToken from "./deployAndInitializeToken"; +import generateKeypair from "./generateKeypair"; +import removeController from "./removeController"; +import removeMinter from "./removeMinter"; +import transferOwnership from "./transferOwnership"; +import updateBlocklister from "./updateBlocklister"; +import updateMasterMinter from "./updateMasterMinter"; +import updateMetadataUpdater from "./updateMetadataUpdater"; +import updatePauser from "./updatePauser"; +import upgradeStablecoinPackage from "./upgradeStablecoinPackage"; +import validateStablecoinState from "./validateStablecoinState"; +import verifyPackage from "./verifyPackage"; +import verifyV1Packages from "./verifyV1Packages"; + +program + .name("scripts") + .description("Scripts related to Aptos development") + .addCommand(acceptAdmin) + .addCommand(acceptOwnership) + .addCommand(calculateDeploymentAddresses) + .addCommand(changeAdmin) + .addCommand(configureController) + .addCommand(configureMinter) + .addCommand(deployAndInitializeToken) + .addCommand(generateKeypair) + .addCommand(removeController) + .addCommand(removeMinter) + .addCommand(transferOwnership) + .addCommand(updateBlocklister) + .addCommand(updateMasterMinter) + .addCommand(updateMetadataUpdater) + .addCommand(updatePauser) + .addCommand(upgradeStablecoinPackage) + .addCommand(validateStablecoinState) + .addCommand(verifyPackage) + .addCommand(verifyV1Packages); + +if (process.env.NODE_ENV !== "TESTING") { + program.parse(); +} diff --git a/scripts/typescript/packages/aptosExtensionsPackage.ts b/scripts/typescript/packages/aptosExtensionsPackage.ts new file mode 100644 index 0000000..2feeafc --- /dev/null +++ b/scripts/typescript/packages/aptosExtensionsPackage.ts @@ -0,0 +1,256 @@ +/** + * 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. + */ + +import { + AccountAddress, + AccountAddressInput, + Aptos, + Ed25519Account, + HexInput, + MoveVector, + UserTransactionResponse +} from "@aptos-labs/ts-sdk"; +import { + callViewFunction, + executeTransaction, + normalizeAddress, + validateAddresses +} from "../utils"; + +export class AptosExtensionsPackage { + readonly id: AccountAddressInput; + readonly upgradable: Upgradable; + readonly manageable: Manageable; + readonly ownable: Ownable; + readonly pausable: Pausable; + + constructor(aptos: Aptos, aptosExtensionsPackageId: AccountAddressInput) { + validateAddresses(aptosExtensionsPackageId); + + this.id = aptosExtensionsPackageId; + this.upgradable = new Upgradable( + aptos, + `${aptosExtensionsPackageId}::upgradable` + ); + this.manageable = new Manageable( + aptos, + `${aptosExtensionsPackageId}::manageable` + ); + this.ownable = new Ownable(aptos, `${aptosExtensionsPackageId}::ownable`); + this.pausable = new Pausable( + aptos, + `${aptosExtensionsPackageId}::pausable` + ); + } +} + +class Upgradable { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async upgradePackage( + sender: Ed25519Account, + packageId: AccountAddressInput, + metadataBytes: HexInput, + bytecode: HexInput[] + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::upgrade_package`, + functionArguments: [ + AccountAddress.fromStrict(packageId), + MoveVector.U8(metadataBytes), + new MoveVector(bytecode.map(MoveVector.U8)) + ] + } + }); + } +} + +class Manageable { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async admin(packageId: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::admin`, + [], + [AccountAddress.fromStrict(packageId)] + ); + + return normalizeAddress(result); + } + + async pendingAdmin(packageId: AccountAddressInput): Promise { + const result = await callViewFunction<{ vec: [string] }>( + this.aptos, + `${this.moduleId}::pending_admin`, + [], + [AccountAddress.fromStrict(packageId)] + ); + + return result.vec[0] ? normalizeAddress(result.vec[0]) : null; + } + + async changeAdmin( + sender: Ed25519Account, + packageId: AccountAddressInput, + newAdmin: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::change_admin`, + functionArguments: [ + AccountAddress.fromStrict(packageId), + AccountAddress.fromStrict(newAdmin) + ] + } + }); + } + + async acceptAdmin( + sender: Ed25519Account, + packageId: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::accept_admin`, + functionArguments: [AccountAddress.fromStrict(packageId)] + } + }); + } +} + +class Ownable { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async owner(objectId: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::owner`, + [], + [AccountAddress.fromStrict(objectId)] + ); + + return normalizeAddress(result); + } + + async pendingOwner(objectId: AccountAddressInput): Promise { + const result = await callViewFunction<{ vec: [string] }>( + this.aptos, + `${this.moduleId}::pending_owner`, + [], + [AccountAddress.fromStrict(objectId)] + ); + + return result.vec[0] ? normalizeAddress(result.vec[0]) : null; + } + + async transferOwnership( + sender: Ed25519Account, + objectId: AccountAddressInput, + newAdmin: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::transfer_ownership`, + functionArguments: [ + AccountAddress.fromStrict(objectId), + AccountAddress.fromStrict(newAdmin) + ] + } + }); + } + + async acceptOwnership( + sender: Ed25519Account, + objectId: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::accept_ownership`, + functionArguments: [AccountAddress.fromStrict(objectId)] + } + }); + } +} + +class Pausable { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async isPaused(objectId: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::is_paused`, + [], + [AccountAddress.fromStrict(objectId)] + ); + + return result; + } + + async pauser(objectId: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::pauser`, + [], + [AccountAddress.fromStrict(objectId)] + ); + + return normalizeAddress(result); + } + + async updatePauser( + sender: Ed25519Account, + objectId: AccountAddressInput, + newPauser: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::update_pauser`, + functionArguments: [ + AccountAddress.fromStrict(objectId), + AccountAddress.fromStrict(newPauser) + ] + } + }); + } +} diff --git a/scripts/typescript/packages/aptosFrameworkPackage.ts b/scripts/typescript/packages/aptosFrameworkPackage.ts new file mode 100644 index 0000000..98d5a48 --- /dev/null +++ b/scripts/typescript/packages/aptosFrameworkPackage.ts @@ -0,0 +1,61 @@ +/** + * 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. + */ + +import { AccountAddress, AccountAddressInput, Aptos } from "@aptos-labs/ts-sdk"; +import { callViewFunction, normalizeAddress } from "../utils"; + +export class AptosFrameworkPackage { + readonly id: AccountAddressInput; + readonly fungibleAsset: FungibleAsset; + + constructor(aptos: Aptos) { + this.id = normalizeAddress("0x1"); + this.fungibleAsset = new FungibleAsset(aptos, `${this.id}::fungible_asset`); + } +} + +class FungibleAsset { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async supply(faAddress: AccountAddressInput): Promise { + const result = await callViewFunction<{ vec: [string] }>( + this.aptos, + `${this.moduleId}::supply`, + [`${this.moduleId}::Metadata`], + [AccountAddress.fromStrict(faAddress)] + ); + + if (!result.vec[0]) { + throw new Error("Fungible Asset supply call failed!"); + } + + return BigInt(result.vec[0]); + } + + async getDecimals(faAddress: AccountAddressInput): Promise { + return callViewFunction( + this.aptos, + `${this.moduleId}::decimals`, + [`${this.moduleId}::Metadata`], + [AccountAddress.fromStrict(faAddress)] + ); + } +} diff --git a/scripts/typescript/packages/stablecoinPackage.ts b/scripts/typescript/packages/stablecoinPackage.ts new file mode 100644 index 0000000..41609c8 --- /dev/null +++ b/scripts/typescript/packages/stablecoinPackage.ts @@ -0,0 +1,324 @@ +/** + * 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. + */ + +import { + AccountAddress, + AccountAddressInput, + Aptos, + Ed25519Account, + MoveString, + U64, + U8, + UserTransactionResponse +} from "@aptos-labs/ts-sdk"; +import { + callViewFunction, + executeTransaction, + normalizeAddress, + validateAddresses +} from "../utils"; + +export class StablecoinPackage { + readonly id: AccountAddressInput; + readonly stablecoin: Stablecoin; + readonly treasury: Treasury; + readonly blocklistable: Blocklistable; + readonly metadata: Metadata; + + constructor(aptos: Aptos, stablecoinPackageId: AccountAddressInput) { + validateAddresses(stablecoinPackageId); + + this.id = stablecoinPackageId; + this.stablecoin = new Stablecoin( + aptos, + `${stablecoinPackageId}::stablecoin` + ); + this.treasury = new Treasury(aptos, `${stablecoinPackageId}::treasury`); + this.blocklistable = new Blocklistable( + aptos, + `${stablecoinPackageId}::blocklistable` + ); + this.metadata = new Metadata(aptos, `${stablecoinPackageId}::metadata`); + } +} + +class Stablecoin { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async stablecoinAddress(): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::stablecoin_address`, + [], + [] + ); + return normalizeAddress(result); + } + + async initializeV1( + sender: Ed25519Account, + name: string, + symbol: string, + decimals: number, + iconUri: string, + projectUri: string + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::initialize_v1`, + functionArguments: [ + new MoveString(name), + new MoveString(symbol), + new U8(decimals), + new MoveString(iconUri), + new MoveString(projectUri) + ] + } + }); + } +} + +class Treasury { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async masterMinter(): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::master_minter`, + [], + [] + ); + + return normalizeAddress(result); + } + + async getMinter(controller: AccountAddressInput): Promise { + const result = await callViewFunction<{ vec: [string] }>( + this.aptos, + `${this.moduleId}::get_minter`, + [], + [AccountAddress.fromStrict(controller)] + ); + + return result.vec[0] ? normalizeAddress(result.vec[0]) : null; + } + + async isMinter(minter: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::is_minter`, + [], + [AccountAddress.fromStrict(minter)] + ); + + return result; + } + + async mintAllowance(minter: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::mint_allowance`, + [], + [AccountAddress.fromStrict(minter)] + ); + + return BigInt(result); + } + + async configureController( + sender: Ed25519Account, + controller: AccountAddressInput, + minter: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::configure_controller`, + functionArguments: [ + AccountAddress.fromStrict(controller), + AccountAddress.fromStrict(minter) + ] + } + }); + } + + async configureMinter( + sender: Ed25519Account, + mintAllowance: bigint + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::configure_minter`, + functionArguments: [new U64(mintAllowance)] + } + }); + } + + async removeController( + sender: Ed25519Account, + controller: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::remove_controller`, + functionArguments: [AccountAddress.fromStrict(controller)] + } + }); + } + + async removeMinter(sender: Ed25519Account): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::remove_minter`, + functionArguments: [] + } + }); + } + + async updateMasterMinter( + sender: Ed25519Account, + newMasterMinter: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::update_master_minter`, + functionArguments: [AccountAddress.fromStrict(newMasterMinter)] + } + }); + } +} + +class Blocklistable { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async blocklister(): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::blocklister`, + [], + [] + ); + + return normalizeAddress(result); + } + + async isBlocklisted(address: AccountAddressInput): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::is_blocklisted`, + [], + [AccountAddress.fromStrict(address)] + ); + + return result; + } + + async updateBlocklister( + sender: Ed25519Account, + newBlocklister: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::update_blocklister`, + functionArguments: [AccountAddress.fromStrict(newBlocklister)] + } + }); + } + + async blocklist( + sender: Ed25519Account, + address: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::blocklist`, + functionArguments: [AccountAddress.fromStrict(address)] + } + }); + } + + async unblocklist( + sender: Ed25519Account, + address: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::unblocklist`, + functionArguments: [AccountAddress.fromStrict(address)] + } + }); + } +} + +class Metadata { + constructor( + private readonly aptos: Aptos, + private readonly moduleId: `${string}::${string}` + ) {} + + async metadataUpdater(): Promise { + const result = await callViewFunction( + this.aptos, + `${this.moduleId}::metadata_updater`, + [], + [] + ); + + return normalizeAddress(result); + } + + async updateMetadataUpdater( + sender: Ed25519Account, + newMetadataUpdater: AccountAddressInput + ): Promise { + return executeTransaction({ + aptos: this.aptos, + sender, + data: { + function: `${this.moduleId}::update_metadata_updater`, + functionArguments: [AccountAddress.fromStrict(newMetadataUpdater)] + } + }); + } +} diff --git a/scripts/typescript/removeController.ts b/scripts/typescript/removeController.ts new file mode 100644 index 0000000..f4532bf --- /dev/null +++ b/scripts/typescript/removeController.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("remove-controller") + .description("Removes a controller") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--master-minter-key ", "Master Minter's private key") + .requiredOption("--controller ", "The controller's address") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(removeController); + +export async function removeController({ + stablecoinPackageId, + masterMinterKey, + controller, + rpcUrl +}: { + stablecoinPackageId: string; + masterMinterKey: string; + controller: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId, controller); + + const aptos = getAptosClient(rpcUrl); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const masterMinter = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(masterMinterKey) + }); + + console.log(`Removing controller ${controller}...`); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.treasury.removeController(masterMinter, controller); +} diff --git a/scripts/typescript/removeMinter.ts b/scripts/typescript/removeMinter.ts new file mode 100644 index 0000000..aa0ddc7 --- /dev/null +++ b/scripts/typescript/removeMinter.ts @@ -0,0 +1,69 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("remove-minter") + .description("Removes a minter") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption( + "--controller-key ", + "Minter's controller private key" + ) + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(removeMinter); + +export async function removeMinter({ + stablecoinPackageId, + controllerKey, + rpcUrl +}: { + stablecoinPackageId: string; + controllerKey: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId); + + const aptos = getAptosClient(rpcUrl); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const controller = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(controllerKey) + }); + const minter = await stablecoinPackage.treasury.getMinter( + controller.accountAddress + ); + + console.log(`Removing minter ${minter}...`); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.treasury.removeMinter(controller); +} diff --git a/scripts/typescript/resources/default_token.json b/scripts/typescript/resources/default_token.json new file mode 100644 index 0000000..0107021 --- /dev/null +++ b/scripts/typescript/resources/default_token.json @@ -0,0 +1,17 @@ +{ + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "iconUri": "https://circle.com/usdc-icon", + "projectUri": "https://circle.com/usdc", + + "admin": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blocklister": "0x0000000000000000000000000000000000000000000000000000000000000000", + "masterMinter": "0x0000000000000000000000000000000000000000000000000000000000000000", + "metadataUpdater": "0x0000000000000000000000000000000000000000000000000000000000000000", + "owner": "0x0000000000000000000000000000000000000000000000000000000000000000", + "pauser": "0x0000000000000000000000000000000000000000000000000000000000000000", + + "controllers": {}, + "minters": {} +} diff --git a/scripts/typescript/resources/usdc_deploy.template.json b/scripts/typescript/resources/usdc_deploy.template.json new file mode 100644 index 0000000..befd4f5 --- /dev/null +++ b/scripts/typescript/resources/usdc_deploy.template.json @@ -0,0 +1,21 @@ +{ + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "iconUri": "https://circle.com/usdc-icon", + "projectUri": "https://circle.com/usdc", + + "admin": "", + "blocklister": "", + "masterMinter": "", + "metadataUpdater": "", + "owner": "", + "pauser": "", + + "controllers": { + "_controllerAddress": "_minterAddress" + }, + "minters": { + "_minterAddress": "_mintAllowance_in_subunits" + } +} diff --git a/scripts/typescript/resources/validate_stablecoin_state.template.json b/scripts/typescript/resources/validate_stablecoin_state.template.json new file mode 100644 index 0000000..90a53b1 --- /dev/null +++ b/scripts/typescript/resources/validate_stablecoin_state.template.json @@ -0,0 +1,42 @@ +{ + "aptosExtensionsPackageId": "", + "stablecoinPackageId": "", + "expectedStates": { + "aptosExtensionsPackage": { + "upgradeNumber": 0, + "upgradePolicy": "immutable", + "sourceCodeExists": false + }, + "stablecoinPackage": { + "upgradeNumber": 0, + "upgradePolicy": "compatible", + "sourceCodeExists": false + }, + + "name": "USDC", + "symbol": "USDC", + "decimals": 6, + "iconUri": "https://circle.com/usdc-icon", + "projectUri": "https://circle.com/usdc", + "paused": false, + "initializedVersion": 1, + "totalSupply": "0", + + "admin": "", + "blocklister": "", + "masterMinter": "", + "metadataUpdater": "", + "owner": "", + "pauser": "", + "pendingOwner": "", + "pendingAdmin": "", + + "controllers": { + "_controllerAddress": "_minterAddress" + }, + "minters": { + "_minterAddress": "_mintAllowance_in_subunits" + }, + "blocklist": ["_blocklistedAddress"] + } +} diff --git a/scripts/typescript/transferOwnership.ts b/scripts/typescript/transferOwnership.ts new file mode 100644 index 0000000..bffd282 --- /dev/null +++ b/scripts/typescript/transferOwnership.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; + +export default program + .createCommand("transfer-ownership") + .description("Starts a two-step ownership transfer for the stablecoin") + .requiredOption( + "--aptos-extensions-package-id ", + "The address where the aptos_extensions package is located." + ) + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--owner-key ", "Owner's private key") + .requiredOption("--new-owner ", "The new owner's address") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(transferOwnership); + +export async function transferOwnership({ + aptosExtensionsPackageId, + stablecoinPackageId, + ownerKey, + newOwner, + rpcUrl +}: { + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + ownerKey: string; + newOwner: string; + rpcUrl: string; +}) { + validateAddresses(aptosExtensionsPackageId, stablecoinPackageId, newOwner); + + const aptos = getAptosClient(rpcUrl); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const owner = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(ownerKey) + }); + + console.log( + `Starting the Owner role transfer from ${owner.accountAddress.toString()} to ${newOwner}...` + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + await aptosExtensionsPackage.ownable.transferOwnership( + owner, + stablecoinAddress, + newOwner + ); +} diff --git a/scripts/typescript/updateBlocklister.ts b/scripts/typescript/updateBlocklister.ts new file mode 100644 index 0000000..922f005 --- /dev/null +++ b/scripts/typescript/updateBlocklister.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("update-blocklister") + .description("Updates the blocklister for the stablecoin") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--owner-key ", "Owner's private key") + .requiredOption("--new-blocklister ", "The new blocklister's address") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(updateBlocklister); + +export async function updateBlocklister({ + stablecoinPackageId, + ownerKey, + newBlocklister, + rpcUrl +}: { + stablecoinPackageId: string; + ownerKey: string; + newBlocklister: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId, newBlocklister); + + const aptos = getAptosClient(rpcUrl); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const owner = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(ownerKey) + }); + const blocklister = await stablecoinPackage.blocklistable.blocklister(); + + console.log( + `Updating the Blocklister from ${blocklister} to ${newBlocklister}...` + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.blocklistable.updateBlocklister( + owner, + newBlocklister + ); +} diff --git a/scripts/typescript/updateMasterMinter.ts b/scripts/typescript/updateMasterMinter.ts new file mode 100644 index 0000000..403f014 --- /dev/null +++ b/scripts/typescript/updateMasterMinter.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("update-master-minter") + .description("Updates the Master Minter for the stablecoin") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--owner-key ", "Owner's private key") + .requiredOption( + "--new-master-minter ", + "The new Master Minter's address" + ) + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(updateMasterMinter); + +export async function updateMasterMinter({ + stablecoinPackageId, + ownerKey, + newMasterMinter, + rpcUrl +}: { + stablecoinPackageId: string; + ownerKey: string; + newMasterMinter: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId, newMasterMinter); + + const aptos = getAptosClient(rpcUrl); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const owner = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(ownerKey) + }); + const masterMinter = await stablecoinPackage.treasury.masterMinter(); + + console.log( + `Updating the Master Minter from ${masterMinter} to ${newMasterMinter}...` + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.treasury.updateMasterMinter(owner, newMasterMinter); +} diff --git a/scripts/typescript/updateMetadataUpdater.ts b/scripts/typescript/updateMetadataUpdater.ts new file mode 100644 index 0000000..875b411 --- /dev/null +++ b/scripts/typescript/updateMetadataUpdater.ts @@ -0,0 +1,75 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; + +export default program + .createCommand("update-metadata-updater") + .description("Updates the Metadata Updater for the stablecoin") + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--owner-key ", "Owner's private key") + .requiredOption( + "--new-metadata-updater ", + "The new Metadata Updater's address" + ) + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(updateMetadataUpdater); + +export async function updateMetadataUpdater({ + stablecoinPackageId, + ownerKey, + newMetadataUpdater, + rpcUrl +}: { + stablecoinPackageId: string; + ownerKey: string; + newMetadataUpdater: string; + rpcUrl: string; +}) { + validateAddresses(stablecoinPackageId, newMetadataUpdater); + + const aptos = getAptosClient(rpcUrl); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + const owner = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(ownerKey) + }); + const metadataUpdater = await stablecoinPackage.metadata.metadataUpdater(); + + console.log( + `Updating the Metadata Updater from ${metadataUpdater} to ${newMetadataUpdater}...` + ); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await stablecoinPackage.metadata.updateMetadataUpdater( + owner, + newMetadataUpdater + ); +} diff --git a/scripts/typescript/updatePauser.ts b/scripts/typescript/updatePauser.ts new file mode 100644 index 0000000..7ef885d --- /dev/null +++ b/scripts/typescript/updatePauser.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; + +export default program + .createCommand("update-pauser") + .description("Updates the pauser for the stablecoin") + .requiredOption( + "--aptos-extensions-package-id ", + "The address where the aptos_extensions package is located." + ) + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--owner-key ", "Owner's private key") + .requiredOption("--new-pauser ", "The new pauser's address") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(updatePauser); + +export async function updatePauser({ + aptosExtensionsPackageId, + stablecoinPackageId, + ownerKey, + newPauser, + rpcUrl +}: { + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + ownerKey: string; + newPauser: string; + rpcUrl: string; +}) { + validateAddresses(aptosExtensionsPackageId, stablecoinPackageId, newPauser); + + const aptos = getAptosClient(rpcUrl); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + + const owner = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(ownerKey) + }); + const pauser = + await aptosExtensionsPackage.pausable.pauser(stablecoinAddress); + + console.log(`Updating the Pauser from ${pauser} to ${newPauser}...`); + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + await aptosExtensionsPackage.pausable.updatePauser( + owner, + stablecoinAddress, + newPauser + ); +} diff --git a/scripts/typescript/upgradeStablecoinPackage.ts b/scripts/typescript/upgradeStablecoinPackage.ts new file mode 100644 index 0000000..dde6d23 --- /dev/null +++ b/scripts/typescript/upgradeStablecoinPackage.ts @@ -0,0 +1,96 @@ +/** + * 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. + */ +import { Account, Ed25519PrivateKey } from "@aptos-labs/ts-sdk"; +import { program } from "commander"; +import { inspect } from "util"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; +import { + getAptosClient, + validateAddresses, + waitForUserConfirmation +} from "./utils"; +import { readPublishPayload } from "./utils/publishPayload"; + +export default program + .createCommand("upgrade-stablecoin-package") + .description("Upgrade the stablecoin package") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .requiredOption("--admin-key ", "Admin private key") + .requiredOption( + "--payload-file-path ", + "The publish package JSON payload file path" + ) + .requiredOption( + "--aptos-extensions-package-id ", + "aptos_extensions package address" + ) + .requiredOption( + "--stablecoin-package-id ", + "stablecoin package address" + ) + .action(upgradeStablecoinPackage); + +export async function upgradeStablecoinPackage({ + adminKey, + rpcUrl, + payloadFilePath, + aptosExtensionsPackageId, + stablecoinPackageId +}: { + adminKey: string; + rpcUrl: string; + payloadFilePath: string; + aptosExtensionsPackageId: string; + stablecoinPackageId: string; +}): Promise { + validateAddresses(aptosExtensionsPackageId, stablecoinPackageId); + + const aptos = getAptosClient(rpcUrl); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + + const admin = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(adminKey) + }); + + console.log(`Admin account: ${admin.accountAddress}`); + + const payload = readPublishPayload(payloadFilePath); + console.log( + "Updating package using payload", + inspect(payload, false, 8, true) + ); + + if (!(await waitForUserConfirmation())) { + process.exit(1); + } + + const metadataBytes = payload.args[0].value; + const bytecode = payload.args[1].value; + + await aptosExtensionsPackage.upgradable.upgradePackage( + admin, + stablecoinPackageId, + metadataBytes, + bytecode + ); + + console.log(`Package upgraded successfully!`); +} diff --git a/scripts/typescript/utils/deployUtils.ts b/scripts/typescript/utils/deployUtils.ts new file mode 100644 index 0000000..17722c8 --- /dev/null +++ b/scripts/typescript/utils/deployUtils.ts @@ -0,0 +1,184 @@ +/** + * 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. + */ + +import { + Aptos, + createResourceAddress, + Ed25519Account, + MoveVector, + UserTransactionResponse +} from "@aptos-labs/ts-sdk"; +import { execSync } from "node:child_process"; +import fs from "fs"; +import path from "path"; + +import { + executeTransaction, + getEventByType, + normalizeAddress, + REPOSITORY_ROOT, + validateAddresses +} from "."; +import { readPublishPayload } from "./publishPayload"; + +export type NamedAddress = { name: string; address: string }; + +/** + * Publishes a package to a newly created resource account + * @returns The address of the published package + * @throws if the transaction fails + */ +export async function publishPackageToResourceAccount({ + aptos, + deployer, + packageName, + seed, + namedDeps, + verifySource +}: { + aptos: Aptos; + deployer: Ed25519Account; + packageName: string; + namedDeps: NamedAddress[]; + seed: Uint8Array; + verifySource: boolean; +}): Promise<[string, UserTransactionResponse]> { + const expectedCodeAddress = createResourceAddress( + deployer.accountAddress, + seed + ).toString(); + + const { metadataBytes, bytecode } = await buildPackage( + packageName, + [ + { + name: packageName, + address: expectedCodeAddress + }, + ...namedDeps + ], + verifySource + ); + const functionArguments = [ + MoveVector.U8(seed), + MoveVector.U8(metadataBytes), + new MoveVector(bytecode.map(MoveVector.U8)) + ]; + + const txOutput = await executeTransaction({ + aptos, + sender: deployer, + data: { + function: + "0x1::resource_account::create_resource_account_and_publish_package", + functionArguments + } + }); + + const rawCodeAddress = getEventByType(txOutput, "0x1::code::PublishPackage") + .data.code_address; + const packageId = normalizeAddress(rawCodeAddress); + + if (packageId !== expectedCodeAddress) { + throw new Error( + `Package was published to an unexpected address! Expected: ${expectedCodeAddress}, but published to ${packageId}` + ); + } + + return [packageId, txOutput]; +} + +/** + * Builds a package with the given package name and named addresses + * @returns The metadata bytes and bytecode of the package + */ +export async function buildPackage( + packageName: string, + namedAddresses: NamedAddress[], + verifySource: boolean +): Promise<{ + metadataBytes: string; + bytecode: string[]; +}> { + const payloadFilePath = await buildPackagePublishPayloadFile( + packageName, + namedAddresses, + verifySource + ); + const publishPayload = readPublishPayload(payloadFilePath); + fs.unlinkSync(payloadFilePath); // delete saved json at PAYLOAD_FILE_PATH + + return { + metadataBytes: publishPayload.args[0].value, + bytecode: publishPayload.args[1].value + }; +} + +export async function buildPackagePublishPayloadFile( + packageName: string, + namedAddresses: NamedAddress[], + verifySource: boolean +): Promise { + const payloadFilePath = path.join( + REPOSITORY_ROOT, + "scripts", + "typescript", + "build-output", + `${packageName}-${Date.now()}.json` + ); + + const buildCommand = `make build-publish-payload \ + package="${packageName}" \ + output="${payloadFilePath}" \ + named_addresses="${formatNamedAddresses(namedAddresses)}" \ + included_artifacts="${getIncludedArtifactsSetting(verifySource)}"`; + const result = execSync(buildCommand, { encoding: "utf-8" }); + + if (!fs.existsSync(payloadFilePath)) { + console.error(result); + throw new Error(`Build failed with the following command: ${buildCommand}`); + } + return payloadFilePath; +} + +/** + * Parses a string of named addresses in the format "name1=address1,name2=address2" + */ +export function parseNamedAddresses(namedAddressArg: string): NamedAddress[] { + return namedAddressArg.split(",").map((arg) => { + const [name, address] = arg.split("="); + validateAddresses(address); + return { name, address }; + }); +} + +/** + * Formats a list of named addresses into a string of the format "name1=address1,name2=address2" + */ +export function formatNamedAddresses(namedAddresses: NamedAddress[]): string { + return namedAddresses + .map(({ name, address }) => `${name}=${address}`) + .join(","); +} + +/** + * Returns the setting for the included artifacts flag + */ +export function getIncludedArtifactsSetting(sourceUploaded?: boolean): string { + return sourceUploaded ? "sparse" : "none"; +} diff --git a/scripts/typescript/utils/index.ts b/scripts/typescript/utils/index.ts new file mode 100644 index 0000000..9f4457d --- /dev/null +++ b/scripts/typescript/utils/index.ts @@ -0,0 +1,253 @@ +/** + * 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. + */ + +import { + AptosConfig, + Aptos, + Ed25519Account, + EntryFunctionArgumentTypes, + Event, + InputGenerateTransactionPayloadData, + MoveFunctionId, + Network, + TypeArgument, + UserTransactionResponse, + AccountAddress, + AccountAddressInput, + MoveModuleBytecode +} from "@aptos-labs/ts-sdk"; +import assert from "assert"; +import path from "path"; +import readline from "readline/promises"; +import * as yup from "yup"; + +export const MAX_U64 = BigInt("0xFFFFFFFFFFFFFFFF"); +export const REPOSITORY_ROOT = path.resolve( + path.join(__dirname, "..", "..", "..") +); +export const LOCAL_RPC_URL = "http://localhost:8080"; +export const LOCAL_FAUCET_URL = "http://localhost:8081"; + +export type PackageMetadata = { + name: string; + upgrade_policy: any; + upgrade_number: number; + source_digest: string; + manifest: number[]; + modules: { source: string; source_map: string }[]; + deps: unknown[]; + extension: { vec: [unknown] }; +}; + +export function getAptosClient(url?: string, faucetUrl?: string): Aptos { + return new Aptos( + new AptosConfig({ + network: Network.CUSTOM, + fullnode: `${url ?? LOCAL_RPC_URL}/v1`, + faucet: faucetUrl ?? LOCAL_FAUCET_URL + }) + ); +} + +/** + * Calls a view function in Move and returns the first result. + */ +export async function callViewFunction( + aptos: Aptos, + functionId: MoveFunctionId, + typeArguments: TypeArgument[], + functionArgs: EntryFunctionArgumentTypes[] +): Promise { + const data = await aptos.view<[T]>({ + payload: { + function: functionId, + typeArguments, + functionArguments: functionArgs + } + }); + return data[0]; +} + +/** + * Executes a transaction and waits for it to be included in a block + * @returns the transaction output + * @throws if the transaction fails + */ +export async function executeTransaction({ + aptos, + sender, + data +}: { + aptos: Aptos; + sender: Ed25519Account; + data: InputGenerateTransactionPayloadData; +}): Promise { + const transaction = await aptos.transaction.build.simple({ + sender: sender.accountAddress, + data + }); + const response = await aptos.signAndSubmitTransaction({ + signer: sender, + transaction + }); + const txOutput = await aptos.waitForTransaction({ + transactionHash: response.hash + }); + if (!txOutput.success) { + console.error(txOutput); + throw new Error("Unexpected transaction failure"); + } + return txOutput as UserTransactionResponse; +} + +/** + * Finds a specific event from the transaction output + */ +export function getEventByType( + txOutput: UserTransactionResponse, + eventType: string +): Event { + const event = txOutput.events.find((e: any) => e.type === eventType); + assert(!!event, `Event ${eventType} not found`); + return event; +} + +/** + * Reformat address to ensure it conforms to AIP-40. + */ +export function normalizeAddress(address: string): string { + return AccountAddress.from(address).toString(); +} + +/** + * Throws if any address does not match the format defined in AIP-40. + */ +export function validateAddresses(...addresses: AccountAddressInput[]) { + for (const address of addresses) { + const result = AccountAddress.isValid({ input: address, strict: true }); + if (!result.valid) { + throw new Error(result.invalidReasonMessage); + } + } +} + +/** + * Prompts the user to confirm an action + */ +export async function waitForUserConfirmation() { + if (process.env.NODE_ENV === "TESTING") { + return true; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + let userResponse: boolean; + + while (true) { + const response = (await rl.question("Are you sure? (Y/N): ")).toUpperCase(); + if (response != "Y" && response != "N") { + continue; + } + userResponse = response === "Y"; + break; + } + rl.close(); + + return userResponse; +} + +/** + * Fetches the bytecode of a package + * Works for packages that have up to 25 modules + * @returns string[] of bytecode + */ +export async function getPackageBytecode( + aptos: Aptos, + packageId: string +): Promise { + const rawRemoteModuleBytecode: MoveModuleBytecode[] = + await aptos.getAccountModules({ + accountAddress: packageId, + options: { limit: 25 } + }); + return rawRemoteModuleBytecode.map((module) => module.bytecode); +} + +/** + * Yup utilities + */ + +export function yupAptosAddress() { + return yup.string().test(isAptosAddress); +} + +export function isAptosAddress(value: any) { + return ( + typeof value === "string" && + AccountAddress.isValid({ input: value, strict: true }).valid + ); +} + +export function isBigIntable(value: any) { + try { + BigInt(value); + } catch (e) { + return false; + } + return true; +} + +export function areSetsEqual(self: Set, other: Set): boolean { + for (const elem of self) { + if (!other.has(elem)) return false; + } + for (const elem of other) { + if (!self.has(elem)) return false; + } + return true; +} + +export async function getPackageMetadata( + aptos: Aptos, + packageId: string, + packageName: string +): Promise { + return ( + await aptos.getAccountResource({ + accountAddress: packageId, + resourceType: "0x1::code::PackageRegistry" + }) + ).packages.find((p: PackageMetadata) => p.name == packageName); +} + +export function checkSourceCodeExistence( + pkgMetadata: PackageMetadata +): boolean { + if (pkgMetadata.modules.every((m) => m.source !== "0x")) { + return true; + } else if (pkgMetadata.modules.every((m) => m.source === "0x")) { + return false; + } else { + throw new Error( + "Only some modules have their source code uploaded. Ensure that either all source code are uploaded, or none is uploaded." + ); + } +} diff --git a/scripts/typescript/utils/publishPayload.ts b/scripts/typescript/utils/publishPayload.ts new file mode 100644 index 0000000..4ee913e --- /dev/null +++ b/scripts/typescript/utils/publishPayload.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ + +import fs from "fs"; +import * as yup from "yup"; + +export type PublishPayload = yup.InferType; + +const hexString = yup + .string() + .matches(/0x[a-f]*/) + .required(); + +const publishPayloadSchema = yup.object({ + args: yup + .tuple([ + yup.object({ + type: yup.string().oneOf(["hex"]).required(), + value: hexString + }), + yup.object({ + type: yup.string().oneOf(["hex"]).required(), + value: yup.array(hexString).required() + }) + ]) + .required() +}); + +/** + * Reads and validates the build-publish-payload output. + * @param payloadFilePath Path to a valid build-publish-payload output. + * @returns The parsed and validated publish payload. + */ +export function readPublishPayload(payloadFilePath: string): PublishPayload { + if (!fs.existsSync(payloadFilePath)) { + throw new Error(`Failed to load payload file: ${payloadFilePath}`); + } + + const payload = publishPayloadSchema.validateSync( + JSON.parse(fs.readFileSync(payloadFilePath, "utf-8")), + { abortEarly: true } + ); + + return payload; +} diff --git a/scripts/typescript/utils/tokenConfig.ts b/scripts/typescript/utils/tokenConfig.ts new file mode 100644 index 0000000..7f06b74 --- /dev/null +++ b/scripts/typescript/utils/tokenConfig.ts @@ -0,0 +1,98 @@ +/** + * 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. + */ + +import fs from "fs"; +import * as yup from "yup"; +import { + areSetsEqual, + isAptosAddress, + isBigIntable, + MAX_U64, + yupAptosAddress +} from "."; + +export type TokenConfig = yup.InferType; + +const tokenConfigSchema = yup.object().shape({ + name: yup.string().required(), + symbol: yup.string().required(), + decimals: yup.number().required(), + iconUri: yup.string().url().required(), + projectUri: yup.string().url().required(), + + admin: yupAptosAddress().required(), + blocklister: yupAptosAddress().required(), + masterMinter: yupAptosAddress().required(), + metadataUpdater: yupAptosAddress().required(), + owner: yupAptosAddress().required(), + pauser: yupAptosAddress().required(), + controllers: yup + .mixed( + (input): input is Record => + typeof input === "object" && + Object.keys(input).every(isAptosAddress) && + Object.values(input).every(isAptosAddress) + ) + .required(), + minters: yup + .mixed( + (input): input is Record => + typeof input === "object" && + Object.keys(input).every(isAptosAddress) && + Object.values(input).every(isBigIntable) + ) + .required() +}); + +/** + * Reads and validates the token configuration file. + * @param tokenConfigPath Path to a valid token configuration file. + * @returns The parsed and validated token configuration. + */ +export function readTokenConfig(tokenConfigPath: string): TokenConfig { + if (!fs.existsSync(tokenConfigPath)) { + throw new Error(`Failed to load config file: ${tokenConfigPath}`); + } + + // Validate that the token configuration JSON follows the expected schema. + const tokenConfig = tokenConfigSchema.validateSync( + JSON.parse(fs.readFileSync(tokenConfigPath, "utf8")), + { abortEarly: false, strict: true } + ); + + // Additional data validation. + if ( + !areSetsEqual( + new Set(Object.values(tokenConfig.controllers)), + new Set(Object.keys(tokenConfig.minters)) + ) + ) { + throw new Error( + "The set of minters in tokenConfig.controllers does not match the set of minters in tokenConfig.minters!" + ); + } + + if ( + Object.values(tokenConfig.minters).some( + (mintAllowance) => BigInt(mintAllowance) > MAX_U64 + ) + ) { + throw new Error("There are mint allowances that exceed MAX_U64!"); + } + return tokenConfig; +} diff --git a/scripts/typescript/validateStablecoinState.ts b/scripts/typescript/validateStablecoinState.ts new file mode 100644 index 0000000..5afec70 --- /dev/null +++ b/scripts/typescript/validateStablecoinState.ts @@ -0,0 +1,329 @@ +/** + * 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. + */ + +import { Aptos } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import { program } from "commander"; +import fs from "fs"; +import * as yup from "yup"; +import { AptosExtensionsPackage } from "./packages/aptosExtensionsPackage"; +import { AptosFrameworkPackage } from "./packages/aptosFrameworkPackage"; +import { StablecoinPackage } from "./packages/stablecoinPackage"; +import { + checkSourceCodeExistence, + getAptosClient, + getPackageMetadata, + isAptosAddress, + isBigIntable, + yupAptosAddress +} from "./utils"; + +export type ConfigFile = yup.InferType; +type ExpectedStates = ConfigFile["expectedStates"]; +type PartialExpectedStates = Omit< + ConfigFile["expectedStates"], + "controllers" | "minters" | "blocklist" +>; + +const configSchema = yup.object().shape({ + aptosExtensionsPackageId: yup.string().required(), + stablecoinPackageId: yup.string().required(), + expectedStates: yup.object({ + aptosExtensionsPackage: yup + .object({ + upgradeNumber: yup.number().required(), + upgradePolicy: yup + .string() + .oneOf(["immutable", "compatible"]) + .required(), + sourceCodeExists: yup.boolean().required() + }) + .required(), + stablecoinPackage: yup + .object({ + upgradeNumber: yup.number().required(), + upgradePolicy: yup + .string() + .oneOf(["immutable", "compatible"]) + .required(), + sourceCodeExists: yup.boolean().required() + }) + .required(), + + name: yup.string().required(), + symbol: yup.string().required(), + decimals: yup.number().required(), + iconUri: yup.string().url().required(), + projectUri: yup.string().url().required(), + paused: yup.boolean().required(), + initializedVersion: yup.number().required(), + totalSupply: yup.string().required(), + + admin: yupAptosAddress().required(), + blocklister: yupAptosAddress().required(), + masterMinter: yupAptosAddress().required(), + metadataUpdater: yupAptosAddress().required(), + owner: yupAptosAddress().required(), + pauser: yupAptosAddress().required(), + pendingOwner: yupAptosAddress().nullable(), + pendingAdmin: yupAptosAddress().nullable(), + + controllers: yup + .mixed( + (input): input is Record => + typeof input === "object" && + Object.keys(input).every(isAptosAddress) && + Object.values(input).every(isAptosAddress) + ) + .required(), + minters: yup + .mixed( + (input): input is Record => + typeof input === "object" && + Object.keys(input).every(isAptosAddress) && + Object.values(input).every(isBigIntable) + ) + .required(), + + blocklist: yup.array(yup.string().required()).required() + }) +}); + +export default program + .createCommand("validate-stablecoin-state") + .description("Validates the stablecoin state") + .argument("", "Path to a validateStablecoinState config file") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .action(validateStablecoinState); + +/** + * This script validates that all configurable states on a given stablecoin + * are correctly configured. + * Notably, for the controllers, minters and blocklist tables, it strongly verifies + * that known addresses are configured correctly, and no additional unknown + * addresses have been configured. + */ +export async function validateStablecoinState( + configFilePath: string, + { rpcUrl }: { rpcUrl: string } +) { + const aptos = getAptosClient(rpcUrl); + const config = configSchema.validateSync( + JSON.parse(fs.readFileSync(configFilePath, "utf8")), + { abortEarly: false, strict: true } + ); + + const aptosFrameworkPackage = new AptosFrameworkPackage(aptos); + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + config.aptosExtensionsPackageId + ); + const stablecoinPackage = new StablecoinPackage( + aptos, + config.stablecoinPackageId + ); + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + + const actualTokenState = await buildCurrentTokenState( + aptos, + aptosFrameworkPackage, + aptosExtensionsPackage, + stablecoinPackage, + stablecoinAddress + ); + + await validateTokenState({ + aptos, + expectedTokenState: config.expectedStates, + actualTokenState, + stablecoinPackage + }); + + console.log("\u001b[32mValidation success!\u001b[0m"); +} + +async function buildCurrentTokenState( + aptos: Aptos, + aptosFrameworkPackage: AptosFrameworkPackage, + aptosExtensionsPackage: AptosExtensionsPackage, + stablecoinPackage: StablecoinPackage, + stablecoinAddress: string +): Promise { + const aptosExtensionsPkgMetadata = await getPackageMetadata( + aptos, + aptosExtensionsPackage.id.toString(), + "AptosExtensions" + ); + + const stablecoinPkgMetadata = await getPackageMetadata( + aptos, + stablecoinPackage.id.toString(), + "Stablecoin" + ); + + const faMetadata = await aptos.getAccountResource({ + accountAddress: stablecoinAddress, + resourceType: "0x1::fungible_asset::Metadata" + }); + + const stablecoinStateResource = await aptos.getAccountResource({ + accountAddress: stablecoinAddress, + resourceType: `${stablecoinPackage.id}::stablecoin::StablecoinState` + }); + + return { + aptosExtensionsPackage: { + upgradeNumber: Number(aptosExtensionsPkgMetadata.upgrade_number), + upgradePolicy: getUpgradePolicy( + aptosExtensionsPkgMetadata.upgrade_policy.policy + ), + sourceCodeExists: checkSourceCodeExistence(aptosExtensionsPkgMetadata) + }, + stablecoinPackage: { + upgradeNumber: Number(stablecoinPkgMetadata.upgrade_number), + upgradePolicy: getUpgradePolicy( + stablecoinPkgMetadata.upgrade_policy.policy + ), + sourceCodeExists: checkSourceCodeExistence(stablecoinPkgMetadata) + }, + name: faMetadata.name, + symbol: faMetadata.symbol, + decimals: faMetadata.decimals, + iconUri: faMetadata.icon_uri, + projectUri: faMetadata.project_uri, + paused: await aptosExtensionsPackage.pausable.isPaused(stablecoinAddress), + initializedVersion: Number(stablecoinStateResource.initialized_version), + + admin: await aptosExtensionsPackage.manageable.admin(stablecoinPackage.id), + blocklister: await stablecoinPackage.blocklistable.blocklister(), + masterMinter: await stablecoinPackage.treasury.masterMinter(), + metadataUpdater: await stablecoinPackage.metadata.metadataUpdater(), + owner: await aptosExtensionsPackage.ownable.owner(stablecoinAddress), + pauser: await aptosExtensionsPackage.pausable.pauser(stablecoinAddress), + pendingOwner: + await aptosExtensionsPackage.ownable.pendingOwner(stablecoinAddress), + pendingAdmin: await aptosExtensionsPackage.manageable.pendingAdmin( + stablecoinPackage.id + ), + + totalSupply: ( + await aptosFrameworkPackage.fungibleAsset.supply(stablecoinAddress) + ).toString() + }; +} + +async function validateTokenState({ + aptos, + expectedTokenState, + actualTokenState, + stablecoinPackage +}: { + aptos: Aptos; + expectedTokenState: ExpectedStates; + actualTokenState: PartialExpectedStates; + stablecoinPackage: StablecoinPackage; +}) { + const { controllers, minters, blocklist, ...restExpectedStates } = + expectedTokenState; + + assert.deepStrictEqual(restExpectedStates, actualTokenState); + + const stablecoinAddress = + await stablecoinPackage.stablecoin.stablecoinAddress(); + const treasuryStateResource = await aptos.getAccountResource({ + accountAddress: stablecoinAddress, + resourceType: `${stablecoinPackage.id}::treasury::TreasuryState` + }); + const controllerCount = Number(treasuryStateResource.controllers.size); + const minterCount = Number(treasuryStateResource.mint_allowances.size); + + const blocklistStateResource = await aptos.getAccountResource({ + accountAddress: stablecoinAddress, + resourceType: `${stablecoinPackage.id}::blocklistable::BlocklistState` + }); + const blocklistedCount = Number(blocklistStateResource.blocklist.length); + + await Promise.all( + Object.entries(controllers).map(async ([controller, expectedMinter]) => { + const actualMinter = + await stablecoinPackage.treasury.getMinter(controller); + + if (actualMinter !== expectedMinter) { + console.error({ [controller]: { actualMinter, expectedMinter } }); + throw new Error("Invalid controller configuration"); + } + }) + ); + assert.strictEqual( + controllerCount, + Object.entries(controllers).length, + "Additional controllers configured" + ); + + await Promise.all( + Object.entries(minters).map(async ([minter, expectedMintAllowance]) => { + const actualMintAllowance = + await stablecoinPackage.treasury.mintAllowance(minter); + const expectedMintAllowanceBigInt = BigInt(expectedMintAllowance); + + if (actualMintAllowance !== expectedMintAllowanceBigInt) { + console.error({ + [minter]: { + actualMintAllowance, + expectedMintAllowance: expectedMintAllowanceBigInt + } + }); + throw new Error("Invalid minter configuration"); + } + }) + ); + assert.strictEqual( + minterCount, + Object.entries(minters).length, + "Additional minters configured" + ); + + await Promise.all( + blocklist.map(async (blocklistedAddress) => { + const isBlocklisted = + await stablecoinPackage.blocklistable.isBlocklisted(blocklistedAddress); + + if (!isBlocklisted) { + console.error({ [blocklistedAddress]: isBlocklisted }); + throw new Error("Invalid blocklist configuration"); + } + }) + ); + assert.strictEqual( + blocklistedCount, + Object.entries(blocklist).length, + "Additional addresses blocklisted" + ); +} + +function getUpgradePolicy(policy: number): "immutable" | "compatible" { + switch (policy) { + case 1: + return "compatible"; + case 2: + return "immutable"; + default: + throw new Error(`Unknown ${policy}`); + } +} diff --git a/scripts/typescript/verifyPackage.ts b/scripts/typescript/verifyPackage.ts new file mode 100644 index 0000000..440d72e --- /dev/null +++ b/scripts/typescript/verifyPackage.ts @@ -0,0 +1,124 @@ +/** + * 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. + */ + +import { strict as assert } from "assert"; +import { execSync } from "child_process"; +import { program } from "commander"; +import { getAptosClient, getPackageBytecode, validateAddresses } from "./utils"; +import { + buildPackage, + formatNamedAddresses, + getIncludedArtifactsSetting, + NamedAddress, + parseNamedAddresses +} from "./utils/deployUtils"; + +export default program + .createCommand("verify-package") + .description( + "Verify bytecode and metadata of a deployed package match local source code." + ) + .requiredOption( + "--package-name ", + "The name of the package to verify." + ) + .requiredOption( + "--package-id ", + "The address where the package is located." + ) + .requiredOption( + "--named-deps ", + "Named dependency addresses of the deployed package." + ) + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .option( + "--source-uploaded", + "Whether source code verification was enabled during package deployment." + ) + .action(async (options) => { + const namedDeps = parseNamedAddresses(options.namedDeps); + const result = await verifyPackage(Object.assign(options, { namedDeps })); + console.log(result); + }); + +type BytecodeVerificationResult = { + packageName: string; + bytecodeVerified: boolean; + metadataVerified: boolean; +}; + +export async function verifyPackage({ + packageName, + packageId, + namedDeps, + rpcUrl, + sourceUploaded +}: { + packageName: string; + packageId: string; + namedDeps: NamedAddress[]; + rpcUrl: string; + sourceUploaded?: boolean; +}): Promise { + validateAddresses(packageId); + + const aptos = getAptosClient(rpcUrl); + const namedAddresses = [ + ...namedDeps, + { name: packageName, address: packageId } + ]; + + const localModuleBytecode = ( + await buildPackage( + packageName, + namedAddresses, + false /* value does not affect verification */ + ) + ).bytecode; + const remoteModuleBytecode = await getPackageBytecode(aptos, packageId); + + // Comparing remote bytecode against local compilation + // Local bytecode list is arranged according to the module dependency hierarchy + // For simplicity, we compare the sorted list of bytecode + localModuleBytecode.sort(); + remoteModuleBytecode.sort(); + + let bytecodeVerified = false; + try { + assert.deepStrictEqual(localModuleBytecode, remoteModuleBytecode); + bytecodeVerified = true; + } catch (e) { + console.error(e); + } + + // Begin verifying package metadata + // Setting to enable or disable source code verification + const verifyMetadataCommand = `make verify-metadata \ + package="${packageName}" \ + package_id="${packageId}" \ + url="${rpcUrl}" \ + named_addresses="${formatNamedAddresses(namedAddresses)}" \ + included_artifacts="${getIncludedArtifactsSetting(sourceUploaded)}"`; + + const result = execSync(verifyMetadataCommand, { encoding: "utf-8" }); + const metadataVerified = result.includes( + "Successfully verified source of package" + ); + + return { packageName, bytecodeVerified, metadataVerified }; +} diff --git a/scripts/typescript/verifyV1Packages.ts b/scripts/typescript/verifyV1Packages.ts new file mode 100644 index 0000000..0a19e52 --- /dev/null +++ b/scripts/typescript/verifyV1Packages.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +import { program } from "commander"; +import { verifyPackage } from "./verifyPackage"; + +export default program + .createCommand("verify-v1-packages") + .description( + "Verify bytecode and metadata of deployed packages match local source code." + ) + .requiredOption( + "--aptos-extensions-package-id ", + "The address where the aptos_extenisons package is located." + ) + .requiredOption( + "--stablecoin-package-id ", + "The address where the stablecoin package is located." + ) + .requiredOption("--deployer ", "Address of the deployer account.") + .requiredOption("-r, --rpc-url ", "Network RPC URL") + .option( + "--source-uploaded", + "Whether source code verification was enabled during package deployment." + ) + .action(async (options) => { + const results = await verifyV1Packages(options); + console.log(results); + }); + +export async function verifyV1Packages({ + deployer, + aptosExtensionsPackageId, + stablecoinPackageId, + rpcUrl, + sourceUploaded = false +}: { + deployer: string; + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + rpcUrl: string; + sourceUploaded?: boolean; +}) { + const aptosExtensionsPkgNamedAddresses = [ + { name: "deployer", address: deployer } + ]; + const aptosExtensionsInputParams = { + packageName: "aptos_extensions", + packageId: aptosExtensionsPackageId, + namedDeps: aptosExtensionsPkgNamedAddresses, + sourceUploaded, + rpcUrl + }; + const aptosExtensionsResult = await verifyPackage(aptosExtensionsInputParams); + + const stablecoinPkgNamedAddresses = [ + { name: "deployer", address: deployer }, + { name: "aptos_extensions", address: aptosExtensionsPackageId } + ]; + const stablecoinInputParams = { + packageName: "stablecoin", + packageId: stablecoinPackageId, + namedDeps: stablecoinPkgNamedAddresses, + sourceUploaded, + rpcUrl + }; + const stablecoinResult = await verifyPackage(stablecoinInputParams); + + return [aptosExtensionsResult, stablecoinResult]; +} diff --git a/test/typescript/acceptAdmin.test.ts b/test/typescript/acceptAdmin.test.ts new file mode 100644 index 0000000..5501ef1 --- /dev/null +++ b/test/typescript/acceptAdmin.test.ts @@ -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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { acceptAdmin } from "../../scripts/typescript/acceptAdmin"; +import * as aptosExtensionsPackageModule from "../../scripts/typescript/packages/aptosExtensionsPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("acceptAdmin", () => { + let aptosExtensionsPackageStub: SinonStub; + + beforeEach(() => { + aptosExtensionsPackageStub = sinon.stub( + aptosExtensionsPackageModule, + "AptosExtensionsPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the acceptAdmin function with correct inputs", async () => { + const aptosExtensionsPackageId = AccountAddress.ZERO.toString(); + const stablecoinPackageId = AccountAddress.ONE.toString(); + const newAdmin = Account.generate(); + const rpcUrl = "http://localhost:8080"; + + const acceptAdminFn = sinon.fake(); + aptosExtensionsPackageStub.returns({ + manageable: { + acceptAdmin: acceptAdminFn + } + }); + + await acceptAdmin({ + aptosExtensionsPackageId, + stablecoinPackageId, + newAdminKey: newAdmin.privateKey.toString(), + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(aptosExtensionsPackageStub); + sinon.assert.calledWithExactly( + aptosExtensionsPackageStub, + getAptosClient(rpcUrl), + aptosExtensionsPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + acceptAdminFn.calledOnceWithExactly(newAdmin, stablecoinPackageId), + true + ); + }); +}); diff --git a/test/typescript/acceptOwnership.test.ts b/test/typescript/acceptOwnership.test.ts new file mode 100644 index 0000000..28db09c --- /dev/null +++ b/test/typescript/acceptOwnership.test.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { acceptOwnership } from "../../scripts/typescript/acceptOwnership"; +import * as aptosExtensionsPackageModule from "../../scripts/typescript/packages/aptosExtensionsPackage"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("acceptOwnership", () => { + let aptosExtensionsPackageStub: SinonStub; + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + aptosExtensionsPackageStub = sinon.stub( + aptosExtensionsPackageModule, + "AptosExtensionsPackage" + ); + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the acceptOwnership function with correct inputs", async () => { + const aptosExtensionsPackageId = AccountAddress.ZERO.toString(); + const stablecoinPackageId = AccountAddress.ONE.toString(); + const stablecoinAddress = AccountAddress.TWO.toString(); + const newOwner = Account.generate(); + const rpcUrl = "http://localhost:8080"; + + const acceptOwnershipFn = sinon.fake(); + aptosExtensionsPackageStub.returns({ + ownable: { + acceptOwnership: acceptOwnershipFn + } + }); + + stablecoinPackageStub.returns({ + stablecoin: { + stablecoinAddress: sinon.fake.returns(stablecoinAddress) + } + }); + + await acceptOwnership({ + aptosExtensionsPackageId, + stablecoinPackageId, + newOwnerKey: newOwner.privateKey.toString(), + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(aptosExtensionsPackageStub); + sinon.assert.calledWithExactly( + aptosExtensionsPackageStub, + getAptosClient(rpcUrl), + aptosExtensionsPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + acceptOwnershipFn.calledOnceWithExactly(newOwner, stablecoinAddress), + true + ); + }); +}); diff --git a/test/typescript/changeAdmin.test.ts b/test/typescript/changeAdmin.test.ts new file mode 100644 index 0000000..321732c --- /dev/null +++ b/test/typescript/changeAdmin.test.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { changeAdmin } from "../../scripts/typescript/changeAdmin"; +import * as aptosExtensionsPackageModule from "../../scripts/typescript/packages/aptosExtensionsPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("changeAdmin", () => { + let aptosExtensionsPackageStub: SinonStub; + + beforeEach(() => { + aptosExtensionsPackageStub = sinon.stub( + aptosExtensionsPackageModule, + "AptosExtensionsPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the changeAdmin function with correct inputs", async () => { + const aptosExtensionsPackageId = AccountAddress.ZERO.toString(); + const stablecoinPackageId = AccountAddress.ONE.toString(); + const admin = Account.generate(); + const newAdmin = AccountAddress.THREE.toString(); + const rpcUrl = "http://localhost:8080"; + + const changeAdminFn = sinon.fake(); + aptosExtensionsPackageStub.returns({ + manageable: { + changeAdmin: changeAdminFn + } + }); + + await changeAdmin({ + aptosExtensionsPackageId, + stablecoinPackageId, + adminKey: admin.privateKey.toString(), + newAdmin, + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(aptosExtensionsPackageStub); + sinon.assert.calledWithExactly( + aptosExtensionsPackageStub, + getAptosClient(rpcUrl), + aptosExtensionsPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + changeAdminFn.calledOnceWithExactly(admin, stablecoinPackageId, newAdmin), + true + ); + }); +}); diff --git a/test/typescript/configureController.test.ts b/test/typescript/configureController.test.ts new file mode 100644 index 0000000..5e154ed --- /dev/null +++ b/test/typescript/configureController.test.ts @@ -0,0 +1,78 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { configureController } from "../../scripts/typescript/configureController"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("configureController", () => { + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the configureController function with correct inputs", async () => { + const masterMinter = Account.generate(); + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const rpcUrl = "http://localhost:8080"; + const controller = AccountAddress.ONE.toString(); + const minter = AccountAddress.TWO.toString(); + + const configureControllerFn = sinon.fake(); + stablecoinPackageStub.returns({ + treasury: { + configureController: configureControllerFn + } + }); + + await configureController({ + stablecoinPackageId, + masterMinterKey: masterMinter.privateKey.toString(), + controller, + minter, + rpcUrl + }); + + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + assert.strictEqual( + configureControllerFn.calledOnceWithExactly( + masterMinter, + controller, + minter + ), + true + ); + }); +}); diff --git a/test/typescript/configureMinter.test.ts b/test/typescript/configureMinter.test.ts new file mode 100644 index 0000000..5680d0a --- /dev/null +++ b/test/typescript/configureMinter.test.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { configureMinter } from "../../scripts/typescript/configureMinter"; +import * as aptosFrameworkPackageModule from "../../scripts/typescript/packages/aptosFrameworkPackage"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("configureMinter", () => { + let aptosFrameworkPackageStub: SinonStub; + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + aptosFrameworkPackageStub = sinon.stub( + aptosFrameworkPackageModule, + "AptosFrameworkPackage" + ); + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the configureMinter function with correct inputs", async () => { + const controller = Account.generate(); + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const rpcUrl = "http://localhost:8080"; + const mintAllowance = "1000000000"; + + aptosFrameworkPackageStub.returns({ + fungibleAsset: { + getDecimals: sinon.fake.returns(6) + } + }); + + const configureMinterFn = sinon.fake(); + stablecoinPackageStub.returns({ + stablecoin: { + stablecoinAddress: sinon.fake.returns(AccountAddress.ONE.toString()) + }, + treasury: { + configureMinter: configureMinterFn, + getMinter: sinon.fake.returns(AccountAddress.TWO.toString()) + } + }); + + await configureMinter({ + stablecoinPackageId, + controllerKey: controller.privateKey.toString(), + mintAllowance, + rpcUrl + }); + + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + assert.strictEqual( + configureMinterFn.calledOnceWithExactly( + controller, + BigInt(mintAllowance) + ), + true + ); + }); +}); diff --git a/test/typescript/deployAndInitializeToken.test.ts b/test/typescript/deployAndInitializeToken.test.ts new file mode 100644 index 0000000..cb12669 --- /dev/null +++ b/test/typescript/deployAndInitializeToken.test.ts @@ -0,0 +1,223 @@ +/** + * 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. + */ + +import { AccountAddressInput, Ed25519Account } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { deployAndInitializeToken } from "../../scripts/typescript/deployAndInitializeToken"; +import { + getAptosClient, + getPackageMetadata, + LOCAL_RPC_URL +} from "../../scripts/typescript/utils"; +import * as tokenConfigModule from "../../scripts/typescript/utils/tokenConfig"; +import { TokenConfig } from "../../scripts/typescript/utils/tokenConfig"; +import { generateKeypairs, validateSourceCodeExistence } from "./testUtils"; +import { StablecoinPackage } from "../../scripts/typescript/packages/stablecoinPackage"; +import { generateKeypair } from "../../scripts/typescript/generateKeypair"; +import { AptosExtensionsPackage } from "../../scripts/typescript/packages/aptosExtensionsPackage"; + +describe("deployAndInitializeToken E2E test", () => { + const TEST_TOKEN_CONFIG_PATH = "path/to/token_config.json"; + + const aptos = getAptosClient(LOCAL_RPC_URL); + + let readTokenConfigStub: SinonStub; + let tokenConfig: TokenConfig; + + let deployer: Ed25519Account; + let admin: Ed25519Account; + let blocklister: Ed25519Account; + let masterMinter: Ed25519Account; + let metadataUpdater: Ed25519Account; + let owner: Ed25519Account; + let pauser: Ed25519Account; + let controller: Ed25519Account; + let minter: Ed25519Account; + + beforeEach(async () => { + readTokenConfigStub = sinon.stub(tokenConfigModule, "readTokenConfig"); + deployer = await generateKeypair({ prefund: true }); + + [ + admin, + blocklister, + masterMinter, + metadataUpdater, + owner, + pauser, + controller, + minter + ] = await generateKeypairs(8, false); + + tokenConfig = { + name: "USDC", + symbol: "USDC", + decimals: 6, + iconUri: "https://circle.com/usdc-icon", + projectUri: "https://circle.com/usdc", + + admin: admin.accountAddress.toString(), + blocklister: blocklister.accountAddress.toString(), + masterMinter: masterMinter.accountAddress.toString(), + metadataUpdater: metadataUpdater.accountAddress.toString(), + owner: owner.accountAddress.toString(), + pauser: pauser.accountAddress.toString(), + + controllers: { + [controller.accountAddress.toString()]: minter.accountAddress.toString() + }, + minters: { + [minter.accountAddress.toString()]: "1000000000" + } + }; + + readTokenConfigStub.withArgs(TEST_TOKEN_CONFIG_PATH).returns(tokenConfig); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should succeed when source code verification is enabled", async () => { + const verifySource = true; + + const result = await deployAndInitializeToken({ + deployerKey: deployer.privateKey.toString(), + rpcUrl: LOCAL_RPC_URL, + verifySource, + tokenConfigPath: TEST_TOKEN_CONFIG_PATH + }); + + await validatePostState(result, deployer.accountAddress, verifySource); + }); + + it("should succeed when source code verification is disabled", async () => { + const verifySource = false; + + const result = await deployAndInitializeToken({ + deployerKey: deployer.privateKey.toString(), + rpcUrl: LOCAL_RPC_URL, + verifySource, + tokenConfigPath: TEST_TOKEN_CONFIG_PATH + }); + + await validatePostState(result, deployer.accountAddress, verifySource); + }); + + async function validatePostState( + inputs: { + aptosExtensionsPackageId: string; + stablecoinPackageId: string; + stablecoinAddress: string; + }, + deployer: AccountAddressInput, + sourceCodeExists: boolean + ) { + const { stablecoinAddress, aptosExtensionsPackageId, stablecoinPackageId } = + inputs; + const aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + const stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + + // Ensure that AptosExtensions is published correctly. + const aptosExtensionsPkgMetadata = await getPackageMetadata( + aptos, + aptosExtensionsPackageId, + "AptosExtensions" + ); + assert.strictEqual(aptosExtensionsPkgMetadata.name, "AptosExtensions"); + assert.strictEqual(aptosExtensionsPkgMetadata.upgrade_number, "0"); + assert.strictEqual(aptosExtensionsPkgMetadata.upgrade_policy.policy, 2); // Immutable package + validateSourceCodeExistence(aptosExtensionsPkgMetadata, sourceCodeExists); + + // Ensure that Stablecoin is published correctly. + const stablecoinPkgMetadata = await getPackageMetadata( + aptos, + stablecoinPackageId, + "Stablecoin" + ); + assert.strictEqual(stablecoinPkgMetadata.name, "Stablecoin"); + assert.strictEqual(stablecoinPkgMetadata.upgrade_number, "0"); + assert.strictEqual(stablecoinPkgMetadata.upgrade_policy.policy, 1); // Upgradeable package + validateSourceCodeExistence(aptosExtensionsPkgMetadata, sourceCodeExists); + + // Ensure that the FungibleAsset's metadata is set up correctly. + const metadata = await aptos.getAccountResource({ + accountAddress: stablecoinAddress, + resourceType: "0x1::fungible_asset::Metadata" + }); + assert.strictEqual(metadata.name, tokenConfig.name); + assert.strictEqual(metadata.symbol, tokenConfig.symbol); + assert.strictEqual(metadata.decimals, tokenConfig.decimals); + assert.strictEqual(metadata.icon_uri, tokenConfig.iconUri); + assert.strictEqual(metadata.project_uri, tokenConfig.projectUri); + + // Ensure that controllers are set up. + for (const [controller, minter] of Object.entries( + tokenConfig.controllers + )) { + assert.strictEqual( + await stablecoinPackage.treasury.getMinter(controller), + minter + ); + } + + // Ensure that minters are set up. + for (const [minter, mintAllowance] of Object.entries(tokenConfig.minters)) { + assert.strictEqual( + await stablecoinPackage.treasury.mintAllowance(minter), + BigInt(mintAllowance) + ); + } + + // Ensure that the deployer is not a controller. + assert.strictEqual( + await stablecoinPackage.treasury.getMinter(deployer), + null + ); + + // Ensure that the privileged roles are rotated. + assert.strictEqual( + await stablecoinPackage.treasury.masterMinter(), + masterMinter.accountAddress.toString() + ); + assert.strictEqual( + await stablecoinPackage.blocklistable.blocklister(), + blocklister.accountAddress.toString() + ); + assert.strictEqual( + await stablecoinPackage.metadata.metadataUpdater(), + metadataUpdater.accountAddress.toString() + ); + assert.strictEqual( + await aptosExtensionsPackage.pausable.pauser(stablecoinAddress), + pauser.accountAddress.toString() + ); + assert.strictEqual( + await aptosExtensionsPackage.ownable.pendingOwner(stablecoinAddress), + owner.accountAddress.toString() + ); + assert.strictEqual( + await aptosExtensionsPackage.manageable.pendingAdmin(stablecoinPackageId), + admin.accountAddress.toString() + ); + } +}); diff --git a/test/typescript/generateKeypair.test.ts b/test/typescript/generateKeypair.test.ts new file mode 100644 index 0000000..5f1eede --- /dev/null +++ b/test/typescript/generateKeypair.test.ts @@ -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. + */ + +import { strict as assert } from "assert"; +import { generateKeypair } from "../../scripts/typescript/generateKeypair"; +import { + getAptosClient, + LOCAL_FAUCET_URL, + LOCAL_RPC_URL +} from "../../scripts/typescript/utils"; + +describe("generateKeypair", () => { + const aptos = getAptosClient(LOCAL_RPC_URL, LOCAL_FAUCET_URL); + + it("should create an unfunded keypair", async () => { + const keypair = await generateKeypair({ + rpcUrl: LOCAL_RPC_URL, + faucetUrl: LOCAL_FAUCET_URL, + prefund: false + }); + + assert.strictEqual( + await aptos.getAccountAPTAmount({ + accountAddress: keypair.accountAddress + }), + 0 + ); + }); + + it("should create a keypair and prefund it with 10 APT", async () => { + const keypair = await generateKeypair({ + rpcUrl: LOCAL_RPC_URL, + faucetUrl: LOCAL_FAUCET_URL, + prefund: true + }); + + assert.strictEqual( + await aptos.getAccountAPTAmount({ + accountAddress: keypair.accountAddress + }), + 1_000_000_000 + ); + }); +}); diff --git a/test/typescript/packages/aptosExtensionsPackage.test.ts b/test/typescript/packages/aptosExtensionsPackage.test.ts new file mode 100644 index 0000000..7b4a9de --- /dev/null +++ b/test/typescript/packages/aptosExtensionsPackage.test.ts @@ -0,0 +1,199 @@ +/** + * 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. + */ + +import { Aptos, Ed25519Account } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import { generateKeypair } from "../../../scripts/typescript/generateKeypair"; +import { AptosExtensionsPackage } from "../../../scripts/typescript/packages/aptosExtensionsPackage"; +import { getAptosClient } from "../../../scripts/typescript/utils"; +import { + buildPackage, + publishPackageToResourceAccount +} from "../../../scripts/typescript/utils/deployUtils"; +import { StablecoinPackage } from "../../../scripts/typescript/packages/stablecoinPackage"; + +describe("AptosExtensionsPackage", () => { + let aptos: Aptos; + let deployer: Ed25519Account; + let aptosExtensionsPackageId: string; + let aptosExtensionsPackage: AptosExtensionsPackage; + let stablecoinPackageId: string; + let stablecoinAddress: string; + + before(async () => { + aptos = getAptosClient(); + + const aptosExtensionsDeployer = await generateKeypair({ prefund: true }); + [aptosExtensionsPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer: aptosExtensionsDeployer, + packageName: "aptos_extensions", + seed: new Uint8Array(Buffer.from("aptos_extensions")), + namedDeps: [ + { + name: "deployer", + address: aptosExtensionsDeployer.accountAddress.toString() + } + ], + verifySource: false + }); + + aptosExtensionsPackage = new AptosExtensionsPackage( + aptos, + aptosExtensionsPackageId + ); + }); + + beforeEach(async () => { + deployer = await generateKeypair({ prefund: true }); + + [stablecoinPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "stablecoin", + seed: new Uint8Array(Buffer.from("stablecoin")), + namedDeps: [ + { name: "aptos_extensions", address: aptosExtensionsPackageId }, + { + name: "deployer", + address: deployer.accountAddress.toString() + } + ], + verifySource: false + }); + + stablecoinAddress = await new StablecoinPackage( + aptos, + stablecoinPackageId + ).stablecoin.stablecoinAddress(); + }); + + describe("Upgradable", () => { + describe("upgradePackage", () => { + it("should upgrade stablecoin package successfully", async () => { + const { metadataBytes, bytecode } = await buildPackage( + "stablecoin", + [ + { name: "aptos_extensions", address: aptosExtensionsPackageId }, + { name: "stablecoin", address: stablecoinPackageId }, + { + name: "deployer", + address: deployer.accountAddress.toString() + } + ], + false + ); + + await aptosExtensionsPackage.upgradable.upgradePackage( + deployer, + stablecoinPackageId, + metadataBytes, + bytecode + ); + }); + }); + }); + + describe("Manageable", () => { + describe("two step admin transfer", async () => { + it("should succeed", async () => { + const newAdmin = await generateKeypair({ prefund: true }); + + await aptosExtensionsPackage.manageable.changeAdmin( + deployer, + stablecoinPackageId, + newAdmin.accountAddress + ); + + assert.strictEqual( + await aptosExtensionsPackage.manageable.pendingAdmin( + stablecoinPackageId + ), + newAdmin.accountAddress.toString() + ); + + await aptosExtensionsPackage.manageable.acceptAdmin( + newAdmin, + stablecoinPackageId + ); + + assert.strictEqual( + await aptosExtensionsPackage.manageable.admin(stablecoinPackageId), + newAdmin.accountAddress.toString() + ); + }); + }); + }); + + describe("Ownable", () => { + describe("two step ownership transfer", async () => { + it("should succeed", async () => { + const newOwner = await generateKeypair({ prefund: true }); + + await aptosExtensionsPackage.ownable.transferOwnership( + deployer, + stablecoinAddress, + newOwner.accountAddress + ); + + assert.strictEqual( + await aptosExtensionsPackage.ownable.pendingOwner(stablecoinAddress), + newOwner.accountAddress.toString() + ); + + await aptosExtensionsPackage.ownable.acceptOwnership( + newOwner, + stablecoinAddress + ); + + assert.strictEqual( + await aptosExtensionsPackage.ownable.owner(stablecoinAddress), + newOwner.accountAddress.toString() + ); + }); + }); + }); + + describe("Pausable", () => { + describe("isPaused", async () => { + it("should succeed", async () => { + assert.strictEqual( + await aptosExtensionsPackage.pausable.isPaused(stablecoinAddress), + false + ); + }); + }); + + describe("updatePauser", async () => { + it("should succeed", async () => { + const newPauser = await generateKeypair({ prefund: false }); + + await aptosExtensionsPackage.pausable.updatePauser( + deployer, + stablecoinAddress, + newPauser.accountAddress + ); + + assert.strictEqual( + await aptosExtensionsPackage.pausable.pauser(stablecoinAddress), + newPauser.accountAddress.toString() + ); + }); + }); + }); +}); diff --git a/test/typescript/packages/aptosFrameworkPackage.test.ts b/test/typescript/packages/aptosFrameworkPackage.test.ts new file mode 100644 index 0000000..bc25ec0 --- /dev/null +++ b/test/typescript/packages/aptosFrameworkPackage.test.ts @@ -0,0 +1,58 @@ +/** + * 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. + */ + +import { Aptos } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import { AptosFrameworkPackage } from "../../../scripts/typescript/packages/aptosFrameworkPackage"; +import { + getAptosClient, + normalizeAddress +} from "../../../scripts/typescript/utils"; + +describe("AptosFrameworkPackage", () => { + const APT_FUNGIBLE_ASSET = normalizeAddress("0xA"); + + let aptos: Aptos; + let aptosFrameworkPackage: AptosFrameworkPackage; + + before(async () => { + aptos = getAptosClient(); + aptosFrameworkPackage = new AptosFrameworkPackage(aptos); + }); + + describe("FungibleAsset", () => { + describe("supply", () => { + it("should return total supply when given a valid fungible asset", async () => { + const supply = + await aptosFrameworkPackage.fungibleAsset.supply(APT_FUNGIBLE_ASSET); + assert(typeof supply === "bigint"); + }); + }); + + describe("getDecimals", () => { + it("should return correct decimals when given a valid fungible asset", async () => { + assert.strictEqual( + await aptosFrameworkPackage.fungibleAsset.getDecimals( + APT_FUNGIBLE_ASSET + ), + 8 + ); + }); + }); + }); +}); diff --git a/test/typescript/packages/stablecoinPackage.test.ts b/test/typescript/packages/stablecoinPackage.test.ts new file mode 100644 index 0000000..cc15a9d --- /dev/null +++ b/test/typescript/packages/stablecoinPackage.test.ts @@ -0,0 +1,235 @@ +/** + * 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. + */ + +import { + AccountAddress, + Aptos, + createObjectAddress, + Ed25519Account +} from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import { generateKeypair } from "../../../scripts/typescript/generateKeypair"; +import { StablecoinPackage } from "../../../scripts/typescript/packages/stablecoinPackage"; +import { + getAptosClient, + normalizeAddress +} from "../../../scripts/typescript/utils"; +import { publishPackageToResourceAccount } from "../../../scripts/typescript/utils/deployUtils"; + +describe("StablecoinPackage", () => { + let aptos: Aptos; + let deployer: Ed25519Account; + let aptosExtensionsPackageId: string; + let stablecoinPackageId: string; + let stablecoinPackage: StablecoinPackage; + + before(async () => { + aptos = getAptosClient(); + + const aptosExtensionsDeployer = await generateKeypair({ prefund: true }); + + [aptosExtensionsPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer: aptosExtensionsDeployer, + packageName: "aptos_extensions", + seed: new Uint8Array(Buffer.from("aptos_extensions")), + namedDeps: [ + { + name: "deployer", + address: aptosExtensionsDeployer.accountAddress.toString() + } + ], + verifySource: false + }); + }); + + beforeEach(async () => { + deployer = await generateKeypair({ prefund: true }); + + [stablecoinPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "stablecoin", + seed: new Uint8Array(Buffer.from("stablecoin")), + namedDeps: [ + { name: "aptos_extensions", address: aptosExtensionsPackageId }, + { + name: "deployer", + address: deployer.accountAddress.toString() + } + ], + verifySource: false + }); + + stablecoinPackage = new StablecoinPackage(aptos, stablecoinPackageId); + }); + + describe("Stablecoin", () => { + describe("stablecoinAddress", () => { + it("should return the correct stablecoin address", async () => { + assert.strictEqual( + await stablecoinPackage.stablecoin.stablecoinAddress(), + createObjectAddress( + AccountAddress.fromStrict(stablecoinPackageId), + new Uint8Array(Buffer.from("stablecoin")) + ).toString() + ); + }); + }); + + describe("initializeV1", () => { + it("should succeed", async () => { + await stablecoinPackage.stablecoin.initializeV1( + deployer, + "name", + "symbol", + 6, + "icon_uri", + "project_uri" + ); + }); + }); + }); + + describe("Treasury", () => { + describe("E2E test", () => { + it("should succeed", async () => { + const masterMinter = await generateKeypair({ prefund: true }); + const controller = await generateKeypair({ prefund: true }); + const minter = await generateKeypair({ prefund: false }); + + await stablecoinPackage.treasury.updateMasterMinter( + deployer, + masterMinter.accountAddress + ); + + assert.strictEqual( + await stablecoinPackage.treasury.masterMinter(), + masterMinter.accountAddress.toString() + ); + + await stablecoinPackage.treasury.configureController( + masterMinter, + controller.accountAddress, + minter.accountAddress + ); + + const mintAllowance = BigInt(1_000_000); + await stablecoinPackage.treasury.configureMinter( + controller, + mintAllowance + ); + + assert.strictEqual( + await stablecoinPackage.treasury.getMinter(controller.accountAddress), + minter.accountAddress.toString() + ); + + assert.strictEqual( + await stablecoinPackage.treasury.isMinter(minter.accountAddress), + true + ); + + assert.strictEqual( + await stablecoinPackage.treasury.mintAllowance(minter.accountAddress), + mintAllowance + ); + + await stablecoinPackage.treasury.removeMinter(controller); + + await stablecoinPackage.treasury.removeController( + masterMinter, + controller.accountAddress + ); + }); + }); + }); + + describe("Blocklistable", () => { + describe("isBlocklisted", async () => { + it("should return a boolean", async () => { + assert.strictEqual( + await stablecoinPackage.blocklistable.isBlocklisted( + normalizeAddress("0x123") + ), + false + ); + }); + }); + + describe("updateBlocklister", async () => { + it("should succeed", async () => { + const newBlocklister = await generateKeypair({ prefund: false }); + + await stablecoinPackage.blocklistable.updateBlocklister( + deployer, + newBlocklister.accountAddress + ); + + assert.strictEqual( + await stablecoinPackage.blocklistable.blocklister(), + newBlocklister.accountAddress.toString() + ); + }); + }); + + describe("blocklist and unblocklist", async () => { + it("should succeed", async () => { + const addressToBlock = normalizeAddress("0x123"); + + await stablecoinPackage.blocklistable.blocklist( + deployer, + addressToBlock + ); + + assert.strictEqual( + await stablecoinPackage.blocklistable.isBlocklisted(addressToBlock), + true + ); + + await stablecoinPackage.blocklistable.unblocklist( + deployer, + addressToBlock + ); + + assert.strictEqual( + await stablecoinPackage.blocklistable.isBlocklisted(addressToBlock), + false + ); + }); + }); + }); + + describe("Metadata", () => { + describe("updateMetadataUpdater", async () => { + it("should succeed", async () => { + const newMetadataUpdater = await generateKeypair({ prefund: false }); + + await stablecoinPackage.metadata.updateMetadataUpdater( + deployer, + newMetadataUpdater.accountAddress + ); + + assert.strictEqual( + await stablecoinPackage.metadata.metadataUpdater(), + newMetadataUpdater.accountAddress.toString() + ); + }); + }); + }); +}); diff --git a/test/typescript/removeController.test.ts b/test/typescript/removeController.test.ts new file mode 100644 index 0000000..3b80b51 --- /dev/null +++ b/test/typescript/removeController.test.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { removeController } from "../../scripts/typescript/removeController"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("removeController", () => { + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the removeController function with correct inputs", async () => { + const masterMinter = Account.generate(); + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const rpcUrl = "http://localhost:8080"; + const controller = AccountAddress.ONE.toString(); + + const removeControllerFn = sinon.fake(); + stablecoinPackageStub.returns({ + treasury: { + removeController: removeControllerFn + } + }); + + await removeController({ + stablecoinPackageId, + masterMinterKey: masterMinter.privateKey.toString(), + controller, + rpcUrl + }); + + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + assert.strictEqual( + removeControllerFn.calledOnceWithExactly(masterMinter, controller), + true + ); + }); +}); diff --git a/test/typescript/removeMinter.test.ts b/test/typescript/removeMinter.test.ts new file mode 100644 index 0000000..5187c5a --- /dev/null +++ b/test/typescript/removeMinter.test.ts @@ -0,0 +1,68 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { removeMinter } from "../../scripts/typescript/removeMinter"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("removeMinter", () => { + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the removeMinter function with correct inputs", async () => { + const controller = Account.generate(); + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const rpcUrl = "http://localhost:8080"; + + const removeMinterFn = sinon.fake(); + stablecoinPackageStub.returns({ + treasury: { + getMinter: sinon.fake.returns(AccountAddress.ONE.toString()), + removeMinter: removeMinterFn + } + }); + + await removeMinter({ + stablecoinPackageId, + controllerKey: controller.privateKey.toString(), + rpcUrl + }); + + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + assert.strictEqual(removeMinterFn.calledOnceWithExactly(controller), true); + }); +}); diff --git a/test/typescript/testUtils.ts b/test/typescript/testUtils.ts new file mode 100644 index 0000000..2953b67 --- /dev/null +++ b/test/typescript/testUtils.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ + +import { strict as assert } from "assert"; +import { Ed25519Account } from "@aptos-labs/ts-sdk"; +import { generateKeypair } from "../../scripts/typescript/generateKeypair"; +import { + checkSourceCodeExistence, + PackageMetadata +} from "../../scripts/typescript/utils"; + +export function validateSourceCodeExistence( + pkgMetadata: PackageMetadata, + sourceCodeExists: boolean +) { + assert.strictEqual(checkSourceCodeExistence(pkgMetadata), sourceCodeExists); + + for (const module of pkgMetadata.modules) { + // Source map is never included, when included_artifacts is set to "sparse" or "none" + assert.strictEqual(module.source_map, "0x"); + } +} + +type RepeatTuple< + T, + N extends number, + A extends any[] = [] +> = A["length"] extends N ? A : RepeatTuple; + +export async function generateKeypairs( + n: N, + prefund: boolean +): Promise> { + const keypairs = await Promise.all( + Array.from({ length: n }).map(() => generateKeypair({ prefund })) + ); + return keypairs as RepeatTuple; +} diff --git a/test/typescript/testdata/malicious_bytecode.json b/test/typescript/testdata/malicious_bytecode.json new file mode 100644 index 0000000..f0f0128 --- /dev/null +++ b/test/typescript/testdata/malicious_bytecode.json @@ -0,0 +1,11 @@ +{ + "deployer": "0x04107ccd35298ef6f6e6b855ba456e56a134e58dfb0f093f7bd0ddc491c80666", + "aptos_extensions_package_id": "0x512dcea869e20ae8eb1f38c0e9863e3fb667ab1a5e7ee4ccc292124a396b82b0", + "bytecode": [ + "0xa11ceb0b0700000a08010006020604030a0c05160b0721650886014006c601220ce8010e0000010101020104060000030001000102050203000101060c0002060c05010800106170746f735f657874656e73696f6e73076163636f756e74107265736f757263655f6163636f756e740b696e69745f6d6f64756c65105369676e65724361706162696c6974791d72657472696576655f7265736f757263655f6163636f756e745f636170512dcea869e20ae8eb1f38c0e9863e3fb667ab1a5e7ee4ccc292124a396b82b00000000000000000000000000000000000000000000000000000000000000001052004107ccd35298ef6f6e6b855ba456e56a134e58dfb0f093f7bd0ddc491c806660000000001050b0007001101010200", + "0xa11ceb0b0700000a0c010008020816031e60047e10058e015007de01990208f7034006b7042810df04d0020aaf07210cd00788020dd80904000001010102010300040600000506000006080000070600020f07010000000800010001000902020001000a02010001000b00010001000c03010001000d04010001000e00010001001002050001021407080100010315040200010216090801000102170a0b01000101180b0101060102190b0e010001021a010e01000108020a020b020c0c0d020c0f0c100e0202060c0500010503060c050501060c010b0401050505060b040105070802050501060b04010900010102060b0401090006090001070b0401090001090001080101070802010b040109000108000108030a6d616e61676561626c65056576656e74066f7074696f6e067369676e65721241646d696e4368616e6765537461727465640c41646d696e4368616e6765640941646d696e526f6c651241646d696e526f6c6544657374726f7965640c6163636570745f61646d696e0561646d696e136173736572745f61646d696e5f6578697374730f6173736572745f69735f61646d696e0c6368616e67655f61646d696e0764657374726f79036e6577064f7074696f6e0d70656e64696e675f61646d696e107265736f757263655f61646472657373096f6c645f61646d696e096e65775f61646d696e0769735f736f6d650a616464726573735f6f6608636f6e7461696e73076578747261637404656d697404736f6d65046e6f6e65512dcea869e20ae8eb1f38c0e9863e3fb667ab1a5e7ee4ccc292124a396b82b0000000000000000000000000000000000000000000000000000000000000000103080400000000000000030801000000000000000308020000000000000003080300000000000000126170746f733a3a6d657461646174615f7631bb020401000000000000000a454e4f545f41444d494e1941646472657373206973206e6f74207468652061646d696e2e020000000000000012454e4f545f50454e44494e475f41444d494e2141646472657373206973206e6f74207468652070656e64696e672061646d696e2e0300000000000000164550454e44494e475f41444d494e5f4e4f545f5345541950656e64696e672061646d696e206973206e6f74207365742e040000000000000017454d495353494e475f41444d494e5f5245534f555243451e41646d696e526f6c65207265736f75726365206973206d697373696e672e030c41646d696e4368616e6765640104001241646d696e4368616e6765537461727465640104001241646d696e526f6c6544657374726f796564010400020561646d696e0101000d70656e64696e675f61646d696e0101000002031105120513050102031105120513050202020905100b04010503020111050000040102062f0a012a020c040a04100038000408050e0b00010b04010703270a0410000c030b0011090c020b030e0238010419051d0b04010702270a041001140c060a040f0038020c050a050b040f01150b010b060b051201380302010100010201050b002b02100114020201000001070b00290204040506070027020301000102010a0b0111010b00110921040705090701270204000401020d1c0a012a020c030a031001140b00110921040b050f0b03010701270a0238040a030f00150b010b031001140b0212003805020501000102010b0a0011092c02130201010b00110912033806020601000001060b000b01380712022d0202070100010201050b002b02100014020201020000", + "0xa11ceb0b0700000a0c01000a020a1c032660048601100596015d07f301bd0208b0044006f00428109805f7020a8f08210cb008d5010d850a040000010101020103010400050800000606000007060000080600020907010001030f07010000000a00010001000b02010001000c03010001000d02010001000e0405000100100406000100110701000102150a0501080103160b0c01000101170c010106010218050e010801041903050001021a05100001031b0111010001031c0c1101000107090805090d0a09090f0d050e05091302060c0b040108000002060c0501060c010b040108000105010b05010503060c0b04010800050405050507080001080001060b0401090001070b05010900010900010803010b040109000108010101010b050109000205070800010802076f776e61626c65056576656e74066f626a656374066f7074696f6e067369676e6572094f776e6572526f6c65124f776e6572526f6c6544657374726f796564184f776e6572736869705472616e7366657253746172746564144f776e6572736869705472616e73666572726564064f626a656374106163636570745f6f776e6572736869700f6173736572745f69735f6f776e65720764657374726f79036e6577056f776e6572064f7074696f6e0d70656e64696e675f6f776e6572127472616e736665725f6f776e6572736869700b6f626a5f61646472657373096f6c645f6f776e6572096e65775f6f776e65720e6f626a6563745f61646472657373076578747261637404656d697411616464726573735f746f5f6f626a6563740a616464726573735f6f660969735f6f626a656374046e6f6e6504736f6d65512dcea869e20ae8eb1f38c0e9863e3fb667ab1a5e7ee4ccc292124a396b82b0000000000000000000000000000000000000000000000000000000000000000103080100000000000000030802000000000000000308030000000000000003080400000000000000126170746f733a3a6d657461646174615f7631e20204010000000000000014454e4f4e5f4558495354454e545f4f424a454354144e6f6e2d6578697374656e74206f626a6563742e02000000000000000a454e4f545f4f574e45521941646472657373206973206e6f7420746865206f776e65722e030000000000000012454e4f545f50454e44494e475f4f574e45522141646472657373206973206e6f74207468652070656e64696e67206f776e65722e0400000000000000164550454e44494e475f4f574e45525f4e4f545f5345541950656e64696e67206f776e6572206973206e6f74207365742e04094f776e6572526f6c65010301183078313a3a6f626a6563743a3a4f626a65637447726f7570124f776e6572526f6c6544657374726f796564010400144f776e6572736869705472616e73666572726564010400184f776e6572736869705472616e736665725374617274656401040002056f776e65720101000d70656e64696e675f6f776e65720101000002020e05100b0501050102011205020203120513051405030203120513051405000004010008180e0138000c030a032a000c050a051000140c040a050f0138010c020a020b050f00150b030b040b0212033802020101000100010b0b01380311040b00110b210408050a070127020201000100010b0a00110b2c00130001010b00110b120138040203010000010f0a00110b110c040505090b00010700270b000b01380512002d0002040100010001060e0038002b0010001402050100010001060e0038002b0010011402060004010012130e0138000c030a032a000c040a0238060a040f01150b030b041000140b0212023807020000000100", + "0xa11ceb0b0700000a0c01000a020a1e03285e0486010e0594015007e401be0208a2044006e20428108a05cb020ad5071f0cf4079e020d920a04000001010102010300040005060000060800000706000008060000090600020c07010001041c0800000a00010001000b02010001000d03040001000e05010001000f060100010010030000010011060100010012070100010217000901080103180200000101190b01010601021a0c00010801021b00040001021d0004010801041e0501000108080a0a0b080d0d0a0f0a100a1201050001060c010b05010801010102060c0502060c0b0501080103060c0b0501080105010801010b0501090001080201090001060b050109000108060205070801010800010804030505070801010803087061757361626c65056576656e74066f626a656374067369676e6572076f776e61626c650550617573650a50617573655374617465135061757365537461746544657374726f7965640d5061757365724368616e67656407556e7061757365116173736572745f6e6f745f7061757365640764657374726f79064f626a6563740969735f706175736564036e65770570617573650670617573657207756e70617573650d7570646174655f7061757365720b6f626a5f61646472657373067061757365640a6f6c645f7061757365720a6e65775f70617573657211616464726573735f746f5f6f626a6563740a616464726573735f6f6604656d69740e6f626a6563745f616464726573730969735f6f626a656374094f776e6572526f6c650d6f626a6563745f6578697374730f6173736572745f69735f6f776e6572512dcea869e20ae8eb1f38c0e9863e3fb667ab1a5e7ee4ccc292124a396b82b0000000000000000000000000000000000000000000000000000000000000000103080100000000000000030802000000000000000308030000000000000003080400000000000000126170746f733a3a6d657461646174615f7631b60204010000000000000014454e4f4e5f4558495354454e545f4f424a454354144e6f6e2d6578697374656e74206f626a6563742e020000000000000013454e4f4e5f4558495354454e545f4f574e45521e4e6f6e2d6578697374656e74204f776e6572526f6c65206f626a6563742e03000000000000000b454e4f545f5041555345521543616c6c6572206973206e6f74207061757365722e04000000000000000745504155534544114f626a656374206973207061757365642e0505506175736501040007556e70617573650104000a50617573655374617465010301183078313a3a6f626a6563743a3a4f626a65637447726f75700d5061757365724368616e676564010400135061757365537461746544657374726f79656401040002067061757365720101000969735f70617573656401010000020113050102021401100502020113050302031305150516050402011305000100010101090b00380011022004060508070327020101000101010b0a0011092c01130101010b0011091202380102020100010101060e0038022b01100014020301000000190a0011090c020a02110c0407050b0b00010700270b023803040f05130b00010701270b00090b0112012d010204000401010e1a0e0138020c020a022a010c030a031001140b00110921040e05120b0301070227080b030f00150b021200380402050100010101060e0038022b011001140206000401010e1a0e0138020c020a022a010c030a031001140b00110921040e05120b0301070227090b030f00150b021204380502070004010111170e0138020c030b000a03110e0a032a010c050a051001140c040a020b050f01150b030b040b0212033806020100010100", + "0xa11ceb0b0700000a0c01000c020c10031c3d045904055d32078f01b70208c603400686040a10900495010aa505100cb5056c0da1060200000101010201030104000500060600000706000008080001090600000a00010001000b02030001000c04030001050f00030001031006030106010411070800010512080300010113090800010114090a000102150b0300010405040c02060c0501080302060c08030004060c050a020a0a0201080101090001060c010501060803010c03060c0a020a0a020108000a75706772616461626c65076163636f756e7404636f6465056576656e74067369676e65720a6d616e61676561626c650f5061636b6167655570677261646564125369676e65724361704578747261637465640e5369676e657243617053746f7265105369676e65724361706162696c69747912657874726163745f7369676e65725f636170036e65770f757067726164655f7061636b6167650d7265736f757263655f616363740a7369676e65725f6361700f6173736572745f69735f61646d696e04656d69740a616464726573735f6f66136173736572745f61646d696e5f6578697374731d6765745f7369676e65725f6361706162696c6974795f616464726573731d6372656174655f7369676e65725f776974685f6361706162696c697479137075626c6973685f7061636b6167655f74786e512dcea869e20ae8eb1f38c0e9863e3fb667ab1a5e7ee4ccc292124a396b82b0000000000000000000000000000000000000000000000000000000000000000103080100000000000000126170746f733a3a6d657461646174615f7631800101010000000000000016454d49534d4154434845445f5349474e45525f43415034546865205369676e65724361706162696c697479206973206e6f74207468652063616c6c65722773207369676e6572206361702e020f5061636b6167655570677261646564010400125369676e6572436170457874726163746564010400000002010d050102010d050202010e08030001000102010c0b000a0111030a012c0213020c020b01120138000b02020101000003130a00110511060e0111070a00110521040a050e0b00010700270b000b0112022d020202000401020a100b000a0111030a012b02100011080c040e040b020b0311090b011200380102020000" + ] +} diff --git a/test/typescript/transferOwnership.test.ts b/test/typescript/transferOwnership.test.ts new file mode 100644 index 0000000..42b4ce0 --- /dev/null +++ b/test/typescript/transferOwnership.test.ts @@ -0,0 +1,93 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { transferOwnership } from "../../scripts/typescript/transferOwnership"; +import * as aptosExtensionsPackageModule from "../../scripts/typescript/packages/aptosExtensionsPackage"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("transferOwnership", () => { + let aptosExtensionsPackageStub: SinonStub; + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + aptosExtensionsPackageStub = sinon.stub( + aptosExtensionsPackageModule, + "AptosExtensionsPackage" + ); + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the transferOwnership function with correct inputs", async () => { + const aptosExtensionsPackageId = AccountAddress.ZERO.toString(); + const stablecoinPackageId = AccountAddress.ONE.toString(); + const stablecoinAddress = AccountAddress.TWO.toString(); + const owner = Account.generate(); + const newOwner = AccountAddress.THREE.toString(); + const rpcUrl = "http://localhost:8080"; + + const transferOwnershipFn = sinon.fake(); + aptosExtensionsPackageStub.returns({ + ownable: { + transferOwnership: transferOwnershipFn + } + }); + + stablecoinPackageStub.returns({ + stablecoin: { + stablecoinAddress: sinon.fake.returns(stablecoinAddress) + } + }); + + await transferOwnership({ + aptosExtensionsPackageId, + stablecoinPackageId, + ownerKey: owner.privateKey.toString(), + newOwner, + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(aptosExtensionsPackageStub); + sinon.assert.calledWithExactly( + aptosExtensionsPackageStub, + getAptosClient(rpcUrl), + aptosExtensionsPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + transferOwnershipFn.calledOnceWithExactly( + owner, + stablecoinAddress, + newOwner + ), + true + ); + }); +}); diff --git a/test/typescript/updateBlocklister.test.ts b/test/typescript/updateBlocklister.test.ts new file mode 100644 index 0000000..be83988 --- /dev/null +++ b/test/typescript/updateBlocklister.test.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { updateBlocklister } from "../../scripts/typescript/updateBlocklister"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("updateBlocklister", () => { + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the updateBlocklister function with correct inputs", async () => { + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const owner = Account.generate(); + const blocklister = AccountAddress.ONE.toString(); + const newBlocklister = AccountAddress.TWO.toString(); + const rpcUrl = "http://localhost:8080"; + + const updateBlocklisterFn = sinon.fake(); + stablecoinPackageStub.returns({ + blocklistable: { + blocklister: sinon.fake.returns(blocklister), + updateBlocklister: updateBlocklisterFn + } + }); + + await updateBlocklister({ + stablecoinPackageId, + ownerKey: owner.privateKey.toString(), + newBlocklister, + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + updateBlocklisterFn.calledOnceWithExactly(owner, newBlocklister), + true + ); + }); +}); diff --git a/test/typescript/updateMasterMinter.test.ts b/test/typescript/updateMasterMinter.test.ts new file mode 100644 index 0000000..6c8d29a --- /dev/null +++ b/test/typescript/updateMasterMinter.test.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { updateMasterMinter } from "../../scripts/typescript/updateMasterMinter"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("updateMasterMinter", () => { + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the updateMasterMinter function with correct inputs", async () => { + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const owner = Account.generate(); + const masterMinter = AccountAddress.ONE.toString(); + const newMasterMinter = AccountAddress.TWO.toString(); + const rpcUrl = "http://localhost:8080"; + + const updateMasterMinterFn = sinon.fake(); + stablecoinPackageStub.returns({ + treasury: { + masterMinter: sinon.fake.returns(masterMinter), + updateMasterMinter: updateMasterMinterFn + } + }); + + await updateMasterMinter({ + stablecoinPackageId, + ownerKey: owner.privateKey.toString(), + newMasterMinter, + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + updateMasterMinterFn.calledOnceWithExactly(owner, newMasterMinter), + true + ); + }); +}); diff --git a/test/typescript/updateMetadataUpdater.test.ts b/test/typescript/updateMetadataUpdater.test.ts new file mode 100644 index 0000000..14fca9b --- /dev/null +++ b/test/typescript/updateMetadataUpdater.test.ts @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { updateMetadataUpdater } from "../../scripts/typescript/updateMetadataUpdater"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("updateMetadataUpdater", () => { + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the updateMetadataUpdater function with correct inputs", async () => { + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const owner = Account.generate(); + const metadataUpdater = AccountAddress.ONE.toString(); + const newMetadataUpdater = AccountAddress.TWO.toString(); + const rpcUrl = "http://localhost:8080"; + + const updateMetadataUpdaterFn = sinon.fake(); + stablecoinPackageStub.returns({ + metadata: { + metadataUpdater: sinon.fake.returns(metadataUpdater), + updateMetadataUpdater: updateMetadataUpdaterFn + } + }); + + await updateMetadataUpdater({ + stablecoinPackageId, + ownerKey: owner.privateKey.toString(), + newMetadataUpdater, + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(stablecoinPackageStub); + sinon.assert.calledWithExactly( + stablecoinPackageStub, + getAptosClient(rpcUrl), + stablecoinPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + updateMetadataUpdaterFn.calledOnceWithExactly(owner, newMetadataUpdater), + true + ); + }); +}); diff --git a/test/typescript/updatePauser.test.ts b/test/typescript/updatePauser.test.ts new file mode 100644 index 0000000..8462bc0 --- /dev/null +++ b/test/typescript/updatePauser.test.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import { updatePauser } from "../../scripts/typescript/updatePauser"; +import * as aptosExtensionsPackageModule from "../../scripts/typescript/packages/aptosExtensionsPackage"; +import * as stablecoinPackageModule from "../../scripts/typescript/packages/stablecoinPackage"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("updatePauser", () => { + let aptosExtensionsPackageStub: SinonStub; + let stablecoinPackageStub: SinonStub; + + beforeEach(() => { + aptosExtensionsPackageStub = sinon.stub( + aptosExtensionsPackageModule, + "AptosExtensionsPackage" + ); + stablecoinPackageStub = sinon.stub( + stablecoinPackageModule, + "StablecoinPackage" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the updatePauser function with correct inputs", async () => { + const aptosExtensionsPackageId = AccountAddress.ZERO.toString(); + const stablecoinPackageId = AccountAddress.ONE.toString(); + const stablecoinAddress = AccountAddress.TWO.toString(); + const owner = Account.generate(); + const pauser = AccountAddress.THREE.toString(); + const newPauser = AccountAddress.FOUR.toString(); + const rpcUrl = "http://localhost:8080"; + + const updatePauserFn = sinon.fake(); + aptosExtensionsPackageStub.returns({ + pausable: { + pauser: sinon.fake.returns(pauser), + updatePauser: updatePauserFn + } + }); + + stablecoinPackageStub.returns({ + stablecoin: { + stablecoinAddress: sinon.fake.returns(stablecoinAddress) + } + }); + + await updatePauser({ + aptosExtensionsPackageId, + stablecoinPackageId, + ownerKey: owner.privateKey.toString(), + newPauser, + rpcUrl + }); + + // Ensure that the request will be made to the correct package. + sinon.assert.calledWithNew(aptosExtensionsPackageStub); + sinon.assert.calledWithExactly( + aptosExtensionsPackageStub, + getAptosClient(rpcUrl), + aptosExtensionsPackageId + ); + + // Ensure that the request is correct. + assert.strictEqual( + updatePauserFn.calledOnceWithExactly(owner, stablecoinAddress, newPauser), + true + ); + }); +}); diff --git a/test/typescript/upgradeStablecoinPackage.test.ts b/test/typescript/upgradeStablecoinPackage.test.ts new file mode 100644 index 0000000..8b49557 --- /dev/null +++ b/test/typescript/upgradeStablecoinPackage.test.ts @@ -0,0 +1,94 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import sinon, { SinonStub } from "sinon"; +import * as aptosExtensionsPackageModule from "../../scripts/typescript/packages/aptosExtensionsPackage"; +import { upgradeStablecoinPackage } from "../../scripts/typescript/upgradeStablecoinPackage"; +import * as publishPayloadModule from "../../scripts/typescript/utils/publishPayload"; +import { getAptosClient } from "../../scripts/typescript/utils"; + +describe("Upgrade package", () => { + let aptosExtensionsPackageStub: SinonStub; + let readPublishPayloadStub: SinonStub; + + beforeEach(() => { + aptosExtensionsPackageStub = sinon.stub( + aptosExtensionsPackageModule, + "AptosExtensionsPackage" + ); + + readPublishPayloadStub = sinon.stub( + publishPayloadModule, + "readPublishPayload" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the upgradePackage function with correct inputs", async () => { + const deployer = Account.generate(); + const rpcUrl = "http://localhost:8080"; + const stablecoinPackageId = AccountAddress.ZERO.toString(); + const aptosExtensionsPackageId = AccountAddress.ONE.toString(); + const metadataBytes = "0x10"; + const bytecode = ["0x11", "0x12"]; + + const upgradePackageFn = sinon.fake(); + aptosExtensionsPackageStub.returns({ + upgradable: { + upgradePackage: upgradePackageFn + } + }); + + readPublishPayloadStub.returns({ + args: [ + { type: "hex", value: metadataBytes }, + { type: "hex", value: bytecode } + ] + }); + + await upgradeStablecoinPackage({ + adminKey: deployer.privateKey.toString(), + rpcUrl, + payloadFilePath: "payloadFilePath", + stablecoinPackageId, + aptosExtensionsPackageId + }); + + sinon.assert.calledWithNew(aptosExtensionsPackageStub); + sinon.assert.calledWithExactly( + aptosExtensionsPackageStub, + getAptosClient(rpcUrl), + aptosExtensionsPackageId + ); + + assert.strictEqual( + upgradePackageFn.calledOnceWithExactly( + deployer, + stablecoinPackageId, + metadataBytes, + bytecode + ), + true + ); + }); +}); diff --git a/test/typescript/utils/deployUtils.test.ts b/test/typescript/utils/deployUtils.test.ts new file mode 100644 index 0000000..7db86af --- /dev/null +++ b/test/typescript/utils/deployUtils.test.ts @@ -0,0 +1,137 @@ +/** + * 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. + */ + +import { + Aptos, + createResourceAddress, + Ed25519Account +} from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import { randomBytes } from "crypto"; +import { generateKeypair } from "../../../scripts/typescript/generateKeypair"; +import { + getAptosClient, + getPackageMetadata +} from "../../../scripts/typescript/utils"; +import { publishPackageToResourceAccount } from "../../../scripts/typescript/utils/deployUtils"; +import { validateSourceCodeExistence } from "../testUtils"; + +describe("Deploy utils", () => { + let aptos: Aptos; + let aptosExtensionsPackageId: string; + let deployer: Ed25519Account; + + before(async () => { + aptos = getAptosClient(); + deployer = await generateKeypair({ prefund: true }); + [aptosExtensionsPackageId] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed: new Uint8Array(Buffer.from("aptos_extensions")), + namedDeps: [ + { name: "deployer", address: deployer.accountAddress.toString() } + ], + verifySource: false + }); + }); + + it("should succeed when publishing aptos_extensions package via resource account", async () => { + const seed = new Uint8Array(randomBytes(16)); + const verifySource = false; + + const [packageId, txOutput] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed, + namedDeps: [ + { name: "deployer", address: deployer.accountAddress.toString() } + ], + verifySource + }); + const expectedCodeAddress = createResourceAddress( + deployer.accountAddress, + seed + ).toString(); + + assert.strictEqual(txOutput.events.length, 2); // PublishPackage and FeeStatement + assert.strictEqual(packageId, expectedCodeAddress); + + const packageMetadata = await getPackageMetadata( + aptos, + packageId, + "AptosExtensions" + ); + validateSourceCodeExistence(packageMetadata, verifySource); + }); + + it("should succeed when publishing stablecoin package via resource account", async () => { + const seed = new Uint8Array(randomBytes(16)); + const verifySource = false; + + const [packageId, txOutput] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "stablecoin", + seed, + namedDeps: [ + { name: "aptos_extensions", address: aptosExtensionsPackageId }, + { name: "deployer", address: deployer.accountAddress.toString() } + ], + verifySource + }); + const expectedCodeAddress = createResourceAddress( + deployer.accountAddress, + seed + ).toString(); + + assert.strictEqual(txOutput.events.length, 2); // PublishPackage and FeeStatement + assert.strictEqual(packageId, expectedCodeAddress); + + const packageMetadata = await getPackageMetadata( + aptos, + packageId, + "Stablecoin" + ); + validateSourceCodeExistence(packageMetadata, verifySource); + }); + + it("should succeed when publishing package with source code verification enabled", async () => { + const seed = new Uint8Array(randomBytes(16)); + const verifySource = true; + + const [packageId] = await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed, + namedDeps: [ + { name: "deployer", address: deployer.accountAddress.toString() } + ], + verifySource + }); + + const packageMetadata = await getPackageMetadata( + aptos, + packageId, + "AptosExtensions" + ); + validateSourceCodeExistence(packageMetadata, verifySource); + }); +}); diff --git a/test/typescript/utils/publishPayload.test.ts b/test/typescript/utils/publishPayload.test.ts new file mode 100644 index 0000000..a068c92 --- /dev/null +++ b/test/typescript/utils/publishPayload.test.ts @@ -0,0 +1,83 @@ +/** + * 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. + */ + +import { strict as assert } from "assert"; +import fs from "fs"; +import sinon, { SinonStub } from "sinon"; +import { + PublishPayload, + readPublishPayload +} from "../../../scripts/typescript/utils/publishPayload"; +import { randomBytes } from "crypto"; + +describe("publishPayload", () => { + let existsSyncStub: SinonStub; + let readFileSyncStub: SinonStub; + let validPublishPayload: PublishPayload; + + beforeEach(() => { + existsSyncStub = sinon.stub(fs, "existsSync"); + readFileSyncStub = sinon.stub(fs, "readFileSync"); + + validPublishPayload = { + args: [ + { + type: "hex", + value: `0x${randomBytes(16).toString("hex")}` + }, + { + type: "hex", + value: [ + `0x${randomBytes(16).toString("hex")}`, + `0x${randomBytes(16).toString("hex")}` + ] + } + ] + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should succeed", () => { + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify(validPublishPayload)); + + readPublishPayload("tokenConfigPath"); + }); + + it("should fail if config file does not exist", () => { + existsSyncStub.returns(false); + + assert.throws( + () => readPublishPayload("tokenConfigPath"), + /Failed to load payload file.*/ + ); + }); + + it("should fail if config file format is incorrect", () => { + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify({})); + + assert.throws( + () => readPublishPayload("tokenConfigPath"), + /ValidationError:.*/ + ); + }); +}); diff --git a/test/typescript/utils/tokenConfig.test.ts b/test/typescript/utils/tokenConfig.test.ts new file mode 100644 index 0000000..94af68a --- /dev/null +++ b/test/typescript/utils/tokenConfig.test.ts @@ -0,0 +1,130 @@ +/** + * 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. + */ + +import { Account, AccountAddress } from "@aptos-labs/ts-sdk"; +import { strict as assert } from "assert"; +import fs from "fs"; +import sinon, { SinonStub } from "sinon"; +import { + readTokenConfig, + TokenConfig +} from "../../../scripts/typescript/utils/tokenConfig"; + +describe("tokenConfig", () => { + let existsSyncStub: SinonStub; + let readFileSyncStub: SinonStub; + let validTokenConfig: TokenConfig; + + beforeEach(() => { + existsSyncStub = sinon.stub(fs, "existsSync"); + readFileSyncStub = sinon.stub(fs, "readFileSync"); + + validTokenConfig = { + name: "name", + symbol: "symbol", + decimals: 6, + iconUri: "http://icon_uri.com", + projectUri: "http://project_uri.com", + + admin: AccountAddress.ZERO.toString(), + blocklister: AccountAddress.ONE.toString(), + masterMinter: AccountAddress.TWO.toString(), + metadataUpdater: AccountAddress.THREE.toString(), + owner: AccountAddress.FOUR.toString(), + pauser: AccountAddress.from("0x05").toString(), + controllers: { + [AccountAddress.from("0x06").toString()]: + AccountAddress.from("0x07").toString() + }, + minters: { + [AccountAddress.from("0x07").toString()]: "100000" + } + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + const randomAddress = (): string => + Account.generate().accountAddress.toString(); + + it("should succeed", () => { + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify(validTokenConfig)); + + readTokenConfig("tokenConfigPath"); + }); + + it("should fail if config file does not exist", () => { + existsSyncStub.returns(false); + + assert.throws( + () => readTokenConfig("tokenConfigPath"), + /Failed to load config file.*/ + ); + }); + + it("should fail if config file format is incorrect", () => { + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify({})); + + assert.throws( + () => readTokenConfig("tokenConfigPath"), + /ValidationError:.*/ + ); + }); + + it("should fail if there are no controllers controlling a minter", () => { + validTokenConfig.minters[randomAddress()] = "200000"; + + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify(validTokenConfig)); + + assert.throws( + () => readTokenConfig("tokenConfigPath"), + /The set of minters in tokenConfig.controllers does not match the set of minters in tokenConfig.minters!/ + ); + }); + + it("should fail if there are no mint allowance for defined for a minter", () => { + validTokenConfig.controllers[randomAddress()] = randomAddress(); + + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify(validTokenConfig)); + + assert.throws( + () => readTokenConfig("tokenConfigPath"), + /The set of minters in tokenConfig.controllers does not match the set of minters in tokenConfig.minters!/ + ); + }); + + it("should fail if mint allowance is larger than MAX_U64", () => { + const minter = randomAddress(); + validTokenConfig.controllers[randomAddress()] = minter; + validTokenConfig.minters[minter] = (BigInt(2) ** BigInt(64)).toString(); + + existsSyncStub.returns(true); + readFileSyncStub.returns(JSON.stringify(validTokenConfig)); + + assert.throws( + () => readTokenConfig("tokenConfigPath"), + /There are mint allowances that exceed MAX_U64!/ + ); + }); +}); diff --git a/test/typescript/validateStablecoinState.test.ts b/test/typescript/validateStablecoinState.test.ts new file mode 100644 index 0000000..90f4043 --- /dev/null +++ b/test/typescript/validateStablecoinState.test.ts @@ -0,0 +1,433 @@ +/** + * 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. + */ + +import { strict as assert } from "assert"; +import fs from "fs"; +import sinon, { SinonStub } from "sinon"; +import { deployAndInitializeToken } from "../../scripts/typescript/deployAndInitializeToken"; +import { generateKeypair } from "../../scripts/typescript/generateKeypair"; +import { + getAptosClient, + LOCAL_RPC_URL, + normalizeAddress +} from "../../scripts/typescript/utils"; +import { TokenConfig } from "../../scripts/typescript/utils/tokenConfig"; +import { + validateStablecoinState, + ConfigFile as ValidateStablecoinStateConfigFile +} from "../../scripts/typescript/validateStablecoinState"; +import { generateKeypairs } from "./testUtils"; +import { Ed25519Account } from "@aptos-labs/ts-sdk"; +import { StablecoinPackage } from "../../scripts/typescript/packages/stablecoinPackage"; + +type DeploymentInfo = { + deployer: Ed25519Account; + packages: Awaited>; +}; + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + +describe("validateStablecoinState", () => { + const TEST_TOKEN_CONFIG_PATH = "path/to/token_config.json"; + const TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH = + "path/to/validate_stablecoin_state_config.json"; + + const aptos = getAptosClient(LOCAL_RPC_URL); + + let readFileSyncStub: SinonStub; + let tokenConfig: TokenConfig; + + let admin: Ed25519Account; + let blocklister: Ed25519Account; + let masterMinter: Ed25519Account; + let metadataUpdater: Ed25519Account; + let owner: Ed25519Account; + let pauser: Ed25519Account; + let controller: Ed25519Account; + let minter: Ed25519Account; + + let sourceCodeVerifiedPackages: DeploymentInfo; + let sourceCodeUnverifiedPackages: DeploymentInfo; + let stablecoinPackage: StablecoinPackage; + + before(async () => { + const existsSyncStub = sinon.stub(fs, "existsSync"); + existsSyncStub.returns(true); + + [blocklister] = await generateKeypairs(1, true); + + [admin, masterMinter, metadataUpdater, owner, pauser, controller, minter] = + await generateKeypairs(7, false); + + tokenConfig = { + name: "USDC", + symbol: "USDC", + decimals: 6, + iconUri: "https://circle.com/usdc-icon", + projectUri: "https://circle.com/usdc", + + admin: admin.accountAddress.toString(), + blocklister: blocklister.accountAddress.toString(), + masterMinter: masterMinter.accountAddress.toString(), + metadataUpdater: metadataUpdater.accountAddress.toString(), + owner: owner.accountAddress.toString(), + pauser: pauser.accountAddress.toString(), + + controllers: { + [controller.accountAddress.toString()]: minter.accountAddress.toString() + }, + minters: { + [minter.accountAddress.toString()]: "1000000000" + } + }; + + readFileSyncStub = sinon.stub(fs, "readFileSync"); + readFileSyncStub.callThrough(); + readFileSyncStub + .withArgs(TEST_TOKEN_CONFIG_PATH) + .returns(JSON.stringify(tokenConfig)); + + const verifiedPackagesDeployer = await generateKeypair({ prefund: true }); + sourceCodeVerifiedPackages = { + deployer: verifiedPackagesDeployer, + packages: await deployAndInitializeToken({ + deployerKey: verifiedPackagesDeployer.privateKey.toString(), + rpcUrl: LOCAL_RPC_URL, + verifySource: true, + tokenConfigPath: TEST_TOKEN_CONFIG_PATH + }) + }; + + const unverifiedPackagesDeployer = await generateKeypair({ prefund: true }); + sourceCodeUnverifiedPackages = { + deployer: unverifiedPackagesDeployer, + packages: await deployAndInitializeToken({ + deployerKey: unverifiedPackagesDeployer.privateKey.toString(), + rpcUrl: LOCAL_RPC_URL, + verifySource: false, + tokenConfigPath: TEST_TOKEN_CONFIG_PATH + }) + }; + + stablecoinPackage = new StablecoinPackage( + aptos, + sourceCodeUnverifiedPackages.packages.stablecoinPackageId + ); + }); + + beforeEach(async () => { + readFileSyncStub.restore(); + readFileSyncStub = sinon.stub(fs, "readFileSync"); + }); + + after(() => { + sinon.restore(); + }); + + async function setup( + deploymentInfo: DeploymentInfo, + configOverrides: DeepPartial + ) { + const baseValidateStablecoinStateConfig: ValidateStablecoinStateConfigFile = + { + aptosExtensionsPackageId: + deploymentInfo.packages.aptosExtensionsPackageId, + stablecoinPackageId: deploymentInfo.packages.stablecoinPackageId, + expectedStates: { + aptosExtensionsPackage: { + upgradeNumber: 0, + upgradePolicy: "immutable", + sourceCodeExists: false + }, + stablecoinPackage: { + upgradeNumber: 0, + upgradePolicy: "compatible", + sourceCodeExists: false + }, + + name: tokenConfig.name, + symbol: tokenConfig.symbol, + decimals: tokenConfig.decimals, + iconUri: tokenConfig.iconUri, + projectUri: tokenConfig.projectUri, + paused: false, + initializedVersion: 1, + totalSupply: "0", + + admin: normalizeAddress( + deploymentInfo.deployer.accountAddress.toString() + ), + blocklister: tokenConfig.blocklister, + masterMinter: tokenConfig.masterMinter, + metadataUpdater: tokenConfig.metadataUpdater, + owner: normalizeAddress( + deploymentInfo.deployer.accountAddress.toString() + ), + pauser: tokenConfig.pauser, + pendingAdmin: tokenConfig.admin, + pendingOwner: tokenConfig.owner, + + controllers: tokenConfig.controllers, + minters: tokenConfig.minters, + blocklist: [] + } + }; + + const overriddenConfig = { + ...baseValidateStablecoinStateConfig, + ...configOverrides, + expectedStates: { + ...baseValidateStablecoinStateConfig.expectedStates, + ...(configOverrides.expectedStates ?? {}), + aptosExtensionsPackage: { + ...baseValidateStablecoinStateConfig.expectedStates + .aptosExtensionsPackage, + ...(configOverrides.expectedStates?.aptosExtensionsPackage ?? {}) + }, + stablecoinPackage: { + ...baseValidateStablecoinStateConfig.expectedStates.stablecoinPackage, + ...(configOverrides.expectedStates?.stablecoinPackage ?? {}) + }, + controllers: + configOverrides.expectedStates?.controllers ?? + baseValidateStablecoinStateConfig.expectedStates.controllers, + minters: + configOverrides.expectedStates?.minters ?? + baseValidateStablecoinStateConfig.expectedStates.minters, + blocklist: configOverrides.expectedStates?.blocklist ?? [] + } + }; + + readFileSyncStub + .withArgs(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH) + .returns(JSON.stringify(overriddenConfig)); + } + + it("should succeed if all states matches", async () => { + await setup(sourceCodeUnverifiedPackages, {}); + await validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }); + }); + + it("should succeed if all states matches", async () => { + await setup(sourceCodeVerifiedPackages, { + expectedStates: { + aptosExtensionsPackage: { sourceCodeExists: true }, + stablecoinPackage: { sourceCodeExists: true } + } + }); + await validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }); + }); + + const expectedStatesTestCases: [ + string, + DeepPartial + ][] = [ + [ + "should fail if aptosExtensionsPackage metadata is mismatched", + { aptosExtensionsPackage: { sourceCodeExists: true } } + ], + [ + "should fail if stablecoinPackage metadata is mismatched", + { stablecoinPackage: { sourceCodeExists: true } } + ], + ["should fail if name is mismatched", { name: "name" }], + ["should fail if symbol is mismatched", { symbol: "symbol" }], + ["should fail if decimals is mismatched", { decimals: 0 }], + ["should fail if iconUri is mismatched", { iconUri: "http://iconUri.com" }], + [ + "should fail if projectUri is mismatched", + { projectUri: "http://projectUri.com" } + ], + ["should fail if paused is mismatched", { paused: true }], + [ + "should fail if initializedVersion is mismatched", + { initializedVersion: 0 } + ], + ["should fail if admin is mismatched", { admin: normalizeAddress("0x1") }], + [ + "should fail if blocklister is mismatched", + { blocklister: normalizeAddress("0x1") } + ], + [ + "should fail if masterMinter is mismatched", + { masterMinter: normalizeAddress("0x1") } + ], + [ + "should fail if metadataUpdater is mismatched", + { metadataUpdater: normalizeAddress("0x1") } + ], + ["should fail if owner is mismatched", { owner: normalizeAddress("0x1") }], + [ + "should fail if pauser is mismatched", + { pauser: normalizeAddress("0x1") } + ], + [ + "should fail if pendingOwner is mismatched", + { pendingOwner: normalizeAddress("0x1") } + ], + [ + "should fail if pendingAdmin is mismatched", + { pendingAdmin: normalizeAddress("0x1") } + ], + ["should fail if totalSupply is mismatched", { totalSupply: "999" }] + ]; + + for (const [title, expectedStatesOverride] of expectedStatesTestCases) { + it(title, async () => { + await setup(sourceCodeUnverifiedPackages, { + expectedStates: expectedStatesOverride + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /AssertionError.*: Expected values to be strictly deep-equal/ + ); + }); + } + + it("should fail if a controller is unconfigured", async () => { + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + controllers: { [normalizeAddress("0x1")]: normalizeAddress("0x2") } + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Invalid controller configuration/ + ); + }); + + it("should fail if a controller is misconfigured", async () => { + const currentController = Object.keys(tokenConfig.controllers)[0]; + + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + controllers: { [currentController]: normalizeAddress("0x2") } + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Invalid controller configuration/ + ); + }); + + it("should fail if additional controllers are configured", async () => { + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + controllers: {} + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Additional controllers configured/ + ); + }); + + it("should fail if a minter is unconfigured", async () => { + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + minters: { [normalizeAddress("0x1")]: "1000" } + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Invalid minter configuration/ + ); + }); + + it("should fail if a minter is misconfigured", async () => { + const currentMinter = Object.keys(tokenConfig.minters)[0]; + + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + minters: { [currentMinter]: "1000" } + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Invalid minter configuration/ + ); + }); + + it("should fail if additional minters are configured", async () => { + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + minters: {} + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Additional minters configured/ + ); + }); + + it("should fail if an address is not blocklisted", async () => { + await setup(sourceCodeUnverifiedPackages, { + expectedStates: { + blocklist: [normalizeAddress("0x1")] + } + }); + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Invalid blocklist configuration/ + ); + }); + + it("should fail if additional addresses are blocklisted", async () => { + await setup(sourceCodeUnverifiedPackages, {}); + + const addressToBlock = normalizeAddress("0x111"); + await stablecoinPackage.blocklistable.blocklist( + blocklister, + addressToBlock + ); + + await assert.rejects( + validateStablecoinState(TEST_VALIDATE_STABLECOIN_STATE_CONFIG_PATH, { + rpcUrl: LOCAL_RPC_URL + }), + /Additional addresses blocklisted/ + ); + // Reset blocklisted address + await stablecoinPackage.blocklistable.unblocklist( + blocklister, + addressToBlock + ); + }); +}); diff --git a/test/typescript/verifyPackage.test.ts b/test/typescript/verifyPackage.test.ts new file mode 100644 index 0000000..b1696c9 --- /dev/null +++ b/test/typescript/verifyPackage.test.ts @@ -0,0 +1,261 @@ +/** + * 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. + */ + +import { Aptos, Ed25519Account, MoveVector } from "@aptos-labs/ts-sdk"; +import fs from "fs"; +import path from "path"; +import { verifyPackage } from "../../scripts/typescript/verifyPackage"; +import { generateKeypair } from "../../scripts/typescript/generateKeypair"; +import { + executeTransaction, + getAptosClient, + LOCAL_RPC_URL, + REPOSITORY_ROOT +} from "../../scripts/typescript/utils"; + +import { strict as assert } from "assert"; +import { calculateDeploymentAddresses } from "../../scripts/typescript/calculateDeploymentAddresses"; +import { + buildPackage, + publishPackageToResourceAccount +} from "../../scripts/typescript/utils/deployUtils"; + +describe("Verify package", () => { + const aptos: Aptos = getAptosClient(); + + let deployer: Ed25519Account; + let deployerAddress: string; + let aptosExtensionsPackageId: string; + let stablecoinPackageId: string; + + beforeEach(async () => { + deployer = await generateKeypair({ prefund: true }); + deployerAddress = deployer.accountAddress.toString(); + ({ aptosExtensionsPackageId, stablecoinPackageId } = + calculateDeploymentAddresses({ + deployer: deployerAddress, + aptosExtensionsSeed: "aptos_extensions", + stablecoinSeed: "stablecoin" + })); + }); + + it("Should succeed when verifying matching bytecode and metadata", async () => { + await publishDefaultAptosExtensionsPkg(); + await publishDefaultStablecoinPkg(); + + const aptosExtensionsResult = await verifyPackage({ + packageName: "aptos_extensions", + packageId: aptosExtensionsPackageId, + namedDeps: [{ name: "deployer", address: deployerAddress }], + rpcUrl: LOCAL_RPC_URL, + sourceUploaded: false + }); + const stablecoinResult = await verifyPackage({ + packageName: "stablecoin", + packageId: stablecoinPackageId, + namedDeps: [ + { name: "deployer", address: deployerAddress }, + { name: "aptos_extensions", address: aptosExtensionsPackageId } + ], + rpcUrl: LOCAL_RPC_URL, + sourceUploaded: false + }); + + assert.deepEqual(aptosExtensionsResult, { + packageName: "aptos_extensions", + bytecodeVerified: true, + metadataVerified: true + }); + assert.deepEqual(stablecoinResult, { + packageName: "stablecoin", + bytecodeVerified: true, + metadataVerified: true + }); + }); + + it("Should report failure when package deps do not match", async () => { + const defaultAptosExtensionsPackageId = aptosExtensionsPackageId; + await publishDefaultAptosExtensionsPkg(); + + // Deploy stablecoin package with alternative aptos_extensions package ID + const alternativeSeed = "aptos_extensions_alternative"; + const alternativeAptosExtensionsPackageId = calculateDeploymentAddresses({ + deployer: deployerAddress, + aptosExtensionsSeed: alternativeSeed, + stablecoinSeed: "" /* not needed */ + }).aptosExtensionsPackageId; + + await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed: Uint8Array.from(Buffer.from(alternativeSeed)), + namedDeps: [{ name: "deployer", address: deployerAddress }], + verifySource: false + }); + await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "stablecoin", + seed: Uint8Array.from(Buffer.from("stablecoin")), + namedDeps: [ + { name: "deployer", address: deployerAddress }, + { + name: "aptos_extensions", + address: alternativeAptosExtensionsPackageId + } + ], + verifySource: false + }); + + const result = await verifyPackage({ + packageName: "stablecoin", + packageId: stablecoinPackageId, + namedDeps: [ + { name: "deployer", address: deployerAddress }, + { name: "aptos_extensions", address: defaultAptosExtensionsPackageId } + ], + rpcUrl: LOCAL_RPC_URL, + sourceUploaded: false + }); + + assert.deepEqual(result, { + packageName: "stablecoin", + bytecodeVerified: false, + metadataVerified: false + }); + }); + + it("Should report failure when package bytecode does not match", async () => { + // This file contains bytecode compiled from an altered version of the aptos_extensions package + const maliciousBytecodeFilePath = path.join( + REPOSITORY_ROOT, + "test/typescript/testdata/malicious_bytecode.json" + ); + const { + deployer: originalDeployer, + aptos_extensions_package_id: originalAptosExtensionsPackageId, + bytecode: rawMaliciousBytecode + } = JSON.parse(fs.readFileSync(maliciousBytecodeFilePath).toString()); + + // Replace dependency addresses with current deployer and aptos_extensions package ID + // Without this step, the bytecode would not be deployable + const maliciousBytecode = rawMaliciousBytecode.map( + (moduleBytecode: string) => + moduleBytecode + .replace( + originalDeployer.replace(/^0x/, ""), + deployerAddress.replace(/^0x/, "") + ) + .replace( + originalAptosExtensionsPackageId.replace(/^0x/, ""), + aptosExtensionsPackageId.replace(/^0x/, "") + ) + ); + + // Calculate metadata bytes from unaltered package + const { metadataBytes } = await buildPackage( + "aptos_extensions", + [ + { name: "deployer", address: deployerAddress }, + { name: "aptos_extensions", address: aptosExtensionsPackageId } + ], + false + ); + + // Deploy package with innocent (fake) metadata and malicious bytecode + await executeTransaction({ + aptos, + sender: deployer, + data: { + function: + "0x1::resource_account::create_resource_account_and_publish_package", + functionArguments: [ + MoveVector.U8(Uint8Array.from(Buffer.from("aptos_extensions"))), + MoveVector.U8(metadataBytes), + new MoveVector(maliciousBytecode.map(MoveVector.U8)) + ] + } + }); + + const result = await verifyPackage({ + packageName: "aptos_extensions", + packageId: aptosExtensionsPackageId, + namedDeps: [{ name: "deployer", address: deployerAddress }], + rpcUrl: LOCAL_RPC_URL, + sourceUploaded: false + }); + + assert.deepEqual(result, { + packageName: "aptos_extensions", + bytecodeVerified: false, + metadataVerified: true + }); + }); + + it("Should report failure when package metadata does not match", async () => { + const verifySource = true; + await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed: Uint8Array.from(Buffer.from("aptos_extensions")), + namedDeps: [{ name: "deployer", address: deployerAddress }], + verifySource + }); + + const result = await verifyPackage({ + packageName: "aptos_extensions", + packageId: aptosExtensionsPackageId, + namedDeps: [{ name: "deployer", address: deployerAddress }], + rpcUrl: LOCAL_RPC_URL, + sourceUploaded: false + }); + + assert.deepEqual(result, { + packageName: "aptos_extensions", + bytecodeVerified: true, + metadataVerified: false + }); + }); + + async function publishDefaultAptosExtensionsPkg() { + await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "aptos_extensions", + seed: Uint8Array.from(Buffer.from("aptos_extensions")), + namedDeps: [{ name: "deployer", address: deployerAddress }], + verifySource: false + }); + } + + async function publishDefaultStablecoinPkg() { + await publishPackageToResourceAccount({ + aptos, + deployer, + packageName: "stablecoin", + seed: Uint8Array.from(Buffer.from("stablecoin")), + namedDeps: [ + { name: "deployer", address: deployerAddress }, + { name: "aptos_extensions", address: aptosExtensionsPackageId } + ], + verifySource: false + }); + } +}); diff --git a/test/typescript/verifyV1Packages.test.ts b/test/typescript/verifyV1Packages.test.ts new file mode 100644 index 0000000..56dffbf --- /dev/null +++ b/test/typescript/verifyV1Packages.test.ts @@ -0,0 +1,98 @@ +/** + * 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. + */ + +import { strict as assert } from "assert"; +import sinon from "sinon"; +import { verifyV1Packages } from "../../scripts/typescript/verifyV1Packages"; +import { calculateDeploymentAddresses } from "../../scripts/typescript/calculateDeploymentAddresses"; +import * as verifyPackageModule from "../../scripts/typescript/verifyPackage"; +import { Account } from "@aptos-labs/ts-sdk"; + +describe("Verify V1 packages", () => { + let deployerAddress: string; + let aptosExtensionsPackageId: string; + let stablecoinPackageId: string; + + beforeEach(async () => { + deployerAddress = Account.generate().accountAddress.toString(); + ({ aptosExtensionsPackageId, stablecoinPackageId } = + calculateDeploymentAddresses({ + deployer: deployerAddress, + aptosExtensionsSeed: "package_name", + stablecoinSeed: "stablecoin" + })); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should call the verifyPackage function with correct inputs", async () => { + const rpcUrl = "http://localhost:8080"; + const sourceUploaded = false; + + const stubbedResult = { + packageName: "package_name", + bytecodeVerified: true, + metadataVerified: true + }; + const verifyPackageStub = sinon + .stub(verifyPackageModule, "verifyPackage") + .resolves(stubbedResult); + + const results = await verifyV1Packages({ + deployer: deployerAddress, + aptosExtensionsPackageId, + stablecoinPackageId, + rpcUrl, + sourceUploaded + }); + + sinon.assert.calledTwice(verifyPackageStub); + sinon.assert.calledWithExactly(verifyPackageStub, { + packageName: "aptos_extensions", + packageId: aptosExtensionsPackageId, + namedDeps: [ + { + name: "deployer", + address: deployerAddress + } + ], + rpcUrl, + sourceUploaded + }); + sinon.assert.calledWithExactly(verifyPackageStub, { + packageName: "stablecoin", + packageId: stablecoinPackageId, + namedDeps: [ + { + name: "deployer", + address: deployerAddress + }, + { + name: "aptos_extensions", + address: aptosExtensionsPackageId + } + ], + rpcUrl, + sourceUploaded + }); + + assert.deepEqual(results, [stubbedResult, stubbedResult]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6bf3e29 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "target": "es2019", + "outDir": "build", + "module": "commonjs", + "moduleResolution": "node", + "noErrorTruncation": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..7a30d7c --- /dev/null +++ b/yarn.lock @@ -0,0 +1,2100 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aptos-labs/aptos-cli@^0.2.0": + version "0.2.0" + resolved "https://registry.npmjs.org/@aptos-labs/aptos-cli/-/aptos-cli-0.2.0.tgz" + integrity sha512-6kljJFRsTLXCvgkNhBoOLhVyo7rmih+8+XAtdeciIXkZYwzwVS3TFPLMqBUO2HcY6gYtQQRmTG52R5ihyi/bXA== + +"@aptos-labs/aptos-client@^0.1.1": + version "0.1.1" + resolved "https://registry.npmjs.org/@aptos-labs/aptos-client/-/aptos-client-0.1.1.tgz" + integrity sha512-kJsoy4fAPTOhzVr7Vwq8s/AUg6BQiJDa7WOqRzev4zsuIS3+JCuIZ6vUd7UBsjnxtmguJJulMRs9qWCzVBt2XA== + dependencies: + axios "1.7.4" + got "^11.8.6" + +"@aptos-labs/ts-sdk@1.29.1": + version "1.29.1" + resolved "https://registry.npmjs.org/@aptos-labs/ts-sdk/-/ts-sdk-1.29.1.tgz" + integrity sha512-cQsXBTbSG5XzrulzEgSMc7BPUl6jw257b248nBDYkUv6tMHkWlxuFKF6F2l+6Z8/buNIQKNPZnaZKT26DjVKug== + dependencies: + "@aptos-labs/aptos-cli" "^0.2.0" + "@aptos-labs/aptos-client" "^0.1.1" + "@noble/curves" "^1.4.0" + "@noble/hashes" "^1.4.0" + "@scure/bip32" "^1.4.0" + "@scure/bip39" "^1.3.0" + eventemitter3 "^5.0.1" + form-data "^4.0.0" + js-base64 "^3.7.7" + jwt-decode "^4.0.0" + poseidon-lite "^0.2.0" + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": + version "4.4.0" + resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.6.1": + version "4.11.1" + resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz" + integrity sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q== + +"@eslint/config-array@^0.18.0": + version "0.18.0" + resolved "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz" + integrity sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw== + dependencies: + "@eslint/object-schema" "^2.1.4" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/core@^0.7.0": + version "0.7.0" + resolved "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz" + integrity sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/eslintrc@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz" + integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.1": + version "8.57.1" + resolved "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz" + integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== + +"@eslint/js@9.13.0": + version "9.13.0" + resolved "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz" + integrity sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA== + +"@eslint/object-schema@^2.1.4": + version "2.1.4" + resolved "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz" + integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ== + +"@eslint/plugin-kit@^0.2.0": + version "0.2.2" + resolved "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz" + integrity sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw== + dependencies: + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.5": + version "0.16.6" + resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz" + integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.3.0" + +"@humanwhocodes/config-array@^0.13.0": + version "0.13.0" + resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz" + integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== + dependencies: + "@humanwhocodes/object-schema" "^2.0.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.3": + version "2.0.3" + resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@humanwhocodes/retry@^0.3.0", "@humanwhocodes/retry@^0.3.1": + version "0.3.1" + resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz" + integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.2" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.5.0" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@noble/curves@^1.4.0", "@noble/curves@~1.6.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.6.0.tgz" + integrity sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ== + dependencies: + "@noble/hashes" "1.5.0" + +"@noble/hashes@1.5.0", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz" + integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@scure/base@~1.1.7", "@scure/base@~1.1.8": + version "1.1.9" + resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz" + integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== + +"@scure/bip32@^1.4.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.5.0.tgz" + integrity sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw== + dependencies: + "@noble/curves" "~1.6.0" + "@noble/hashes" "~1.5.0" + "@scure/base" "~1.1.7" + +"@scure/bip39@^1.3.0": + version "1.4.0" + resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.4.0.tgz" + integrity sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw== + dependencies: + "@noble/hashes" "~1.5.0" + "@scure/base" "~1.1.8" + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@sindresorhus/is@^4.0.0": + version "4.6.0" + resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz" + integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== + +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^13.0.1", "@sinonjs/fake-timers@^13.0.2": + version "13.0.3" + resolved "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.3.tgz" + integrity sha512-golm/Sc4CqLV/ZalIP14Nre7zPgd8xG/S3nHULMTBHMX0llyTNhE1O6nrgbfvLX2o0y849CnLKdu8OE05Ztiiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^8.0.1": + version "8.0.2" + resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz" + integrity sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw== + dependencies: + "@sinonjs/commons" "^3.0.1" + lodash.get "^4.4.2" + type-detect "^4.1.0" + +"@sinonjs/text-encoding@^0.7.3": + version "0.7.3" + resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz" + integrity sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA== + +"@szmarczak/http-timer@^4.0.5": + version "4.0.6" + resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz" + integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== + dependencies: + defer-to-connect "^2.0.0" + +"@tsconfig/node10@^1.0.7": + version "1.0.11" + resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz" + integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@types/cacheable-request@^6.0.1": + version "6.0.3" + resolved "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz" + integrity sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "^3.1.4" + "@types/node" "*" + "@types/responselike" "^1.0.0" + +"@types/estree@^1.0.6": + version "1.0.6" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + +"@types/http-cache-semantics@*": + version "4.0.4" + resolved "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz" + integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/keyv@^3.1.4": + version "3.1.4" + resolved "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz" + integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== + dependencies: + "@types/node" "*" + +"@types/mocha@10.0.6": + version "10.0.6" + resolved "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz" + integrity sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg== + +"@types/node@*": + version "22.7.6" + resolved "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz" + integrity sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw== + dependencies: + undici-types "~6.19.2" + +"@types/responselike@^1.0.0": + version "1.0.3" + resolved "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz" + integrity sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw== + dependencies: + "@types/node" "*" + +"@types/sinon@17.0.3": + version "17.0.3" + resolved "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz" + integrity sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + +"@typescript-eslint/eslint-plugin@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz" + integrity sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "7.11.0" + "@typescript-eslint/type-utils" "7.11.0" + "@typescript-eslint/utils" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + graphemer "^1.4.0" + ignore "^5.3.1" + natural-compare "^1.4.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/parser@7.17.0": + version "7.17.0" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz" + integrity sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A== + dependencies: + "@typescript-eslint/scope-manager" "7.17.0" + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/typescript-estree" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" + debug "^4.3.4" + +"@typescript-eslint/parser@^6.7.5": + version "6.21.0" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz" + integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== + dependencies: + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@6.21.0": + version "6.21.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz" + integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + +"@typescript-eslint/scope-manager@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz" + integrity sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw== + dependencies: + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + +"@typescript-eslint/scope-manager@7.17.0": + version "7.17.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz" + integrity sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA== + dependencies: + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" + +"@typescript-eslint/type-utils@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz" + integrity sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg== + dependencies: + "@typescript-eslint/typescript-estree" "7.11.0" + "@typescript-eslint/utils" "7.11.0" + debug "^4.3.4" + ts-api-utils "^1.3.0" + +"@typescript-eslint/types@6.21.0": + version "6.21.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz" + integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== + +"@typescript-eslint/types@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz" + integrity sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w== + +"@typescript-eslint/types@7.17.0": + version "7.17.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz" + integrity sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A== + +"@typescript-eslint/typescript-estree@6.21.0": + version "6.21.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz" + integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/typescript-estree@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz" + integrity sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ== + dependencies: + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/typescript-estree@7.17.0": + version "7.17.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz" + integrity sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw== + dependencies: + "@typescript-eslint/types" "7.17.0" + "@typescript-eslint/visitor-keys" "7.17.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/utils@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz" + integrity sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "7.11.0" + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/typescript-estree" "7.11.0" + +"@typescript-eslint/visitor-keys@6.21.0": + version "6.21.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz" + integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== + dependencies: + "@typescript-eslint/types" "6.21.0" + eslint-visitor-keys "^3.4.1" + +"@typescript-eslint/visitor-keys@7.11.0": + version "7.11.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz" + integrity sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ== + dependencies: + "@typescript-eslint/types" "7.11.0" + eslint-visitor-keys "^3.4.3" + +"@typescript-eslint/visitor-keys@7.17.0": + version "7.17.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz" + integrity sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A== + dependencies: + "@typescript-eslint/types" "7.17.0" + eslint-visitor-keys "^3.4.3" + +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@^8.11.0, acorn@^8.12.0, acorn@^8.4.1, acorn@^8.9.0: + version "8.13.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz" + integrity sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-colors@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz" + integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" + integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@1.7.4: + version "1.7.4" + resolved "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz" + integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +cacheable-lookup@^5.0.3: + version "5.0.4" + resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== + +cacheable-request@^7.0.2: + version "7.0.4" + resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz" + integrity sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz" + integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-response@^1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz" + integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== + dependencies: + mimic-response "^1.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@12.1.0: + version "12.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + +common-tags@^1.4.0: + version "1.8.2" + resolved "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz" + integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cross-spawn@^7.0.2: + version "7.0.3" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5: + version "4.3.7" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +defer-to-connect@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +diff@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +dlv@^1.1.0: + version "1.1.3" + resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dotenv@16.4.5: + version "16.4.5" + resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^1.0.2: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^7.1.1, eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-scope@^8.1.0: + version "8.2.0" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz" + integrity sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz" + integrity sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg== + +eslint@9.13.0: + version "9.13.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz" + integrity sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.11.0" + "@eslint/config-array" "^0.18.0" + "@eslint/core" "^0.7.0" + "@eslint/eslintrc" "^3.1.0" + "@eslint/js" "9.13.0" + "@eslint/plugin-kit" "^0.2.0" + "@humanfs/node" "^0.16.5" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.3.1" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.1.0" + eslint-visitor-keys "^4.1.0" + espree "^10.2.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + text-table "^0.2.0" + +eslint@^8.7.0: + version "8.57.1" + resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" + integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.1" + "@humanwhocodes/config-array" "^0.13.0" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^10.0.1, espree@^10.2.0: + version "10.2.0" + resolved "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz" + integrity sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g== + dependencies: + acorn "^8.12.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.1.0" + +espree@^9.3.1, espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esquery@^1.4.0, esquery@^1.4.2, esquery@^1.5.0: + version "1.6.0" + resolved "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.9: + version "3.3.2" + resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.17.1" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz" + integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + dependencies: + reusify "^1.0.4" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.2.9: + version "3.3.1" + resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz" + integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + +form-data@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz" + integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^8.1.0: + version "8.1.0" + resolved "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +got@^11.8.6: + version "11.8.6" + resolved "https://registry.npmjs.org/got/-/got-11.8.6.tgz" + integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.2" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" + integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg== + dependencies: + ansi-regex "^2.0.0" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +http-cache-semantics@^4.0.0: + version "4.1.1" + resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.3" + resolved "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz" + integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + +husky@9.1.6: + version "9.1.6" + resolved "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz" + integrity sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A== + +ignore@^5.2.0, ignore@^5.3.1: + version "5.3.2" + resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +js-base64@^3.7.7: + version "3.7.7" + resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz" + integrity sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + +keyv@^4.0.0, keyv@^4.5.3, keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.merge@^4.6.0, lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +loglevel-colored-level-prefix@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz" + integrity sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA== + dependencies: + chalk "^1.1.3" + loglevel "^1.4.1" + +loglevel@^1.4.1: + version "1.9.2" + resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz" + integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.4: + version "4.0.8" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-response@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +minimatch@9.0.3: + version "9.0.3" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^5.0.1, minimatch@^5.1.6: + version "5.1.6" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +mocha@10.7.3: + version "10.7.3" + resolved "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz" + integrity sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A== + dependencies: + ansi-colors "^4.1.3" + browser-stdout "^1.3.1" + chokidar "^3.5.3" + debug "^4.3.5" + diff "^5.2.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^8.1.0" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^5.1.6" + ms "^2.1.3" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^6.5.1" + yargs "^16.2.0" + yargs-parser "^20.2.9" + yargs-unparser "^2.0.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +nise@^6.1.1: + version "6.1.1" + resolved "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz" + integrity sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^13.0.1" + "@sinonjs/text-encoding" "^0.7.3" + just-extend "^6.2.0" + path-to-regexp "^8.1.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-cancelable@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz" + integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-to-regexp@^8.1.0: + version "8.2.0" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz" + integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +poseidon-lite@^0.2.0: + version "0.2.1" + resolved "https://registry.npmjs.org/poseidon-lite/-/poseidon-lite-0.2.1.tgz" + integrity sha512-xIr+G6HeYfOhCuswdqcFpSX47SPhm0EpisWJ6h7fHlWwaVIvH3dLnejpatrtw6Xc6HaLrpq05y7VRfvDmDGIog== + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-eslint@16.3.0: + version "16.3.0" + resolved "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-16.3.0.tgz" + integrity sha512-Lh102TIFCr11PJKUMQ2kwNmxGhTsv/KzUg9QYF2Gkw259g/kPgndZDWavk7/ycbRvj2oz4BPZ1gCU8bhfZH/Xg== + dependencies: + "@typescript-eslint/parser" "^6.7.5" + common-tags "^1.4.0" + dlv "^1.1.0" + eslint "^8.7.0" + indent-string "^4.0.0" + lodash.merge "^4.6.0" + loglevel-colored-level-prefix "^1.0.0" + prettier "^3.0.1" + pretty-format "^29.7.0" + require-relative "^0.8.7" + typescript "^5.2.2" + vue-eslint-parser "^9.1.0" + +prettier@3.3.3, prettier@^3.0.1: + version "3.3.3" + resolved "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +property-expr@^2.0.5: + version "2.0.6" + resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz" + integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pump@^3.0.0: + version "3.0.2" + resolved "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz" + integrity sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +react-is@^18.0.0: + version "18.3.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-relative@^0.8.7: + version "0.8.7" + resolved "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz" + integrity sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg== + +resolve-alpn@^1.0.0: + version "1.2.1" + resolved "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +responselike@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz" + integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== + dependencies: + lowercase-keys "^2.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +semver@^7.3.6, semver@^7.5.4, semver@^7.6.0: + version "7.6.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +sinon@19.0.2: + version "19.0.2" + resolved "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz" + integrity sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^13.0.2" + "@sinonjs/samsam" "^8.0.1" + diff "^7.0.0" + nise "^6.1.1" + supports-color "^7.2.0" + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" + integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" + integrity sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g== + +supports-color@^7.1.0, supports-color@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + +ts-api-utils@^1.0.1, ts-api-utils@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz" + integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== + +ts-node@10.9.2: + version "10.9.2" + resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + +typescript@5.6.3, typescript@^5.2.2: + version "5.6.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz" + integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== + +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +vue-eslint-parser@^9.1.0: + version "9.4.3" + resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz" + integrity sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg== + dependencies: + debug "^4.3.4" + eslint-scope "^7.1.1" + eslint-visitor-keys "^3.3.0" + espree "^9.3.1" + esquery "^1.4.0" + lodash "^4.17.21" + semver "^7.3.6" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +workerpool@^6.5.1: + version "6.5.1" + resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" + integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^20.2.2, yargs-parser@^20.2.9: + version "20.2.9" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz" + integrity sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0"