diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 0000000..eb96383 --- /dev/null +++ b/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example rate-limiter-contract_schema" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..024b896 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,143 @@ +--- +name: Build Contracts + +on: + schedule: + - cron: '0 5 * * 1-5' + push: + branches: + - '**' + workflow_dispatch: + inputs: + toolchain: + description: 'Default Rust Toolchain' + default: "1.73.0" + required: true + type: string + target: + description: 'Default Rust Target' + default: "wasm32-unknown-unknown" + required: true + type: string + branch: + description: 'Default Branch or Commit hash to use' + default: "main" + required: true + type: string + id: + description: 'Workflow ID (Optional)' + default: "scheduled" + required: false + type: string + +env: + TOOLCHAIN: ${{ inputs.toolchain || '1.73.0' }} + TARGET: ${{ inputs.target || 'wasm32-unknown-unknown' }} + REF: ${{ github.event_name == 'push' && github.ref || inputs.branch || 'main' }} + ID: ${{ inputs.id || 'scheduled' }} + +jobs: + build: + name: Build & Upload contracts + runs-on: self-hosted + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.REF }} + fetch-depth: 0 + - name: Save SHA + run: echo "sha=$(/usr/bin/git log -1 --format='%H')" >> $GITHUB_ENV + - name: Check input type + run: | + if git show-ref --quiet --heads $REF; then + echo "REF is a branch" + echo "The value is $REF" + echo "REF_TYPE=branch" >> $GITHUB_ENV + BRANCH_NAME="${REF#refs/heads/}" + echo "BRANCH=${BRANCH_NAME}" >> $GITHUB_ENV + else + echo "REF is a commit hash" + echo "The value is $REF" + echo "REF_TYPE=commit" >> $GITHUB_ENV + fi + env: + REF: ${{ env.REF }} + - name: Get branch name from commit + if: ${{ env.REF_TYPE == 'commit' }} + run: | + set -x + echo "REF = ${REF}" + git show -s --pretty=%d "${REF}" + BRANCH_NAME="$(git show -s --pretty=%d "${REF}" | sed -n 's/^.*[(,]\s*origin\/\([^),]*\).*$/\1/p')" + echo "BRANCH_NAME = ${BRANCH_NAME}" + echo "BRANCH=${BRANCH_NAME}" >> $GITHUB_ENV + echo "Commit ${REF} is on branch ${BRANCH_NAME}" + env: + REF: ${{ env.REF }} + - id: 'auth' + name: 'Authenticate to Google Cloud' + uses: 'google-github-actions/auth@v1' + with: + credentials_json: '${{ secrets.GOOGLE_CREDENTIALS }}' + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@v1' + - name: Evaluate Artifacts in GCP + run: | + if gsutil -q stat gs://neutron-contracts/${{ github.repository }}/${{ env.sha }}/*.wasm; then + if [ ${{ env.ID }} != 'scheduled' ]; then + echo "Force Contract Building requested, continuing workflow" + echo "ARTIFACTS_EXIST=false" >> $GITHUB_ENV + else + echo "Directory already exists, stopping workflow" + echo "ARTIFACTS_EXIST=true" >> $GITHUB_ENV + fi + else + echo "Directory does not exist, continuing workflow" + echo "ARTIFACTS_EXIST=false" >> $GITHUB_ENV + fi + - name: Skip Workflow if Artifacts exist + if: ${{ env.ARTIFACTS_EXIST == 'true' }} + run: echo "::notice::Artifacts already exist in GCP Bucket, skipping workflow." + - uses: dtolnay/rust-toolchain@master + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + with: + toolchain: ${{ env.TOOLCHAIN }} + target: ${{ env.TARGET}} + components: rustfmt, clippy + - run: make schema + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + - run: cargo fetch --verbose + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + - run: cargo clippy --all --all-targets -- -D warnings + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + - run: cargo test --verbose --all + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + env: + RUST_BACKTRACE: 1 + - run: cargo fmt -- --check + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + - run: make compile + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + - run: make -j$(nproc) check_contracts + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + - name: 'Upload Contracts to the Cloud (repo/branch/sha)' + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + run: 'gsutil -h "Cache-Control:no-cache, no-store, must-revalidate" cp -r artifacts/* gs://neutron-contracts/${{ github.repository }}/${{ env.BRANCH }}/${{ env.sha }}/' + - name: 'Set Metadata (repo/branch/sha)' + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + run: 'gsutil setmeta -r -h "x-goog-meta-Neutron-Repo: ${{ github.repository }}" -h "x-goog-meta-Neutron-Commit: ${{ env.sha }}" gs://neutron-contracts/${{ github.repository }}/${{ env.BRANCH }}/${{ env.sha }}/' + - name: 'Upload Contracts to the Cloud (repo/branch/WF/ID)' + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + run: 'gsutil -h "Cache-Control:no-cache, no-store, must-revalidate" cp -r artifacts/* gs://neutron-contracts/${{ github.repository }}/${{ env.BRANCH }}/WF/${{ env.ID }}/' + - name: 'Set Metadata (repo/branch/WF/ID)' + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + run: 'gsutil setmeta -r -h "x-goog-meta-Neutron-Repo: ${{ github.repository }}" -h "x-goog-meta-Neutron-Commit: ${{ env.sha }}" gs://neutron-contracts/${{ github.repository }}/${{ env.BRANCH }}/WF/${{ env.ID }}/' + - name: 'Upload Contracts to the Cloud (repo/sha)' + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + run: 'gsutil -h "Cache-Control:no-cache, no-store, must-revalidate" cp -r artifacts/* gs://neutron-contracts/${{ github.repository }}/${{ env.sha }}/' + - name: 'Set Metadata (repo/sha)' + if: ${{ env.ARTIFACTS_EXIST == 'false' }} + run: 'gsutil setmeta -r -h "x-goog-meta-Neutron-Repo: ${{ github.repository }}" -h "x-goog-meta-Neutron-Commit: ${{ env.sha }}" gs://neutron-contracts/${{ github.repository }}/${{ env.sha }}/' + - name: 'Cleanup' + if: always() + uses: AutoModality/action-clean@v1.1.0 diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000..fa2ba7c --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,57 @@ +name: Delete old workflow runs +on: + workflow_dispatch: + inputs: + days: + description: 'Number of days.' + required: true + default: 30 + minimum_runs: + description: 'The minimum runs to keep for each workflow.' + required: true + default: 6 + delete_workflow_pattern: + description: 'The name or filename of the workflow. if not set then it will target all workflows.' + required: false + delete_workflow_by_state_pattern: + description: 'Remove workflow by state: active, deleted, disabled_fork, disabled_inactivity, disabled_manually' + required: true + default: "All" + type: choice + options: + - "All" + - active + - deleted + - disabled_inactivity + - disabled_manually + delete_run_by_conclusion_pattern: + description: 'Remove workflow by conclusion: action_required, cancelled, failure, skipped, success' + required: true + default: "All" + type: choice + options: + - "All" + - action_required + - cancelled + - failure + - skipped + - success + dry_run: + description: 'Only log actions, do not perform any delete operations.' + required: false + +jobs: + del_runs: + runs-on: self-hosted + steps: + - name: Delete workflow runs + uses: Mattraks/delete-workflow-runs@v2 + with: + token: ${{ github.token }} + repository: ${{ github.repository }} + retain_days: ${{ github.event.inputs.days }} + keep_minimum_runs: ${{ github.event.inputs.minimum_runs }} + delete_workflow_pattern: ${{ github.event.inputs.delete_workflow_pattern }} + delete_workflow_by_state_pattern: ${{ github.event.inputs.delete_workflow_by_state_pattern }} + delete_run_by_conclusion_pattern: ${{ github.event.inputs.delete_run_by_conclusion_pattern }} + dry_run: ${{ github.event.inputs.dry_run }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..6d9db1f --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,37 @@ +name: PR + +on: + pull_request: + types: [assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, converted_to_draft, ready_for_review, locked, unlocked, review_requested, review_request_removed] + issue_comment: + types: [created] + pull_request_review: + types: [submitted] + +jobs: + pr_commented: + # This job only runs for pull request comments + name: PR comment + if: ${{ github.event.issue.pull_request }} + runs-on: ubuntu-latest + steps: + - name: Send Notification + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + message: | + User @${{ github.actor }} commented PR #${{ github.event.issue.number }} "${{ github.event.issue.title }}" (${{ github.event.issue.pull_request.html_url }}) + + pull_requests_and_review: + name: Pull request action or review + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + steps: + - name: Send Notification + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + message: | + User @${{ github.actor }} updated PR #${{ github.event.number }} "${{ github.event.pull_request.title }}", action "${{ github.event.action }}" (${{ github.event.pull_request.html_url }}) diff --git a/.github/workflows/test_trigger.yml b/.github/workflows/test_trigger.yml new file mode 100644 index 0000000..e509cfa --- /dev/null +++ b/.github/workflows/test_trigger.yml @@ -0,0 +1,37 @@ +--- +name: Dispatch Workflow + +concurrency: neutron-tests + +on: + push: + branches: + - main + pull_request: + types: + - closed + +jobs: + dispatch: + name: Dispatch Tests Workflow + runs-on: ubuntu-latest + steps: + - name: Evaluate PR Merged Status and Labels + run: | + PR_MERGED=${{ github.event.pull_request.merged }} + CONTAINS_LABEL=${{ contains(github.event.pull_request.labels.*.name, 'trigger-tests') }} + echo "PR Merged Status: $PR_MERGED" + echo "Contains 'trigger-tests' label: $CONTAINS_LABEL" + if [[ "$PR_MERGED" == "true" && "$CONTAINS_LABEL" == "true" ]]; then + echo "CONTINUE=true" >> $GITHUB_ENV + else + echo "CONTINUE=false" >> $GITHUB_ENV + fi + - name: Repository Dispatch + if: ${{ env.CONTINUE == 'true' }} + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.PAT_TOKEN }} + repository: neutron-org/neutron-tests + event-type: run-tests + client_payload: '{"checkratelimiter":"true"}' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..62f2494 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +on: + push: + branches: + - '**' + +name: tests + +jobs: + clippy: + name: Actions - clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.73.0 + components: clippy + profile: minimal + override: true + - run: cargo fetch --verbose + - run: cargo clippy --all --all-targets -- -D warnings + + rustfmt: + name: Actions - rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.73.0 + components: rustfmt + profile: minimal + override: true + - run: cargo fmt -- --check + + unit-test: + name: Actions - unit test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ macOS-latest, ubuntu-latest ] + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.73.0 + profile: minimal + - run: cargo fetch --verbose + - run: cargo build + - run: cargo test --verbose --all + env: + RUST_BACKTRACE: 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a66a931 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Generated by Cargo +# will have compiled files and executables +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +.idea +.vscode +.DS_Store + +artifacts/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cf42f95 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1629 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "anyhow" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rayon", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", + "rayon", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bech32" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bnum" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56953345e39537a3e18bdaeba4cb0c58a78c1f61f361dc0fa7c5c7340ae87c5f" + +[[package]] +name = "bnum" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e31ea183f6ee62ac8b8a8cf7feddd766317adfb13ff469de57ce033efd6a790" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "num-traits", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cosmos-sdk-proto" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32560304ab4c365791fd307282f76637213d8083c1a98490c35159cd67852237" +dependencies = [ + "prost 0.12.6", + "prost-types", + "tendermint-proto", +] + +[[package]] +name = "cosmwasm-core" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d905990ef3afb5753bb709dc7de88e9e370aa32bcc2f31731d4b533b63e82490" + +[[package]] +name = "cosmwasm-crypto" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f862b355f7e47711e0acfe6af92cb3fd8fd5936b66a9eaa338b51edabd1e77d" +dependencies = [ + "digest 0.10.7", + "ed25519-zebra 3.1.0", + "k256", + "rand_core 0.6.4", + "thiserror", +] + +[[package]] +name = "cosmwasm-crypto" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b2a7bd9c1dd9a377a4dc0f4ad97d24b03c33798cd5a6d7ceb8869b41c5d2f2d" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "cosmwasm-core", + "digest 0.10.7", + "ecdsa", + "ed25519-zebra 4.0.3", + "k256", + "num-traits", + "p256", + "rand_core 0.6.4", + "rayon", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "cosmwasm-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd85de6467cd1073688c86b39833679ae6db18cf4771471edd9809f15f1679f1" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-derive" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029910b409398fdf81955d7301b906caf81f2c42b013ea074fbd89720229c424" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "cosmwasm-schema" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc0d4d85e83438ab9a0fea9348446f7268bc016aacfebce37e998559f151294" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf5c8adac41bb7751c050d7c4c18675be19ee128714454454575e894424eeef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "cosmwasm-std" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2685c2182624b2e9e17f7596192de49a3f86b7a0c9a5f6b25c1df5e24592e836" +dependencies = [ + "base64 0.21.7", + "bech32 0.9.1", + "bnum 0.10.0", + "cosmwasm-crypto 1.5.7", + "cosmwasm-derive 1.5.7", + "derivative", + "forward_ref", + "hex", + "schemars", + "serde", + "serde-json-wasm 0.5.2", + "sha2 0.10.8", + "static_assertions", + "thiserror", +] + +[[package]] +name = "cosmwasm-std" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dec99a2e478715c0a4277f0dbeadbb8466500eb7dec873d0924edd086e77f1" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.0", + "bnum 0.11.0", + "cosmwasm-core", + "cosmwasm-crypto 2.1.3", + "cosmwasm-derive 2.1.3", + "derive_more", + "hex", + "rand_core 0.6.4", + "schemars", + "serde", + "serde-json-wasm 1.0.1", + "sha2 0.10.8", + "static_assertions", + "thiserror", +] + +[[package]] +name = "cosmwasm-storage" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66de2ab9db04757bcedef2b5984fbe536903ada4a8a9766717a4a71197ef34f6" +dependencies = [ + "cosmwasm-std 1.5.7", + "serde", +] + +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "cw-multi-test" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0ae276e7a06ad1b7e7da78a3d68aba80634cde30ee7fe8259a94e653603fef8" +dependencies = [ + "anyhow", + "bech32 0.11.0", + "cosmwasm-std 2.1.3", + "cw-storage-plus", + "cw-utils", + "derivative", + "itertools 0.13.0", + "prost 0.13.2", + "schemars", + "serde", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "cw-storage-plus" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f13360e9007f51998d42b1bc6b7fa0141f74feae61ed5fd1e5b0a89eec7b5de1" +dependencies = [ + "cosmwasm-std 2.1.3", + "schemars", + "serde", +] + +[[package]] +name = "cw-utils" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dfee7f12f802431a856984a32bce1cb7da1e6c006b5409e3981035ce562dec" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std 2.1.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04852cd38f044c0751259d5f78255d07590d136b8a86d4e09efdd7666bd6d27" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std 2.1.3", + "cw-storage-plus", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c24f403d068ad0b359e577a77f92392118be3f3c927538f2bb544a5ecd828c6" +dependencies = [ + "curve25519-dalek 3.2.0", + "hashbrown 0.12.3", + "hex", + "rand_core 0.6.4", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-zebra" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9ce6874da5d4415896cd45ffbc4d1cfc0c4f9c079427bd870742c30f2f65a9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519", + "hashbrown 0.14.5", + "hex", + "rand_core 0.6.4", + "sha2 0.10.8", + "zeroize", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "flex-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" +dependencies = [ + "paste", +] + +[[package]] +name = "forward_ref" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.11", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.11", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "k256" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ff9b67e26e1a6a866cb758f12c6f8746208489e3e4a4b5580802f2f0a587b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.8", + "signature", +] + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "neutron-std" +version = "4.2.2-rc" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea7d51a6eb54c2f550bb01630c8414b1988899d645e076d8ae52c068999b9951" +dependencies = [ + "bech32 0.9.1", + "chrono", + "cosmos-sdk-proto", + "cosmwasm-schema", + "cosmwasm-std 2.1.3", + "neutron-std-derive", + "prost 0.12.6", + "prost-types", + "protobuf", + "schemars", + "serde", + "serde-cw-value", + "serde-json-wasm 1.0.1", + "serde_json", + "speedate", + "tendermint-proto", + "thiserror", +] + +[[package]] +name = "neutron-std-derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f4948005dcbb0c4eb644141d00dcbdc422b1b7024fb008712ca2960392ec11" +dependencies = [ + "itertools 0.10.5", + "proc-macro2", + "prost-types", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.8", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2ecbe40f08db5c006b5764a2645f7f3f141ce756412ac9e1dd6087e6d32995" +dependencies = [ + "bytes", + "prost-derive 0.13.2", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "prost-derive" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf0c195eebb4af52c752bec4f52f645da98b6e92077a04110c7f349477ae5ac" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + +[[package]] +name = "protobuf" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65f4a8ec18723a734e5dc09c173e0abf9690432da5340285d536edcb4dac190" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-support" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6872f4d4f4b98303239a2b5838f5bbbb77b01ffc892d627957f37a22d7cfe69c" +dependencies = [ + "thiserror", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rate-limiter" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std 2.1.3", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus", + "cw2", + "hex", + "itertools 0.10.5", + "neutron-std", + "prost 0.12.6", + "schemars", + "serde", + "serde-json-wasm 1.0.1", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "schemars" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.77", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-cw-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75d32da6b8ed758b7d850b6c3c08f1d7df51a4df3cb201296e63e34a78e99d4" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-json-wasm" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9213a07d53faa0b8dd81e767a54a8188a242fdb9be99ab75ec576a774bfdd7" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-json-wasm" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05da0d153dd4595bdffd5099dc0e9ce425b205ee648eb93437ff7302af8c9a5" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "speedate" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "242f76c50fd18cbf098607090ade73a08d39cfd84ea835f3796a2c855223b19b" +dependencies = [ + "strum", + "strum_macros", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.77", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "subtle-encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcb1ed7b8330c5eed5441052651dd7a12c75e2ed88f2ec024ae1fa3a5e59945" +dependencies = [ + "zeroize", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tendermint-proto" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b797dd3d2beaaee91d2f065e7bdf239dc8d80bba4a183a288bc1279dd5a69a1e" +dependencies = [ + "bytes", + "flex-error", + "num-derive", + "num-traits", + "prost 0.12.6", + "prost-types", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-xid" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2e8b210 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "rate-limiter" +version = "0.1.0" +authors = ["Nicolas Lara "] +edition = "2021" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] +# Use the verbose responses feature if you want to include information about +# the remaining quotas in the SendPacket/RecvPacket responses +verbose_responses = [] + +[package.metadata.scripts] +optimize = """docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/rust-optimizer:0.15.1 +""" +[dependencies] +cosmwasm-std = { version = "2.1.0", features = ["cosmwasm_2_0", "std"] } +cosmwasm-schema = { version = "2.1.0", default-features = false } +cosmwasm-storage = "1" +cw-storage-plus = "2" +cw2 = "2" +schemars = "0.8.15" +serde = { version = "1.0.188", features = ["derive"], default-features = false } +thiserror = "1.0.49" +prost = "0.12.3" +neutron-std = "4.2.2-rc" +sha2 = "0.10.6" +hex = "0.4.3" + +[dev-dependencies] +cw-multi-test = "2.1.1" +serde-json-wasm = "1.0.0" +itertools = "0.10" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8609a2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Range + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a294e46 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: schema test clippy proto-gen build fmt + +schema: + @find * -type f -name 'Cargo.toml' -execdir cargo schema \; + +test: + @cargo test + +clippy: + @cargo clippy --all --all-targets -- -D warnings + +fmt: + @cargo fmt -- --check + +check_contracts: + @cargo install cosmwasm-check --version 2.0.4 --locked + @cosmwasm-check --available-capabilities iterator,staking,stargate,neutron,cosmwasm_1_1,cosmwasm_1_2,cosmwasm_1_3,cosmwasm_1_4,cosmwasm_2_0 artifacts/*.wasm + +compile: + @docker run --rm -v "$(CURDIR)":/code \ + --mount type=volume,source="$(notdir $(CURDIR))_cache",target=/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + --platform linux/amd64 \ + cosmwasm/workspace-optimizer:0.15.1 + +build: schema clippy fmt test compile check_contracts diff --git a/README.md b/README.md index 7bb7f29..b5ee70a 100644 --- a/README.md +++ b/README.md @@ -1 +1,244 @@ -# rate-limiter-contract \ No newline at end of file +# Rate limiter contract + +## Motivation + +The motivation of IBC-rate-limit comes from the empirical observations of blockchain bridge hacks that a rate limit would have massively reduced the stolen amount of assets in: + +- [Polynetwork Bridge Hack ($611 million)](https://rekt.news/polynetwork-rekt/) +- [BNB Bridge Hack ($586 million)](https://rekt.news/bnb-bridge-rekt/) +- [Wormhole Bridge Hack ($326 million)](https://rekt.news/wormhole-rekt/) +- [Nomad Bridge Hack ($190 million)](https://rekt.news/nomad-rekt/) +- [Harmony Bridge Hack ($100 million)](https://rekt.news/harmony-rekt/) - (Would require rate limit + monitoring) +- [Dragonberry IBC bug](https://forum.cosmos.network/t/ibc-security-advisory-dragonberry/7702) (can't yet disclose amount at risk, but was saved due to being found first by altruistic Osmosis core developers) + +In the presence of a software bug on Neutron, IBC itself, or on a counterparty chain, we would like to prevent the bridge from being fully depegged. +This stems from the idea that a 30% asset depeg is ~infinitely better than a 100% depeg. +Its _crazy_ that today these complex bridged assets can instantly go to 0 in event of bug. +The goal of a rate limit is to raise an alert that something has potentially gone wrong, allowing validators and developers to have time to analyze, react, and protect larger portions of user funds. + +The thesis of this is that, it is worthwhile to sacrifice liveness in the case of legitimate demand to send extreme amounts of funds, to prevent the terrible long-tail full fund risks. +Rate limits aren't the end-all of safety controls, they're merely the simplest automated one. More should be explored and added onto IBC! + +## Rate limit types + +We express rate limits in time-based periods. +This means, we set rate limits for (say) 6-hour, daily, and weekly intervals. +The rate limit for a given time period stores the relevant amount of assets at the start of the rate limit. +Rate limits are then defined on percentage terms of the asset. +The time windows for rate limits are currently _not_ rolling, they have discrete start/end times. + +We allow setting separate rate limits for the inflow and outflow of assets. +We do all of our rate limits based on the _net flow_ of assets on a channel pair. This prevents DOS issues, of someone repeatedly sending assets back and forth, to trigger rate limits and break liveness. + +We currently envision creating two kinds of rate limits: + +* Per denomination rate limits + - allows safety statements like "Only 30% of Stars on Neutron can flow out in one day" or "The amount of Atom on Neutron can at most double per day". +* Per channel rate limits + - Limit the total inflow and outflow on a given IBC channel, based on "USDC" equivalent, using Neutron as the price oracle. + +We currently only implement per denomination rate limits for non-native assets. We do not yet implement channel based rate limits. + +Currently these rate limits automatically "expire" at the end of the quota duration. TODO: Think of better designs here. E.g. can we have a constant number of subsequent quotas start filled? Or perhaps harmonically decreasing amounts of next few quotas pre-filled? Halted until DAO override seems not-great. + +## Instantiating rate limits + +Today all rate limit quotas must be set manually by governance. +In the future, we should design towards some conservative rate limit to add as a safety-backstop automatically for channels. +Ideas for how this could look: + +* One month after a channel has been created, automatically add in some USDC-based rate limit +* One month after governance incentivizes an asset, add on a per-denomination rate limit. + +Definitely needs far more ideation and iteration! + +## Parameterizing the rate limit + +One element is we don't want any rate limit timespan that's too short, e.g. not enough time for humans to react to. So we wouldn't want a 1 hour rate limit, unless we think that if its hit, it could be assessed within an hour. + +### Handling rate limit boundaries + +We want to be safe against the case where say we have a daily rate limit ending at a given time, and an adversary attempts to attack near the boundary window. +We would not like them to be able to "double extract funds" by timing their extraction near a window boundary. + +Admittedly, not a lot of thought has been put into how to deal with this well. +Right now we envision simply handling this by saying if you want a quota of duration D, instead include two quotas of duration D, but offset by `D/2` from each other. + +Ideally we can change windows to be more 'rolling' in the future, to avoid this overhead and more cleanly handle the problem. (Perhaps rolling ~1 hour at a time) + +### Inflow parameterization + +The "Inflow" side of a rate limit is essentially protection against unforeseen bug on a counterparty chain. +This can be quite conservative (e.g. bridged amount doubling in one week). This covers a few cases: + +* Counter-party chain B having a token theft attack + - TODO: description of how this looks +* Counter-party chain B runaway mint + - TODO: description of how this looks +* IBC theft + - TODO: description of how this looks + +It does get more complex when the counterparty chain is itself a DEX, but this is still much more protection than nothing. + +### Outflow parameterization + +The "Outflow" side of a rate limit is protection against a bug on Neutron OR IBC. +This has potential for much more user-frustrating issues, if set too low. +E.g. if there's some event that causes many people to suddenly withdraw many STARS or many USDC. + +So this parameterization has to contend with being a tradeoff of withdrawal liveness in high volatility periods vs being a crucial safety rail, in event of on-Neutron bug. + +TODO: Better fill out + +### Cosmwasm Contract Concepts + +Something to keep in mind with all of the code, is that we have to reason separately about every item in the following matrix: + +| Native Token | Non-Native Token | +|----------------------|--------------------------| +| Send Native Token | Send Non-Native Token | +| Receive Native Token | Receive Non-Native Token | +| Timeout Native Send | Timeout Non-native Send | + +(Error ACK can reuse the same code as timeout) + +TODO: Spend more time on sudo messages in the following description. We need to better describe how we map the quota concepts onto the code. +Need to describe how we get the quota beginning balance, and that its different for sends and receives. +Explain intracacies of tracking that a timeout and/or ErrorAck must appear from the same quota, else we ignore its update to the quotas. + + +The tracking contract uses the following concepts + +1. **RateLimit** - tracks the value flow transferred and the quota for a path. +2. **Path** - is a (denom, channel) pair. +3. **Flow** - tracks the value that has moved through a path during the current time window. +4. **Quota** - is the percentage of the denom's total value that can be transferred through the path in a given period of time (duration) + +#### Messages + +The contract specifies the following messages: + +##### Query + +* GetQuotas - Returns the quotas for a path + +##### Exec + +* AddPath - Adds a list of quotas for a path +* RemovePath - Removes a path +* ResetPathQuota - If a rate limit has been reached, the contract's governance address can reset the quota so that transfers are allowed again + +##### Sudo + +Sudo messages can only be executed by the chain. + +* SendPacket - Increments the amount used out of the send quota and checks that the send is allowed. If it isn't, it will return a RateLimitExceeded error +* RecvPacket - Increments the amount used out of the receive quota and checks that the receive is allowed. If it isn't, it will return a RateLimitExceeded error +* UndoSend - If a send has failed, the undo message is used to remove its cost from the send quota + +All of these messages receive the packet from the chain and extract the necessary information to process the packet and determine if it should be the rate limited. + +### Necessary information + +To determine if a packet should be rate limited, we need: + +* Channel: The channel on the Neutron side: `packet.SourceChannel` for sends, and `packet.DestinationChannel` for receives. +* Denom: The denom of the token being transferred as known on the Neutron side (more on that below) +* Channel Value: The total value of the channel denominated in `Denom` (i.e.: channel-17 is worth 10k osmo). +* Funds: the amount being transferred + +#### Notes on Channel +The contract also supports quotas on a custom channel called "any" that is checked on every transfer. If either the +transfer channel or the "any" channel have a quota that has been filled, the transaction will be rate limited. + +#### Notes on Denom +We always use the the denom as represented on Neutron. For native assets that is the local denom, and for non-native +assets it's the "ibc" prefix and the sha256 hash of the denom trace (`ibc/...`). + +##### Sends + +For native denoms, we can just use the denom in the packet. If the denom is invalid, it will fail somewhere else along the chain. Example result: `uosmo` + +For non-native denoms, the contract needs to hash the denom trace and append it to the `ibc/` prefix. The +contract always receives the parsed denom (i.e.: `transfer/channel-32/uatom` instead of +`ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2`). This is because of the order in which +the middleware is called. When sending a non-native denom, the packet contains `transfer/source-channel/denom` as it +is built on the `relay.SendTransfer()` in the transfer module and then passed to the middleware. Example result: `ibc/` + +##### Receives + +This behaves slightly different if the asset is an Neutron asset that was sent to the counterparty and is being +returned to the chain, or if the asset is being received by the chain and originates on the counterparty. In ibc this +is called being a "source" or a "sink" respectively. + +If the chain is a sink for the denom, we build the local denom by prefixing the port and the channel +(`transfer/local-channel`) and hashing that denom. Example result: `ibc/` + +If the chain is the source for the denom, there are two possibilities: + +* The token is a native token, in which case we just remove the prefix added by the counterparty. Example result: `uosmo` +* The token is a non-native token, in which case we remove the extra prefix and hash it. Example result `ibc/` + +#### Notes on Channel Value +We have iterated on different strategies for calculating the channel value. Our preferred strategy is the following: +* For non-native tokens (`ibc/...`), the channel value should be the supply of those tokens in Neutron +* For native tokens, the channel value should be the total amount of tokens in escrow across all ibc channels + +The later ensures the limits are lower and represent the amount of native tokens that exist outside Neutron. This is +beneficial as we assume the majority of native tokens exist on the native chain and the amount "normal" ibc transfers is +proportional to the tokens that have left the chain. + +This strategy cannot be implemented at the moment because IBC does not track the amount of tokens in escrow across +all channels ([github issue](https://github.com/cosmos/ibc-go/issues/2664)). Instead, we use the current supply on +Neutron for all denoms (i.e.: treat native and non-native tokens the same way). Once that ticket is fixed, we will +update this strategy. + +##### Caching + +The channel value varies constantly. To have better predictability, and avoid issues of the value growing if there is +a potential infinite mint bug, we cache the channel value at the beginning of the period for every quota. + +This means that if we have a daily quota of 1% of the osmo supply, and the channel value is 1M osmo at the beginning of +the quota, no more than 100k osmo can transferred during that day. If 10M osmo were to be minted or IBC'd in during that +period, the quota will not increase until the period expired. Then it will be 1% of the new channel value (~11M) + +### Integration + +The rate limit middleware wraps the `transferIBCModule` and is added as the entry route for IBC transfers. + +The module is also provided to the underlying `transferIBCModule` as its `ICS4Wrapper`; previously, this would have +pointed to a channel, which also implements the `ICS4Wrapper` interface. + +This integration can be seen in [neutron/app/app.go](https://github.com/neutron-org/neutron/blob/cfa54003cf1f8e9e7dd0d713c80605641d14f610/app/app.go#L1656) + +## Testing strategy + + +A general testing strategy is as follows: + +* Setup two chains. +* Send some tokens from A->B and some from B->A (so that there are IBC tokens to play with in both sides) +* Add the rate limiter on A with low limits (i.e. 1% of supply) +* Test Function for chains A' and B' and denom d + * Send some d tokens from A' to B' and get close to the limit. + * Do the same transfer making sure the amount is above the quota and verify it fails with the rate limit error + * Wait until the reset time has passed, and send again. The transfer should now succeed +* Repeat the above test for the following combination of chains and tokens: `(A,B,a)`, `(B,A,a)`, `(A,B,b)`, `(B,A,b)`, + where `a` and `b` are native tokens to chains A and B respectively. + +For more comprehensive tests we can also: +* Add a third chain C and make sure everything works properly for C tokens that have been transferred to A and to B +* Test that the contracts gov address can reset rate limits if the quota has been hit +* Test the queries for getting information about the state of the quotas +* Test that rate limit symmetries hold (i.e.: sending the a token through a rate-limited channel and then sending back + reduces the rate limits by the same amount that it was increased during the first send) +* Ensure that the channels between the test chains have different names (A->B="channel-0", B->A="channel-1", for example) + +## Known Future work + +Items that have been highlighted above: + +* Making automated rate limits get added for channels, instead of manual configuration only +* Improving parameterization strategies / data analysis +* Adding the USDC based rate limits +* We need better strategies for how rate limits "expire". diff --git a/examples/rate-limiter-contract_schema.rs b/examples/rate-limiter-contract_schema.rs new file mode 100644 index 0000000..954edd4 --- /dev/null +++ b/examples/rate-limiter-contract_schema.rs @@ -0,0 +1,13 @@ +use cosmwasm_schema::write_api; + +use rate_limiter::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, SudoMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + sudo: SudoMsg, + migrate: MigrateMsg, + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7897a24 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.75.0" diff --git a/schema/rate-limiter.json b/schema/rate-limiter.json new file mode 100644 index 0000000..0d6b3b5 --- /dev/null +++ b/schema/rate-limiter.json @@ -0,0 +1,1494 @@ +{ + "contract_name": "rate-limiter", + "contract_version": "0.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Initialize the contract with the address of the IBC module and any existing channels. Only the ibc module is allowed to execute actions on this contract", + "type": "object", + "required": [ + "gov_module", + "paths" + ], + "properties": { + "gov_module": { + "$ref": "#/definitions/Addr" + }, + "paths": { + "type": "array", + "items": { + "$ref": "#/definitions/PathMsg" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "PathMsg": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quotas" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quotas": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaMsg" + } + } + }, + "additionalProperties": false + }, + "QuotaMsg": { + "type": "object", + "required": [ + "duration", + "name", + "send_recv" + ], + "properties": { + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "send_recv": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "The caller (IBC module) is responsible for correctly calculating the funds being sent through the channel", + "oneOf": [ + { + "type": "object", + "required": [ + "add_path" + ], + "properties": { + "add_path": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quotas" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quotas": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaMsg" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_path" + ], + "properties": { + "remove_path": { + "type": "object", + "required": [ + "channel_id", + "denom" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reset_path_quota" + ], + "properties": { + "reset_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota_id" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_denom_restrictions" + ], + "properties": { + "set_denom_restrictions": { + "type": "object", + "required": [ + "allowed_channels", + "denom" + ], + "properties": { + "allowed_channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unset_denom_restrictions" + ], + "properties": { + "unset_denom_restrictions": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Grants a role to the given signer", + "type": "object", + "required": [ + "grant_role" + ], + "properties": { + "grant_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "full list of roles to grant the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes the role that has been granted to the signer", + "type": "object", + "required": [ + "revoke_role" + ], + "properties": { + "revoke_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "fill list of roles to revoke from the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Replaces the quota identified by QuotaMsg::Name", + "type": "object", + "required": [ + "edit_path_quota" + ], + "properties": { + "edit_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota": { + "description": "similar to ResetPathQuota, but QuotaMsg::Name is used as the quota_id", + "allOf": [ + { + "$ref": "#/definitions/QuotaMsg" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to remove a message from the message queue to prevent execution", + "type": "object", + "required": [ + "remove_message" + ], + "properties": { + "remove_message": { + "type": "object", + "required": [ + "message_id" + ], + "properties": { + "message_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to change the timelock delay for newly submitted messages", + "type": "object", + "required": [ + "set_timelock_delay" + ], + "properties": { + "set_timelock_delay": { + "type": "object", + "required": [ + "hours", + "signer" + ], + "properties": { + "hours": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "signer": { + "description": "the address to apply the timelock delay to", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Permissionless message that anyone can invoke to trigger execution of queued messages that have passed the timelock delay\n\nIf both count and message_ids are some, message_ids is used. If both are None returns an error", + "type": "object", + "required": [ + "process_messages" + ], + "properties": { + "process_messages": { + "type": "object", + "properties": { + "count": { + "description": "number of queued messages to process, a value of 0 will attempt to process all queued messages", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "message_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "QuotaMsg": { + "type": "object", + "required": [ + "duration", + "name", + "send_recv" + ], + "properties": { + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "send_recv": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + }, + "Roles": { + "description": "Roles defines the available permissions that can be assigned to addresses as part of the RBAC system", + "oneOf": [ + { + "type": "string", + "enum": [ + "ManageDenomRestrictions" + ] + }, + { + "description": "Has the ability to add a new rate limit", + "type": "string", + "enum": [ + "AddRateLimit" + ] + }, + { + "description": "Has the ability to complete remove a configured rate limit", + "type": "string", + "enum": [ + "RemoveRateLimit" + ] + }, + { + "description": "Has the ability to reset tracked quotas", + "type": "string", + "enum": [ + "ResetPathQuota" + ] + }, + { + "description": "Has the ability to edit existing quotas", + "type": "string", + "enum": [ + "EditPathQuota" + ] + }, + { + "description": "Has the ability to grant roles to an address", + "type": "string", + "enum": [ + "GrantRole" + ] + }, + { + "description": "Has the ability to revoke granted roles to an address", + "type": "string", + "enum": [ + "RevokeRole" + ] + }, + { + "description": "Has the ability to remove queued messages", + "type": "string", + "enum": [ + "RemoveMessage" + ] + }, + { + "description": "Has the ability to alter timelock delay's", + "type": "string", + "enum": [ + "SetTimelockDelay" + ] + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "get_quotas" + ], + "properties": { + "get_quotas": { + "type": "object", + "required": [ + "channel_id", + "denom" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a vector of all addresses that have been allocated one or more roles", + "type": "string", + "enum": [ + "get_role_owners" + ] + }, + { + "description": "Returns a vector of all roles that have been granted to `owner`", + "type": "object", + "required": [ + "get_roles" + ], + "properties": { + "get_roles": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a vector of queued message id's", + "type": "string", + "enum": [ + "get_message_ids" + ] + }, + { + "description": "Returns the queued message matching id", + "type": "object", + "required": [ + "get_message" + ], + "properties": { + "get_message": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the restrictions for a given denom", + "type": "object", + "required": [ + "get_denom_restrictions" + ], + "properties": { + "get_denom_restrictions": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SudoMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "packet" + ], + "properties": { + "packet": { + "$ref": "#/definitions/Packet" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "recv_packet" + ], + "properties": { + "recv_packet": { + "type": "object", + "required": [ + "packet" + ], + "properties": { + "packet": { + "$ref": "#/definitions/Packet" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "undo_send" + ], + "properties": { + "undo_send": { + "type": "object", + "required": [ + "packet" + ], + "properties": { + "packet": { + "$ref": "#/definitions/Packet" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "FungibleTokenData": { + "type": "object", + "required": [ + "amount", + "denom", + "receiver", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint256" + }, + "denom": { + "type": "string" + }, + "receiver": { + "$ref": "#/definitions/Addr" + }, + "sender": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "Height": { + "type": "object", + "properties": { + "revision_height": { + "description": "The height of a block", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "revision_number": { + "description": "Previously known as \"epoch\"", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Packet": { + "type": "object", + "required": [ + "data", + "destination_channel", + "destination_port", + "sequence", + "source_channel", + "source_port", + "timeout_height" + ], + "properties": { + "data": { + "$ref": "#/definitions/FungibleTokenData" + }, + "destination_channel": { + "type": "string" + }, + "destination_port": { + "type": "string" + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "source_channel": { + "type": "string" + }, + "source_port": { + "type": "string" + }, + "timeout_height": { + "$ref": "#/definitions/Height" + }, + "timeout_timestamp": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "type": "string" + } + } + }, + "responses": { + "get_denom_restrictions": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + }, + "get_message": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueuedMessage", + "type": "object", + "required": [ + "message", + "message_id", + "submitted_at", + "timelock_delay" + ], + "properties": { + "message": { + "description": "the message that submitted to the contract after a sucessful governance proposal", + "allOf": [ + { + "$ref": "#/definitions/ExecuteMsg" + } + ] + }, + "message_id": { + "description": "Constructed using format!(\"{}_{}\", Env::BlockInfo::Height Env::Transaction::Index)\n\nCan be used to remove a message from the queue without processing it", + "type": "string" + }, + "submitted_at": { + "description": "the time which the message was processed by the contract", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + }, + "timelock_delay": { + "description": "the timelock delay that was in place when the message was queued for execution", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "ExecuteMsg": { + "description": "The caller (IBC module) is responsible for correctly calculating the funds being sent through the channel", + "oneOf": [ + { + "type": "object", + "required": [ + "add_path" + ], + "properties": { + "add_path": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quotas" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quotas": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaMsg" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_path" + ], + "properties": { + "remove_path": { + "type": "object", + "required": [ + "channel_id", + "denom" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reset_path_quota" + ], + "properties": { + "reset_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota_id" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_denom_restrictions" + ], + "properties": { + "set_denom_restrictions": { + "type": "object", + "required": [ + "allowed_channels", + "denom" + ], + "properties": { + "allowed_channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unset_denom_restrictions" + ], + "properties": { + "unset_denom_restrictions": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Grants a role to the given signer", + "type": "object", + "required": [ + "grant_role" + ], + "properties": { + "grant_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "full list of roles to grant the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes the role that has been granted to the signer", + "type": "object", + "required": [ + "revoke_role" + ], + "properties": { + "revoke_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "fill list of roles to revoke from the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Replaces the quota identified by QuotaMsg::Name", + "type": "object", + "required": [ + "edit_path_quota" + ], + "properties": { + "edit_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota": { + "description": "similar to ResetPathQuota, but QuotaMsg::Name is used as the quota_id", + "allOf": [ + { + "$ref": "#/definitions/QuotaMsg" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to remove a message from the message queue to prevent execution", + "type": "object", + "required": [ + "remove_message" + ], + "properties": { + "remove_message": { + "type": "object", + "required": [ + "message_id" + ], + "properties": { + "message_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to change the timelock delay for newly submitted messages", + "type": "object", + "required": [ + "set_timelock_delay" + ], + "properties": { + "set_timelock_delay": { + "type": "object", + "required": [ + "hours", + "signer" + ], + "properties": { + "hours": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "signer": { + "description": "the address to apply the timelock delay to", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Permissionless message that anyone can invoke to trigger execution of queued messages that have passed the timelock delay\n\nIf both count and message_ids are some, message_ids is used. If both are None returns an error", + "type": "object", + "required": [ + "process_messages" + ], + "properties": { + "process_messages": { + "type": "object", + "properties": { + "count": { + "description": "number of queued messages to process, a value of 0 will attempt to process all queued messages", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "message_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "QuotaMsg": { + "type": "object", + "required": [ + "duration", + "name", + "send_recv" + ], + "properties": { + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "send_recv": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + }, + "Roles": { + "description": "Roles defines the available permissions that can be assigned to addresses as part of the RBAC system", + "oneOf": [ + { + "type": "string", + "enum": [ + "ManageDenomRestrictions" + ] + }, + { + "description": "Has the ability to add a new rate limit", + "type": "string", + "enum": [ + "AddRateLimit" + ] + }, + { + "description": "Has the ability to complete remove a configured rate limit", + "type": "string", + "enum": [ + "RemoveRateLimit" + ] + }, + { + "description": "Has the ability to reset tracked quotas", + "type": "string", + "enum": [ + "ResetPathQuota" + ] + }, + { + "description": "Has the ability to edit existing quotas", + "type": "string", + "enum": [ + "EditPathQuota" + ] + }, + { + "description": "Has the ability to grant roles to an address", + "type": "string", + "enum": [ + "GrantRole" + ] + }, + { + "description": "Has the ability to revoke granted roles to an address", + "type": "string", + "enum": [ + "RevokeRole" + ] + }, + { + "description": "Has the ability to remove queued messages", + "type": "string", + "enum": [ + "RemoveMessage" + ] + }, + { + "description": "Has the ability to alter timelock delay's", + "type": "string", + "enum": [ + "SetTimelockDelay" + ] + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "get_message_ids": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + }, + "get_quotas": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_RateLimit", + "type": "array", + "items": { + "$ref": "#/definitions/RateLimit" + }, + "definitions": { + "Flow": { + "description": "A Flow represents the transfer of value for a denom through an IBC channel during a time window.\n\nIt tracks inflows (transfers into neturon) and outflows (transfers out of neturon).\n\nThe period_end represents the last point in time for which this Flow is tracking the value transfer.\n\nPeriods are discrete repeating windows. A period only starts when a contract call to update the Flow (SendPacket/RecvPackt) is made, and not right after the period ends. This means that if no calls happen after a period expires, the next period will begin at the time of the next call and be valid for the specified duration for the quota.\n\nThis is a design decision to avoid the period calculations and thus reduce gas consumption", + "type": "object", + "required": [ + "inflow", + "outflow", + "period_end" + ], + "properties": { + "inflow": { + "$ref": "#/definitions/Uint256" + }, + "outflow": { + "$ref": "#/definitions/Uint256" + }, + "period_end": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + "Quota": { + "description": "A Quota is the percentage of the denom's total value that can be transferred through the channel in a given period of time (duration)\n\nPercentages can be different for send and recv\n\nThe name of the quota is expected to be a human-readable representation of the duration (i.e.: \"weekly\", \"daily\", \"every-six-months\", ...)", + "type": "object", + "required": [ + "duration", + "max_percentage_recv", + "max_percentage_send", + "name" + ], + "properties": { + "channel_value": { + "anyOf": [ + { + "$ref": "#/definitions/Uint256" + }, + { + "type": "null" + } + ] + }, + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "max_percentage_recv": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "max_percentage_send": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "RateLimit": { + "description": "RateLimit is the main structure tracked for each channel/denom pair. Its quota represents rate limit configuration, and the flow its current state (i.e.: how much value has been transfered in the current period)", + "type": "object", + "required": [ + "flow", + "quota" + ], + "properties": { + "flow": { + "$ref": "#/definitions/Flow" + }, + "quota": { + "$ref": "#/definitions/Quota" + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "get_role_owners": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + }, + "get_roles": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Roles", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + }, + "definitions": { + "Roles": { + "description": "Roles defines the available permissions that can be assigned to addresses as part of the RBAC system", + "oneOf": [ + { + "type": "string", + "enum": [ + "ManageDenomRestrictions" + ] + }, + { + "description": "Has the ability to add a new rate limit", + "type": "string", + "enum": [ + "AddRateLimit" + ] + }, + { + "description": "Has the ability to complete remove a configured rate limit", + "type": "string", + "enum": [ + "RemoveRateLimit" + ] + }, + { + "description": "Has the ability to reset tracked quotas", + "type": "string", + "enum": [ + "ResetPathQuota" + ] + }, + { + "description": "Has the ability to edit existing quotas", + "type": "string", + "enum": [ + "EditPathQuota" + ] + }, + { + "description": "Has the ability to grant roles to an address", + "type": "string", + "enum": [ + "GrantRole" + ] + }, + { + "description": "Has the ability to revoke granted roles to an address", + "type": "string", + "enum": [ + "RevokeRole" + ] + }, + { + "description": "Has the ability to remove queued messages", + "type": "string", + "enum": [ + "RemoveMessage" + ] + }, + { + "description": "Has the ability to alter timelock delay's", + "type": "string", + "enum": [ + "SetTimelockDelay" + ] + } + ] + } + } + } + } +} diff --git a/schema/raw/execute.json b/schema/raw/execute.json new file mode 100644 index 0000000..e45e31e --- /dev/null +++ b/schema/raw/execute.json @@ -0,0 +1,427 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "The caller (IBC module) is responsible for correctly calculating the funds being sent through the channel", + "oneOf": [ + { + "type": "object", + "required": [ + "add_path" + ], + "properties": { + "add_path": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quotas" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quotas": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaMsg" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_path" + ], + "properties": { + "remove_path": { + "type": "object", + "required": [ + "channel_id", + "denom" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reset_path_quota" + ], + "properties": { + "reset_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota_id" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_denom_restrictions" + ], + "properties": { + "set_denom_restrictions": { + "type": "object", + "required": [ + "allowed_channels", + "denom" + ], + "properties": { + "allowed_channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unset_denom_restrictions" + ], + "properties": { + "unset_denom_restrictions": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Grants a role to the given signer", + "type": "object", + "required": [ + "grant_role" + ], + "properties": { + "grant_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "full list of roles to grant the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes the role that has been granted to the signer", + "type": "object", + "required": [ + "revoke_role" + ], + "properties": { + "revoke_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "fill list of roles to revoke from the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Replaces the quota identified by QuotaMsg::Name", + "type": "object", + "required": [ + "edit_path_quota" + ], + "properties": { + "edit_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota": { + "description": "similar to ResetPathQuota, but QuotaMsg::Name is used as the quota_id", + "allOf": [ + { + "$ref": "#/definitions/QuotaMsg" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to remove a message from the message queue to prevent execution", + "type": "object", + "required": [ + "remove_message" + ], + "properties": { + "remove_message": { + "type": "object", + "required": [ + "message_id" + ], + "properties": { + "message_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to change the timelock delay for newly submitted messages", + "type": "object", + "required": [ + "set_timelock_delay" + ], + "properties": { + "set_timelock_delay": { + "type": "object", + "required": [ + "hours", + "signer" + ], + "properties": { + "hours": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "signer": { + "description": "the address to apply the timelock delay to", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Permissionless message that anyone can invoke to trigger execution of queued messages that have passed the timelock delay\n\nIf both count and message_ids are some, message_ids is used. If both are None returns an error", + "type": "object", + "required": [ + "process_messages" + ], + "properties": { + "process_messages": { + "type": "object", + "properties": { + "count": { + "description": "number of queued messages to process, a value of 0 will attempt to process all queued messages", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "message_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "QuotaMsg": { + "type": "object", + "required": [ + "duration", + "name", + "send_recv" + ], + "properties": { + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "send_recv": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + }, + "Roles": { + "description": "Roles defines the available permissions that can be assigned to addresses as part of the RBAC system", + "oneOf": [ + { + "type": "string", + "enum": [ + "ManageDenomRestrictions" + ] + }, + { + "description": "Has the ability to add a new rate limit", + "type": "string", + "enum": [ + "AddRateLimit" + ] + }, + { + "description": "Has the ability to complete remove a configured rate limit", + "type": "string", + "enum": [ + "RemoveRateLimit" + ] + }, + { + "description": "Has the ability to reset tracked quotas", + "type": "string", + "enum": [ + "ResetPathQuota" + ] + }, + { + "description": "Has the ability to edit existing quotas", + "type": "string", + "enum": [ + "EditPathQuota" + ] + }, + { + "description": "Has the ability to grant roles to an address", + "type": "string", + "enum": [ + "GrantRole" + ] + }, + { + "description": "Has the ability to revoke granted roles to an address", + "type": "string", + "enum": [ + "RevokeRole" + ] + }, + { + "description": "Has the ability to remove queued messages", + "type": "string", + "enum": [ + "RemoveMessage" + ] + }, + { + "description": "Has the ability to alter timelock delay's", + "type": "string", + "enum": [ + "SetTimelockDelay" + ] + } + ] + } + } +} diff --git a/schema/raw/instantiate.json b/schema/raw/instantiate.json new file mode 100644 index 0000000..4110344 --- /dev/null +++ b/schema/raw/instantiate.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Initialize the contract with the address of the IBC module and any existing channels. Only the ibc module is allowed to execute actions on this contract", + "type": "object", + "required": [ + "gov_module", + "paths" + ], + "properties": { + "gov_module": { + "$ref": "#/definitions/Addr" + }, + "paths": { + "type": "array", + "items": { + "$ref": "#/definitions/PathMsg" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "PathMsg": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quotas" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quotas": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaMsg" + } + } + }, + "additionalProperties": false + }, + "QuotaMsg": { + "type": "object", + "required": [ + "duration", + "name", + "send_recv" + ], + "properties": { + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "send_recv": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + } +} diff --git a/schema/raw/migrate.json b/schema/raw/migrate.json new file mode 100644 index 0000000..7fbe8c5 --- /dev/null +++ b/schema/raw/migrate.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false +} diff --git a/schema/raw/query.json b/schema/raw/query.json new file mode 100644 index 0000000..fb7a860 --- /dev/null +++ b/schema/raw/query.json @@ -0,0 +1,111 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "get_quotas" + ], + "properties": { + "get_quotas": { + "type": "object", + "required": [ + "channel_id", + "denom" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a vector of all addresses that have been allocated one or more roles", + "type": "string", + "enum": [ + "get_role_owners" + ] + }, + { + "description": "Returns a vector of all roles that have been granted to `owner`", + "type": "object", + "required": [ + "get_roles" + ], + "properties": { + "get_roles": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a vector of queued message id's", + "type": "string", + "enum": [ + "get_message_ids" + ] + }, + { + "description": "Returns the queued message matching id", + "type": "object", + "required": [ + "get_message" + ], + "properties": { + "get_message": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the restrictions for a given denom", + "type": "object", + "required": [ + "get_denom_restrictions" + ], + "properties": { + "get_denom_restrictions": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/schema/raw/response_to_get_denom_restrictions.json b/schema/raw/response_to_get_denom_restrictions.json new file mode 100644 index 0000000..4290cb1 --- /dev/null +++ b/schema/raw/response_to_get_denom_restrictions.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } +} diff --git a/schema/raw/response_to_get_message.json b/schema/raw/response_to_get_message.json new file mode 100644 index 0000000..c65f1a0 --- /dev/null +++ b/schema/raw/response_to_get_message.json @@ -0,0 +1,477 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueuedMessage", + "type": "object", + "required": [ + "message", + "message_id", + "submitted_at", + "timelock_delay" + ], + "properties": { + "message": { + "description": "the message that submitted to the contract after a sucessful governance proposal", + "allOf": [ + { + "$ref": "#/definitions/ExecuteMsg" + } + ] + }, + "message_id": { + "description": "Constructed using format!(\"{}_{}\", Env::BlockInfo::Height Env::Transaction::Index)\n\nCan be used to remove a message from the queue without processing it", + "type": "string" + }, + "submitted_at": { + "description": "the time which the message was processed by the contract", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + }, + "timelock_delay": { + "description": "the timelock delay that was in place when the message was queued for execution", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "ExecuteMsg": { + "description": "The caller (IBC module) is responsible for correctly calculating the funds being sent through the channel", + "oneOf": [ + { + "type": "object", + "required": [ + "add_path" + ], + "properties": { + "add_path": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quotas" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quotas": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaMsg" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_path" + ], + "properties": { + "remove_path": { + "type": "object", + "required": [ + "channel_id", + "denom" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reset_path_quota" + ], + "properties": { + "reset_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota_id" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_denom_restrictions" + ], + "properties": { + "set_denom_restrictions": { + "type": "object", + "required": [ + "allowed_channels", + "denom" + ], + "properties": { + "allowed_channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unset_denom_restrictions" + ], + "properties": { + "unset_denom_restrictions": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Grants a role to the given signer", + "type": "object", + "required": [ + "grant_role" + ], + "properties": { + "grant_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "full list of roles to grant the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes the role that has been granted to the signer", + "type": "object", + "required": [ + "revoke_role" + ], + "properties": { + "revoke_role": { + "type": "object", + "required": [ + "roles", + "signer" + ], + "properties": { + "roles": { + "description": "fill list of roles to revoke from the signer", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + } + }, + "signer": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Replaces the quota identified by QuotaMsg::Name", + "type": "object", + "required": [ + "edit_path_quota" + ], + "properties": { + "edit_path_quota": { + "type": "object", + "required": [ + "channel_id", + "denom", + "quota" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "denom": { + "type": "string" + }, + "quota": { + "description": "similar to ResetPathQuota, but QuotaMsg::Name is used as the quota_id", + "allOf": [ + { + "$ref": "#/definitions/QuotaMsg" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to remove a message from the message queue to prevent execution", + "type": "object", + "required": [ + "remove_message" + ], + "properties": { + "remove_message": { + "type": "object", + "required": [ + "message_id" + ], + "properties": { + "message_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to change the timelock delay for newly submitted messages", + "type": "object", + "required": [ + "set_timelock_delay" + ], + "properties": { + "set_timelock_delay": { + "type": "object", + "required": [ + "hours", + "signer" + ], + "properties": { + "hours": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "signer": { + "description": "the address to apply the timelock delay to", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Permissionless message that anyone can invoke to trigger execution of queued messages that have passed the timelock delay\n\nIf both count and message_ids are some, message_ids is used. If both are None returns an error", + "type": "object", + "required": [ + "process_messages" + ], + "properties": { + "process_messages": { + "type": "object", + "properties": { + "count": { + "description": "number of queued messages to process, a value of 0 will attempt to process all queued messages", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "message_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "QuotaMsg": { + "type": "object", + "required": [ + "duration", + "name", + "send_recv" + ], + "properties": { + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "send_recv": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + }, + "Roles": { + "description": "Roles defines the available permissions that can be assigned to addresses as part of the RBAC system", + "oneOf": [ + { + "type": "string", + "enum": [ + "ManageDenomRestrictions" + ] + }, + { + "description": "Has the ability to add a new rate limit", + "type": "string", + "enum": [ + "AddRateLimit" + ] + }, + { + "description": "Has the ability to complete remove a configured rate limit", + "type": "string", + "enum": [ + "RemoveRateLimit" + ] + }, + { + "description": "Has the ability to reset tracked quotas", + "type": "string", + "enum": [ + "ResetPathQuota" + ] + }, + { + "description": "Has the ability to edit existing quotas", + "type": "string", + "enum": [ + "EditPathQuota" + ] + }, + { + "description": "Has the ability to grant roles to an address", + "type": "string", + "enum": [ + "GrantRole" + ] + }, + { + "description": "Has the ability to revoke granted roles to an address", + "type": "string", + "enum": [ + "RevokeRole" + ] + }, + { + "description": "Has the ability to remove queued messages", + "type": "string", + "enum": [ + "RemoveMessage" + ] + }, + { + "description": "Has the ability to alter timelock delay's", + "type": "string", + "enum": [ + "SetTimelockDelay" + ] + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/schema/raw/response_to_get_message_ids.json b/schema/raw/response_to_get_message_ids.json new file mode 100644 index 0000000..4290cb1 --- /dev/null +++ b/schema/raw/response_to_get_message_ids.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } +} diff --git a/schema/raw/response_to_get_quotas.json b/schema/raw/response_to_get_quotas.json new file mode 100644 index 0000000..9fb2ff3 --- /dev/null +++ b/schema/raw/response_to_get_quotas.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_RateLimit", + "type": "array", + "items": { + "$ref": "#/definitions/RateLimit" + }, + "definitions": { + "Flow": { + "description": "A Flow represents the transfer of value for a denom through an IBC channel during a time window.\n\nIt tracks inflows (transfers into neturon) and outflows (transfers out of neturon).\n\nThe period_end represents the last point in time for which this Flow is tracking the value transfer.\n\nPeriods are discrete repeating windows. A period only starts when a contract call to update the Flow (SendPacket/RecvPackt) is made, and not right after the period ends. This means that if no calls happen after a period expires, the next period will begin at the time of the next call and be valid for the specified duration for the quota.\n\nThis is a design decision to avoid the period calculations and thus reduce gas consumption", + "type": "object", + "required": [ + "inflow", + "outflow", + "period_end" + ], + "properties": { + "inflow": { + "$ref": "#/definitions/Uint256" + }, + "outflow": { + "$ref": "#/definitions/Uint256" + }, + "period_end": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + "Quota": { + "description": "A Quota is the percentage of the denom's total value that can be transferred through the channel in a given period of time (duration)\n\nPercentages can be different for send and recv\n\nThe name of the quota is expected to be a human-readable representation of the duration (i.e.: \"weekly\", \"daily\", \"every-six-months\", ...)", + "type": "object", + "required": [ + "duration", + "max_percentage_recv", + "max_percentage_send", + "name" + ], + "properties": { + "channel_value": { + "anyOf": [ + { + "$ref": "#/definitions/Uint256" + }, + { + "type": "null" + } + ] + }, + "duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "max_percentage_recv": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "max_percentage_send": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "RateLimit": { + "description": "RateLimit is the main structure tracked for each channel/denom pair. Its quota represents rate limit configuration, and the flow its current state (i.e.: how much value has been transfered in the current period)", + "type": "object", + "required": [ + "flow", + "quota" + ], + "properties": { + "flow": { + "$ref": "#/definitions/Flow" + }, + "quota": { + "$ref": "#/definitions/Quota" + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/schema/raw/response_to_get_role_owners.json b/schema/raw/response_to_get_role_owners.json new file mode 100644 index 0000000..4290cb1 --- /dev/null +++ b/schema/raw/response_to_get_role_owners.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } +} diff --git a/schema/raw/response_to_get_roles.json b/schema/raw/response_to_get_roles.json new file mode 100644 index 0000000..f16ac06 --- /dev/null +++ b/schema/raw/response_to_get_roles.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Roles", + "type": "array", + "items": { + "$ref": "#/definitions/Roles" + }, + "definitions": { + "Roles": { + "description": "Roles defines the available permissions that can be assigned to addresses as part of the RBAC system", + "oneOf": [ + { + "type": "string", + "enum": [ + "ManageDenomRestrictions" + ] + }, + { + "description": "Has the ability to add a new rate limit", + "type": "string", + "enum": [ + "AddRateLimit" + ] + }, + { + "description": "Has the ability to complete remove a configured rate limit", + "type": "string", + "enum": [ + "RemoveRateLimit" + ] + }, + { + "description": "Has the ability to reset tracked quotas", + "type": "string", + "enum": [ + "ResetPathQuota" + ] + }, + { + "description": "Has the ability to edit existing quotas", + "type": "string", + "enum": [ + "EditPathQuota" + ] + }, + { + "description": "Has the ability to grant roles to an address", + "type": "string", + "enum": [ + "GrantRole" + ] + }, + { + "description": "Has the ability to revoke granted roles to an address", + "type": "string", + "enum": [ + "RevokeRole" + ] + }, + { + "description": "Has the ability to remove queued messages", + "type": "string", + "enum": [ + "RemoveMessage" + ] + }, + { + "description": "Has the ability to alter timelock delay's", + "type": "string", + "enum": [ + "SetTimelockDelay" + ] + } + ] + } + } +} diff --git a/schema/raw/sudo.json b/schema/raw/sudo.json new file mode 100644 index 0000000..9c3b956 --- /dev/null +++ b/schema/raw/sudo.json @@ -0,0 +1,173 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SudoMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "packet" + ], + "properties": { + "packet": { + "$ref": "#/definitions/Packet" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "recv_packet" + ], + "properties": { + "recv_packet": { + "type": "object", + "required": [ + "packet" + ], + "properties": { + "packet": { + "$ref": "#/definitions/Packet" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "undo_send" + ], + "properties": { + "undo_send": { + "type": "object", + "required": [ + "packet" + ], + "properties": { + "packet": { + "$ref": "#/definitions/Packet" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "FungibleTokenData": { + "type": "object", + "required": [ + "amount", + "denom", + "receiver", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint256" + }, + "denom": { + "type": "string" + }, + "receiver": { + "$ref": "#/definitions/Addr" + }, + "sender": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "Height": { + "type": "object", + "properties": { + "revision_height": { + "description": "The height of a block", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "revision_number": { + "description": "Previously known as \"epoch\"", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Packet": { + "type": "object", + "required": [ + "data", + "destination_channel", + "destination_port", + "sequence", + "source_channel", + "source_port", + "timeout_height" + ], + "properties": { + "data": { + "$ref": "#/definitions/FungibleTokenData" + }, + "destination_channel": { + "type": "string" + }, + "destination_port": { + "type": "string" + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "source_channel": { + "type": "string" + }, + "source_port": { + "type": "string" + }, + "timeout_height": { + "$ref": "#/definitions/Height" + }, + "timeout_timestamp": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "type": "string" + } + } +} diff --git a/src/blocking.rs b/src/blocking.rs new file mode 100644 index 0000000..936750c --- /dev/null +++ b/src/blocking.rs @@ -0,0 +1,150 @@ +use cosmwasm_std::Deps; + +use crate::{ + packet::Packet, + state::{flow::FlowType, storage::ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM}, + ContractError, +}; + +pub fn check_restricted_denoms( + deps: Deps, + packet: &Packet, + direction: &FlowType, +) -> Result<(), ContractError> { + // we are only limiting out-flow. In-flow is always allowed + if matches!(direction, FlowType::In) { + return Ok(()); + } + + let channels = ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .load(deps.storage, packet.data.denom.to_string()) + .unwrap_or_default(); + + // if no channels are blocked, we can return early + if channels.is_empty() { + return Ok(()); + } + + // Only channels in the list are allowed. If the source channel is not in the list, we reject the packet + if !channels.contains(&packet.source_channel) { + return Err(ContractError::ChannelBlocked { + denom: packet.data.denom.clone(), + channel: packet.source_channel.to_string(), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::mock_dependencies; + use cosmwasm_std::Uint256; + + #[test] + fn test_in_flow_allowed() { + let deps = mock_dependencies(); + let packet = Packet::mock( + "src_channel".to_string(), + "dest_channel".to_string(), + "denom1".to_string(), + Uint256::from(100u128), + ); + let flow_type = FlowType::In; + + let result = check_restricted_denoms(deps.as_ref(), &packet, &flow_type); + assert!(result.is_ok()); + } + + #[test] + fn test_out_flow_unrestricted_denom() { + let deps = mock_dependencies(); + let packet = Packet::mock( + "src_channel".to_string(), + "dest_channel".to_string(), + "denom2".to_string(), + Uint256::from(100u128), + ); + let flow_type = FlowType::Out; + + // denom2 is not in the restricted list + let result = check_restricted_denoms(deps.as_ref(), &packet, &flow_type); + assert!(result.is_ok()); + } + + #[test] + fn test_out_flow_restricted_denom_allowed_channel() { + let mut deps = mock_dependencies(); + let packet = Packet::mock( + "src_channel_allowed".to_string(), + "dest_channel".to_string(), + "denom1".to_string(), + Uint256::from(100u128), + ); + let flow_type = FlowType::Out; + + // Add denom1 to restricted list with allowed channels + ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .save( + deps.as_mut().storage, + "denom1".to_string(), + &vec!["src_channel_allowed".to_string()], + ) + .unwrap(); + + let result = check_restricted_denoms(deps.as_ref(), &packet, &flow_type); + assert!(result.is_ok()); + } + + #[test] + fn test_out_flow_restricted_denom_blocked_channel() { + let mut deps = mock_dependencies(); + let packet = Packet::mock( + "src_channel_blocked".to_string(), + "dest_channel".to_string(), + "denom1".to_string(), + Uint256::from(100u128), + ); + let flow_type = FlowType::Out; + + // Add denom1 to restricted list with allowed channels + ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .save( + deps.as_mut().storage, + "denom1".to_string(), + &vec!["src_channel_allowed".to_string()], + ) + .unwrap(); + + let result = check_restricted_denoms(deps.as_ref(), &packet, &flow_type); + assert!(result.is_err()); + + if let Err(ContractError::ChannelBlocked { denom, channel }) = result { + assert_eq!(denom, "denom1".to_string()); + assert_eq!(channel, "src_channel_blocked".to_string()); + } else { + panic!("Expected ChannelBlocked error"); + } + } + + #[test] + fn test_out_flow_restricted_denom_empty_channel_list() { + let mut deps = mock_dependencies(); + let packet = Packet::mock( + "src_channel_blocked".to_string(), + "dest_channel".to_string(), + "denom1".to_string(), + Uint256::from(100u128), + ); + let flow_type = FlowType::Out; + + // Add denom1 to restricted list but with an empty allowed channels list + ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .save(deps.as_mut().storage, "denom1".to_string(), &vec![]) + .unwrap(); + + let result = check_restricted_denoms(deps.as_ref(), &packet, &flow_type); + assert!(result.is_ok()); + } +} diff --git a/src/contract.rs b/src/contract.rs new file mode 100644 index 0000000..c2cef39 --- /dev/null +++ b/src/contract.rs @@ -0,0 +1,186 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw2::{get_contract_version, set_contract_version}; + +use crate::error::ContractError; +use crate::message_queue::{must_queue_message, queue_message}; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, SudoMsg}; +use crate::rbac::can_invoke_message; +use crate::state::rbac::Roles; +use crate::state::storage::RBAC_PERMISSIONS; +use crate::state::{flow::FlowType, storage::GOVMODULE}; +use crate::{execute, message_queue, query, rbac, sudo}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:rate-limiter"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + GOVMODULE.save(deps.storage, &msg.gov_module)?; + // grant the gov address full permissions + RBAC_PERMISSIONS.save( + deps.storage, + msg.gov_module.to_string(), + &Roles::all_roles().into_iter().collect(), + )?; + + execute::add_new_paths(&mut deps, msg.paths, env.block.time)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("gov_module", msg.gov_module.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + // check to see if special permissions are required to invoke the message, and that the sender has the required permissions + can_invoke_message(&deps, &info, &msg)?; + // check to see if messages sent by MessageInfo::sender require a timelock + // + // if a timelock is required the message must be queued for execution + + if msg.skip_queue() { + match_execute(&mut deps, &env, msg) + } else if must_queue_message(&mut deps, &info) { + let message_id = queue_message(&mut deps, env, msg, info)?; + Ok(Response::new().add_attribute("message.id", message_id)) + } else { + match_execute(&mut deps, &env, msg) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn sudo(deps: DepsMut, env: Env, msg: SudoMsg) -> Result { + match msg { + SudoMsg::SendPacket { + packet, + #[cfg(test)] + channel_value_mock, + } => sudo::process_packet( + deps, + packet, + FlowType::Out, + env.block.time, + #[cfg(test)] + channel_value_mock, + ), + SudoMsg::RecvPacket { + packet, + #[cfg(test)] + channel_value_mock, + } => sudo::process_packet( + deps, + packet, + FlowType::In, + env.block.time, + #[cfg(test)] + channel_value_mock, + ), + SudoMsg::UndoSend { packet } => sudo::undo_send(deps, packet), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetQuotas { channel_id, denom } => { + query::get_quotas(deps.storage, channel_id, denom) + } + QueryMsg::GetRoleOwners => query::get_role_owners(deps.storage), + QueryMsg::GetRoles { owner } => query::get_roles(deps.storage, owner), + QueryMsg::GetMessageIds => query::get_message_ids(deps.storage), + QueryMsg::GetMessage { id } => query::get_queued_message(deps.storage, id), + QueryMsg::GetDenomRestrictions { denom } => { + query::get_denom_restrictions(deps.storage, denom) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + let contract_version = get_contract_version(deps.storage)?; + + // update contract version + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new() + .add_attribute("method", "migrate") + .add_attribute("version.old", contract_version.version) + .add_attribute("version.new", CONTRACT_VERSION)) +} + +/// Processes `msg` and executes the corresponding message handler +/// +/// This shouldn't be called directly and instead invoked by the `execute` function, or internally via message queue processing +pub(crate) fn match_execute( + deps: &mut DepsMut, + env: &Env, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::AddPath { + channel_id, + denom, + quotas, + } => execute::try_add_path(deps, channel_id, denom, quotas, env.block.time), + ExecuteMsg::RemovePath { channel_id, denom } => { + execute::try_remove_path(deps, channel_id, denom) + } + ExecuteMsg::ResetPathQuota { + channel_id, + denom, + quota_id, + } => execute::try_reset_path_quota(deps, channel_id, denom, quota_id, env.block.time), + ExecuteMsg::SetDenomRestrictions { + denom, + allowed_channels, + } => execute::set_denom_restrictions(deps, denom, allowed_channels), + ExecuteMsg::UnsetDenomRestrictions { denom } => { + execute::unset_denom_restrictions(deps, denom) + } + ExecuteMsg::GrantRole { signer, roles } => { + rbac::grant_role(deps, signer, roles)?; + Ok(Response::new().add_attribute("method", "grant_role")) + } + ExecuteMsg::RevokeRole { signer, roles } => { + rbac::revoke_role(deps, signer, roles)?; + Ok(Response::new().add_attribute("method", "revoke_role")) + } + ExecuteMsg::EditPathQuota { + channel_id, + denom, + quota, + } => { + execute::edit_path_quota(deps, channel_id, denom, quota)?; + Ok(Response::new().add_attribute("method", "edit_path_quota")) + } + ExecuteMsg::RemoveMessage { message_id } => { + message_queue::remove_message(deps, message_id)?; + Ok(Response::new().add_attribute("method", "remove_message")) + } + ExecuteMsg::SetTimelockDelay { signer, hours } => { + crate::rbac::set_timelock_delay(deps, signer.clone(), hours)?; + Ok(Response::new() + .add_attribute("method", "set_timelock_delay") + .add_attribute("signer", signer) + .add_attribute("hours", hours.to_string())) + } + ExecuteMsg::ProcessMessages { count, message_ids } => { + message_queue::process_message_queue(deps, env, count, message_ids)?; + Ok(Response::new().add_attribute("method", "process_messages")) + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..aa846a2 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,37 @@ +use cosmwasm_std::{StdError, Timestamp, Uint256}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("IBC Rate Limit exceeded for {channel}/{denom}. Tried to transfer {amount} which exceeds capacity on the '{quota_name}' quota ({used}/{max}). Try again after {reset:?}")] + RateLimitExceded { + channel: String, + denom: String, + amount: Uint256, + quota_name: String, + used: Uint256, + max: Uint256, + reset: Timestamp, + }, + + #[error("Quota {quota_id} not found for channel {channel_id}")] + QuotaNotFound { + quota_id: String, + channel_id: String, + denom: String, + }, + #[error("{0}")] + InvalidParameters(String), + + #[error("Channel {channel} has been blocked for denom {denom}")] + ChannelBlocked { channel: String, denom: String }, + + #[error("A message is being executed not as a part of a transaction")] + NotTransaction, +} diff --git a/src/execute.rs b/src/execute.rs new file mode 100644 index 0000000..63b5fe0 --- /dev/null +++ b/src/execute.rs @@ -0,0 +1,439 @@ +use crate::msg::{PathMsg, QuotaMsg}; + +use crate::state::storage::ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM; +use crate::state::{flow::Flow, path::Path, rate_limit::RateLimit, storage::RATE_LIMIT_TRACKERS}; +use crate::ContractError; +use cosmwasm_std::{DepsMut, Response, Timestamp}; + +pub fn add_new_paths( + deps: &mut DepsMut, + path_msgs: Vec, + now: Timestamp, +) -> Result<(), ContractError> { + for path_msg in path_msgs { + let path = Path::new(path_msg.channel_id, path_msg.denom); + + RATE_LIMIT_TRACKERS.save( + deps.storage, + path.into(), + &path_msg + .quotas + .iter() + .map(|q| RateLimit { + quota: q.into(), + flow: Flow::new(0_u128, 0_u128, now, q.duration), + }) + .collect(), + )? + } + Ok(()) +} + +pub fn try_add_path( + deps: &mut DepsMut, + channel_id: String, + denom: String, + quotas: Vec, + now: Timestamp, +) -> Result { + add_new_paths(deps, vec![PathMsg::new(&channel_id, &denom, quotas)], now)?; + + Ok(Response::new() + .add_attribute("method", "try_add_channel") + .add_attribute("channel_id", channel_id) + .add_attribute("denom", denom)) +} + +pub fn try_remove_path( + deps: &mut DepsMut, + channel_id: String, + denom: String, +) -> Result { + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.remove(deps.storage, path.into()); + Ok(Response::new() + .add_attribute("method", "try_remove_channel") + .add_attribute("denom", denom) + .add_attribute("channel_id", channel_id)) +} + +// Reset specified quote_id for the given channel_id +pub fn try_reset_path_quota( + deps: &mut DepsMut, + channel_id: String, + denom: String, + quota_id: String, + now: Timestamp, +) -> Result { + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.update(deps.storage, path.into(), |maybe_rate_limit| { + match maybe_rate_limit { + None => Err(ContractError::QuotaNotFound { + quota_id, + channel_id: channel_id.clone(), + denom: denom.clone(), + }), + Some(mut limits) => { + // Q: What happens here if quote_id not found? seems like we return ok? + limits.iter_mut().for_each(|limit| { + if limit.quota.name == quota_id.as_ref() { + limit.flow.expire(now, limit.quota.duration) + } + }); + Ok(limits) + } + } + })?; + + Ok(Response::new() + .add_attribute("method", "try_reset_channel") + .add_attribute("channel_id", channel_id)) +} + +pub fn edit_path_quota( + deps: &mut DepsMut, + channel_id: String, + denom: String, + quota: QuotaMsg, +) -> Result<(), ContractError> { + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.update(deps.storage, path.into(), |maybe_rate_limit| { + match maybe_rate_limit { + None => Err(ContractError::QuotaNotFound { + quota_id: quota.name, + channel_id: channel_id.clone(), + denom: denom.clone(), + }), + Some(mut limits) => { + limits.iter_mut().for_each(|limit| { + if limit.quota.name.eq("a.name) { + // TODO: is this the current way of handling channel_value when editing the quota? + + // cache the current channel_value + let channel_value = limit.quota.channel_value; + // update the quota + limit.quota = From::from("a); + // copy the channel_value + limit.quota.channel_value = channel_value; + } + }); + Ok(limits) + } + } + })?; + Ok(()) +} + +pub fn set_denom_restrictions( + deps: &mut DepsMut, + denom: String, + allowed_channels: Vec, +) -> Result { + ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM.save(deps.storage, denom, &allowed_channels)?; + Ok(Response::new().add_attribute("method", "set_denom_restrictions")) +} + +pub fn unset_denom_restrictions( + deps: &mut DepsMut, + denom: String, +) -> Result { + ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM.remove(deps.storage, denom); + Ok(Response::new().add_attribute("method", "unset_denom_restrictions")) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{from_json, StdError}; + + use crate::contract::{execute, query}; + use crate::msg::{ExecuteMsg, QueryMsg, QuotaMsg}; + use crate::state::rbac::Roles; + use crate::state::{ + rate_limit::RateLimit, + storage::{ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM, GOVMODULE}, + }; + use crate::tests::helpers::tests::verify_query_response; + use crate::ContractError; + + const GOV_ADDR: &str = "neutron1w02khza7ux68ccwmz2hln97mkjspjxes8y2k9v"; + + #[test] // Tests AddPath and RemovePath messages + fn management_add_and_remove_path() { + let mut deps = mock_dependencies(); + GOVMODULE + .save( + deps.as_mut().storage, + &MockApi::default().addr_make(GOV_ADDR), + ) + .unwrap(); + + // grant role to IBC_ADDR + crate::rbac::grant_role( + &mut deps.as_mut(), + MockApi::default().addr_make(GOV_ADDR).to_string(), + vec![Roles::AddRateLimit, Roles::RemoveRateLimit], + ) + .unwrap(); + + let msg = ExecuteMsg::AddPath { + channel_id: "channel".to_string(), + denom: "denom".to_string(), + quotas: vec![QuotaMsg { + name: "daily".to_string(), + duration: 1600, + send_recv: (3, 5), + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + + let env = mock_env(); + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + + let query_msg = QueryMsg::GetQuotas { + channel_id: "channel".to_string(), + denom: "denom".to_string(), + }; + + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + + let value: Vec = from_json(res).unwrap(); + verify_query_response( + &value[0], + "daily", + (3, 5), + 1600, + 0_u32.into(), + 0_u32.into(), + env.block.time.plus_seconds(1600), + ); + + assert_eq!(value.len(), 1); + + // Add another path + let msg = ExecuteMsg::AddPath { + channel_id: "channel2".to_string(), + denom: "denom".to_string(), + quotas: vec![QuotaMsg { + name: "daily".to_string(), + duration: 1600, + send_recv: (3, 5), + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // remove the first one + let msg = ExecuteMsg::RemovePath { + channel_id: "channel".to_string(), + denom: "denom".to_string(), + }; + + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // The channel is not there anymore + let err = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap_err(); + assert!(matches!(err, StdError::NotFound { .. })); + + // The second channel is still there + let query_msg = QueryMsg::GetQuotas { + channel_id: "channel2".to_string(), + denom: "denom".to_string(), + }; + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_json(res).unwrap(); + assert_eq!(value.len(), 1); + verify_query_response( + &value[0], + "daily", + (3, 5), + 1600, + 0_u32.into(), + 0_u32.into(), + env.block.time.plus_seconds(1600), + ); + + // Paths are overriden if they share a name and denom + let msg = ExecuteMsg::AddPath { + channel_id: "channel2".to_string(), + denom: "denom".to_string(), + quotas: vec![QuotaMsg { + name: "different".to_string(), + duration: 5000, + send_recv: (50, 30), + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + let query_msg = QueryMsg::GetQuotas { + channel_id: "channel2".to_string(), + denom: "denom".to_string(), + }; + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_json(res).unwrap(); + assert_eq!(value.len(), 1); + + verify_query_response( + &value[0], + "different", + (50, 30), + 5000, + 0_u32.into(), + 0_u32.into(), + env.block.time.plus_seconds(5000), + ); + } + + #[test] + fn test_execute_set_denom_restrictions() { + let mut deps = mock_dependencies(); + + // Set up the message and the environment + let denom = "denom1".to_string(); + let allowed_channels = vec!["channel1".to_string(), "channel2".to_string()]; + let msg = ExecuteMsg::SetDenomRestrictions { + denom: denom.clone(), + allowed_channels: allowed_channels.clone(), + }; + let info = message_info(&MockApi::default().addr_make("executor"), &[]); + + // Grant the necessary role + crate::rbac::grant_role( + &mut deps.as_mut(), + MockApi::default().addr_make("executor").to_string(), + vec![Roles::ManageDenomRestrictions], + ) + .unwrap(); + + // Execute the message + let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + assert_eq!(res.attributes[0].value, "set_denom_restrictions"); + + // Verify the restriction was set + let stored_channels = ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .load(deps.as_ref().storage, denom) + .unwrap(); + assert_eq!(stored_channels, allowed_channels); + } + + #[test] + fn test_execute_unset_denom_restrictions() { + let mut deps = mock_dependencies(); + + // First, set a restriction + let denom = "denom1".to_string(); + let allowed_channels = vec!["channel1".to_string()]; + let set_msg = ExecuteMsg::SetDenomRestrictions { + denom: denom.clone(), + allowed_channels: allowed_channels.clone(), + }; + let info = message_info(&MockApi::default().addr_make("executor"), &[]); + + // Grant the necessary role + crate::rbac::grant_role( + &mut deps.as_mut(), + MockApi::default().addr_make("executor").to_string(), + vec![Roles::ManageDenomRestrictions], + ) + .unwrap(); + + // Execute the set message + execute(deps.as_mut(), mock_env(), info.clone(), set_msg).unwrap(); + + // Verify the restriction was set + let stored_channels = ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .load(deps.as_ref().storage, denom.clone()) + .unwrap(); + assert_eq!(stored_channels, allowed_channels); + + // Now unset the restriction + let unset_msg = ExecuteMsg::UnsetDenomRestrictions { + denom: denom.clone(), + }; + let res = execute(deps.as_mut(), mock_env(), info, unset_msg).unwrap(); + assert_eq!(res.attributes[0].value, "unset_denom_restrictions"); + + // Verify the restriction was removed + let stored_channels = ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .may_load(deps.as_ref().storage, denom) + .unwrap(); + assert!(stored_channels.is_none()); + } + + #[test] + fn test_query_denom_restrictions() { + let mut deps = mock_dependencies(); + + // Set up initial restrictions + let denom = "denom1".to_string(); + let allowed_channels = vec!["channel1".to_string(), "channel2".to_string()]; + let set_msg = ExecuteMsg::SetDenomRestrictions { + denom: denom.clone(), + allowed_channels: allowed_channels.clone(), + }; + let info = message_info(&MockApi::default().addr_make("executor"), &[]); + + // Grant the necessary role + crate::rbac::grant_role( + &mut deps.as_mut(), + MockApi::default().addr_make("executor").to_string(), + vec![Roles::ManageDenomRestrictions], + ) + .unwrap(); + + // Execute the set message + execute(deps.as_mut(), mock_env(), info, set_msg).unwrap(); + + // Query the restrictions + let query_msg = QueryMsg::GetDenomRestrictions { + denom: denom.clone(), + }; + let res = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let returned_channels: Vec = from_json(res).unwrap(); + assert_eq!(returned_channels, allowed_channels); + } + + #[test] + fn test_query_unset_denom_restrictions() { + let deps = mock_dependencies(); + + // Attempt to query restrictions on a denom with no restrictions + let denom = "denom1".to_string(); + let query_msg = QueryMsg::GetDenomRestrictions { + denom: denom.clone(), + }; + query(deps.as_ref(), mock_env(), query_msg).unwrap_err(); + } + + #[test] + fn test_permissions_enforced() { + let mut deps = mock_dependencies(); + + // Set up the message and the environment + let denom = "denom1".to_string(); + let allowed_channels = vec!["channel1".to_string(), "channel2".to_string()]; + let msg = ExecuteMsg::SetDenomRestrictions { + denom: denom.clone(), + allowed_channels: allowed_channels.clone(), + }; + let info = message_info(&MockApi::default().addr_make("unauthorized_user"), &[]); + + // Attempt to execute the message without the necessary role + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + + // Verify no restrictions were set + let stored_channels = ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM + .may_load(deps.as_ref().storage, denom) + .unwrap(); + assert!(stored_channels.is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..78896ec --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,22 @@ +#![allow(clippy::result_large_err)] + +// Contract +pub mod contract; +mod error; +pub mod msg; +mod state; + +pub mod blocking; +pub mod message_queue; +pub mod packet; +pub mod rbac; + +// Functions +mod execute; +mod query; +mod sudo; + +#[cfg(test)] +pub mod tests; + +pub use crate::error::ContractError; diff --git a/src/message_queue.rs b/src/message_queue.rs new file mode 100644 index 0000000..41c079e --- /dev/null +++ b/src/message_queue.rs @@ -0,0 +1,443 @@ +use cosmwasm_std::{DepsMut, Env, MessageInfo, Response, Storage}; + +use crate::{ + error::ContractError, + msg::ExecuteMsg, + state::{ + rbac::QueuedMessage, + storage::{MESSAGE_QUEUE, TIMELOCK_DELAY}, + }, +}; + +/// Used to iterate over the message queue and process any messages that have passed the time lock delay. +/// +/// If count is a non-zero value, we process no more than `count` message. This can be used to limit the number +/// of message processed in a single transaction to avoid running into OOG (out of gas) errors. +/// +/// Because we iterate over the queue by popping items from the front, multiple transactions can be issued +/// in sequence to iterate over the queue +pub fn process_message_queue( + deps: &mut DepsMut, + env: &Env, + count: Option, + message_ids: Option>, +) -> Result { + let mut response = Response::new(); + + if let Some(message_ids) = message_ids { + let messages = pop_messages(deps.storage, &message_ids)?; + + for message in messages { + let message_id = message.message_id.clone(); + if let Err(err) = try_process_message(deps, env, message) { + response = response.add_attribute(message_id, err.to_string()); + } else { + response = response.add_attribute(message_id, "ok"); + } + } + return Ok(response); + } + + let Some(count) = count else { + return Err(ContractError::InvalidParameters( + "both count and message_ids are None".to_string(), + )); + }; + + let queue_len = MESSAGE_QUEUE.len(deps.storage)? as usize; + + for idx in 0..queue_len { + if idx + 1 > count as usize { + break; + } + if let Some(message) = MESSAGE_QUEUE.pop_front(deps.storage)? { + let message_id = message.message_id.clone(); + if let Err(err) = try_process_message(deps, env, message) { + response = response.add_attribute(message_id, err.to_string()); + } else { + response = response.add_attribute(message_id, "ok"); + } + } + } + Ok(response) +} + +/// Given a message to execute, insert into the message queued with execution delayed by the timelock that is applied to the sender of the message +/// +/// Returns the id of the queued message +pub fn queue_message( + deps: &mut DepsMut, + env: Env, + msg: ExecuteMsg, + info: MessageInfo, +) -> Result { + let message_id = match env.transaction { + None => return Err(ContractError::NotTransaction), + Some(v) => format!("{}_{}", env.block.height, v.index), + }; + + let timelock_delay = TIMELOCK_DELAY.load(deps.storage, info.sender.to_string())?; + MESSAGE_QUEUE.push_back( + deps.storage, + &QueuedMessage { + message_id: message_id.clone(), + message: msg, + timelock_delay, + submitted_at: env.block.time, + }, + )?; + Ok(message_id) +} + +/// Check to see if the message sender has a non-zero timelock delay configured +pub fn must_queue_message(deps: &mut DepsMut, info: &MessageInfo) -> bool { + // if a non zero value is set, then it means a timelock delay is required + TIMELOCK_DELAY + .load(deps.storage, info.sender.to_string()) + .unwrap_or(0) + > 0 +} + +/// Removes a message from the message queue if it matches message_id +pub fn remove_message(deps: &mut DepsMut, message_id: String) -> Result<(), ContractError> { + pop_messages(deps.storage, &[message_id])?; + Ok(()) +} + +fn pop_messages( + storage: &mut dyn Storage, + message_ids: &[String], +) -> Result, ContractError> { + let queue_len = MESSAGE_QUEUE.len(storage)? as usize; + let mut messages = Vec::with_capacity(message_ids.len()); + + for _ in 0..queue_len { + if let Some(message) = MESSAGE_QUEUE.pop_front(storage)? { + if message_ids.contains(&message.message_id) { + messages.push(message); + } else { + // reinsert + MESSAGE_QUEUE.push_back(storage, &message)?; + } + } + } + Ok(messages) +} + +// attempts to process the message if the timelock has passed, otherwise reinsert into queue +// +// returns the id of the message +fn try_process_message( + deps: &mut DepsMut, + env: &Env, + message: QueuedMessage, +) -> Result<(), ContractError> { + // compute the minimum time at which the message is unlocked + let min_unlock = message + .submitted_at + .plus_seconds(message.timelock_delay * 60 * 60); + + // check to see if the timelock delay has passed, which we need to first convert from hours into seconds + if env.block.time.ge(&min_unlock) { + crate::contract::match_execute(deps, env, message.message)?; + } else { + MESSAGE_QUEUE.push_back(deps.storage, &message)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{ + from_json, + testing::{mock_dependencies, mock_env, MockApi}, + Timestamp, TransactionInfo, + }; + + use crate::{msg::QuotaMsg, query::get_queued_message, rbac::set_timelock_delay}; + + use super::*; + + #[test] + fn test_must_queue_message() { + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + let foobar_info = MessageInfo { + sender: MockApi::default().addr_make("foobar"), + funds: vec![], + }; + let foobarbaz_info = MessageInfo { + sender: MockApi::default().addr_make("foobarbaz"), + funds: vec![], + }; + + TIMELOCK_DELAY + .save( + deps.storage, + MockApi::default().addr_make("foobar").to_string(), + &1, + ) + .unwrap(); + + assert!(must_queue_message(&mut deps, &foobar_info)); + assert!(!must_queue_message(&mut deps, &foobarbaz_info)); + } + + #[test] + fn test_queue_message() { + let env = mock_env(); + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + + let foobar_info = MessageInfo { + sender: MockApi::default().addr_make("foobar"), + funds: vec![], + }; + let foobarbaz_info = MessageInfo { + sender: MockApi::default().addr_make("foobarbaz"), + funds: vec![], + }; + let foobar_test_msg = ExecuteMsg::AddPath { + channel_id: "channel".to_string(), + denom: "denom".to_string(), + quotas: vec![QuotaMsg { + name: "quota".to_string(), + duration: 5, + send_recv: (10, 10), + }], + }; + let foobarbaz_test_msg = ExecuteMsg::SetTimelockDelay { + signer: "gov".to_string(), + hours: 5, + }; + set_timelock_delay( + &mut deps, + MockApi::default().addr_make("foobar").to_string(), + 10, + ) + .unwrap(); + set_timelock_delay( + &mut deps, + MockApi::default().addr_make("foobarbaz").to_string(), + 1, + ) + .unwrap(); + let foobar_message_id = { + let mut env = env.clone(); + env.transaction = Some(TransactionInfo { index: 1 }); + queue_message( + &mut deps, + env.clone(), + foobar_test_msg.clone(), + foobar_info.clone(), + ) + .unwrap() + }; + let foobarbaz_message_id = { + let mut env = env.clone(); + env.transaction = Some(TransactionInfo { index: 2 }); + queue_message( + &mut deps, + env.clone(), + foobarbaz_test_msg.clone(), + foobarbaz_info.clone(), + ) + .unwrap() + }; + // get foobar's queued message, and validate the type is as expected + timelock delays + let msg = get_queued_message(deps.storage, foobar_message_id.clone()).unwrap(); + let msg: QueuedMessage = from_json(msg).unwrap(); + assert_eq!(msg.timelock_delay, 10); + assert_eq!(msg.message, foobar_test_msg); + + // get foobarbaz's queued message, and validate the type is as expected + timelock delays + let msg = get_queued_message(deps.storage, foobarbaz_message_id.clone()).unwrap(); + let msg: QueuedMessage = from_json(msg).unwrap(); + assert_eq!(msg.timelock_delay, 1); + assert_eq!(msg.message, foobarbaz_test_msg); + } + + #[test] + fn test_process_message_queue_basic() { + // basic test which simply iterates over the message queues + // does include tests with some unlocked items vs some locked items + + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + let env = mock_env(); + create_n_messages(&mut deps, 10, &mut |_i: u64| Timestamp::default()); + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 10); + + process_message_queue(&mut deps, &env.clone(), Some(1), None).unwrap(); + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 9); + + process_message_queue(&mut deps, &env.clone(), Some(0), None).unwrap(); + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 9); + + process_message_queue(&mut deps, &env.clone(), Some(5), None).unwrap(); + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 4); + + process_message_queue(&mut deps, &env.clone(), Some(10), None).unwrap(); + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 0); + + create_n_messages(&mut deps, 10, &mut |_i: u64| Timestamp::default()); + + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 10); + + let message_ids = MESSAGE_QUEUE + .iter(deps.storage) + .unwrap() + .filter_map(|msg| Some(msg.ok()?.message_id)) + .collect::>(); + + // get the first 4 message ids + let msg_ids = message_ids[0..4].to_vec(); + process_message_queue(&mut deps, &env.clone(), None, Some(msg_ids)).unwrap(); + // should be 6 messages left + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 6); + + // get the remaining messages + let msg_ids = message_ids[4..].to_vec(); + process_message_queue(&mut deps, &env.clone(), None, Some(msg_ids)).unwrap(); + // should be 0 messages left + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 0); + } + + #[test] + fn test_process_message_queue_complete() { + // complete message queues testing, including some locked vs unlocked + // as well as validating execution + + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + let mut env = mock_env(); + + // starting time for tests, may 20th 12:32am PST + let time = Timestamp::from_seconds(1716190293); + env.block.time = time; + + create_n_messages(&mut deps, 10, &mut |i: u64| { + // increment time by 1 hour * i + time.plus_seconds(3600 * i) + }); + + // no messages should be processed as not enough time has passed + process_message_queue(&mut deps, &env.clone(), Some(10), None).unwrap(); + + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 10); + + // increase time by 24 hours + env.block.time = env.block.time.plus_seconds(3600 * 24); + + // one message should be processed + process_message_queue(&mut deps, &env.clone(), Some(10), None).unwrap(); + + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 9); + + // signer should have a timelock delay of 1 hour + assert_eq!( + TIMELOCK_DELAY + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .unwrap(), + 1 + ); + + // advance time by 2 hours + env.block.time = env.block.time.plus_seconds(3600 * 2); + + // 2 messages should be processed, + process_message_queue(&mut deps, &env.clone(), Some(10), None).unwrap(); + + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 7); + + //signer should have a timelock delay of 3 hours + assert_eq!( + TIMELOCK_DELAY + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .unwrap(), + 3 + ); + + // advance time by 24 hours + env.block.time = env.block.time.plus_seconds(3600 * 24); + + // all messages should be processed + process_message_queue(&mut deps, &env.clone(), Some(10), None).unwrap(); + + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 0); + + // signer should have a delay of 10 hours + assert_eq!( + TIMELOCK_DELAY + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .unwrap(), + 10 + ); + + create_n_messages(&mut deps, 1, &mut |i: u64| { + // increment time by 1 hour * i + time.plus_seconds(3600 * i) + }); + + let message_ids = MESSAGE_QUEUE + .iter(deps.storage) + .unwrap() + .filter_map(|msg| Some(msg.ok()?.message_id)) + .collect::>(); + + process_message_queue(&mut deps, &env.clone(), None, Some(message_ids)).unwrap(); + // signer should have a delay of 1 hours + assert_eq!( + TIMELOCK_DELAY + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .unwrap(), + 1 + ); + } + + #[test] + #[should_panic(expected = "both count and message_ids are None")] + fn test_process_message_queue_invalid_parameters() { + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + let env = mock_env(); + create_n_messages(&mut deps, 10, &mut |_i: u64| Timestamp::default()); + assert_eq!(MESSAGE_QUEUE.len(deps.storage).unwrap(), 10); + + process_message_queue(&mut deps, &env.clone(), None, None).unwrap(); + } + + // helper function which inserts N messages into the message queue + // message types inserted are of ExecuteMsg::SetTimelockDelay + fn create_n_messages(deps: &mut DepsMut, n: usize, ts: &mut dyn FnMut(u64) -> Timestamp) { + for i in 0..n { + MESSAGE_QUEUE + .push_back( + deps.storage, + &QueuedMessage { + message: ExecuteMsg::SetTimelockDelay { + signer: MockApi::default().addr_make("signer").to_string(), + hours: i as u64 + 1, + }, + submitted_at: ts(i as u64), + timelock_delay: 24, + message_id: format!("prop-{i}"), + }, + ) + .unwrap(); + } + } +} diff --git a/src/msg.rs b/src/msg.rs new file mode 100644 index 0000000..91bad1b --- /dev/null +++ b/src/msg.rs @@ -0,0 +1,196 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Addr; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[cfg(test)] +use cosmwasm_std::Uint256; + +use crate::{packet::Packet, state::rbac::Roles}; + +// PathMsg contains a channel_id and denom to represent a unique identifier within ibc-go, and a list of rate limit quotas +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct PathMsg { + pub channel_id: String, + pub denom: String, + pub quotas: Vec, +} + +impl PathMsg { + pub fn new( + channel: impl Into, + denom: impl Into, + quotas: Vec, + ) -> Self { + PathMsg { + channel_id: channel.into(), + denom: denom.into(), + quotas, + } + } +} + +// QuotaMsg represents a rate limiting Quota when sent as a wasm msg +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct QuotaMsg { + pub name: String, + pub duration: u64, + pub send_recv: (u32, u32), +} + +impl QuotaMsg { + pub fn new(name: &str, seconds: u64, send_percentage: u32, recv_percentage: u32) -> Self { + QuotaMsg { + name: name.to_string(), + duration: seconds, + send_recv: (send_percentage, recv_percentage), + } + } +} + +/// Initialize the contract with the address of the IBC module and any existing channels. +/// Only the ibc module is allowed to execute actions on this contract +#[cw_serde] +pub struct InstantiateMsg { + pub gov_module: Addr, + pub paths: Vec, +} + +/// The caller (IBC module) is responsible for correctly calculating the funds +/// being sent through the channel +#[cw_serde] +pub enum ExecuteMsg { + AddPath { + channel_id: String, + denom: String, + quotas: Vec, + }, + RemovePath { + channel_id: String, + denom: String, + }, + ResetPathQuota { + channel_id: String, + denom: String, + quota_id: String, + }, + SetDenomRestrictions { + denom: String, + allowed_channels: Vec, + }, + UnsetDenomRestrictions { + denom: String, + }, + /// Grants a role to the given signer + GrantRole { + signer: String, + /// full list of roles to grant the signer + roles: Vec, + }, + /// Removes the role that has been granted to the signer + RevokeRole { + signer: String, + /// fill list of roles to revoke from the signer + roles: Vec, + }, + /// Replaces the quota identified by QuotaMsg::Name + EditPathQuota { + channel_id: String, + denom: String, + /// similar to ResetPathQuota, but QuotaMsg::Name is used as the quota_id + quota: QuotaMsg, + }, + /// Used to remove a message from the message queue to prevent execution + RemoveMessage { + message_id: String, + }, + /// Used to change the timelock delay for newly submitted messages + SetTimelockDelay { + /// the address to apply the timelock delay to + signer: String, + hours: std::primitive::u64, + }, + /// Permissionless message that anyone can invoke to trigger execution + /// of queued messages that have passed the timelock delay + /// + /// If both count and message_ids are some, message_ids is used. If both are None returns an error + ProcessMessages { + /// number of queued messages to process, a value of 0 will attempt to process all queued messages + count: Option, + message_ids: Option>, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Vec)] + GetQuotas { channel_id: String, denom: String }, + /// Returns a vector of all addresses that have been allocated one or more roles + #[returns(Vec)] + GetRoleOwners, + /// Returns a vector of all roles that have been granted to `owner` + #[returns(Vec)] + GetRoles { owner: String }, + /// Returns a vector of queued message id's + #[returns(Vec)] + GetMessageIds, + /// Returns the queued message matching id + #[returns(crate::state::rbac::QueuedMessage)] + GetMessage { id: String }, + /// Returns the restrictions for a given denom + #[returns(Vec)] + GetDenomRestrictions { denom: String }, +} + +#[cw_serde] +pub enum SudoMsg { + SendPacket { + packet: Packet, + #[cfg(test)] + channel_value_mock: Option, + }, + RecvPacket { + packet: Packet, + #[cfg(test)] + channel_value_mock: Option, + }, + UndoSend { + packet: Packet, + }, +} + +#[cw_serde] +pub struct MigrateMsg {} + +impl ExecuteMsg { + /// Given an ExecuteMsg variant returns the required RBAC role + /// that must be held by the address which is invoking the message. + /// + /// If no RBAC role is required, returns None + pub fn required_permission(&self) -> Option { + match self { + Self::AddPath { .. } => Some(Roles::AddRateLimit), + Self::RemovePath { .. } => Some(Roles::RemoveRateLimit), + Self::ResetPathQuota { .. } => Some(Roles::ResetPathQuota), + Self::SetDenomRestrictions { .. } => Some(Roles::ManageDenomRestrictions), + Self::UnsetDenomRestrictions { .. } => Some(Roles::ManageDenomRestrictions), + Self::GrantRole { .. } => Some(Roles::GrantRole), + Self::RevokeRole { .. } => Some(Roles::RevokeRole), + Self::EditPathQuota { .. } => Some(Roles::EditPathQuota), + Self::RemoveMessage { .. } => Some(Roles::RemoveMessage), + Self::SetTimelockDelay { .. } => Some(Roles::SetTimelockDelay), + Self::ProcessMessages { .. } => None, + } + } + /// Checks to see if the message type is able to skip queueing. + /// + /// This is limited to the message type responsible for processing the queue + pub fn skip_queue(&self) -> bool { + #[allow(clippy::match_like_matches_macro)] + match self { + Self::ProcessMessages { .. } => true, + _ => false, + } + } +} diff --git a/src/packet.rs b/src/packet.rs new file mode 100644 index 0000000..e85527c --- /dev/null +++ b/src/packet.rs @@ -0,0 +1,472 @@ +use crate::state::flow::FlowType; +use cosmwasm_std::{Addr, Deps, StdError, Uint256}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Height { + /// Previously known as "epoch" + pub revision_number: Option, + + /// The height of a block + pub revision_height: Option, +} + +// IBC transfer data +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct FungibleTokenData { + pub denom: String, + pub amount: Uint256, + pub sender: Addr, + pub receiver: Addr, +} + +// An IBC packet +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Packet { + pub sequence: u64, + pub source_port: String, + pub source_channel: String, + pub destination_port: String, + pub destination_channel: String, + pub data: FungibleTokenData, + pub timeout_height: Height, + pub timeout_timestamp: Option, +} + +// SupplyOf query message definition. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, +)] +pub struct QuerySupplyOfRequest { + #[prost(string, tag = "1")] + pub denom: ::prost::alloc::string::String, +} + +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, +)] +pub struct QuerySupplyOfResponse { + #[prost(message, optional, tag = "1")] + pub amount: ::core::option::Option, +} +// End of SupplyOf query message definition + +// Needed to parse the coin's String as Uint256 + +fn hash_denom(denom: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(denom.as_bytes()); + let result = hasher.finalize(); + let hash = hex::encode(result); + format!("ibc/{}", hash.to_uppercase()) +} + +impl Packet { + pub fn mock( + source_channel: String, + dest_channel: String, + denom: String, + funds: Uint256, + ) -> Packet { + Packet { + sequence: 0, + source_port: "transfer".to_string(), + source_channel, + destination_port: "transfer".to_string(), + destination_channel: dest_channel, + data: crate::packet::FungibleTokenData { + denom, + amount: funds, + sender: Addr::unchecked("sender"), + receiver: Addr::unchecked("receiver"), + }, + timeout_height: crate::packet::Height { + revision_number: None, + revision_height: None, + }, + timeout_timestamp: None, + } + } + + pub fn channel_value(&self, deps: Deps, direction: &FlowType) -> Result { + Ok(Uint256::from_uint128( + deps.querier + .query_supply(self.local_denom(direction))? + .amount, + )) + } + + pub fn get_funds(&self) -> Uint256 { + self.data.amount + } + + fn local_channel(&self, direction: &FlowType) -> String { + // Pick the appropriate channel depending on whether this is a send or a recv + match direction { + FlowType::In => self.destination_channel.clone(), + FlowType::Out => self.source_channel.clone(), + } + } + + fn receiver_chain_is_source(&self) -> bool { + self.data + .denom + .starts_with(&format!("transfer/{}", self.source_channel)) + } + + fn handle_denom_for_sends(&self) -> String { + if !self.data.denom.starts_with("transfer/") { + // For native tokens we just use what's on the packet + return self.data.denom.clone(); + } + // For non-native tokens, we need to generate the IBCDenom + hash_denom(&self.data.denom) + } + + fn handle_denom_for_recvs(&self) -> String { + if self.receiver_chain_is_source() { + // These are tokens that have been sent to the counterparty and are returning + let unprefixed = self + .data + .denom + .strip_prefix(&format!("transfer/{}/", self.source_channel)) + .unwrap_or_default(); + let split: Vec<&str> = unprefixed.split('/').collect(); + if split[0] == unprefixed { + // This is a native token. Return the unprefixed token + unprefixed.to_string() + } else { + // This is a non-native that was sent to the counterparty. + // We need to hash it. + // The ibc-go implementation checks that the denom has been built correctly. We + // don't need to do that here because if it hasn't, the transfer module will catch it. + hash_denom(unprefixed) + } + } else { + // Tokens that come directly from the counterparty. + // Since the sender didn't prefix them, we need to do it here. + let prefixed = format!("transfer/{}/", self.destination_channel) + &self.data.denom; + hash_denom(&prefixed) + } + } + + fn local_denom(&self, direction: &FlowType) -> String { + match direction { + FlowType::In => self.handle_denom_for_recvs(), + FlowType::Out => self.handle_denom_for_sends(), + } + } + + pub fn path_data(&self, direction: &FlowType) -> (String, String) { + (self.local_channel(direction), self.local_denom(direction)) + } +} + +// Helpers + +// Create a new packet for testing +#[cfg(test)] +#[macro_export] +macro_rules! test_msg_send { + (channel_id: $channel_id:expr, denom: $denom:expr, channel_value: $channel_value:expr, funds: $funds:expr) => { + $crate::msg::SudoMsg::SendPacket { + packet: $crate::packet::Packet::mock($channel_id, $channel_id, $denom, $funds), + channel_value_mock: Some($channel_value), + } + }; +} + +#[cfg(test)] +#[macro_export] +macro_rules! test_msg_recv { + (channel_id: $channel_id:expr, denom: $denom:expr, channel_value: $channel_value:expr, funds: $funds:expr) => { + $crate::msg::SudoMsg::RecvPacket { + packet: $crate::packet::Packet::mock( + $channel_id, + $channel_id, + format!("transfer/{}/{}", $channel_id, $denom), + $funds, + ), + channel_value_mock: Some($channel_value), + } + }; +} + +#[cfg(test)] +pub mod tests { + use crate::msg::SudoMsg; + + use super::*; + + #[test] + fn send_native() { + let packet = Packet::mock( + "channel-17-local".to_string(), + "channel-42-counterparty".to_string(), + "uosmo".to_string(), + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::Out), "uosmo"); + } + + #[test] + fn send_non_native() { + // The transfer module "unhashes" the denom from + // ibc/09E4864A262249507925831FBAD69DAD08F66FAAA0640714E765912A0751289A + // to port/channel/denom before passing it along to the contrace + let packet = Packet::mock( + "channel-17-local".to_string(), + "channel-42-counterparty".to_string(), + "transfer/channel-17-local/ujuno".to_string(), + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::Out), + "ibc/09E4864A262249507925831FBAD69DAD08F66FAAA0640714E765912A0751289A" + ); + } + + #[test] + fn receive_non_native() { + // The counterparty chain sends their own native token to us + let packet = Packet::mock( + "channel-42-counterparty".to_string(), // The counterparty's channel is the source here + "channel-17-local".to_string(), // Our channel is the dest channel + "ujuno".to_string(), // This is unwrapped. It is our job to wrap it + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::In), + "ibc/09E4864A262249507925831FBAD69DAD08F66FAAA0640714E765912A0751289A" + ); + } + + #[test] + fn receive_native() { + // The counterparty chain sends us back our native token that they had wrapped + let packet = Packet::mock( + "channel-42-counterparty".to_string(), // The counterparty's channel is the source here + "channel-17-local".to_string(), // Our channel is the dest channel + "transfer/channel-42-counterparty/uosmo".to_string(), + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::In), "uosmo"); + } + + // Let's assume we have two chains A and B (local and counterparty) connected in the following way: + // + // Chain A <---> channel-17-local <---> channel-42-counterparty <---> Chain B + // + // The following tests should pass + // + + const WRAPPED_OSMO_ON_HUB_TRACE: &str = "transfer/channel-141/uosmo"; + const WRAPPED_ATOM_ON_OSMOSIS_TRACE: &str = "transfer/channel-0/uatom"; + const WRAPPED_ATOM_ON_OSMOSIS_HASH: &str = + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2"; + const WRAPPED_OSMO_ON_HUB_HASH: &str = + "ibc/14F9BC3E44B8A9C1BE1FB08980FAB87034C9905EF17CF2F5008FC085218811CC"; + + #[test] + fn sanity_check() { + // Examples using the official channels as of Nov 2022. + + // uatom sent to osmosis + let packet = Packet::mock( + "channel-141".to_string(), // from: hub + "channel-0".to_string(), // to: osmosis + "uatom".to_string(), + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::In), + WRAPPED_ATOM_ON_OSMOSIS_HASH + ); + + // uatom on osmosis sent back to the hub + let packet = Packet::mock( + "channel-0".to_string(), // from: osmosis + "channel-141".to_string(), // to: hub + WRAPPED_ATOM_ON_OSMOSIS_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::In), "uatom"); + + // osmo sent to the hub + let packet = Packet::mock( + "channel-0".to_string(), // from: osmosis + "channel-141".to_string(), // to: hub + "uosmo".to_string(), + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::Out), "uosmo"); + + // osmo on the hub sent back to osmosis + // send + let packet = Packet::mock( + "channel-141".to_string(), // from: hub + "channel-0".to_string(), // to: osmosis + WRAPPED_OSMO_ON_HUB_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::Out), WRAPPED_OSMO_ON_HUB_HASH); + + // receive + let packet = Packet::mock( + "channel-141".to_string(), // from: hub + "channel-0".to_string(), // to: osmosis + WRAPPED_OSMO_ON_HUB_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::In), "uosmo"); + + // Now let's pretend we're the hub. + // The following tests are from perspective of the the hub (i.e.: if this contract were deployed there) + // + // osmo sent to the hub + let packet = Packet::mock( + "channel-0".to_string(), // from: osmosis + "channel-141".to_string(), // to: hub + "uosmo".to_string(), + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::In), WRAPPED_OSMO_ON_HUB_HASH); + + // uosmo on the hub sent back to the osmosis + let packet = Packet::mock( + "channel-141".to_string(), // from: hub + "channel-0".to_string(), // to: osmosis + WRAPPED_OSMO_ON_HUB_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::In), "uosmo"); + + // uatom sent to osmosis + let packet = Packet::mock( + "channel-141".to_string(), // from: hub + "channel-0".to_string(), // to: osmosis + "uatom".to_string(), + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::Out), "uatom"); + + // utaom on the osmosis sent back to the hub + // send + let packet = Packet::mock( + "channel-0".to_string(), // from: osmosis + "channel-141".to_string(), // to: hub + WRAPPED_ATOM_ON_OSMOSIS_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::Out), + WRAPPED_ATOM_ON_OSMOSIS_HASH + ); + + // receive + let packet = Packet::mock( + "channel-0".to_string(), // from: osmosis + "channel-141".to_string(), // to: hub + WRAPPED_ATOM_ON_OSMOSIS_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!(packet.local_denom(&FlowType::In), "uatom"); + } + + #[test] + fn sanity_double() { + // Now let's deal with double wrapping + + let juno_wrapped_osmosis_wrapped_atom_hash = + "ibc/6CDD4663F2F09CD62285E2D45891FC149A3568E316CE3EBBE201A71A78A69388"; + + // Send uatom on stored on osmosis to juno + // send + let packet = Packet::mock( + "channel-42".to_string(), // from: osmosis + "channel-0".to_string(), // to: juno + WRAPPED_ATOM_ON_OSMOSIS_TRACE.to_string(), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::Out), + WRAPPED_ATOM_ON_OSMOSIS_HASH + ); + + // receive + let packet = Packet::mock( + "channel-42".to_string(), // from: osmosis + "channel-0".to_string(), // to: juno + WRAPPED_ATOM_ON_OSMOSIS_TRACE.to_string(), + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::In), + juno_wrapped_osmosis_wrapped_atom_hash + ); + + // Send back that multi-wrapped token to osmosis + // send + let packet = Packet::mock( + "channel-0".to_string(), // from: juno + "channel-42".to_string(), // to: osmosis + format!("{}{}", "transfer/channel-0/", WRAPPED_ATOM_ON_OSMOSIS_TRACE), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::Out), + juno_wrapped_osmosis_wrapped_atom_hash + ); + + // receive + let packet = Packet::mock( + "channel-0".to_string(), // from: juno + "channel-42".to_string(), // to: osmosis + format!("{}{}", "transfer/channel-0/", WRAPPED_ATOM_ON_OSMOSIS_TRACE), // unwrapped before reaching the contract + 0_u128.into(), + ); + assert_eq!( + packet.local_denom(&FlowType::In), + WRAPPED_ATOM_ON_OSMOSIS_HASH + ); + } + + #[test] + fn tokenfactory_packet() { + let json = r#"{"send_packet":{"packet":{"sequence":4,"source_port":"transfer","source_channel":"channel-0","destination_port":"transfer","destination_channel":"channel-1491","data":{"denom":"transfer/channel-0/factory/osmo12smx2wdlyttvyzvzg54y2vnqwq2qjateuf7thj/czar","amount":"100000000000000000","sender":"osmo1cyyzpxplxdzkeea7kwsydadg87357qnahakaks","receiver":"osmo1c584m4lq25h83yp6ag8hh4htjr92d954vklzja"},"timeout_height":{},"timeout_timestamp":1668024476848430980}}}"#; + let parsed: SudoMsg = serde_json_wasm::from_str(json).unwrap(); + //println!("{parsed:?}"); + + match parsed { + SudoMsg::SendPacket { packet, .. } => { + assert_eq!( + packet.local_denom(&FlowType::Out), + "ibc/07A1508F49D0753EDF95FA18CA38C0D6974867D793EB36F13A2AF1A5BB148B22" + ); + } + _ => panic!("parsed into wrong variant"), + } + } + + #[test] + fn packet_with_memo() { + // extra fields (like memo) get ignored. + let json = r#"{"recv_packet":{"packet":{"sequence":1,"source_port":"transfer","source_channel":"channel-0","destination_port":"transfer","destination_channel":"channel-0","data":{"denom":"stake","amount":"1","sender":"osmo177uaalkhra6wth6hc9hu79f72eq903kwcusx4r","receiver":"osmo1fj6yt4pwfea4865z763fvhwktlpe020ef93dlq","memo":"some info"},"timeout_height":{"revision_height":100}}}}"#; + let _parsed: SudoMsg = serde_json_wasm::from_str(json).unwrap(); + //println!("{parsed:?}"); + } +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..dd4ad9b --- /dev/null +++ b/src/query.rs @@ -0,0 +1,187 @@ +use crate::state::{ + path::Path, + storage::{ + ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM, MESSAGE_QUEUE, RATE_LIMIT_TRACKERS, + RBAC_PERMISSIONS, + }, +}; +use cosmwasm_std::{to_json_binary, Binary, StdResult}; +use cosmwasm_std::{Order::Ascending, StdError, Storage}; + +pub fn get_quotas( + storage: &dyn Storage, + channel_id: impl Into, + denom: impl Into, +) -> StdResult { + let path = Path::new(channel_id, denom); + to_json_binary(&RATE_LIMIT_TRACKERS.load(storage, path.into())?) +} + +/// Returns all addresses which have been assigned one or more roles +pub fn get_role_owners(storage: &dyn Storage) -> StdResult { + to_json_binary( + &RBAC_PERMISSIONS + .keys(storage, None, None, Ascending) + .filter_map(|key| key.ok()) + .collect::>(), + ) +} + +/// Returns all the roles that have been granted to `owner` (if any) +pub fn get_roles(storage: &dyn Storage, owner: String) -> StdResult { + to_json_binary(&RBAC_PERMISSIONS.load(storage, owner)?) +} + +/// Returns the id's of all queued messages +pub fn get_message_ids(storage: &dyn Storage) -> StdResult { + to_json_binary( + &MESSAGE_QUEUE + .iter(storage)? + .filter_map(|message| Some(message.ok()?.message_id)) + .collect::>(), + ) +} + +/// Searches MESSAGE_QUEUE for a message_id matching `id` +pub fn get_queued_message(storage: &dyn Storage, id: String) -> StdResult { + to_json_binary( + &MESSAGE_QUEUE + .iter(storage)? + .find(|message| { + let Ok(message) = message else { return false }; + message.message_id.eq(&id) + }) + .ok_or_else(|| StdError::not_found(id))??, + ) +} + +pub fn get_denom_restrictions(storage: &dyn Storage, denom: String) -> StdResult { + to_json_binary(&ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM.load(storage, denom)?) +} + +#[cfg(test)] +mod test { + use cosmwasm_std::{from_json, testing::mock_dependencies, Timestamp}; + + use crate::{ + msg::ExecuteMsg, + state::rbac::{QueuedMessage, Roles}, + }; + + use super::*; + #[test] + fn test_get_role_owners() { + let mut deps = mock_dependencies(); + + // test getting role owners when no owners exist + let response = get_role_owners(deps.as_ref().storage).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert!(decoded.is_empty()); + + // insert 1 role owner, and test getting role owners + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::SetTimelockDelay].into_iter().collect(), + ) + .unwrap(); + let response = get_role_owners(deps.as_ref().storage).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0], "foobar"); + + // insert another role owner and test getting role owners + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobarbaz".to_string(), + &vec![Roles::SetTimelockDelay].into_iter().collect(), + ) + .unwrap(); + let response = get_role_owners(deps.as_ref().storage).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0], "foobar"); + assert_eq!(decoded[1], "foobarbaz"); + } + + #[test] + fn test_get_roles() { + let mut deps = mock_dependencies(); + + // test retrieving roles for a missing role owner + assert!(get_roles(deps.as_ref().storage, "foobar".to_string()).is_err()); + + // assign roles and test retrieving roles owned by address + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::SetTimelockDelay].into_iter().collect(), + ) + .unwrap(); + let response = get_roles(deps.as_ref().storage, "foobar".to_string()).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0], Roles::SetTimelockDelay); + + // add additional roles foobar, and test retrierval + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::SetTimelockDelay, Roles::EditPathQuota] + .into_iter() + .collect(), + ) + .unwrap(); + let response = get_roles(deps.as_ref().storage, "foobar".to_string()).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert_eq!(decoded.len(), 2); + assert!(decoded.contains(&Roles::SetTimelockDelay)); + assert!(decoded.contains(&Roles::EditPathQuota)); + } + + #[test] + fn test_get_messageids() { + let mut deps = mock_dependencies(); + let response = get_message_ids(deps.as_ref().storage).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert_eq!(decoded.len(), 0); + + MESSAGE_QUEUE + .push_back( + &mut deps.storage, + &QueuedMessage { + message_id: "prop-1".to_string(), + message: ExecuteMsg::ProcessMessages { + count: Some(1), + message_ids: None, + }, + submitted_at: Timestamp::default(), + timelock_delay: 0, + }, + ) + .unwrap(); + MESSAGE_QUEUE + .push_back( + &mut deps.storage, + &QueuedMessage { + message_id: "prop-2".to_string(), + message: ExecuteMsg::ProcessMessages { + count: Some(1), + message_ids: None, + }, + submitted_at: Timestamp::default(), + timelock_delay: 0, + }, + ) + .unwrap(); + let response = get_message_ids(deps.as_ref().storage).unwrap(); + let decoded: Vec = from_json(response).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0], "prop-1"); + assert_eq!(decoded[1], "prop-2"); + } +} diff --git a/src/rbac.rs b/src/rbac.rs new file mode 100644 index 0000000..b0a60d5 --- /dev/null +++ b/src/rbac.rs @@ -0,0 +1,491 @@ +use cosmwasm_std::{DepsMut, MessageInfo}; + +use crate::{ + msg::ExecuteMsg, + state::{ + rbac::Roles, + storage::{RBAC_PERMISSIONS, TIMELOCK_DELAY}, + }, + ContractError, +}; + +/// Check to see if the sender of the message can invoke the message by holding the required rbac role +/// +/// # Errors +/// +/// ContractError::Unauthorized if the sender does not have the required permission +/// +/// StdErr::NotFound if the RBAC_PERMISSIONS storage variable does not have an entry for the sender +pub fn can_invoke_message( + deps: &DepsMut, + info: &MessageInfo, + msg: &ExecuteMsg, +) -> Result<(), ContractError> { + // get the required permission to execute the message + let Some(required_permission) = msg.required_permission() else { + // no permission required so return ok + return Ok(()); + }; + let permissions = RBAC_PERMISSIONS + .load(deps.storage, info.sender.to_string()) + .unwrap_or_default(); + if permissions.contains(&required_permission) { + return Ok(()); + } + Err(ContractError::Unauthorized {}) +} + +/// Sets a timelock delay for `signer` of `hours` +pub fn set_timelock_delay( + deps: &mut DepsMut, + signer: String, + hours: u64, +) -> Result<(), ContractError> { + let signer = deps.api.addr_validate(&signer)?; + Ok(TIMELOCK_DELAY.save(deps.storage, signer.to_string(), &hours)?) +} + +/// Grants `roles` to `signer` +pub fn grant_role( + deps: &mut DepsMut, + signer: String, + roles: Vec, +) -> Result<(), ContractError> { + let signer = deps.api.addr_validate(&signer)?; + // get the current roles, if no current roles will be an empty vec + let mut current_roles = RBAC_PERMISSIONS + .load(deps.storage, signer.to_string()) + .unwrap_or_default(); + for role in roles { + current_roles.insert(role); + } + + // persist new roles + Ok(RBAC_PERMISSIONS.save(deps.storage, signer.to_string(), ¤t_roles)?) +} + +// Revokes `roles` from `signer`, if this results in an empty set of roles remove the storage variable +pub fn revoke_role( + deps: &mut DepsMut, + signer: String, + roles: Vec, +) -> Result<(), ContractError> { + let signer = deps.api.addr_validate(&signer)?; + + let mut current_roles = RBAC_PERMISSIONS.load(deps.storage, signer.to_string())?; + for role in roles { + current_roles.remove(&role); + } + if current_roles.is_empty() { + // no more roles, remove storage variable to save resources + RBAC_PERMISSIONS.remove(deps.storage, signer.to_string()); + Ok(()) + } else { + Ok(RBAC_PERMISSIONS.save(deps.storage, signer.to_string(), ¤t_roles)?) + } +} + +#[cfg(test)] +mod test { + use std::collections::BTreeSet; + + use crate::{msg::QuotaMsg, state::rbac::Roles}; + use cosmwasm_std::{ + testing::{mock_dependencies, MockApi}, + Addr, + }; + use itertools::Itertools; + + use super::*; + #[test] + fn test_set_timelock_delay() { + let mut deps = mock_dependencies(); + assert!(TIMELOCK_DELAY + .load( + &deps.storage, + MockApi::default().addr_make("foobar").to_string() + ) + .is_err()); + set_timelock_delay( + &mut deps.as_mut(), + MockApi::default().addr_make("foobar").to_string(), + 6, + ) + .unwrap(); + assert_eq!( + TIMELOCK_DELAY + .load( + &deps.storage, + MockApi::default().addr_make("foobar").to_string() + ) + .unwrap(), + 6 + ); + } + #[test] + fn test_can_invoke_add_path() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + let msg = ExecuteMsg::AddPath { + channel_id: "channelid".into(), + denom: "denom".into(), + quotas: vec![], + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::AddRateLimit].into_iter().collect(), + ) + .unwrap(); + + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_remove_path() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + let msg = ExecuteMsg::RemovePath { + channel_id: "channelid".into(), + denom: "denom".into(), + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::RemoveRateLimit].into_iter().collect(), + ) + .unwrap(); + + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_reset_path_quota() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::ResetPathQuota { + channel_id: "channelid".into(), + denom: "denom".into(), + quota_id: "quota".into(), + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::ResetPathQuota].into_iter().collect(), + ) + .unwrap(); + + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_grant_role() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::GrantRole { + signer: "signer".into(), + roles: vec![Roles::GrantRole], + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::GrantRole].into_iter().collect(), + ) + .unwrap(); + + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_revoke_role() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::RevokeRole { + signer: "signer".into(), + roles: vec![Roles::GrantRole], + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::RevokeRole].into_iter().collect(), + ) + .unwrap(); + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_edit_path_quota() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::EditPathQuota { + quota: QuotaMsg { + name: "name".into(), + duration: 0, + send_recv: (1, 2), + }, + channel_id: "channel_id".into(), + denom: "denom".into(), + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::EditPathQuota].into_iter().collect(), + ) + .unwrap(); + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_remove_message() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::RemoveMessage { + message_id: "message".into(), + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::RemoveMessage].into_iter().collect(), + ) + .unwrap(); + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_set_timelock_delay() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::SetTimelockDelay { + signer: "signer".into(), + hours: 5, + }; + RBAC_PERMISSIONS + .save( + &mut deps.storage, + "foobar".to_string(), + &vec![Roles::SetTimelockDelay].into_iter().collect(), + ) + .unwrap(); + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_err()); + } + + #[test] + fn test_can_invoke_process_messages() { + let mut deps = mock_dependencies(); + + let info_foobar = MessageInfo { + sender: Addr::unchecked("foobar".to_string()), + funds: vec![], + }; + let info_foobarbaz = MessageInfo { + sender: Addr::unchecked("foobarbaz".to_string()), + funds: vec![], + }; + + let msg = ExecuteMsg::ProcessMessages { + count: Some(1), + message_ids: None, + }; + + // all addresses should be able to invoke this + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_ok()); + + // try again with message_ids Some + + let msg = ExecuteMsg::ProcessMessages { + count: None, + message_ids: Some(vec!["foobar".to_string()]), + }; + + // all addresses should be able to invoke this + assert!(can_invoke_message(&deps.as_mut(), &info_foobar, &msg).is_ok()); + assert!(can_invoke_message(&deps.as_mut(), &info_foobarbaz, &msg).is_ok()); + } + + #[test] + fn test_grant_role() { + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + + let all_roles = Roles::all_roles().into_iter().chunks(2); + + // no roles, should fail + assert!(RBAC_PERMISSIONS + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .is_err()); + + let mut granted_roles = BTreeSet::new(); + + for roles in &all_roles { + let roles = roles.collect::>(); + + grant_role( + &mut deps, + MockApi::default().addr_make("signer").to_string(), + roles.clone(), + ) + .unwrap(); + roles.iter().for_each(|role| { + granted_roles.insert(*role); + }); + + let assigned_roles = RBAC_PERMISSIONS + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string(), + ) + .unwrap(); + + assert_eq!(granted_roles, assigned_roles); + } + } + + #[test] + fn test_revoke_role() { + let mut deps = mock_dependencies(); + let mut deps = deps.as_mut(); + + let all_roles = Roles::all_roles(); + // no roles, should fail + assert!(RBAC_PERMISSIONS + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .is_err()); + + // grant all roles + RBAC_PERMISSIONS + .save( + deps.storage, + MockApi::default().addr_make("signer").to_string(), + &all_roles.iter().copied().collect::>(), + ) + .unwrap(); + + let mut granted_roles: BTreeSet<_> = all_roles.iter().copied().collect(); + + for roles in &all_roles.iter().chunks(2) { + let roles = roles.copied().collect::>(); + + revoke_role( + &mut deps, + MockApi::default().addr_make("signer").to_string(), + roles.clone(), + ) + .unwrap(); + + roles.iter().for_each(|role| { + granted_roles.remove(role); + }); + + if granted_roles.is_empty() { + // no roles, should fail + assert!(RBAC_PERMISSIONS + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string() + ) + .is_err()); + } else { + let assigned_roles = RBAC_PERMISSIONS + .load( + deps.storage, + MockApi::default().addr_make("signer").to_string(), + ) + .unwrap(); + + assert_eq!(assigned_roles, granted_roles); + } + } + } +} diff --git a/src/state/flow.rs b/src/state/flow.rs new file mode 100644 index 0000000..a230ae2 --- /dev/null +++ b/src/state/flow.rs @@ -0,0 +1,167 @@ +use cosmwasm_std::{Timestamp, Uint256}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::quota::Quota; + +#[derive(Debug, Clone)] +pub enum FlowType { + In, + Out, +} + +/// A Flow represents the transfer of value for a denom through an IBC channel +/// during a time window. +/// +/// It tracks inflows (transfers into neturon) and outflows (transfers out of +/// neturon). +/// +/// The period_end represents the last point in time for which this Flow is +/// tracking the value transfer. +/// +/// Periods are discrete repeating windows. A period only starts when a contract +/// call to update the Flow (SendPacket/RecvPackt) is made, and not right after +/// the period ends. This means that if no calls happen after a period expires, +/// the next period will begin at the time of the next call and be valid for the +/// specified duration for the quota. +/// +/// This is a design decision to avoid the period calculations and thus reduce gas consumption +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, Copy)] +pub struct Flow { + pub inflow: Uint256, + pub outflow: Uint256, + pub period_end: Timestamp, +} + +impl Flow { + pub fn new( + inflow: impl Into, + outflow: impl Into, + now: Timestamp, + duration: u64, + ) -> Self { + Self { + inflow: inflow.into(), + outflow: outflow.into(), + period_end: now.plus_seconds(duration), + } + } + + /// The balance of a flow is how much absolute value for the denom has moved + /// through the channel before period_end. It returns a tuple of + /// (balance_in, balance_out) where balance_in in is how much has been + /// transferred into the flow, and balance_out is how much value transferred + /// out. + pub fn balance(&self) -> (Uint256, Uint256) { + ( + self.inflow.saturating_sub(self.outflow), + self.outflow.saturating_sub(self.inflow), + ) + } + + /// checks if the flow, in the current state, has exceeded a max allowance + pub fn exceeds(&self, direction: &FlowType, max_inflow: Uint256, max_outflow: Uint256) -> bool { + let (balance_in, balance_out) = self.balance(); + match direction { + FlowType::In => balance_in > max_inflow, + FlowType::Out => balance_out > max_outflow, + } + } + + /// returns the balance in a direction. This is used for displaying cleaner errors + pub fn balance_on(&self, direction: &FlowType) -> Uint256 { + let (balance_in, balance_out) = self.balance(); + match direction { + FlowType::In => balance_in, + FlowType::Out => balance_out, + } + } + + /// If now is greater than the period_end, the Flow is considered expired. + pub fn is_expired(&self, now: Timestamp) -> bool { + self.period_end < now + } + + // Mutating methods + + /// Expire resets the Flow to start tracking the value transfer from the + /// moment this method is called. + pub fn expire(&mut self, now: Timestamp, duration: u64) { + self.inflow = Uint256::zero(); + self.outflow = Uint256::zero(); + self.period_end = now.plus_seconds(duration); + } + + /// Updates the current flow incrementing it by a transfer of value. + pub fn add_flow(&mut self, direction: FlowType, value: Uint256) { + match direction { + FlowType::In => self.inflow = self.inflow.saturating_add(value), + FlowType::Out => self.outflow = self.outflow.saturating_add(value), + } + } + + /// Updates the current flow reducing it by a transfer of value. + pub fn undo_flow(&mut self, direction: FlowType, value: Uint256) { + match direction { + FlowType::In => self.inflow = self.inflow.saturating_sub(value), + FlowType::Out => self.outflow = self.outflow.saturating_sub(value), + } + } + + /// Applies a transfer. If the Flow is expired (now > period_end), it will + /// reset it before applying the transfer. + pub(crate) fn apply_transfer( + &mut self, + direction: &FlowType, + funds: Uint256, + now: Timestamp, + quota: &Quota, + ) -> bool { + let mut expired = false; + if self.is_expired(now) { + self.expire(now, quota.duration); + expired = true; + } + self.add_flow(direction.clone(), funds); + expired + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + pub const RESET_TIME_DAILY: u64 = 60 * 60 * 24; + pub const RESET_TIME_WEEKLY: u64 = 60 * 60 * 24 * 7; + pub const RESET_TIME_MONTHLY: u64 = 60 * 60 * 24 * 30; + + #[test] + fn flow() { + let epoch = Timestamp::from_seconds(0); + let mut flow = Flow::new(0_u32, 0_u32, epoch, RESET_TIME_WEEKLY); + + assert!(!flow.is_expired(epoch)); + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_DAILY))); + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY))); + assert!(flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY).plus_nanos(1))); + + assert_eq!(flow.balance(), (0_u32.into(), 0_u32.into())); + flow.add_flow(FlowType::In, 5_u32.into()); + assert_eq!(flow.balance(), (5_u32.into(), 0_u32.into())); + flow.add_flow(FlowType::Out, 2_u32.into()); + assert_eq!(flow.balance(), (3_u32.into(), 0_u32.into())); + // Adding flow doesn't affect expiration + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_DAILY))); + + flow.expire(epoch.plus_seconds(RESET_TIME_WEEKLY), RESET_TIME_WEEKLY); + assert_eq!(flow.balance(), (0_u32.into(), 0_u32.into())); + assert_eq!(flow.inflow, Uint256::from(0_u32)); + assert_eq!(flow.outflow, Uint256::from(0_u32)); + assert_eq!(flow.period_end, epoch.plus_seconds(RESET_TIME_WEEKLY * 2)); + + // Expiration has moved + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY).plus_nanos(1))); + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY * 2))); + assert!(flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY * 2).plus_nanos(1))); + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..ed5e136 --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,8 @@ +//! The state module provides utilities for working with contract state, as well as interacting with key-value storage + +pub mod flow; +pub mod path; +pub mod quota; +pub mod rate_limit; +pub mod rbac; +pub mod storage; diff --git a/src/state/path.rs b/src/state/path.rs new file mode 100644 index 0000000..03d5a2f --- /dev/null +++ b/src/state/path.rs @@ -0,0 +1,33 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// This represents the key for our rate limiting tracker. A tuple of a denom and +/// a channel. When interacting with storage, it's preffered to use this struct +/// and call path.into() on it to convert it to the composite key of the +/// RATE_LIMIT_TRACKERS map +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Path { + pub denom: String, + pub channel: String, +} + +impl Path { + pub fn new(channel: impl Into, denom: impl Into) -> Self { + Path { + channel: channel.into(), + denom: denom.into(), + } + } +} + +impl From for (String, String) { + fn from(path: Path) -> (String, String) { + (path.channel, path.denom) + } +} + +impl From<&Path> for (String, String) { + fn from(path: &Path) -> (String, String) { + (path.channel.to_owned(), path.denom.to_owned()) + } +} diff --git a/src/state/quota.rs b/src/state/quota.rs new file mode 100644 index 0000000..ee7c543 --- /dev/null +++ b/src/state/quota.rs @@ -0,0 +1,64 @@ +use cosmwasm_std::Uint256; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::msg::QuotaMsg; + +use super::flow::FlowType; + +/// A Quota is the percentage of the denom's total value that can be transferred +/// through the channel in a given period of time (duration) +/// +/// Percentages can be different for send and recv +/// +/// The name of the quota is expected to be a human-readable representation of +/// the duration (i.e.: "weekly", "daily", "every-six-months", ...) +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Quota { + pub name: String, + pub max_percentage_send: u32, + pub max_percentage_recv: u32, + pub duration: u64, + pub channel_value: Option, +} + +impl Quota { + /// Calculates the max capacity (absolute value in the same unit as + /// total_value) in each direction based on the total value of the denom in + /// the channel. The result tuple represents the max capacity when the + /// transfer is in directions: (FlowType::In, FlowType::Out) + pub fn capacity(&self) -> (Uint256, Uint256) { + match self.channel_value { + Some(total_value) => ( + total_value * Uint256::from(self.max_percentage_recv) / Uint256::from(100_u32), + total_value * Uint256::from(self.max_percentage_send) / Uint256::from(100_u32), + ), + None => (0_u32.into(), 0_u32.into()), // This should never happen, but ig the channel value is not set, we disallow any transfer + } + } + + /// returns the capacity in a direction. This is used for displaying cleaner errors + pub fn capacity_on(&self, direction: &FlowType) -> Uint256 { + let (max_in, max_out) = self.capacity(); + match direction { + FlowType::In => max_in, + FlowType::Out => max_out, + } + } +} + +impl From<&QuotaMsg> for Quota { + fn from(msg: &QuotaMsg) -> Self { + let send_recv = ( + std::cmp::min(msg.send_recv.0, 100), + std::cmp::min(msg.send_recv.1, 100), + ); + Quota { + name: msg.name.clone(), + max_percentage_send: send_recv.0, + max_percentage_recv: send_recv.1, + duration: msg.duration, + channel_value: None, + } + } +} diff --git a/src/state/rate_limit.rs b/src/state/rate_limit.rs new file mode 100644 index 0000000..fba96b4 --- /dev/null +++ b/src/state/rate_limit.rs @@ -0,0 +1,98 @@ +use cosmwasm_std::{Timestamp, Uint256}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::ContractError; + +use super::{ + flow::{Flow, FlowType}, + path::Path, + quota::Quota, +}; + +/// RateLimit is the main structure tracked for each channel/denom pair. Its quota +/// represents rate limit configuration, and the flow its +/// current state (i.e.: how much value has been transfered in the current period) +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct RateLimit { + pub quota: Quota, + pub flow: Flow, +} + +impl RateLimit { + /// Checks if a transfer is allowed and updates the data structures + /// accordingly. + /// + /// If the transfer is not allowed, it will return a RateLimitExceeded error. + /// + /// Otherwise it will return a RateLimitResponse with the updated data structures + pub fn allow_transfer( + &mut self, + path: &Path, + direction: &FlowType, + funds: Uint256, + channel_value: Uint256, + now: Timestamp, + ) -> Result { + // Flow used before this transaction is applied. + // This is used to make error messages more informative + let initial_flow = self.flow.balance_on(direction); + + // Apply the transfer. From here on, we will updated the flow with the new transfer + // and check if it exceeds the quota at the current time + + let expired = self.flow.apply_transfer(direction, funds, now, &self.quota); + // Cache the channel value if it has never been set or it has expired. + if self.quota.channel_value.is_none() || expired { + self.quota.channel_value = Some(calculate_channel_value( + channel_value, + &path.denom, + funds, + direction, + )) + } + + let (max_in, max_out) = self.quota.capacity(); + // Return the effects of applying the transfer or an error. + match self.flow.exceeds(direction, max_in, max_out) { + true => Err(ContractError::RateLimitExceded { + channel: path.channel.to_string(), + denom: path.denom.to_string(), + amount: funds, + quota_name: self.quota.name.to_string(), + used: initial_flow, + max: self.quota.capacity_on(direction), + reset: self.flow.period_end, + }), + false => Ok(RateLimit { + quota: self.quota.clone(), // Cloning here because self.quota.name (String) does not allow us to implement Copy + flow: self.flow, // We can Copy flow, so this is slightly more efficient than cloning the whole RateLimit + }), + } + } +} + +// The channel value on send depends on the amount on escrow. The ibc transfer +// module modifies the escrow amount by "funds" on sends before calling the +// contract. This function takes that into account so that the channel value +// that we track matches the channel value at the moment when the ibc +// transaction started executing +fn calculate_channel_value( + channel_value: Uint256, + denom: &str, + funds: Uint256, + direction: &FlowType, +) -> Uint256 { + match direction { + FlowType::Out => { + if denom.starts_with("ibc") { + channel_value + funds // Non-Native tokens get removed from the supply on send. Add that amount back + } else { + // The commented-out code in the golang calculate channel value is what we want, but we're currently using the whole supply temporarily for efficiency. see rate_limit.go/CalculateChannelValue(..) + //channel_value - funds // Native tokens increase escrow amount on send. Remove that amount here + channel_value + } + } + FlowType::In => channel_value, + } +} diff --git a/src/state/rbac.rs b/src/state/rbac.rs new file mode 100644 index 0000000..76de0e7 --- /dev/null +++ b/src/state/rbac.rs @@ -0,0 +1,78 @@ +use cosmwasm_std::Timestamp; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::msg::ExecuteMsg; + +/// Roles defines the available permissions that can be assigned to addresses as part of the RBAC system +#[derive( + Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, PartialOrd, Ord, Hash, +)] +pub enum Roles { + /// Has the ability to add a new rate limit + AddRateLimit, + /// Has the ability to complete remove a configured rate limit + RemoveRateLimit, + /// Has the ability to reset tracked quotas + ResetPathQuota, + /// Has the ability to edit existing quotas + EditPathQuota, + /// Has the ability to grant roles to an address + GrantRole, + /// Has the ability to revoke granted roles to an address + RevokeRole, + /// Has the ability to remove queued messages + RemoveMessage, + /// Has the ability to alter timelock delay's + SetTimelockDelay, + // Has the ability to manage denom restrictions + ManageDenomRestrictions, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct QueuedMessage { + /// the message that submitted to the contract after a sucessful governance proposal + pub message: ExecuteMsg, + /// the time which the message was processed by the contract + pub submitted_at: Timestamp, + /// the timelock delay that was in place when the message was queued for execution + pub timelock_delay: u64, + /// Constructed using format!("{}_{}", Env::BlockInfo::Height Env::Transaction::Index) + /// + /// Can be used to remove a message from the queue without processing it + pub message_id: String, +} + +impl Roles { + /// helper function that returns a vec containing all variants of the Roles enum + pub fn all_roles() -> Vec { + vec![ + Roles::AddRateLimit, + Roles::RemoveRateLimit, + Roles::ResetPathQuota, + Roles::EditPathQuota, + Roles::GrantRole, + Roles::RevokeRole, + Roles::RemoveMessage, + Roles::SetTimelockDelay, + Roles::ManageDenomRestrictions, + ] + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn test_all_roles() { + let roles = Roles::all_roles(); + assert!(roles.contains(&Roles::AddRateLimit)); + assert!(roles.contains(&Roles::RemoveRateLimit)); + assert!(roles.contains(&Roles::ResetPathQuota)); + assert!(roles.contains(&Roles::EditPathQuota)); + assert!(roles.contains(&Roles::GrantRole)); + assert!(roles.contains(&Roles::RevokeRole)); + assert!(roles.contains(&Roles::RemoveMessage)); + assert!(roles.contains(&Roles::SetTimelockDelay)); + } +} diff --git a/src/state/storage.rs b/src/state/storage.rs new file mode 100644 index 0000000..d37bf02 --- /dev/null +++ b/src/state/storage.rs @@ -0,0 +1,50 @@ +//! storage variables + +use std::collections::BTreeSet; + +use cosmwasm_std::Addr; +use cw_storage_plus::{Deque, Item, Map}; + +use super::{ + rate_limit::RateLimit, + rbac::{QueuedMessage, Roles}, +}; + +/// Only this address can manage the contract. This will likely be the +/// governance module, but could be set to something else if needed +pub const GOVMODULE: Item = Item::new("gov_module"); + +/// RATE_LIMIT_TRACKERS is the main state for this contract. It maps a path (IBC +/// Channel + denom) to a vector of `RateLimit`s. +/// +/// The `RateLimit` struct contains the information about how much value of a +/// denom has moved through the channel during the currently active time period +/// (channel_flow.flow) and what percentage of the denom's value we are +/// allowing to flow through that channel in a specific duration (quota) +/// +/// For simplicity, the channel in the map keys refers to the "host" channel on +/// the neutron side. This means that on PacketSend it will refer to the source +/// channel while on PacketRecv it refers to the destination channel. +/// +/// It is the responsibility of the go module to pass the appropriate channel +/// when sending the messages +/// +/// The map key (String, String) represents (channel_id, denom). We use +/// composite keys instead of a struct to avoid having to implement the +/// PrimaryKey trait +pub const RATE_LIMIT_TRACKERS: Map<(String, String), Vec> = Map::new("flow"); + +/// Maps address -> delay, automatically applying a timelock delay to all +/// messages submitted by a specific address +pub const TIMELOCK_DELAY: Map = Map::new("timelock_delay"); + +/// Storage variable which is used to queue messages for execution that are the result of a successful dao message. +/// In order for the message to be processed, X hours must past from QueuedMessage::submited_at +pub const MESSAGE_QUEUE: Deque = Deque::new("queued_messages"); + +/// Storage variable that is used to map signing addresses and the permissions they have been granted +pub const RBAC_PERMISSIONS: Map> = Map::new("rbac"); + +/// Accepted channels for restricted denom. This is a map of denom -> channels. +/// If a denom is not in this map, it is unrestricted. +pub const ACCEPTED_CHANNELS_FOR_RESTRICTED_DENOM: Map> = Map::new("acfd"); diff --git a/src/sudo.rs b/src/sudo.rs new file mode 100644 index 0000000..b3b39de --- /dev/null +++ b/src/sudo.rs @@ -0,0 +1,199 @@ +use cosmwasm_std::{DepsMut, Response, Timestamp, Uint256}; + +use crate::{ + blocking::check_restricted_denoms, + packet::Packet, + state::{flow::FlowType, path::Path, rate_limit::RateLimit, storage::RATE_LIMIT_TRACKERS}, + ContractError, +}; + +// This function will process a packet and extract the paths information, funds, +// and channel value from it. This is will have to interact with the chain via grpc queries to properly +// obtain this information. +// +// For backwards compatibility, we're teporarily letting the chain override the +// denom and channel value, but these should go away in favour of the contract +// extracting these from the packet +pub fn process_packet( + deps: DepsMut, + packet: Packet, + direction: FlowType, + now: Timestamp, + #[cfg(test)] channel_value_mock: Option, +) -> Result { + check_restricted_denoms(deps.as_ref(), &packet, &direction)?; + + let (channel_id, denom) = packet.path_data(&direction); + #[allow(clippy::needless_borrow)] + let path = &Path::new(channel_id, denom); + let funds = packet.get_funds(); + + #[cfg(test)] + // When testing we override the channel value with the mock since we can't get it from the chain + let channel_value = match channel_value_mock { + Some(channel_value) => channel_value, + None => packet.channel_value(deps.as_ref(), &direction)?, // This should almost never be used, but left for completeness in case we want to send an empty channel_value from the test + }; + + #[cfg(not(test))] + let channel_value = packet.channel_value(deps.as_ref(), &direction)?; + + try_transfer(deps, path, channel_value, funds, direction, now) +} + +/// This function checks the rate limit and, if successful, stores the updated data about the value +/// that has been transfered through the channel for a specific denom. +/// If the period for a RateLimit has ended, the Flow information is reset. +/// +/// The channel_value is the current value of the denom for the the channel as +/// calculated by the caller. This should be the total supply of a denom +pub fn try_transfer( + deps: DepsMut, + path: &Path, + channel_value: Uint256, + funds: Uint256, + direction: FlowType, + now: Timestamp, +) -> Result { + // Sudo call. Only go modules should be allowed to access this + + // Fetch potential trackers for "any" channel of the required token + let any_path = Path::new("any", path.denom.clone()); + let mut any_trackers = RATE_LIMIT_TRACKERS + .may_load(deps.storage, any_path.clone().into())? + .unwrap_or_default(); + // Fetch trackers for the requested path + let mut trackers = RATE_LIMIT_TRACKERS + .may_load(deps.storage, path.into())? + .unwrap_or_default(); + + let not_configured = trackers.is_empty() && any_trackers.is_empty(); + + if not_configured { + // No Quota configured for the current path. Allowing all messages. + return Ok(Response::new() + .add_attribute("method", "try_transfer") + .add_attribute("channel_id", path.channel.to_string()) + .add_attribute("denom", path.denom.to_string()) + .add_attribute("quota", "none")); + } + + // If any of the RateLimits fails, allow_transfer() will return + // ContractError::RateLimitExceded, which we'll propagate out + let results: Vec = trackers + .iter_mut() + .map(|limit| limit.allow_transfer(path, &direction, funds, channel_value, now)) + .collect::>()?; + + let any_results: Vec = any_trackers + .iter_mut() + .map(|limit| limit.allow_transfer(path, &direction, funds, channel_value, now)) + .collect::>()?; + + RATE_LIMIT_TRACKERS.save(deps.storage, path.into(), &results)?; + RATE_LIMIT_TRACKERS.save(deps.storage, any_path.into(), &any_results)?; + + let response = Response::new() + .add_attribute("method", "try_transfer") + .add_attribute("channel_id", path.channel.to_string()) + .add_attribute("denom", path.denom.to_string()); + + // Adds the attributes for each path to the response. In prod, the + // addtribute add_rate_limit_attributes is a noop + let response: Result = + any_results.iter().try_fold(response, |acc, result| { + Ok(add_rate_limit_attributes(acc, result)) + }); + + results.iter().try_fold(response?, |acc, result| { + Ok(add_rate_limit_attributes(acc, result)) + }) +} + +// #[cfg(any(feature = "verbose_responses", test))] +fn add_rate_limit_attributes(response: Response, result: &RateLimit) -> Response { + let (used_in, used_out) = result.flow.balance(); + let (max_in, max_out) = result.quota.capacity(); + // These attributes are only added during testing. That way we avoid + // calculating these again on prod. + response + .add_attribute( + format!("{}_used_in", result.quota.name), + used_in.to_string(), + ) + .add_attribute( + format!("{}_used_out", result.quota.name), + used_out.to_string(), + ) + .add_attribute(format!("{}_max_in", result.quota.name), max_in.to_string()) + .add_attribute( + format!("{}_max_out", result.quota.name), + max_out.to_string(), + ) + .add_attribute( + format!("{}_period_end", result.quota.name), + result.flow.period_end.to_string(), + ) +} + +// Leaving the attributes in until we can conditionally compile the contract +// for the go tests in CI: https://github.com/mandrean/cw-optimizoor/issues/19 +// +// #[cfg(not(any(feature = "verbose_responses", test)))] +// fn add_rate_limit_attributes(response: Response, _result: &RateLimit) -> Response { +// response +// } + +// This function manually injects an inflow. This is used when reverting a +// packet that failed ack or timed-out. +pub fn undo_send(deps: DepsMut, packet: Packet) -> Result { + // Sudo call. Only go modules should be allowed to access this + let (channel_id, denom) = packet.path_data(&FlowType::Out); // Sends have direction out. + #[allow(clippy::needless_borrow)] + let path = &Path::new(channel_id, &denom); + let any_path = Path::new("any", &denom); + let funds = packet.get_funds(); + + let mut any_trackers = RATE_LIMIT_TRACKERS + .may_load(deps.storage, any_path.clone().into())? + .unwrap_or_default(); + let mut trackers = RATE_LIMIT_TRACKERS + .may_load(deps.storage, path.into())? + .unwrap_or_default(); + + let not_configured = trackers.is_empty() && any_trackers.is_empty(); + + if not_configured { + // No Quota configured for the current path. Allowing all messages. + return Ok(Response::new() + .add_attribute("method", "try_transfer") + .add_attribute("channel_id", path.channel.to_string()) + .add_attribute("denom", path.denom.to_string()) + .add_attribute("quota", "none")); + } + + // We force update the flow to remove a failed send + let results: Vec = trackers + .iter_mut() + .map(|limit| { + limit.flow.undo_flow(FlowType::Out, funds); + limit.to_owned() + }) + .collect(); + let any_results: Vec = any_trackers + .iter_mut() + .map(|limit| { + limit.flow.undo_flow(FlowType::Out, funds); + limit.to_owned() + }) + .collect(); + + RATE_LIMIT_TRACKERS.save(deps.storage, path.into(), &results)?; + RATE_LIMIT_TRACKERS.save(deps.storage, any_path.into(), &any_results)?; + + Ok(Response::new() + .add_attribute("method", "undo_send") + .add_attribute("channel_id", path.channel.to_string()) + .add_attribute("denom", path.denom.to_string()) + .add_attribute("any_channel", (!any_trackers.is_empty()).to_string())) +} diff --git a/src/tests/contract_tests.rs b/src/tests/contract_tests.rs new file mode 100644 index 0000000..e6c2fa4 --- /dev/null +++ b/src/tests/contract_tests.rs @@ -0,0 +1,405 @@ +#![cfg(test)] + +use crate::packet::Packet; +use crate::state::rbac::Roles; +use crate::{contract::*, test_msg_recv, test_msg_send, ContractError}; +use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; +use cosmwasm_std::{from_json, Attribute, Uint256}; + +use crate::msg::{InstantiateMsg, PathMsg, QueryMsg, QuotaMsg, SudoMsg}; +use crate::state::flow::tests::RESET_TIME_WEEKLY; +use crate::state::rate_limit::RateLimit; +use crate::state::storage::{GOVMODULE, RATE_LIMIT_TRACKERS, RBAC_PERMISSIONS}; +use crate::tests::helpers::tests::verify_query_response; +const IBC_ADDR: &str = "neutron1cdlz8scnf3mmxdnf4njmtp7vz4gps7fswphrqn"; +const GOV_ADDR: &str = "neutron1w02khza7ux68ccwmz2hln97mkjspjxes8y2k9v"; + +#[test] // Tests we ccan instantiate the contract and that the owners are set correctly +fn proper_instantiation() { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![], + }; + let info = message_info(&MockApi::default().addr_make(IBC_ADDR), &[]); + + // we can just call .unwrap() to assert this was a success + let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + + // The ibc and gov modules are properly stored + assert_eq!( + GOVMODULE.load(deps.as_ref().storage).unwrap().to_string(), + MockApi::default().addr_make(GOV_ADDR).to_string() + ); + + let permissions = RBAC_PERMISSIONS + .load( + &deps.storage, + MockApi::default().addr_make(GOV_ADDR).to_string(), + ) + .unwrap(); + for permission in Roles::all_roles() { + assert!(permissions.contains(&permission)); + } +} + +#[test] // Tests that when a packet is transferred, the peropper allowance is consummed +fn consume_allowance() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![quota], + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom") , + channel_value: 3_300_u32.into(), + funds: 300_u32.into() + ); + let res = sudo(deps.as_mut(), mock_env(), msg).unwrap(); + + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_300_u32.into(), + funds: 300_u32.into() + ); + let err = sudo(deps.as_mut(), mock_env(), msg).unwrap_err(); + assert!(matches!(err, ContractError::RateLimitExceded { .. })); +} + +#[test] // Tests that the balance of send and receive is maintained (i.e: recives are sustracted from the send allowance and sends from the receives) +fn symetric_flows_dont_consume_allowance() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![quota], + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + let _res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); + + let send_msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_300_u32.into(), + funds: 300_u32.into() + ); + let recv_msg = test_msg_recv!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 300_u32.into() + ); + + let res = sudo(deps.as_mut(), mock_env(), send_msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + + let res = sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "0"); + + // We can still use the path. Even if we have sent more than the + // allowance through the path (900 > 3000*.1), the current "balance" + // of inflow vs outflow is still lower than the path's capacity/quota + let res = sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "0"); + + let err = sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap_err(); + + assert!(matches!(err, ContractError::RateLimitExceded { .. })); +} + +#[test] // Tests that we can have different quotas for send and receive. In this test we use 4% send and 1% receive +fn asymetric_quotas() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 4, 1); + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![quota], + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + let _res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); + + // Sending 2% + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_060_u32.into(), + funds: 60_u32.into() + ); + let res = sudo(deps.as_mut(), mock_env(), msg).unwrap(); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "60"); + + // Sending 2% more. Allowed, as sending has a 4% allowance + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_060_u32.into(), + funds: 60_u32.into() + ); + + let res = sudo(deps.as_mut(), mock_env(), msg).unwrap(); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "120"); + + // Receiving 1% should still work. 4% *sent* through the path, but we can still receive. + let recv_msg = test_msg_recv!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 30_u32.into() + ); + let res = sudo(deps.as_mut(), mock_env(), recv_msg).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "90"); + + // Sending 2%. Should fail. In balance, we've sent 4% and received 1%, so only 1% left to send. + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_060_u32.into(), + funds: 60_u32.into() + ); + let err = sudo(deps.as_mut(), mock_env(), msg.clone()).unwrap_err(); + assert!(matches!(err, ContractError::RateLimitExceded { .. })); + + // Sending 1%: Allowed; because sending has a 4% allowance. We've sent 4% already, but received 1%, so there's send cappacity again + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_060_u32.into(), + funds: 30_u32.into() + ); + let res = sudo(deps.as_mut(), mock_env(), msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "120"); +} + +#[test] // Tests we can get the current state of the trackers +fn query_state() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![quota], + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + let env = mock_env(); + let _res = instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + let query_msg = QueryMsg::GetQuotas { + channel_id: "any".to_string(), + denom: "denom".to_string(), + }; + + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_json(res).unwrap(); + assert_eq!(value[0].quota.name, "weekly"); + assert_eq!(value[0].quota.max_percentage_send, 10); + assert_eq!(value[0].quota.max_percentage_recv, 10); + assert_eq!(value[0].quota.duration, RESET_TIME_WEEKLY); + assert_eq!(value[0].flow.inflow, Uint256::from(0_u32)); + assert_eq!(value[0].flow.outflow, Uint256::from(0_u32)); + assert_eq!( + value[0].flow.period_end, + env.block.time.plus_seconds(RESET_TIME_WEEKLY) + ); + + let send_msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_300_u32.into(), + funds: 300_u32.into() + ); + sudo(deps.as_mut(), mock_env(), send_msg.clone()).unwrap(); + + let recv_msg = test_msg_recv!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 30_u32.into() + ); + sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap(); + + // Query + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_json(res).unwrap(); + verify_query_response( + &value[0], + "weekly", + (10, 10), + RESET_TIME_WEEKLY, + 30_u32.into(), + 300_u32.into(), + env.block.time.plus_seconds(RESET_TIME_WEEKLY), + ); +} + +#[test] // Tests quota percentages are between [0,100] +fn bad_quotas() { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![QuotaMsg { + name: "bad_quota".to_string(), + duration: 200, + send_recv: (5000, 101), + }], + }], + }; + let info = message_info(&MockApi::default().addr_make(IBC_ADDR), &[]); + + let env = mock_env(); + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // If a quota is higher than 100%, we set it to 100% + let query_msg = QueryMsg::GetQuotas { + channel_id: "any".to_string(), + denom: "denom".to_string(), + }; + let res = query(deps.as_ref(), env.clone(), query_msg).unwrap(); + let value: Vec = from_json(res).unwrap(); + verify_query_response( + &value[0], + "bad_quota", + (100, 100), + 200, + 0_u32.into(), + 0_u32.into(), + env.block.time.plus_seconds(200), + ); +} + +#[test] // Tests that undo reverts a packet send without affecting expiration or channel value +fn undo_send() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths: vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![quota], + }], + }; + let info = message_info(&MockApi::default().addr_make(GOV_ADDR), &[]); + let _res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); + + let send_msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_300_u32.into(), + funds: 300_u32.into() + ); + let undo_msg = SudoMsg::UndoSend { + packet: Packet::mock( + "channel".to_string(), + "channel".to_string(), + "denom".to_string(), + 300_u32.into(), + ), + }; + + sudo(deps.as_mut(), mock_env(), send_msg.clone()).unwrap(); + + let trackers = RATE_LIMIT_TRACKERS + .load(&deps.storage, ("any".to_string(), "denom".to_string())) + .unwrap(); + assert_eq!( + trackers.first().unwrap().flow.outflow, + Uint256::from(300_u32) + ); + let period_end = trackers.first().unwrap().flow.period_end; + let channel_value = trackers.first().unwrap().quota.channel_value; + + sudo(deps.as_mut(), mock_env(), undo_msg.clone()).unwrap(); + + let trackers = RATE_LIMIT_TRACKERS + .load(&deps.storage, ("any".to_string(), "denom".to_string())) + .unwrap(); + assert_eq!(trackers.first().unwrap().flow.outflow, Uint256::from(0_u32)); + assert_eq!(trackers.first().unwrap().flow.period_end, period_end); + assert_eq!(trackers.first().unwrap().quota.channel_value, channel_value); +} + +#[test] +fn test_basic_message() { + let json = r#"{"send_packet":{"packet":{"sequence":2,"source_port":"transfer","source_channel":"channel-0","destination_port":"transfer","destination_channel":"channel-0","data":{"denom":"stake","amount":"125000000000011250","sender":"osmo1dwtagd6xzl4eutwtyv6mewra627lkg3n3w26h6","receiver":"osmo1yvjkt8lnpxucjmspaj5ss4aa8562gx0a3rks8s"},"timeout_height":{"revision_height":100}}}}"#; + let _parsed: SudoMsg = serde_json_wasm::from_str(json).unwrap(); + //println!("{parsed:?}"); +} + +#[test] +fn test_testnet_message() { + let json = r#"{"send_packet":{"packet":{"sequence":4,"source_port":"transfer","source_channel":"channel-0","destination_port":"transfer","destination_channel":"channel-1491","data":{"denom":"uosmo","amount":"100","sender":"osmo1cyyzpxplxdzkeea7kwsydadg87357qnahakaks","receiver":"osmo1c584m4lq25h83yp6ag8hh4htjr92d954vklzja"},"timeout_height":{},"timeout_timestamp":1668024637477293371}}}"#; + let _parsed: SudoMsg = serde_json_wasm::from_str(json).unwrap(); + //println!("{parsed:?}"); +} + +#[test] +fn test_tokenfactory_message() { + let json = r#"{"send_packet":{"packet":{"sequence":4,"source_port":"transfer","source_channel":"channel-0","destination_port":"transfer","destination_channel":"channel-1491","data":{"denom":"transfer/channel-0/factory/osmo12smx2wdlyttvyzvzg54y2vnqwq2qjateuf7thj/czar","amount":"100000000000000000","sender":"osmo1cyyzpxplxdzkeea7kwsydadg87357qnahakaks","receiver":"osmo1c584m4lq25h83yp6ag8hh4htjr92d954vklzja"},"timeout_height":{},"timeout_timestamp":1668024476848430980}}}"#; + let _parsed: SudoMsg = serde_json_wasm::from_str(json).unwrap(); + //println!("{parsed:?}"); +} diff --git a/src/tests/helpers.rs b/src/tests/helpers.rs new file mode 100644 index 0000000..0bd885b --- /dev/null +++ b/src/tests/helpers.rs @@ -0,0 +1,61 @@ +#![cfg(test)] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg}; + +use crate::msg::ExecuteMsg; +use crate::msg::SudoMsg; + +/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers +/// for working with this. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct RateLimitingContract(pub Addr); + +impl RateLimitingContract { + pub fn addr(&self) -> Addr { + self.0.clone() + } + + pub fn call>(&self, msg: T) -> StdResult { + let msg = to_json_binary(&msg.into())?; + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg, + funds: vec![], + } + .into()) + } + + pub fn sudo>(&self, msg: T) -> cw_multi_test::SudoMsg { + let msg = to_json_binary(&msg.into()).unwrap(); + cw_multi_test::SudoMsg::Wasm(cw_multi_test::WasmSudo { + contract_addr: self.addr(), + message: msg, + }) + } +} + +pub mod tests { + use cosmwasm_std::{Timestamp, Uint256}; + + use crate::state::rate_limit::RateLimit; + + pub fn verify_query_response( + value: &RateLimit, + quota_name: &str, + send_recv: (u32, u32), + duration: u64, + inflow: Uint256, + outflow: Uint256, + period_end: Timestamp, + ) { + assert_eq!(value.quota.name, quota_name); + assert_eq!(value.quota.max_percentage_send, send_recv.0); + assert_eq!(value.quota.max_percentage_recv, send_recv.1); + assert_eq!(value.quota.duration, duration); + assert_eq!(value.flow.inflow, inflow); + assert_eq!(value.flow.outflow, outflow); + assert_eq!(value.flow.period_end, period_end); + } +} diff --git a/src/tests/integration_tests.rs b/src/tests/integration_tests.rs new file mode 100644 index 0000000..5d30ff0 --- /dev/null +++ b/src/tests/integration_tests.rs @@ -0,0 +1,1019 @@ +#![cfg(test)] + +use crate::{ + msg::{ExecuteMsg, QueryMsg}, + state::{rate_limit::RateLimit, rbac::Roles}, + test_msg_send, + tests::helpers::RateLimitingContract, + ContractError, +}; +use cosmwasm_std::{testing::MockApi, Addr, Coin, Empty, Timestamp, Uint128, Uint256}; +use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor}; + +use crate::{ + msg::{InstantiateMsg, PathMsg, QuotaMsg}, + state::flow::tests::{RESET_TIME_DAILY, RESET_TIME_MONTHLY, RESET_TIME_WEEKLY}, +}; + +pub fn contract_template() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_sudo(crate::contract::sudo); + Box::new(contract) +} + +const USER: &str = "USER"; +const GOV_ADDR: &str = "neutron1w02khza7ux68ccwmz2hln97mkjspjxes8y2k9v"; +const NATIVE_DENOM: &str = "nosmo"; + +fn mock_app() -> App { + AppBuilder::new().build(|router, _, storage| { + router + .bank + .init_balance( + storage, + &Addr::unchecked(USER), + vec![Coin { + denom: NATIVE_DENOM.to_string(), + amount: Uint128::new(1_000), + }], + ) + .unwrap(); + }) +} + +// Instantiate the contract +fn proper_instantiate(paths: Vec) -> (App, RateLimitingContract) { + let mut app = mock_app(); + let cw_code_id = app.store_code(contract_template()); + + let msg = InstantiateMsg { + gov_module: MockApi::default().addr_make(GOV_ADDR), + paths, + }; + + let cw_rate_limit_contract_addr = app + .instantiate_contract( + cw_code_id, + MockApi::default().addr_make(GOV_ADDR), + &msg, + &[], + "test", + None, + ) + .unwrap(); + + let cw_rate_limit_contract = RateLimitingContract(cw_rate_limit_contract_addr); + + (app, cw_rate_limit_contract) +} + +use cosmwasm_std::Attribute; + +#[test] // Checks that the RateLimit flows are expired properly when time passes +fn expiration() { + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![quota], + }]); + + // Using all the allowance + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 300_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + let res = app.sudo(cosmos_msg).unwrap(); + + let Attribute { key, value } = &res.custom_attrs(1)[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.custom_attrs(1)[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[5]; + assert_eq!(key, "weekly_max_in"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[6]; + assert_eq!(key, "weekly_max_out"); + assert_eq!(value, "300"); + + // Another packet is rate limited + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 300_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + let err = app.sudo(cosmos_msg).unwrap_err(); + + assert_eq!( + err.downcast_ref::().unwrap(), + &ContractError::RateLimitExceded { + channel: "channel".to_string(), + denom: "denom".to_string(), + amount: Uint256::from_u128(300), + quota_name: "weekly".to_string(), + used: Uint256::from_u128(300), + max: Uint256::from_u128(300), + reset: Timestamp::from_nanos(1572402219879305533), + } + ); + + // ... Time passes + app.update_block(|b| { + b.height += 1000; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // Sending the packet should work now + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 300_u32.into() + ); + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + let res = app.sudo(cosmos_msg).unwrap(); + + let Attribute { key, value } = &res.custom_attrs(1)[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.custom_attrs(1)[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[5]; + assert_eq!(key, "weekly_max_in"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[6]; + assert_eq!(key, "weekly_max_out"); + assert_eq!(value, "300"); +} + +#[test] // Tests we can have different maximums for different quotaas (daily, weekly, etc) and that they all are active at the same time +fn multiple_quotas() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + // Sending 1% to use the daily allowance + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // Another packet is rate limited + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // Sending the packet should work now + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // Do that for 4 more days + for _ in 1..4 { + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // Sending the packet should work now + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + } + + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // We now have exceeded the weekly limit! Even if the daily limit allows us, the weekly doesn't + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One week passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // We can still can't send because the weekly and monthly limits are the same + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // Waiting a week again, doesn't help!! + // ... One week passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // We can still can't send because the monthly limit hasn't passed + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // Only after two more weeks we can send again + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds((RESET_TIME_WEEKLY * 2) + 1) // Two weeks + }); + + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); +} + +#[test] // Tests that the channel value is based on the value at the beginning of the period +fn channel_value_cached() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 2, 2), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + // Sending 1% (half of the daily allowance) + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // Sending 3% is now rate limited + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100_u32.into(), + funds: 3_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // Even if the channel value increases, the percentage is calculated based on the value at period start + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100000_u32.into(), + funds: 3_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // New Channel Value world! + + // Sending 1% of a new value (10_000) passes the daily check, cause it + // has expired, but not the weekly check (The value for last week is + // sitll 100, as only 1 day has passed) + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 10_000_u32.into(), + funds: 100_u32.into() + ); + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One week passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // Sending 1% of a new value should work and set the value for the day at 10_000 + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 10_000_u32.into(), + funds: 100_u32.into() + ); + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // If the value magically decreasses. We can still send up to 100 more (1% of 10k) + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 1_u32.into(), + funds: 75_u32.into() + ); + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); +} + +#[test] // Checks that RateLimits added after instantiation are respected +fn add_paths_later() { + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![]); + + // All sends are allowed + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000_u32.into(), + funds: 300_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg.clone()); + let res = app.sudo(cosmos_msg).unwrap(); + + let Attribute { key, value } = &res.custom_attrs(1)[3]; + assert_eq!(key, "quota"); + assert_eq!(value, "none"); + + // Add a weekly limit of 1% + let management_msg = ExecuteMsg::AddPath { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas: vec![QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 1, 1)], + }; + + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg) + .unwrap(); + + // Executing the same message again should fail, as it is now rate limited + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); +} + +#[test] +fn test_execute_add_path() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + let management_msg = ExecuteMsg::AddPath { + channel_id: "new_channel_id".to_string(), + denom: "new_denom".to_string(), + quotas: vec![QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 1, 1)], + }; + + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // Sending 1% to use the daily allowance + let msg = test_msg_send!( + channel_id: format!("new_channel_id"), + denom: format!("new_denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg.clone()); + app.sudo(cosmos_msg).unwrap(); + + let response: Vec = app + .wrap() + .query_wasm_smart( + cw_rate_limit_contract.addr(), + &QueryMsg::GetQuotas { + channel_id: "new_channel_id".to_string(), + denom: "new_denom".to_string(), + }, + ) + .unwrap(); + assert_eq!(response.len(), 1); + assert_eq!(response[0].flow.outflow, Uint256::one()); + assert_eq!( + response[0].quota.channel_value, + Some(Uint256::from_u128(101)) + ); +} +#[test] +fn test_execute_remove_path() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + let management_msg = ExecuteMsg::RemovePath { + channel_id: "any".to_string(), + denom: "denom".to_string(), + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // rate limits should be removed + assert!(app + .wrap() + .query_wasm_smart::( + cw_rate_limit_contract.addr(), + &QueryMsg::GetQuotas { + channel_id: "any".to_string(), + denom: "denom".to_string() + } + ) + .is_err()); +} + +#[test] +fn test_execute_reset_path_quota() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + // Sending 1% to use the daily allowance + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg.clone()); + app.sudo(cosmos_msg).unwrap(); + + let management_msg = ExecuteMsg::ResetPathQuota { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quota_id: "daily".to_string(), + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + let response = app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetQuotas { + channel_id: "any".to_string(), + denom: "denom".to_string(), + }, + ) + .unwrap(); + + // daily quota should be reset + let daily_quota = response + .iter() + .find(|rate_limit| rate_limit.quota.name.eq("daily")) + .unwrap(); + assert_eq!(daily_quota.flow.inflow, Uint256::zero()); + assert_eq!(daily_quota.flow.outflow, Uint256::zero()); + + // weekly and monthly should not be reset + let weekly_quota = response + .iter() + .find(|rate_limit| rate_limit.quota.name.eq("weekly")) + .unwrap(); + assert_eq!(weekly_quota.flow.inflow, Uint256::zero()); + assert_eq!(weekly_quota.flow.outflow, Uint256::one()); + + let monthly_quota = response + .iter() + .find(|rate_limit| rate_limit.quota.name.eq("monthly")) + .unwrap(); + assert_eq!(monthly_quota.flow.inflow, Uint256::zero()); + assert_eq!(monthly_quota.flow.outflow, Uint256::one()); +} + +#[test] +fn test_execute_grant_and_revoke_role() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + let management_msg = ExecuteMsg::GrantRole { + signer: MockApi::default().addr_make("foobar").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + let response = app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetRoles { + owner: MockApi::default().addr_make("foobar").to_string(), + }, + ) + .unwrap(); + assert_eq!(response.len(), 1); + assert_eq!(response[0], Roles::GrantRole); + + // test foobar can grant a role + let management_msg = ExecuteMsg::GrantRole { + signer: MockApi::default().addr_make("foobarbaz").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .unwrap(); + + let response = app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetRoles { + owner: MockApi::default().addr_make("foobarbaz").to_string(), + }, + ) + .unwrap(); + assert_eq!(response.len(), 1); + assert_eq!(response[0], Roles::GrantRole); + + // test role revocation + + let management_msg = ExecuteMsg::RevokeRole { + signer: MockApi::default().addr_make("foobar").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // foobar should no longer have roles + assert!(app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetRoles { + owner: MockApi::default().addr_make("foobar").to_string() + } + ) + .is_err()); +} + +#[test] +fn test_execute_edit_path_quota() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + // Sending 1% to use the daily allowance + let msg = test_msg_send!( + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 101_u32.into(), + funds: 1_u32.into() + ); + let cosmos_msg = cw_rate_limit_contract.sudo(msg.clone()); + app.sudo(cosmos_msg).unwrap(); + + let management_msg = ExecuteMsg::EditPathQuota { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quota: QuotaMsg { + send_recv: (81, 58), + name: "monthly".to_string(), + duration: RESET_TIME_MONTHLY, + }, + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + let response = app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetQuotas { + channel_id: "any".to_string(), + denom: "denom".to_string(), + }, + ) + .unwrap(); + let monthly_quota = response + .iter() + .find(|rate_limit| rate_limit.quota.name.eq("monthly")) + .unwrap(); + assert_eq!(monthly_quota.quota.max_percentage_send, 81); + assert_eq!(monthly_quota.quota.max_percentage_recv, 58); +} +#[test] +fn test_execute_remove_message() { + // this test case also covers timelock delay set, as a non zero timelock + // will force the message to be queued, thus allowing queue removal + + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + let management_msg = ExecuteMsg::GrantRole { + signer: MockApi::default().addr_make("foobar").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // set a timelock delay for foobar + let management_msg = ExecuteMsg::SetTimelockDelay { + signer: MockApi::default().addr_make("foobar").to_string(), + hours: 1, + }; + + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke as insufficient permissions + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // message submitter by foobar should not be queued + let management_msg = ExecuteMsg::GrantRole { + signer: MockApi::default().addr_make("foobarbaz").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .unwrap(); + let response = app + .wrap() + .query_wasm_smart::>(cw_rate_limit_contract.addr(), &QueryMsg::GetMessageIds) + .unwrap(); + assert_eq!(response.len(), 1); + + // remove the message + let management_msg = ExecuteMsg::RemoveMessage { + message_id: response[0].clone(), + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // no messges should be present + assert_eq!( + app.wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetMessageIds + ) + .unwrap() + .len(), + 0 + ); +} + +#[test] +fn test_execute_process_messages() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: "any".to_string(), + denom: "denom".to_string(), + quotas, + }]); + + // allocate GrantRole and RevokeRole to `foobar` + let management_msg = ExecuteMsg::GrantRole { + signer: MockApi::default().addr_make("foobar").to_string(), + roles: vec![Roles::GrantRole, Roles::RevokeRole], + }; + + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // set a timelock delay for foobar + let management_msg = ExecuteMsg::SetTimelockDelay { + signer: MockApi::default().addr_make("foobar").to_string(), + hours: 1, + }; + + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + // non gov cant invoke as insufficient permissions + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); + // gov addr can invoke + app.execute(MockApi::default().addr_make(GOV_ADDR), cosmos_msg.clone()) + .unwrap(); + + // message submitted by foobar should be queued + // allocate GrantRole to foobarbaz + let management_msg = ExecuteMsg::GrantRole { + signer: MockApi::default().addr_make("foobarbaz").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .unwrap(); + let response = app + .wrap() + .query_wasm_smart::>(cw_rate_limit_contract.addr(), &QueryMsg::GetMessageIds) + .unwrap(); + assert_eq!(response.len(), 1); + + // any address should be able to trigger queue message processing + let management_msg = ExecuteMsg::ProcessMessages { + count: Some(1), + message_ids: None, + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(Addr::unchecked("veryrandomaddress"), cosmos_msg) + .unwrap(); + + // insufficient time has passed so queue should still be 1 + assert_eq!( + app.wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetMessageIds + ) + .unwrap() + .len(), + 1 + ); + + // advance time + app.update_block(|block| { + block.height += 100; + block.time = block.time.plus_seconds(3601) + }); + + // any address should be able to trigger queue message processing + let management_msg = ExecuteMsg::ProcessMessages { + count: Some(1), + message_ids: None, + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(Addr::unchecked("veryrandomaddress"), cosmos_msg) + .unwrap(); + + // no messges should be present as time passed and message was executed + assert_eq!( + app.wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetMessageIds + ) + .unwrap() + .len(), + 0 + ); + + // foobarbaz should have the GrantRole permission + let response = app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetRoles { + owner: MockApi::default().addr_make("foobarbaz").to_string(), + }, + ) + .unwrap(); + assert_eq!(response.len(), 1); + assert_eq!(response[0], Roles::GrantRole); + + app.update_block(|block| { + block.height += 1; + block.time = block.time.plus_seconds(3600); + }); + + let management_msg = ExecuteMsg::RevokeRole { + signer: MockApi::default().addr_make("foobarbaz").to_string(), + roles: vec![Roles::GrantRole], + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .unwrap(); + + let message_ids = app + .wrap() + .query_wasm_smart::>(cw_rate_limit_contract.addr(), &QueryMsg::GetMessageIds) + .unwrap(); + assert_eq!(message_ids.len(), 1); + + app.update_block(|block| { + block.height += 1; + }); + + let management_msg = ExecuteMsg::ProcessMessages { + count: Some(1), + message_ids: None, + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .unwrap(); + + // insufficient time has passed so queue length is still 1 + let response = app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetRoles { + owner: MockApi::default().addr_make("foobarbaz").to_string(), + }, + ) + .unwrap(); + assert_eq!(response.len(), 1); + + // advance time + app.update_block(|block| { + block.height += 100; + block.time = block.time.plus_seconds(3601); + }); + + let management_msg = ExecuteMsg::ProcessMessages { + count: None, + message_ids: Some(message_ids.clone()), + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .unwrap(); + + // sufficient time has passed, empty queue + let message_ids = app + .wrap() + .query_wasm_smart::>(cw_rate_limit_contract.addr(), &QueryMsg::GetMessageIds) + .unwrap(); + assert_eq!(message_ids.len(), 0); + + // no rolles allocated, storage key should be removed + assert!(app + .wrap() + .query_wasm_smart::>( + cw_rate_limit_contract.addr(), + &QueryMsg::GetRoles { + owner: MockApi::default().addr_make("foobarbaz").to_string() + } + ) + .is_err()); + + // error should be returned when all params are None + let management_msg = ExecuteMsg::ProcessMessages { + count: None, + message_ids: None, + }; + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + assert!(app + .execute(MockApi::default().addr_make("foobar"), cosmos_msg.clone()) + .is_err()); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..474652d --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,3 @@ +pub mod contract_tests; +pub mod helpers; +pub mod integration_tests;