├── .editorconfig ├── .env.example ├── .github └── workflows │ ├── ci-deep.yml │ ├── ci-fork.yml │ ├── ci-multibuild.yml │ ├── ci.yml │ └── cron-stale.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.yml ├── .prettierignore ├── .prettierrc.yml ├── .solhint.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DIAGRAMS.md ├── LICENSE-GPL.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── TECHNICAL-DOC.md ├── benchmark ├── Flow.Gas.t.sol └── results │ └── SablierFlow.md ├── bun.lockb ├── codecov.yml ├── foundry.toml ├── funding.json ├── package.json ├── remappings.txt ├── script ├── Base.s.sol ├── DeployDeterministicFlow.s.sol ├── DeployFlow.s.sol └── Init.s.sol ├── shell └── prepare-artifacts.sh ├── slither.config.json ├── src ├── FlowNFTDescriptor.sol ├── SablierFlow.sol ├── abstracts │ ├── Adminable.sol │ ├── Batch.sol │ ├── NoDelegateCall.sol │ └── SablierFlowBase.sol ├── interfaces │ ├── IAdminable.sol │ ├── IBatch.sol │ ├── IFlowNFTDescriptor.sol │ ├── ISablierFlow.sol │ └── ISablierFlowBase.sol ├── libraries │ ├── Errors.sol │ └── Helpers.sol └── types │ └── DataTypes.sol └── tests ├── Base.t.sol ├── fork ├── Flow.t.sol └── Fork.t.sol ├── integration ├── Integration.t.sol ├── concrete │ ├── Concrete.t.sol │ ├── adjust-rate-per-second │ │ ├── adjustRatePerSecond.t.sol │ │ └── adjustRatePerSecond.tree │ ├── batch │ │ └── batch.t.sol │ ├── collect-fees │ │ ├── collectFees.t.sol │ │ └── collectFees.tree │ ├── collect-protocol-revenue │ │ ├── collectProtocolRevenue.t.sol │ │ └── collectProtocolRevenue.tree │ ├── constructor.t.sol │ ├── covered-debt-of │ │ ├── coveredDebtOf.t.sol │ │ └── coveredDebtOf.tree │ ├── create-and-deposit │ │ ├── createAndDeposit.t.sol │ │ └── createAndDeposit.tree │ ├── create │ │ ├── create.t.sol │ │ └── create.tree │ ├── depletion-time-of │ │ ├── depletionTimeOf.t.sol │ │ └── depletionTimeOf.tree │ ├── deposit-and-pause │ │ ├── depositAndPause.t.sol │ │ └── depositAndPause.tree │ ├── deposit-via-broker │ │ ├── depositViaBroker.t.sol │ │ └── depositViaBroker.tree │ ├── deposit │ │ ├── deposit.t.sol │ │ └── deposit.tree │ ├── getters │ │ ├── getters.t.sol │ │ └── getters.tree │ ├── ongoing-debt-of │ │ ├── ongoingDebtScaledOf.t.sol │ │ └── ongoingDebtScaledOf.tree │ ├── pause │ │ ├── pause.t.sol │ │ └── pause.tree │ ├── payable │ │ ├── payable.t.sol │ │ └── payable.tree │ ├── recover │ │ ├── recover.t.sol │ │ └── recover.tree │ ├── refund-and-pause │ │ ├── refundAndPause.t.sol │ │ └── refundAndPause.tree │ ├── refund-max │ │ ├── refundMax.t.sol │ │ └── refundMax.tree │ ├── refund │ │ ├── refund.t.sol │ │ └── refund.tree │ ├── refundable-amount-of │ │ ├── refundableAmountOf.t.sol │ │ └── refundableAmountOf.tree │ ├── restart-and-deposit │ │ ├── restartAndDeposit.t.sol │ │ └── restartAndDeposit.tree │ ├── restart │ │ ├── restart.t.sol │ │ └── restart.tree │ ├── set-nft-descriptor │ │ ├── setNFTDescriptor.t.sol │ │ └── setNFTDescriptor.tree │ ├── set-protocol-fee │ │ ├── setProtocolFee.t.sol │ │ └── setProtocolFee.tree │ ├── status-of │ │ ├── statusOf.t.sol │ │ └── statusOf.tree │ ├── token-uri │ │ ├── tokenURI.t.sol │ │ └── tokenURI.tree │ ├── total-debt-of │ │ ├── totalDebtOf.t.sol │ │ └── totalDebtOf.tree │ ├── transfer-from │ │ ├── transferFrom.t.sol │ │ └── transferFrom.tree │ ├── uncovered-debt-of │ │ ├── uncoveredDebtOf.t.sol │ │ └── uncoveredDebtOf.tree │ ├── void │ │ ├── void.t.sol │ │ └── void.tree │ ├── withdraw-max │ │ ├── withdrawMax.t.sol │ │ └── withdrawMax.tree │ ├── withdraw │ │ ├── withdraw.t.sol │ │ └── withdraw.tree │ └── withdrawable-amount-of │ │ └── withdrawableAmountOf.t.sol └── fuzz │ ├── Fuzz.t.sol │ ├── adjustRatePerSecond.t.sol │ ├── coveredDebtOf.t.sol │ ├── create.t.sol │ ├── depletionTimeOf.t.sol │ ├── deposit.t.sol │ ├── ongoingDebtScaledOf.t.sol │ ├── pause.t.sol │ ├── refund.t.sol │ ├── refundMax.t.sol │ ├── refundableAmountOf.t.sol │ ├── restart.t.sol │ ├── totalDebtOf.t.sol │ ├── uncoveredDebtOf.t.sol │ ├── void.t.sol │ ├── withdraw.t.sol │ ├── withdrawMax.t.sol │ └── withdrawMultiple.t.sol ├── invariant ├── Flow.t.sol ├── handlers │ ├── BaseHandler.sol │ ├── FlowAdminHandler.sol │ ├── FlowCreateHandler.sol │ └── FlowHandler.sol └── stores │ └── FlowStore.sol ├── mocks ├── ERC20MissingReturn.sol ├── ERC20Mock.sol └── Receive.sol └── utils ├── .npmignore ├── Assertions.sol ├── Constants.sol ├── Modifiers.sol ├── Types.sol ├── Utils.sol └── Vars.sol /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # All files 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.sol] 16 | indent_size = 4 17 | 18 | [*.tree] 19 | indent_size = 1 20 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Run `cp .env.example .env` command to create your .env file 2 | export EOA="YOUR_EOA_ADDRESS" 3 | export FOUNDRY_PROFILE="lite" 4 | export MNEMONIC="YOUR_MNEMONIC" 5 | export MAINNET_RPC_URL="YOUR_MAINNET_RPC_URL" 6 | -------------------------------------------------------------------------------- /.github/workflows/ci-deep.yml: -------------------------------------------------------------------------------- 1 | name: "CI Deep" 2 | 3 | env: 4 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 5 | 6 | on: 7 | schedule: 8 | - cron: "0 3 * * 3,6" # at 3:00am UTC on Wednesday and Saturday 9 | workflow_dispatch: 10 | inputs: 11 | integrationFuzzRuns: 12 | default: "100000" 13 | description: "Integration: number of fuzz runs." 14 | required: false 15 | invariantRuns: 16 | default: "50000" 17 | description: "Invariant runs: number of sequences of function calls generated and run." 18 | required: false 19 | invariantDepth: 20 | default: "200" 21 | description: "Invariant depth: number of function calls made in a given run." 22 | required: false 23 | forkFuzzRuns: 24 | default: "20" 25 | description: "Fork: number of fuzz runs." 26 | required: false 27 | 28 | jobs: 29 | lint: 30 | uses: "sablier-labs/gha-utils/.github/workflows/evm-lint.yml@main" 31 | 32 | build: 33 | uses: "sablier-labs/gha-utils/.github/workflows/forge-build.yml@main" 34 | 35 | test-integration: 36 | needs: ["lint", "build"] 37 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 38 | with: 39 | foundry-fuzz-runs: ${{ fromJSON(inputs.integrationFuzzRuns || '100000') }} 40 | foundry-profile: "test-optimized" 41 | match-path: "tests/integration/**/*.sol" 42 | name: "Integration tests" 43 | 44 | test-invariant: 45 | needs: ["lint", "build"] 46 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 47 | with: 48 | foundry-invariant-depth: ${{ fromJSON(inputs.invariantDepth || '200') }} 49 | foundry-invariant-runs: ${{ fromJSON(inputs.invariantRuns || '50000') }} 50 | foundry-profile: "test-optimized" 51 | match-path: "tests/invariant/**/*.sol" 52 | name: "Invariant tests" 53 | 54 | test-fork: 55 | needs: ["lint", "build"] 56 | secrets: 57 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 58 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 59 | with: 60 | foundry-fuzz-runs: ${{ fromJSON(inputs.forkFuzzRuns || '20') }} 61 | foundry-profile: "test-optimized" 62 | match-path: "tests/fork/**/*.sol" 63 | name: "Fork tests" 64 | 65 | notify-on-failure: 66 | if: failure() 67 | needs: ["lint", "build", "test-integration", "test-invariant", "test-fork"] 68 | runs-on: "ubuntu-latest" 69 | steps: 70 | - name: "Send Slack notification" 71 | uses: "rtCamp/action-slack-notify@v2" 72 | env: 73 | SLACK_CHANNEL: "#ci-notifications" 74 | SLACK_MESSAGE: "CI Workflow failed for ${{ github.repository }} on branch ${{ github.ref }} at job ${{ github.job }}." 75 | SLACK_USERNAME: "GitHub CI" 76 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} 77 | -------------------------------------------------------------------------------- /.github/workflows/ci-fork.yml: -------------------------------------------------------------------------------- 1 | name: "CI Fork tests" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 3 * * 2,4" # at 3:00 AM UTC on Tuesday and Thursday 6 | workflow_dispatch: 7 | 8 | jobs: 9 | lint: 10 | uses: "sablier-labs/gha-utils/.github/workflows/evm-lint.yml@main" 11 | 12 | build: 13 | uses: "sablier-labs/gha-utils/.github/workflows/forge-build.yml@main" 14 | 15 | test-fork: 16 | needs: ["lint", "build"] 17 | secrets: 18 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 19 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 20 | with: 21 | foundry-fuzz-runs: 100 22 | foundry-profile: "test-optimized" 23 | fuzz-seed: true 24 | match-path: "tests/fork/**/*.sol" 25 | name: "Fork tests" 26 | 27 | notify-on-failure: 28 | if: failure() 29 | needs: ["lint", "build", "test-fork"] 30 | runs-on: "ubuntu-latest" 31 | steps: 32 | - name: "Send Slack notification" 33 | uses: "rtCamp/action-slack-notify@v2" 34 | env: 35 | SLACK_CHANNEL: "#ci-notifications" 36 | SLACK_MESSAGE: "CI Workflow failed for ${{ github.repository }} on branch ${{ github.ref }} at job ${{ github.job }}." 37 | SLACK_USERNAME: "GitHub CI" 38 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} 39 | -------------------------------------------------------------------------------- /.github/workflows/ci-multibuild.yml: -------------------------------------------------------------------------------- 1 | name: "Multibuild" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 3 * * 0" # at 3:00am UTC every Sunday 7 | 8 | jobs: 9 | multibuild: 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - name: "Check out the repo" 13 | uses: "actions/checkout@v4" 14 | 15 | - name: "Install Bun" 16 | uses: "oven-sh/setup-bun@v2" 17 | with: 18 | bun-version: "latest" 19 | 20 | - name: "Install the Node.js dependencies" 21 | run: "bun install --frozen-lockfile" 22 | 23 | - name: "Check that the project can be built with multiple Solidity versions" 24 | uses: "PaulRBerg/foundry-multibuild@v1" 25 | with: 26 | min: "0.8.22" 27 | max: "0.8.29" 28 | skip-test: "true" 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | concurrency: 4 | cancel-in-progress: true 5 | group: ${{github.workflow}}-${{github.ref}} 6 | 7 | on: 8 | workflow_dispatch: 9 | pull_request: 10 | push: 11 | branches: 12 | - "main" 13 | - "staging" 14 | 15 | jobs: 16 | lint: 17 | uses: "sablier-labs/gha-utils/.github/workflows/evm-lint.yml@main" 18 | 19 | build: 20 | uses: "sablier-labs/gha-utils/.github/workflows/forge-build.yml@main" 21 | 22 | test-bulloak: 23 | needs: ["lint", "build"] 24 | if: needs.build.outputs.cache-status != 'primary' 25 | uses: "sablier-labs/gha-utils/.github/workflows/bulloak-check.yml@main" 26 | with: 27 | skip-modifiers: true 28 | tree-path: "tests/integration" 29 | 30 | test-integration: 31 | needs: ["lint", "build"] 32 | if: needs.build.outputs.cache-status != 'primary' 33 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 34 | with: 35 | foundry-fuzz-runs: 10000 36 | foundry-profile: "test-optimized" 37 | match-path: "tests/integration/**/*.sol" 38 | name: "Integration tests" 39 | 40 | test-invariant: 41 | needs: ["lint", "build"] 42 | if: needs.build.outputs.cache-status != 'primary' 43 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 44 | with: 45 | foundry-invariant-depth: 100 46 | foundry-invariant-runs: 1000 47 | foundry-profile: "test-optimized" 48 | match-path: "tests/invariant/**/*.sol" 49 | name: "Invariant tests" 50 | 51 | test-fork: 52 | needs: ["lint", "build"] 53 | if: needs.build.outputs.cache-status != 'primary' 54 | secrets: 55 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 56 | uses: "sablier-labs/gha-utils/.github/workflows/forge-test.yml@main" 57 | with: 58 | foundry-fuzz-runs: 20 59 | foundry-profile: "test-optimized" 60 | match-path: "tests/fork/**/*.sol" 61 | name: "Fork tests" 62 | 63 | coverage: 64 | needs: ["lint", "build"] 65 | if: needs.build.outputs.cache-status != 'primary' 66 | secrets: 67 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 68 | uses: "sablier-labs/gha-utils/.github/workflows/forge-coverage.yml@main" 69 | with: 70 | match-path: "tests/{integration}/**/*.sol" 71 | -------------------------------------------------------------------------------- /.github/workflows/cron-stale.yml: -------------------------------------------------------------------------------- 1 | name: "Cron: Close Stale Issues and PRs" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 3 * * 0" # at 3:00am UTC every Sunday 7 | 8 | jobs: 9 | stale: 10 | uses: "sablier-labs/gha-utils/.github/workflows/cron-stale.yml@main" 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # directories 2 | artifacts 3 | artifacts-zk 4 | broadcast 5 | cache 6 | cache_hardhat-zk 7 | coverage 8 | deployments 9 | deployments-zk 10 | docs 11 | node_modules 12 | out 13 | out-optimized 14 | out-svg 15 | typechain-types 16 | 17 | # files 18 | *.env 19 | *.log 20 | .DS_Store 21 | .pnp.* 22 | deployments.md 23 | lcov.info 24 | package-lock.json 25 | pnpm-lock.yaml 26 | yarn.lock 27 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | bun lint-staged 3 | -------------------------------------------------------------------------------- /.lintstagedrc.yml: -------------------------------------------------------------------------------- 1 | "*.{json,md,svg,yml}": "prettier --write" 2 | "*.sol": 3 | - "bun solhint --fix --noPrompt" 4 | - "forge fmt" 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .husky 3 | broadcast 4 | cache 5 | coverage 6 | docs 7 | node_modules 8 | out 9 | out-optimized 10 | out-svg 11 | 12 | # files 13 | *.env 14 | *.log 15 | *.sol 16 | .DS_Store 17 | .pnp.* 18 | bun.lockb 19 | lcov.info 20 | package-lock.json 21 | pnpm-lock.yaml 22 | yarn.lock 23 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 120 2 | trailingComma: "all" 3 | overrides: 4 | - files: "*.svg" 5 | options: 6 | parser: "html" 7 | - files: "*.md" 8 | options: 9 | proseWrap: "always" 10 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "avoid-low-level-calls": "off", 5 | "code-complexity": ["error", 9], 6 | "compiler-version": ["error", ">=0.8.22"], 7 | "contract-name-camelcase": "off", 8 | "const-name-snakecase": "off", 9 | "func-name-mixedcase": "off", 10 | "func-visibility": ["error", { "ignoreConstructors": true }], 11 | "gas-custom-errors": "off", 12 | "imports-order": "warn", 13 | "max-line-length": ["error", 124], 14 | "named-parameters-mapping": "warn", 15 | "no-empty-blocks": "off", 16 | "not-rely-on-time": "off", 17 | "one-contract-per-file": "off", 18 | "var-name-mixedcase": "off" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[solidity]": { 3 | "editor.defaultFormatter": "NomicFoundation.hardhat-solidity" 4 | }, 5 | "[toml]": { 6 | "editor.defaultFormatter": "tamasfe.even-better-toml" 7 | }, 8 | "files.associations": { 9 | ".gas-snapshot": "julia" 10 | }, 11 | "editor.formatOnSave": true, 12 | "prettier.documentSelectors": ["**/*.svg"], 13 | "search.exclude": { 14 | "**/node_modules": true 15 | }, 16 | "solidity.formatter": "forge" 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Common Changelog](https://common-changelog.org/). 6 | 7 | [1.1.1]: https://github.com/sablier-labs/flow/compare/v1.1.0...v1.1.1 8 | [1.1.0]: https://github.com/sablier-labs/flow/compare/v1.0.0...v1.1.0 9 | [1.0.0]: https://github.com/sablier-labs/flow/releases/tag/v1.0.0 10 | 11 | ## [1.1.1] - 2025-02-05 12 | 13 | ### Changed 14 | 15 | - Use relative path to import source contracts in test utils files 16 | ([#383](https://github.com/sablier-labs/flow/pull/383)) 17 | 18 | ## [1.1.0] - 2025-01-29 19 | 20 | ### Changed 21 | 22 | - Refactor the `batch` function to return an array of results if all call succeeds, and bubble up the revert if any call 23 | fails ([#358](https://github.com/sablier-labs/flow/pull/358)) 24 | 25 | ### Added 26 | 27 | - Add `payable` modifier to all the functions ([#348](https://github.com/sablier-labs/flow/pull/348)) 28 | 29 | ## [1.0.0] - 2024-12-07 30 | 31 | ### Added 32 | 33 | - Initial release 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Feel free to dive in! [Open](https://github.com/sablier-labs/flow/issues/new) an issue, 4 | [start](https://github.com/sablier-labs/flow/discussions/new) a discussion or submit a PR. For any informal concerns or 5 | feedback, please join our [Discord server](https://discord.gg/bSwRCwWRsT). 6 | 7 | Contributions to Sablier Flow are welcome by anyone interested in writing more tests, improving readability, optimizing 8 | for gas efficiency, or extending the protocol via new features. 9 | 10 | ## Pre Requisites 11 | 12 | You will need the following software on your machine: 13 | 14 | - [Git](https://git-scm.com/downloads) 15 | - [Foundry](https://github.com/foundry-rs/foundry) 16 | - [Node.Js](https://nodejs.org/en/download/) 17 | - [Bun](https://bun.sh/) 18 | 19 | In addition, familiarity with [Solidity](https://soliditylang.org/) is requisite. 20 | 21 | ## Set Up 22 | 23 | Clone this repository: 24 | 25 | ```shell 26 | $ git clone git@github.com:sablier-labs/flow.git && cd flow 27 | ``` 28 | 29 | Then, inside the project's directory, run this to install the Node.js dependencies and build the contracts: 30 | 31 | ```shell 32 | $ bun install 33 | $ bun run build 34 | ``` 35 | 36 | Now you can start making changes. 37 | 38 | To see a list of all available scripts: 39 | 40 | ```shell 41 | $ bun run 42 | ``` 43 | 44 | ## Pull Requests 45 | 46 | When making a pull request, ensure that: 47 | 48 | - All tests pass. 49 | - Concrete tests are generated using Bulloak and the Branching Tree Technique (BTT). 50 | - You can learn more about this on the [Bulloak website](https://bulloak.dev). 51 | - If you modify a test tree, use this command to generate the corresponding test contract that complies with BTT: 52 | `bulloak scaffold -wf /path/to/file.tree` 53 | - Code coverage remains the same or greater. 54 | - All new code adheres to the style guide: 55 | - All lint checks pass. 56 | - Code is thoroughly commented with NatSpec where relevant. 57 | - If making a change to the contracts: 58 | - Gas snapshots are provided and demonstrate an improvement (or an acceptable deficit given other improvements). 59 | - Reference contracts are modified correspondingly if relevant. 60 | - New tests are included for all new features or code paths. 61 | - A descriptive summary of the PR has been provided. 62 | 63 | ## Environment Variables 64 | 65 | ### Local setup 66 | 67 | To build locally, follow the [`.env.example`](./.env.example) file to create a `.env` file at the root of the repo and 68 | populate it with the appropriate environment values. You need to provide your mnemonic phrase and a few API keys. 69 | 70 | ### Deployment 71 | 72 | To make CI work in your pull request, ensure that the necessary environment variables are configured in your forked 73 | repository's secrets. Please add the following variable in your GitHub Secrets: 74 | 75 | - `MAINNET_RPC_URL` 76 | 77 | ## Integration with VSCode: 78 | 79 | Install the following VSCode extensions: 80 | 81 | - [esbenp.prettier-vscode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 82 | - [hardhat-solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) 83 | - [vscode-tree-language](https://marketplace.visualstudio.com/items?itemName=CTC.vscode-tree-extension) 84 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Business Source License 1.1 2 | 3 | License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of 4 | MariaDB Corporation Ab. 5 | 6 | --- 7 | 8 | Parameters 9 | 10 | Licensor: Sablier Labs Ltd 11 | 12 | Licensed Work: Sablier Flow The Licensed Work is (C) 2025 Sablier Labs Ltd 13 | 14 | Additional Use Grant: Any uses listed and defined at flow-license-grants.sablier.eth 15 | 16 | Change Date: The earlier of 2028-12-01 or a date specified at flow-license-date.sablier.eth 17 | 18 | Change License: GNU General Public License v3.0 or later 19 | 20 | --- 21 | 22 | Terms 23 | 24 | The Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production 25 | use of the Licensed Work. The Licensor may make an Additional Use Grant, above, permitting limited production use. 26 | 27 | Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of a specific 28 | version of the Licensed Work under this License, whichever comes first, the Licensor hereby grants you rights under the 29 | terms of the Change License, and the rights granted in the paragraph above terminate. 30 | 31 | If your use of the Licensed Work does not comply with the requirements currently in effect as described in this License, 32 | you must purchase a commercial license from the Licensor, its affiliated entities, or authorized resellers, or you must 33 | refrain from using the Licensed Work. 34 | 35 | All copies of the original and modified Licensed Work, and derivative works of the Licensed Work, are subject to this 36 | License. This License applies separately for each version of the Licensed Work and the Change Date may vary for each 37 | version of the Licensed Work released by Licensor. 38 | 39 | You must conspicuously display this License on each original or modified copy of the Licensed Work. If you receive the 40 | Licensed Work in original or modified form from a third party, the terms and conditions set forth in this License apply 41 | to your use of that work. 42 | 43 | Any use of the Licensed Work in violation of this License will automatically terminate your rights under this License 44 | for the current and all other versions of the Licensed Work. 45 | 46 | This License does not grant you any right in any trademark or logo of Licensor or its affiliates (provided that you may 47 | use a trademark or logo of Licensor as expressly required by this License). 48 | 49 | TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS 50 | ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS 51 | FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. 52 | 53 | MariaDB hereby grants you permission to use this License’s text to license your works, and to refer to it using the 54 | trademark "Business Source License", as long as you comply with the Covenants of Licensor below. 55 | 56 | --- 57 | 58 | Covenants of Licensor 59 | 60 | In consideration of the right to use this License’s text and the "Business Source License" name and trademark, Licensor 61 | covenants to MariaDB, and to all other recipients of the licensed work to be provided by Licensor: 62 | 63 | 1. To specify as the Change License the GPL Version 3.0 or any later version, or a license that is compatible with GPL 64 | Version 3.0 or a later version, where "compatible" means that software provided under the Change License can be 65 | included in a program with software provided under GPL Version 3.0 or a later version. Licensor may specify 66 | additional Change Licenses without limitation. 67 | 68 | 2. To either: (a) specify an additional grant of rights to use that does not impose any additional restriction on the 69 | right granted in this License, as the Additional Use Grant; or (b) insert the text "None". 70 | 71 | 3. To specify a Change Date. 72 | 73 | 4. Not to modify this License in any other way. 74 | 75 | --- 76 | 77 | Notice 78 | 79 | The Business Source License (this document, or the "License") is not an Open Source license. However, the Licensed Work 80 | will eventually be made available under an Open Source License, as stated in this License. 81 | -------------------------------------------------------------------------------- /benchmark/results/SablierFlow.md: -------------------------------------------------------------------------------- 1 | # Benchmarks using 6-decimal token 2 | 3 | | Function | Gas Usage | 4 | | ----------------------------- | --------- | 5 | | `adjustRatePerSecond` | 44193 | 6 | | `create` | 113703 | 7 | | `deposit` | 32997 | 8 | | `depositViaBroker` | 22754 | 9 | | `pause` | 7544 | 10 | | `refund` | 22842 | 11 | | `refundMax` | 23840 | 12 | | `restart` | 7058 | 13 | | `void (solvent stream)` | 9982 | 14 | | `void (insolvent stream)` | 37482 | 15 | | `withdraw (insolvent stream)` | 57711 | 16 | | `withdraw (solvent stream)` | 38178 | 17 | | `withdrawMax` | 52010 | 18 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sablier-labs/flow/fbb3146e2d2aa73046cdd927ee34a5bd721e5090/bun.lockb -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: false 3 | comment: false 4 | coverage: 5 | status: 6 | patch: off 7 | ignore: 8 | - "script" 9 | - "test" 10 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | auto_detect_solc = false 3 | bytecode_hash = "none" 4 | evm_version = "shanghai" 5 | fs_permissions = [ 6 | { access = "read", path = "./out-optimized" }, 7 | { access = "read", path = "package.json" }, 8 | { access = "read-write", path = "./benchmark/results"}, 9 | { access = "read-write", path = "./script"} 10 | ] 11 | gas_limit = 9223372036854775807 12 | gas_reports = ["SablierFlow"] 13 | optimizer = true 14 | optimizer_runs = 1000 15 | out = "out" 16 | script = "script" 17 | sender = "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38" 18 | solc = "0.8.26" 19 | src = "src" 20 | test = "tests" 21 | 22 | [profile.default.fuzz] 23 | max_test_rejects = 1_000_000 # Number of times `vm.assume` can fail 24 | runs = 10000 25 | 26 | [profile.default.invariant] 27 | call_override = false # Override unsafe external calls to perform reentrancy checks 28 | dictionary_weight = 50 29 | depth = 100 # Number of calls executed in one run 30 | fail_on_revert = true 31 | runs = 1000 32 | shrink_run_limit = 0 # Disable shrinking of a failed sequence 33 | 34 | # Run only the code inside benchmark directory 35 | [profile.benchmark] 36 | test = "benchmark" 37 | 38 | # Speed up compilation and tests during development 39 | [profile.lite] 40 | optimizer = false 41 | 42 | [profile.lite.invariant] 43 | depth = 50 44 | runs = 50 45 | 46 | [profile.lite.fuzz] 47 | runs = 20 48 | 49 | # Compile only the production code and the test mocks with via IR and 10,000 optimizer runs 50 | [profile.optimized] 51 | optimizer = true 52 | optimizer_runs = 10_000 53 | out = "out-optimized" 54 | test = "tests/mocks" 55 | via_ir = true 56 | 57 | # Test the optimized contracts without re-compiling them 58 | [profile.test-optimized] 59 | src = "tests" 60 | 61 | [doc] 62 | ignore = ["**/*.t.sol"] 63 | out = "docs" 64 | repository = "https://github.com/sablier-labs/flow" 65 | 66 | [etherscan] 67 | etherscan = { key = "${ETHERSCAN_API_KEY}" } 68 | 69 | [fmt] 70 | bracket_spacing = true 71 | int_types = "long" 72 | line_length = 120 73 | multiline_func_header = "all" 74 | number_underscore = "thousands" 75 | quote_style = "double" 76 | tab_width = 4 77 | wrap_comments = true 78 | 79 | [rpc_endpoints] 80 | # mainnets 81 | arbitrum = "${ARBITRUM_RPC_URL}" 82 | avalanche = "${AVALANCHE_RPC_URL}" 83 | base = "https://mainnet.base.org" 84 | blast = "https://rpc.blast.io" 85 | bnb = "https://bsc-dataseed.binance.org" 86 | core_dao = "https://rpc.coredao.org" 87 | gnosis = "https://rpc.gnosischain.com" 88 | lightlink = "https://replicator.phoenix.lightlink.io/rpc/v1" 89 | linea = "https://rpc.linea.build" 90 | mainnet = "${MAINNET_RPC_URL}" 91 | meld = "https://rpc-1.meld.com" 92 | mode = "https://mainnet.mode.network/" 93 | morph = "https://rpc.morphl2.io" 94 | optimism = "${OPTIMISM_RPC_URL}" 95 | polygon = "${POLYGON_RPC_URL}" 96 | scroll = "https://rpc.scroll.io/" 97 | sei = "https://evm-rpc.sei-apis.com" 98 | superseed = "https://mainnet.superseed.xyz" 99 | taiko_mainnet = "https://rpc.mainnet.taiko.xyz" 100 | # testnets 101 | arbitrum_sepolia = "https://sepolia-rollup.arbitrum.io/rpc" 102 | base_sepolia = "https://sepolia.base.org" 103 | berachain_artio = "https://bartio.rpc.berachain.com/" 104 | blast_sepolia = "https://sepolia.blast.io" 105 | linea_sepolia = "https://rpc.sepolia.linea.build" 106 | localhost = "http://localhost:8545" 107 | mode_sepolia = "https://sepolia.mode.network/" 108 | morph_holesky = "https://rpc-holesky.morphl2.io" 109 | optimism_sepolia = "https://sepolia.optimism.io" 110 | sei_testnet = "https://evm-rpc.arctic-1.seinetwork.io" 111 | sepolia = "${SEPOLIA_RPC_URL}" 112 | superseed_sepolia = "https://sepolia.superseed.xyz" 113 | taiko_hekla = "https://rpc.hekla.taiko.xyz" -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x7262ed9c020b3b41ac7ba405aab4ff37575f8b6f975ebed2e65554a08419f8f4" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sablier/flow", 3 | "description": "Flow smart contracts of the Sablier token distribution protocol", 4 | "license": "BUSL-1.1", 5 | "version": "1.1.1", 6 | "author": { 7 | "name": "Sablier Labs Ltd", 8 | "url": "https://sablier.com" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/sablier-labs/flow/issues" 12 | }, 13 | "dependencies": { 14 | "@openzeppelin/contracts": "5.0.2", 15 | "@prb/math": "4.1.0" 16 | }, 17 | "devDependencies": { 18 | "forge-std": "github:foundry-rs/forge-std#v1.8.2", 19 | "husky": "^9.1.4", 20 | "lint-staged": "^15.2.8", 21 | "prettier": "^3.3.2", 22 | "solady": "0.0.208", 23 | "solhint": "^5.0.3" 24 | }, 25 | "files": [ 26 | "artifacts", 27 | "src", 28 | "tests/utils", 29 | "LICENSE-GPL.md" 30 | ], 31 | "keywords": [ 32 | "asset-distribution", 33 | "asset-streaming", 34 | "blockchain", 35 | "crypto", 36 | "cryptoasset-streaming", 37 | "cryptotoken-streaming", 38 | "ethereum", 39 | "forge", 40 | "foundry", 41 | "money-streaming", 42 | "real-time-finance", 43 | "payroll", 44 | "sablier", 45 | "smart-contracts", 46 | "solidity", 47 | "token-distribution", 48 | "token-streaming", 49 | "web3" 50 | ], 51 | "peerDependencies": { 52 | "@prb/math": "4.1.x" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | }, 57 | "scripts": { 58 | "benchmark": "bun run build:optimized && FOUNDRY_PROFILE=benchmark forge test --mt testGas && bun run prettier:write", 59 | "build": "forge build", 60 | "build:optimized": "FOUNDRY_PROFILE=optimized forge build", 61 | "clean": "rm -rf artifacts broadcast cache docs out out-optimized out-svg", 62 | "lint": "bun run lint:sol && bun run prettier:check", 63 | "lint:fix": "bun run lint:sol:fix && forge fmt", 64 | "lint:sol": "forge fmt --check && bun solhint \"{benchmark,script,src,tests}/**/*.sol\"", 65 | "lint:sol:fix": "bun solhint \"{benchmark,script,src,tests}/**/*.sol\" --fix --noPrompt", 66 | "prepack": "bun install && bash ./shell/prepare-artifacts.sh", 67 | "prepare": "husky", 68 | "prettier:check": "prettier --check \"**/*.{json,md,svg,yml}\"", 69 | "prettier:write": "prettier --write \"**/*.{json,md,svg,yml}\"", 70 | "test": "forge test", 71 | "test:lite": "FOUNDRY_PROFILE=lite forge test --nmt \"testFork\"", 72 | "test:optimized": "bun run build:optimized && FOUNDRY_PROFILE=test-optimized forge test" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ 2 | @prb/math/=node_modules/@prb/math/ 3 | forge-std/=node_modules/forge-std/ 4 | solady/=node_modules/solady/ 5 | -------------------------------------------------------------------------------- /script/Base.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | // solhint-disable no-console 3 | pragma solidity >=0.8.22; 4 | 5 | import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; 6 | 7 | import { console2 } from "forge-std/src/console2.sol"; 8 | import { Script } from "forge-std/src/Script.sol"; 9 | import { stdJson } from "forge-std/src/StdJson.sol"; 10 | 11 | abstract contract BaseScript is Script { 12 | using Strings for uint256; 13 | using stdJson for string; 14 | 15 | /// @dev The address of the default Sablier admin. 16 | address internal constant DEFAULT_SABLIER_ADMIN = 0xb1bEF51ebCA01EB12001a639bDBbFF6eEcA12B9F; 17 | 18 | /// @dev Included to enable compilation of the script without a $MNEMONIC environment variable. 19 | string internal constant TEST_MNEMONIC = "test test test test test test test test test test test junk"; 20 | 21 | /// @dev Admin address mapped by the chain Id. 22 | mapping(uint256 chainId => address admin) internal adminMap; 23 | 24 | /// @dev The address of the transaction broadcaster. 25 | address internal broadcaster; 26 | 27 | /// @dev Used to derive the broadcaster's address if $ETH_FROM is not defined. 28 | string internal mnemonic; 29 | 30 | /// @dev Initializes the transaction broadcaster like this: 31 | /// 32 | /// - If $ETH_FROM is defined, use it. 33 | /// - Otherwise, derive the broadcaster address from $MNEMONIC. 34 | /// - If $MNEMONIC is not defined, default to a test mnemonic. 35 | /// 36 | /// The use case for $ETH_FROM is to specify the broadcaster key and its address via the command line. 37 | constructor() { 38 | address from = vm.envOr({ name: "ETH_FROM", defaultValue: address(0) }); 39 | if (from != address(0)) { 40 | broadcaster = from; 41 | } else { 42 | mnemonic = vm.envOr({ name: "MNEMONIC", defaultValue: TEST_MNEMONIC }); 43 | (broadcaster,) = deriveRememberKey({ mnemonic: mnemonic, index: 0 }); 44 | } 45 | 46 | // Populate the admin map. 47 | populateAdminMap(); 48 | 49 | // If there is no admin set for a specific chain, use the default Sablier admin. 50 | if (adminMap[block.chainid] == address(0)) { 51 | adminMap[block.chainid] = DEFAULT_SABLIER_ADMIN; 52 | } 53 | } 54 | 55 | modifier broadcast() { 56 | vm.startBroadcast(broadcaster); 57 | _; 58 | vm.stopBroadcast(); 59 | } 60 | 61 | /// @dev The presence of the salt instructs Forge to deploy contracts via this deterministic CREATE2 factory: 62 | /// https://github.com/Arachnid/deterministic-deployment-proxy 63 | /// 64 | /// Notes: 65 | /// - The salt format is "ChainID , Version ". 66 | function constructCreate2Salt() internal view returns (bytes32) { 67 | string memory chainId = block.chainid.toString(); 68 | string memory version = getVersion(); 69 | string memory create2Salt = string.concat("ChainID ", chainId, ", Version ", version); 70 | console2.log("The CREATE2 salt is %s", create2Salt); 71 | return bytes32(abi.encodePacked(create2Salt)); 72 | } 73 | 74 | /// @dev The version is obtained from `package.json`. 75 | function getVersion() internal view returns (string memory) { 76 | string memory json = vm.readFile("package.json"); 77 | return json.readString(".version"); 78 | } 79 | 80 | /// @dev Populates the admin map. The reason the chain IDs configured for the admin map do not match the other 81 | /// maps is that we only have multisigs for the chains listed below, otherwise, the default admin is used.​ 82 | function populateAdminMap() internal { 83 | adminMap[42_161] = 0xF34E41a6f6Ce5A45559B1D3Ee92E141a3De96376; // Arbitrum 84 | adminMap[43_114] = 0x4735517616373c5137dE8bcCDc887637B8ac85Ce; // Avalanche 85 | adminMap[8453] = 0x83A6fA8c04420B3F9C7A4CF1c040b63Fbbc89B66; // Base 86 | adminMap[56] = 0x6666cA940D2f4B65883b454b7Bc7EEB039f64fa3; // BNB 87 | adminMap[100] = 0x72ACB57fa6a8fa768bE44Db453B1CDBa8B12A399; // Gnosis 88 | adminMap[1] = 0x79Fb3e81aAc012c08501f41296CCC145a1E15844; // Mainnet 89 | adminMap[59_144] = 0x72dCfa0483d5Ef91562817C6f20E8Ce07A81319D; // Linea 90 | adminMap[10] = 0x43c76FE8Aec91F63EbEfb4f5d2a4ba88ef880350; // Optimism 91 | adminMap[137] = 0x40A518C5B9c1d3D6d62Ba789501CE4D526C9d9C6; // Polygon 92 | adminMap[534_352] = 0x0F7Ad835235Ede685180A5c611111610813457a9; // Scroll 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /script/DeployDeterministicFlow.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { FlowNFTDescriptor } from "src/FlowNFTDescriptor.sol"; 5 | import { FlowNFTDescriptor } from "src/FlowNFTDescriptor.sol"; 6 | import { SablierFlow } from "src/SablierFlow.sol"; 7 | 8 | import { BaseScript } from "./Base.s.sol"; 9 | 10 | /// @notice Deploys {SablierFlow} at a deterministic address across chains. 11 | /// @dev Reverts if the contract has already been deployed. 12 | contract DeployDeterministicFlow is BaseScript { 13 | function run() public returns (SablierFlow flow, FlowNFTDescriptor nftDescriptor) { 14 | (flow, nftDescriptor) = _run(adminMap[block.chainid]); 15 | } 16 | 17 | function run(address initialAdmin) public returns (SablierFlow flow, FlowNFTDescriptor nftDescriptor) { 18 | (flow, nftDescriptor) = _run(initialAdmin); 19 | } 20 | 21 | function _run(address initialAdmin) 22 | internal 23 | broadcast 24 | returns (SablierFlow flow, FlowNFTDescriptor nftDescriptor) 25 | { 26 | bytes32 salt = constructCreate2Salt(); 27 | nftDescriptor = new FlowNFTDescriptor{ salt: salt }(); 28 | flow = new SablierFlow{ salt: salt }(initialAdmin, nftDescriptor); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /script/DeployFlow.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { FlowNFTDescriptor } from "src/FlowNFTDescriptor.sol"; 5 | import { FlowNFTDescriptor } from "src/FlowNFTDescriptor.sol"; 6 | import { SablierFlow } from "src/SablierFlow.sol"; 7 | 8 | import { BaseScript } from "./Base.s.sol"; 9 | 10 | /// @notice Deploys {SablierFlow}. 11 | contract DeployFlow is BaseScript { 12 | function run() public returns (SablierFlow flow, FlowNFTDescriptor nftDescriptor) { 13 | (flow, nftDescriptor) = _run(adminMap[block.chainid]); 14 | } 15 | 16 | function run(address initialAdmin) public returns (SablierFlow flow, FlowNFTDescriptor nftDescriptor) { 17 | (flow, nftDescriptor) = _run(initialAdmin); 18 | } 19 | 20 | function _run(address initialAdmin) 21 | internal 22 | broadcast 23 | returns (SablierFlow flow, FlowNFTDescriptor nftDescriptor) 24 | { 25 | nftDescriptor = new FlowNFTDescriptor(); 26 | flow = new SablierFlow(initialAdmin, nftDescriptor); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /script/Init.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22 <0.9.0; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { BaseScript } from "./Base.s.sol"; 10 | 11 | interface IERC20Mint { 12 | function mint(address beneficiary, uint256 value) external; 13 | } 14 | 15 | /// @notice Initializes the protocol by creating some streams and interacting with them. 16 | contract Init is BaseScript { 17 | function run(ISablierFlow flow, IERC20 token) public broadcast { 18 | address sender = broadcaster; 19 | address recipient = broadcaster; 20 | 21 | // Approve the Flow contracts to transfer the ERC-20 tokens from the sender. 22 | token.approve({ spender: address(flow), value: type(uint256).max }); 23 | 24 | for (uint256 i; i < 10; ++i) { 25 | flow.create({ 26 | sender: sender, 27 | recipient: recipient, 28 | ratePerSecond: UD21x18.wrap(uint128(i + 1) * 0.0000001e18), 29 | token: token, 30 | transferable: true 31 | }); 32 | } 33 | 34 | // Deposit into 1st stream. 35 | flow.deposit({ streamId: 1, amount: 2e18, sender: broadcaster, recipient: broadcaster }); 36 | 37 | // Pause the 2nd and 3rd stream. 38 | flow.pause({ streamId: 2 }); 39 | flow.pause({ streamId: 3 }); 40 | 41 | // Partial refund from the 1st stream. 42 | flow.refund({ streamId: 1, amount: 0.1e18 }); 43 | 44 | // Restart the 3rd stream. 45 | flow.restart({ streamId: 3, ratePerSecond: UD21x18.wrap(0.01e18) }); 46 | 47 | // Void the 10th stream. 48 | flow.void({ streamId: 10 }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /shell/prepare-artifacts.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Pre-requisites: 4 | # - foundry (https://getfoundry.sh) 5 | # - bun (https://bun.sh) 6 | 7 | # Strict mode: https://gist.github.com/vncsna/64825d5609c146e80de8b1fd623011ca 8 | set -euo pipefail 9 | 10 | # Delete the current artifacts 11 | artifacts=./artifacts 12 | rm -rf $artifacts 13 | 14 | # Create the new artifacts directories 15 | mkdir $artifacts \ 16 | "$artifacts/interfaces" \ 17 | "$artifacts/interfaces/erc20" \ 18 | "$artifacts/interfaces/erc721" \ 19 | "$artifacts/libraries" 20 | 21 | # Generate the artifacts with Forge 22 | FOUNDRY_PROFILE=optimized forge build 23 | 24 | # Copy the production artifacts 25 | cp out-optimized/SablierFlow.sol/SablierFlow.json $artifacts 26 | cp out-optimized/FlowNFTDescriptor.sol/FlowNFTDescriptor.json $artifacts 27 | 28 | interfaces=./artifacts/interfaces 29 | cp out-optimized/ISablierFlow.sol/ISablierFlow.json $interfaces 30 | cp out-optimized/ISablierFlowBase.sol/ISablierFlowBase.json $interfaces 31 | cp out-optimized/IFlowNFTDescriptor.sol/IFlowNFTDescriptor.json $interfaces 32 | 33 | erc20=./artifacts/interfaces/erc20 34 | cp out-optimized/IERC20.sol/IERC20.json $erc20 35 | 36 | erc721=./artifacts/interfaces/erc721 37 | cp out-optimized/IERC721.sol/IERC721.json $erc721 38 | cp out-optimized/IERC721Metadata.sol/IERC721Metadata.json $erc721 39 | 40 | libraries=./artifacts/libraries 41 | cp out-optimized/Errors.sol/Errors.json $libraries 42 | 43 | # Format the artifacts with Prettier 44 | bun prettier --write ./artifacts 45 | -------------------------------------------------------------------------------- /slither.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "detectors_to_exclude": "naming-convention,reentrancy-events,solc-version,timestamp", 3 | "filter_paths": "(node_modules/,tests/)", 4 | "solc_remaps": [ 5 | "@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/", 6 | "@prb/math/=node_modules/@prb-math/", 7 | "forge-std/=node_modules/forge-std/" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/FlowNFTDescriptor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; 5 | import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; 6 | 7 | import { IFlowNFTDescriptor } from "./interfaces/IFlowNFTDescriptor.sol"; 8 | 9 | /// @title FlowNFTDescriptor 10 | /// @notice See the documentation in {IFlowNFTDescriptor}. 11 | contract FlowNFTDescriptor is IFlowNFTDescriptor { 12 | /// @inheritdoc IFlowNFTDescriptor 13 | function tokenURI( 14 | IERC721Metadata, /* sablierFlow */ 15 | uint256 /* streamId */ 16 | ) 17 | external 18 | pure 19 | override 20 | returns (string memory uri) 21 | { 22 | // solhint-disable max-line-length,quotes 23 | string memory svg = 24 | ''; 25 | 26 | string memory json = string.concat( 27 | '{"description": "This NFT represents a payment stream in Sablier Flow",', 28 | '"external_url": "https://sablier.com",', 29 | '"name": "Sablier Flow",', 30 | '"image": "data:image/svg+xml;base64,', 31 | Base64.encode(bytes(svg)), 32 | '"}' 33 | ); 34 | 35 | uri = string.concat("data:application/json;base64,", Base64.encode(bytes(json))); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/abstracts/Adminable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { IAdminable } from "../interfaces/IAdminable.sol"; 5 | import { Errors } from "../libraries/Errors.sol"; 6 | 7 | /// @title Adminable 8 | /// @notice See the documentation in {IAdminable}. 9 | abstract contract Adminable is IAdminable { 10 | /*////////////////////////////////////////////////////////////////////////// 11 | STATE VARIABLES 12 | //////////////////////////////////////////////////////////////////////////*/ 13 | 14 | /// @inheritdoc IAdminable 15 | address public override admin; 16 | 17 | /*////////////////////////////////////////////////////////////////////////// 18 | MODIFIERS 19 | //////////////////////////////////////////////////////////////////////////*/ 20 | 21 | /// @notice Reverts if called by any account other than the admin. 22 | modifier onlyAdmin() { 23 | if (admin != msg.sender) { 24 | revert Errors.CallerNotAdmin({ admin: admin, caller: msg.sender }); 25 | } 26 | _; 27 | } 28 | 29 | /*////////////////////////////////////////////////////////////////////////// 30 | USER-FACING NON-CONSTANT FUNCTIONS 31 | //////////////////////////////////////////////////////////////////////////*/ 32 | 33 | /// @inheritdoc IAdminable 34 | function transferAdmin(address newAdmin) public virtual override onlyAdmin { 35 | // Effect: update the admin. 36 | admin = newAdmin; 37 | 38 | // Log the transfer of the admin. 39 | emit IAdminable.TransferAdmin({ oldAdmin: msg.sender, newAdmin: newAdmin }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/abstracts/Batch.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | // solhint-disable no-inline-assembly 3 | pragma solidity >=0.8.22; 4 | 5 | import { IBatch } from "../interfaces/IBatch.sol"; 6 | 7 | /// @title Batch 8 | /// @notice See the documentation in {IBatch}. 9 | abstract contract Batch is IBatch { 10 | /*////////////////////////////////////////////////////////////////////////// 11 | USER-FACING NON-CONSTANT FUNCTIONS 12 | //////////////////////////////////////////////////////////////////////////*/ 13 | 14 | /// @inheritdoc IBatch 15 | /// @dev Since `msg.value` can be reused across calls, be VERY CAREFUL when using it. Refer to 16 | /// https://paradigm.xyz/2021/08/two-rights-might-make-a-wrong for more information. 17 | function batch(bytes[] calldata calls) external payable override returns (bytes[] memory results) { 18 | uint256 count = calls.length; 19 | results = new bytes[](count); 20 | 21 | for (uint256 i = 0; i < count; ++i) { 22 | (bool success, bytes memory result) = address(this).delegatecall(calls[i]); 23 | 24 | // Check: If the delegatecall failed, load and bubble up the revert data. 25 | if (!success) { 26 | assembly { 27 | // Get the length of the result stored in the first 32 bytes. 28 | let resultSize := mload(result) 29 | 30 | // Forward the pointer by 32 bytes to skip the length argument, and revert with the result. 31 | revert(add(32, result), resultSize) 32 | } 33 | } 34 | 35 | // Push the result into the results array. 36 | results[i] = result; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/abstracts/NoDelegateCall.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { Errors } from "../libraries/Errors.sol"; 5 | 6 | /// @title NoDelegateCall 7 | /// @notice This contract implements logic to prevent delegate calls. 8 | abstract contract NoDelegateCall { 9 | /// @dev The address of the original contract that was deployed. 10 | address private immutable ORIGINAL; 11 | 12 | /// @dev Sets the original contract address. 13 | constructor() { 14 | ORIGINAL = address(this); 15 | } 16 | 17 | /// @notice Prevents delegate calls. 18 | modifier noDelegateCall() { 19 | _preventDelegateCall(); 20 | _; 21 | } 22 | 23 | /// @dev This function checks whether the current call is a delegate call, and reverts if it is. 24 | /// 25 | /// - A private function is used instead of inlining this logic in a modifier because Solidity copies modifiers into 26 | /// every function that uses them. The `ORIGINAL` address would get copied in every place the modifier is used, 27 | /// which would increase the contract size. By using a function instead, we can avoid this duplication of code 28 | /// and reduce the overall size of the contract. 29 | function _preventDelegateCall() private view { 30 | if (address(this) != ORIGINAL) { 31 | revert Errors.DelegateCall(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/interfaces/IAdminable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | /// @title IAdminable 5 | /// @notice Contract module that provides a basic access control mechanism, with an admin that can be 6 | /// granted exclusive access to specific functions. The inheriting contract must set the initial admin 7 | /// in the constructor. 8 | interface IAdminable { 9 | /*////////////////////////////////////////////////////////////////////////// 10 | EVENTS 11 | //////////////////////////////////////////////////////////////////////////*/ 12 | 13 | /// @notice Emitted when the admin is transferred. 14 | /// @param oldAdmin The address of the old admin. 15 | /// @param newAdmin The address of the new admin. 16 | event TransferAdmin(address indexed oldAdmin, address indexed newAdmin); 17 | 18 | /*////////////////////////////////////////////////////////////////////////// 19 | CONSTANT FUNCTIONS 20 | //////////////////////////////////////////////////////////////////////////*/ 21 | 22 | /// @notice The address of the admin account or contract. 23 | function admin() external view returns (address); 24 | 25 | /*////////////////////////////////////////////////////////////////////////// 26 | NON-CONSTANT FUNCTIONS 27 | //////////////////////////////////////////////////////////////////////////*/ 28 | 29 | /// @notice Transfers the contract admin to a new address. 30 | /// 31 | /// @dev Notes: 32 | /// - Does not revert if the admin is the same. 33 | /// - This function can potentially leave the contract without an admin, thereby removing any 34 | /// functionality that is only available to the admin. 35 | /// 36 | /// Requirements: 37 | /// - `msg.sender` must be the contract admin. 38 | /// 39 | /// @param newAdmin The address of the new admin. 40 | function transferAdmin(address newAdmin) external; 41 | } 42 | -------------------------------------------------------------------------------- /src/interfaces/IBatch.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | /// @notice This contract implements logic to batch call any function. 5 | interface IBatch { 6 | /// @notice Allows batched calls to self, i.e., `this` contract. 7 | /// @dev Since `msg.value` can be reused across calls, be VERY CAREFUL when using it. Refer to 8 | /// https://paradigm.xyz/2021/08/two-rights-might-make-a-wrong for more information. 9 | /// @param calls An array of inputs for each call. 10 | /// @return results An array of results from each call. Empty when the calls do not return anything. 11 | function batch(bytes[] calldata calls) external payable returns (bytes[] memory results); 12 | } 13 | -------------------------------------------------------------------------------- /src/interfaces/IFlowNFTDescriptor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; 5 | 6 | /// @title IFlowNFTDescriptor 7 | /// @notice This contract generates the URI describing the Sablier Flow stream NFTs. 8 | interface IFlowNFTDescriptor { 9 | /// @notice Produces the URI describing a particular stream NFT. 10 | /// 11 | /// @dev Currently it returns the Sablier logo as an SVG. In the future, it will return an NFT SVG. 12 | /// 13 | /// @param sablierFlow The address of the Sablier Flow the stream was created in. 14 | /// @param streamId The ID of the stream for which to produce a description. 15 | /// 16 | /// @return uri The URI of the ERC721-compliant metadata. 17 | function tokenURI(IERC721Metadata sablierFlow, uint256 streamId) external view returns (string memory uri); 18 | } 19 | -------------------------------------------------------------------------------- /src/libraries/Helpers.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { ud, UD60x18 } from "@prb/math/src/UD60x18.sol"; 5 | 6 | import { Broker } from "./../types/DataTypes.sol"; 7 | import { Errors } from "./Errors.sol"; 8 | 9 | /// @title Helpers 10 | /// @notice Library with helper functions in {SablierFlow} contract. 11 | library Helpers { 12 | /// @dev Calculate the fee amount and the net amount after subtracting the fee, based on the `fee` percentage. 13 | function calculateAmountsFromFee( 14 | uint128 totalAmount, 15 | UD60x18 fee 16 | ) 17 | internal 18 | pure 19 | returns (uint128 feeAmount, uint128 netAmount) 20 | { 21 | // Calculate the fee amount based on the fee percentage. 22 | feeAmount = ud(totalAmount).mul(fee).intoUint128(); 23 | 24 | // Calculate the net amount after subtracting the fee from the total amount. 25 | netAmount = totalAmount - feeAmount; 26 | } 27 | 28 | /// @dev Checks the `Broker` parameter, and then calculates the broker fee amount and the deposit amount from the 29 | /// total amount. 30 | function checkAndCalculateBrokerFee( 31 | uint128 totalAmount, 32 | Broker memory broker, 33 | UD60x18 maxFee 34 | ) 35 | internal 36 | pure 37 | returns (uint128 brokerFeeAmount, uint128 depositAmount) 38 | { 39 | // Check: the broker's fee is not greater than `MAX_FEE`. 40 | if (broker.fee.gt(maxFee)) { 41 | revert Errors.SablierFlow_BrokerFeeTooHigh(broker.fee, maxFee); 42 | } 43 | 44 | // Check: the broker recipient is not the zero address. 45 | if (broker.account == address(0)) { 46 | revert Errors.SablierFlow_BrokerAddressZero(); 47 | } 48 | 49 | // Calculate the broker fee amount that is going to be transferred to the `broker.account`. 50 | (brokerFeeAmount, depositAmount) = calculateAmountsFromFee(totalAmount, broker.fee); 51 | } 52 | 53 | /// @dev Descales the provided `amount` from 18 decimals fixed-point number to token's decimals number. 54 | function descaleAmount(uint256 amount, uint8 decimals) internal pure returns (uint256) { 55 | if (decimals == 18) { 56 | return amount; 57 | } 58 | 59 | unchecked { 60 | uint256 scaleFactor = 10 ** (18 - decimals); 61 | return amount / scaleFactor; 62 | } 63 | } 64 | 65 | /// @dev Scales the provided `amount` from token's decimals number to 18 decimals fixed-point number. 66 | function scaleAmount(uint256 amount, uint8 decimals) internal pure returns (uint256) { 67 | if (decimals == 18) { 68 | return amount; 69 | } 70 | 71 | unchecked { 72 | uint256 scaleFactor = 10 ** (18 - decimals); 73 | return amount * scaleFactor; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/types/DataTypes.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | import { UD60x18 } from "@prb/math/src/UD60x18.sol"; 7 | 8 | /// @notice Struct encapsulating the broker parameters. 9 | /// 10 | /// @param account The address receiving the broker's fee. 11 | /// @param fee The broker's percentage fee charged from the deposit amount, denoted as a fixed-point percentage where 12 | /// 1e18 is 100%. 13 | struct Broker { 14 | address account; 15 | UD60x18 fee; 16 | } 17 | 18 | library Flow { 19 | /// @notice Enum representing the different statuses of a stream. 20 | /// 21 | /// @dev Explanations for the two types of streams: 22 | /// 1. Streaming: when the total debt is increasing. 23 | /// 2. Paused: when the total debt is not increasing. 24 | /// 25 | /// @custom:value0 STREAMING_SOLVENT Streaming stream when there is no uncovered debt. 26 | /// @custom:value1 STREAMING_INSOLVENT Streaming stream when there is uncovered debt. 27 | /// @custom:value2 PAUSED_SOLVENT Paused stream when there is no uncovered debt. 28 | /// @custom:value3 PAUSED_INSOLVENT Paused stream when there is uncovered debt. 29 | /// @custom:value4 VOIDED Paused stream with no uncovered debt and it cannot be restarted. 30 | enum Status { 31 | // Streaming 32 | STREAMING_SOLVENT, 33 | STREAMING_INSOLVENT, 34 | // Paused 35 | PAUSED_SOLVENT, 36 | PAUSED_INSOLVENT, 37 | // Voided 38 | VOIDED 39 | } 40 | 41 | /// @notice Struct representing Flow streams. 42 | /// 43 | /// @dev The fields are arranged like this to save gas via tight variable packing. 44 | /// 45 | /// @param balance The amount of tokens that are currently available in the stream, denoted in token's decimals. 46 | /// This is the sum of deposited amounts minus the sum of withdrawn amounts. 47 | /// @param ratePerSecond The payment rate per second, denoted as a fixed-point number where 1e18 is 1 token per 48 | /// second. For example, to stream 1000 tokens per week, this parameter would have the value $(1000 * 10^18) / (7 49 | /// days in seconds)$. 50 | /// @param sender The address streaming the tokens, with the ability to pause the stream. 51 | /// @param snapshotTime The Unix timestamp used for the ongoing debt calculation. 52 | /// @param isStream Boolean indicating if the struct entity exists. 53 | /// @param isTransferable Boolean indicating if the stream NFT is transferable. 54 | /// @param isVoided Boolean indicating if the stream is voided. Voiding any stream is non-reversible and it cannot 55 | /// be restarted. Voiding an insolvent stream sets its uncovered debt to zero. 56 | /// @param token The contract address of the ERC-20 token to stream. 57 | /// @param tokenDecimals The decimals of the ERC-20 token to stream. 58 | /// @param snapshotDebtScaled The amount of tokens that the sender owed to the recipient at snapshot time, denoted 59 | /// as a 18-decimals fixed-point number. This, along with the ongoing debt, can be used to calculate the total debt 60 | /// at any given point in time. 61 | struct Stream { 62 | // slot 0 63 | uint128 balance; 64 | UD21x18 ratePerSecond; 65 | // slot 1 66 | address sender; 67 | uint40 snapshotTime; 68 | bool isStream; 69 | bool isTransferable; 70 | bool isVoided; 71 | // slot 2 72 | IERC20 token; 73 | uint8 tokenDecimals; 74 | // slot 3 75 | uint256 snapshotDebtScaled; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/integration/Integration.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | 7 | import { Base_Test } from "../Base.t.sol"; 8 | 9 | /// @notice Common logic needed by all integration tests, both concrete and fuzz tests. 10 | abstract contract Integration_Test is Base_Test { 11 | /*////////////////////////////////////////////////////////////////////////// 12 | SET-UP 13 | //////////////////////////////////////////////////////////////////////////*/ 14 | 15 | function setUp() public virtual override { 16 | Base_Test.setUp(); 17 | } 18 | 19 | /*////////////////////////////////////////////////////////////////////////// 20 | HELPERS 21 | //////////////////////////////////////////////////////////////////////////*/ 22 | 23 | function createDefaultStream(IERC20 token_) internal returns (uint256) { 24 | return createDefaultStream(RATE_PER_SECOND, token_); 25 | } 26 | 27 | function createDefaultStream(UD21x18 ratePerSecond, IERC20 token_) internal returns (uint256) { 28 | return flow.create({ 29 | sender: users.sender, 30 | recipient: users.recipient, 31 | ratePerSecond: ratePerSecond, 32 | token: token_, 33 | transferable: TRANSFERABLE 34 | }); 35 | } 36 | 37 | /// @dev Helper function to create an token with the `decimals` and then a stream using the newly created token. 38 | function createTokenAndStream(uint8 decimals) internal returns (IERC20 token, uint256 streamId) { 39 | token = createToken(decimals); 40 | 41 | // Hash the next stream ID and the decimal to generate a seed. 42 | UD21x18 ratePerSecond = 43 | boundRatePerSecond(ud21x18(uint128(uint256(keccak256(abi.encodePacked(flow.nextStreamId(), decimals)))))); 44 | 45 | // Create stream. 46 | streamId = createDefaultStream(ratePerSecond, token); 47 | } 48 | 49 | function deposit(uint256 streamId, uint128 amount) internal { 50 | IERC20 token = flow.getToken(streamId); 51 | 52 | deal({ token: address(token), to: users.sender, give: UINT128_MAX }); 53 | token.approve(address(flow), UINT128_MAX); 54 | 55 | flow.deposit(streamId, amount, users.sender, users.recipient); 56 | } 57 | 58 | function depositDefaultAmount(uint256 streamId) internal { 59 | uint8 decimals = flow.getTokenDecimals(streamId); 60 | uint128 depositAmount = getDefaultDepositAmount(decimals); 61 | 62 | deposit(streamId, depositAmount); 63 | } 64 | 65 | /// @dev Updates the snapshot time and snapshot debt by temporarily adjusting the rate per second.. 66 | function updateSnapshot(uint256 streamId) internal { 67 | // Read the current caller. 68 | (, address originalCaller,) = vm.readCallers(); 69 | 70 | // Switch to the sender and adjust the rate per second. 71 | resetPrank(users.sender); 72 | UD21x18 ratePerSecond = flow.getRatePerSecond(streamId); 73 | 74 | // Take the snapshot by temporarily setting the rate per second to 1. 75 | flow.adjustRatePerSecond(streamId, ud21x18(1)); 76 | 77 | // Restore the original rate per second. 78 | flow.adjustRatePerSecond(streamId, ratePerSecond); 79 | 80 | // Switch back to the original caller. 81 | resetPrank(originalCaller); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/integration/concrete/adjust-rate-per-second/adjustRatePerSecond.tree: -------------------------------------------------------------------------------- 1 | AdjustRatePerSecond_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given paused 9 | │ └── it should revert 10 | └── given not paused 11 | ├── when caller not sender 12 | │ ├── when caller recipient 13 | │ │ └── it should revert 14 | │ └── when caller malicious third party 15 | │ └── it should revert 16 | └── when caller sender 17 | ├── when new rate per second equals current rate per second 18 | │ └── it should revert 19 | └── when new rate per second not equals current rate per second 20 | ├── when rate per second zero 21 | │ ├── it should change the status to PAUSED 22 | │ └── it should set the rate per second to zero 23 | └── when rate per second not zero 24 | ├── it should update snapshot debt 25 | ├── it should update snapshot time 26 | ├── it should set the new rate per second 27 | └── it should emit 1 {AdjustFlowStream}, 1 {MetadataUpdate} events 28 | -------------------------------------------------------------------------------- /tests/integration/concrete/collect-fees/collectFees.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22 <0.9.0; 3 | 4 | import { ISablierFlowBase } from "src/interfaces/ISablierFlowBase.sol"; 5 | import { Errors } from "src/libraries/Errors.sol"; 6 | 7 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 8 | 9 | contract CollectFees_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 10 | function setUp() public override { 11 | Shared_Integration_Concrete_Test.setUp(); 12 | depositToDefaultStream(); 13 | 14 | // Make a withdrawal and pay the fee. 15 | flow.withdrawMax{ value: FEE }({ streamId: defaultStreamId, to: users.recipient }); 16 | 17 | resetPrank({ msgSender: users.admin }); 18 | } 19 | 20 | function test_GivenAdminIsNotContract() external { 21 | _test_CollectFees(users.admin); 22 | } 23 | 24 | function test_RevertGiven_AdminDoesNotImplementReceiveFunction() external givenAdminIsContract { 25 | // Transfer the admin to a contract that does not implement the receive function. 26 | flow.transferAdmin(address(contractWithoutReceive)); 27 | 28 | // Make the contract the caller. 29 | resetPrank({ msgSender: address(contractWithoutReceive) }); 30 | 31 | // Expect a revert. 32 | vm.expectRevert( 33 | abi.encodeWithSelector( 34 | Errors.SablierFlowBase_FeeTransferFail.selector, address(contractWithoutReceive), address(flow).balance 35 | ) 36 | ); 37 | 38 | // Collect the fees. 39 | flow.collectFees(); 40 | } 41 | 42 | function test_GivenAdminImplementsReceiveFunction() external givenAdminIsContract { 43 | // Transfer the admin to a contract that implements the receive function. 44 | flow.transferAdmin(address(contractWithReceive)); 45 | 46 | // Make the contract the caller. 47 | resetPrank({ msgSender: address(contractWithReceive) }); 48 | 49 | // Run the tests. 50 | _test_CollectFees(address(contractWithReceive)); 51 | } 52 | 53 | function _test_CollectFees(address admin) private { 54 | vm.warp({ newTimestamp: WITHDRAW_TIME }); 55 | 56 | // Load the initial ETH balance of the admin. 57 | uint256 initialAdminBalance = admin.balance; 58 | 59 | // It should emit a {CollectFees} event. 60 | vm.expectEmit({ emitter: address(flow) }); 61 | emit ISablierFlowBase.CollectFees({ admin: admin, feeAmount: FEE }); 62 | 63 | flow.collectFees(); 64 | 65 | // It should transfer the fee. 66 | assertEq(admin.balance, initialAdminBalance + FEE, "admin ETH balance"); 67 | 68 | // It should decrease contract balance to zero. 69 | assertEq(address(flow).balance, 0, "flow ETH balance"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/integration/concrete/collect-fees/collectFees.tree: -------------------------------------------------------------------------------- 1 | CollectFees_Integration_Concrete_Test 2 | ├── given admin is not contract 3 | │ ├── it should transfer fee 4 | │ ├── it should decrease contract balance to zero 5 | │ └── it should emit a {CollectFees} event 6 | └── given admin is contract 7 | ├── given admin does not implement receive function 8 | │ └── it should revert 9 | └── given admin implements receive function 10 | ├── it should transfer fee 11 | ├── it should decrease contract balance to zero 12 | └── it should emit a {CollectFees} event -------------------------------------------------------------------------------- /tests/integration/concrete/collect-protocol-revenue/collectProtocolRevenue.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | 6 | import { ISablierFlowBase } from "src/interfaces/ISablierFlowBase.sol"; 7 | import { Errors } from "src/libraries/Errors.sol"; 8 | 9 | import { Shared_Integration_Concrete_Test } from "./../Concrete.t.sol"; 10 | 11 | contract CollectProtocolRevenue_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 12 | uint256 internal streamIdWithProtocolFee; 13 | 14 | function setUp() public override { 15 | Shared_Integration_Concrete_Test.setUp(); 16 | 17 | // Go back in time to create a stream with a protocol fee. 18 | vm.warp({ newTimestamp: OCT_1_2024 }); 19 | 20 | streamIdWithProtocolFee = createDefaultStream(tokenWithProtocolFee); 21 | depositDefaultAmount(streamIdWithProtocolFee); 22 | 23 | // Simulate one month of streaming. 24 | vm.warp({ newTimestamp: ONE_MONTH_SINCE_START }); 25 | } 26 | 27 | function test_RevertWhen_CallerNotAdmin() external { 28 | resetPrank({ msgSender: users.eve }); 29 | vm.expectRevert(abi.encodeWithSelector(Errors.CallerNotAdmin.selector, users.admin, users.eve)); 30 | flow.collectProtocolRevenue(tokenWithProtocolFee, users.eve); 31 | } 32 | 33 | function test_RevertGiven_ProtocolRevenueZero() external whenCallerAdmin { 34 | vm.expectRevert( 35 | abi.encodeWithSelector(Errors.SablierFlowBase_NoProtocolRevenue.selector, address(tokenWithProtocolFee)) 36 | ); 37 | flow.collectProtocolRevenue(tokenWithProtocolFee, users.admin); 38 | } 39 | 40 | function test_GivenProtocolRevenueNotZero() external whenCallerAdmin { 41 | // Withdraw to generate protocol revenue. 42 | flow.withdraw({ streamId: streamIdWithProtocolFee, to: users.recipient, amount: WITHDRAW_AMOUNT_6D }); 43 | 44 | uint256 previousAggregateAmount = flow.aggregateBalance(tokenWithProtocolFee); 45 | 46 | // It should transfer protocol revenue to provided address. 47 | expectCallToTransfer({ token: tokenWithProtocolFee, to: users.admin, amount: PROTOCOL_FEE_AMOUNT_6D }); 48 | 49 | // It should emit {CollectProtocolRevenue} and {Transfer} events. 50 | vm.expectEmit({ emitter: address(tokenWithProtocolFee) }); 51 | emit IERC20.Transfer({ from: address(flow), to: users.admin, value: PROTOCOL_FEE_AMOUNT_6D }); 52 | vm.expectEmit({ emitter: address(flow) }); 53 | emit ISablierFlowBase.CollectProtocolRevenue( 54 | users.admin, tokenWithProtocolFee, users.admin, PROTOCOL_FEE_AMOUNT_6D 55 | ); 56 | 57 | flow.collectProtocolRevenue(tokenWithProtocolFee, users.admin); 58 | 59 | // It should reduce the aggregate amount. 60 | assertEq( 61 | flow.aggregateBalance(tokenWithProtocolFee), 62 | previousAggregateAmount - PROTOCOL_FEE_AMOUNT_6D, 63 | "aggregate amount" 64 | ); 65 | 66 | // It should set protocol revenue to zero. 67 | assertEq(flow.protocolRevenue(tokenWithProtocolFee), 0, "protocol revenue"); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/integration/concrete/collect-protocol-revenue/collectProtocolRevenue.tree: -------------------------------------------------------------------------------- 1 | CollectProtocolRevenue_Integration_Concrete_Test 2 | ├── when caller not admin 3 | │ └── it should revert 4 | └── when caller admin 5 | ├── given protocol revenue zero 6 | │ └── it should revert 7 | └── given protocol revenue not zero 8 | ├── it should transfer protocol revenue to provided address 9 | ├── it should reduce the aggregate amount 10 | ├── it should set protocol revenue to zero 11 | └── it should emit {CollectProtocolRevenue} and {Transfer} events 12 | -------------------------------------------------------------------------------- /tests/integration/concrete/constructor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { UD60x18 } from "@prb/math/src/UD60x18.sol"; 5 | import { SablierFlow } from "src/SablierFlow.sol"; 6 | 7 | import { Shared_Integration_Concrete_Test } from "./Concrete.t.sol"; 8 | 9 | contract Constructor_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 10 | function test_Constructor() external { 11 | // Construct the contract. 12 | SablierFlow constructedFlow = new SablierFlow(users.admin, nftDescriptor); 13 | 14 | // {SablierFlowBase.MAX_FEE} 15 | UD60x18 actualMaxFee = constructedFlow.MAX_FEE(); 16 | UD60x18 expectedMaxFee = UD60x18.wrap(0.1e18); 17 | assertEq(actualMaxFee, expectedMaxFee, "MAX_FEE"); 18 | 19 | // {SablierFlowBase.nextStreamId} 20 | uint256 actualStreamId = constructedFlow.nextStreamId(); 21 | uint256 expectedStreamId = 1; 22 | assertEq(actualStreamId, expectedStreamId, "nextStreamId"); 23 | 24 | address actualAdmin = constructedFlow.admin(); 25 | address expectedAdmin = users.admin; 26 | assertEq(actualAdmin, expectedAdmin, "admin"); 27 | 28 | // {SablierFlowBase.supportsInterface} 29 | assertTrue(constructedFlow.supportsInterface(0x49064906), "ERC-4906 interface ID"); 30 | 31 | address actualNFTDescriptor = address(constructedFlow.nftDescriptor()); 32 | address expectedNFTDescriptor = address(nftDescriptor); 33 | assertEq(actualNFTDescriptor, expectedNFTDescriptor, "nftDescriptor"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/integration/concrete/covered-debt-of/coveredDebtOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 5 | 6 | contract CoveredDebtOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 7 | function test_RevertGiven_Null() external { 8 | bytes memory callData = abi.encodeCall(flow.coveredDebtOf, nullStreamId); 9 | expectRevert_Null(callData); 10 | } 11 | 12 | function test_GivenBalanceZero() external givenNotNull { 13 | // Create a new stream with zero balance. 14 | uint256 streamId = createDefaultStream(dai); 15 | 16 | // It should return zero. 17 | uint128 coveredDebt = flow.coveredDebtOf(streamId); 18 | assertEq(coveredDebt, 0, "covered debt"); 19 | } 20 | 21 | function test_WhenTotalDebtExceedsBalance() external givenNotNull givenBalanceNotZero { 22 | // Simulate the passage of time until debt becomes uncovered. 23 | vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD }); 24 | 25 | uint128 balance = flow.getBalance(defaultStreamId); 26 | 27 | // It should return the stream balance. 28 | uint128 coveredDebt = flow.coveredDebtOf(defaultStreamId); 29 | assertEq(coveredDebt, balance, "covered debt"); 30 | } 31 | 32 | function test_WhenTotalDebtNotExceedBalance() external givenNotNull givenBalanceNotZero { 33 | // It should return the correct covered debt. 34 | uint128 coveredDebt = flow.coveredDebtOf(defaultStreamId); 35 | assertEq(coveredDebt, ONE_MONTH_DEBT_6D, "covered debt"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/integration/concrete/covered-debt-of/coveredDebtOf.tree: -------------------------------------------------------------------------------- 1 | CoveredDebtOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given balance zero 6 | │ └── it should return zero 7 | └── given balance not zero 8 | ├── when total debt exceeds balance 9 | │ └── it should return the stream balance 10 | └── when total debt not exceed balance 11 | └── it should return the correct covered debt 12 | -------------------------------------------------------------------------------- /tests/integration/concrete/create-and-deposit/createAndDeposit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | import { Flow } from "src/types/DataTypes.sol"; 9 | 10 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 11 | 12 | contract CreateAndDeposit_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 13 | function test_RevertWhen_DelegateCall() external { 14 | bytes memory callData = abi.encodeCall( 15 | flow.createAndDeposit, 16 | (users.sender, users.recipient, RATE_PER_SECOND, usdc, TRANSFERABLE, DEPOSIT_AMOUNT_6D) 17 | ); 18 | expectRevert_DelegateCall(callData); 19 | } 20 | 21 | function test_WhenNoDelegateCall() external { 22 | uint256 expectedStreamId = flow.nextStreamId(); 23 | 24 | // It should emit events: 1 {MetadataUpdate}, 1 {CreateFlowStream}, 1 {Transfer}, 1 25 | // {DepositFlowStream} 26 | vm.expectEmit({ emitter: address(flow) }); 27 | emit IERC4906.MetadataUpdate({ _tokenId: expectedStreamId }); 28 | 29 | vm.expectEmit({ emitter: address(flow) }); 30 | emit ISablierFlow.CreateFlowStream({ 31 | streamId: expectedStreamId, 32 | sender: users.sender, 33 | recipient: users.recipient, 34 | ratePerSecond: RATE_PER_SECOND, 35 | token: usdc, 36 | transferable: TRANSFERABLE 37 | }); 38 | 39 | vm.expectEmit({ emitter: address(usdc) }); 40 | emit IERC20.Transfer({ from: users.sender, to: address(flow), value: DEPOSIT_AMOUNT_6D }); 41 | 42 | vm.expectEmit({ emitter: address(flow) }); 43 | emit ISablierFlow.DepositFlowStream({ 44 | streamId: expectedStreamId, 45 | funder: users.sender, 46 | amount: DEPOSIT_AMOUNT_6D 47 | }); 48 | 49 | // It should perform the ERC-20 transfers 50 | expectCallToTransferFrom({ token: usdc, from: users.sender, to: address(flow), amount: DEPOSIT_AMOUNT_6D }); 51 | 52 | uint256 actualStreamId = flow.createAndDeposit({ 53 | sender: users.sender, 54 | recipient: users.recipient, 55 | ratePerSecond: RATE_PER_SECOND, 56 | token: usdc, 57 | transferable: TRANSFERABLE, 58 | amount: DEPOSIT_AMOUNT_6D 59 | }); 60 | 61 | Flow.Stream memory actualStream = flow.getStream(actualStreamId); 62 | Flow.Stream memory expectedStream = defaultStreamWithDeposit(); 63 | 64 | // It should create the stream 65 | assertEq(actualStream, expectedStream); 66 | 67 | // It should bump the next stream id 68 | assertEq(flow.nextStreamId(), expectedStreamId + 1, "next stream id"); 69 | 70 | // It should mint the NFT 71 | address actualNFTOwner = flow.ownerOf({ tokenId: actualStreamId }); 72 | address expectedNFTOwner = users.recipient; 73 | assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner"); 74 | 75 | // It should update the stream balance 76 | uint128 actualStreamBalance = flow.getBalance(expectedStreamId); 77 | uint128 expectedStreamBalance = DEPOSIT_AMOUNT_6D; 78 | assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/integration/concrete/create-and-deposit/createAndDeposit.tree: -------------------------------------------------------------------------------- 1 | CreateAndDeposit_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── it should create the stream 6 | ├── it should bump the next stream id 7 | ├── it should mint the NFT 8 | ├── it should update the stream balance 9 | ├── it should perform the ERC20 transfer 10 | └── it should emit events: 1 {MetadataUpdate}, 1 {CreateFlowStream}, 1 {Transfer}, 1 {DepositFlowStream} 11 | -------------------------------------------------------------------------------- /tests/integration/concrete/create/create.tree: -------------------------------------------------------------------------------- 1 | 2 | Create_Integration_Concrete_Test 3 | ├── when delegate call 4 | │ └── it should revert 5 | └── when no delegate call 6 | ├── when sender address zero 7 | │ └── it should revert 8 | └── when sender not address zero 9 | ├── when token not implement decimals 10 | │ └── it should revert 11 | └── when token implements decimals 12 | ├── when token decimals exceeds 18 13 | │ └── it should revert 14 | └── when token decimals not exceed 18 15 | ├── when recipient address zero 16 | │ └── it should revert 17 | └── when recipient not address zero 18 | ├── when rate per second zero 19 | │ └── it should create a paused stream 20 | └── when rate per second not zero 21 | ├── it should create a streaming stream 22 | ├── it should bump the next stream id 23 | ├── it should mint the NFT 24 | └── it should emit 1 {MetadataUpdate}, 1 {CreateFlowStream} and 1 {Transfer} events 25 | -------------------------------------------------------------------------------- /tests/integration/concrete/depletion-time-of/depletionTimeOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 5 | import { Errors } from "src/libraries/Errors.sol"; 6 | 7 | import { Shared_Integration_Concrete_Test } from "./../Concrete.t.sol"; 8 | 9 | contract DepletionTimeOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 10 | function test_RevertGiven_Null() external { 11 | bytes memory callData = abi.encodeCall(flow.depletionTimeOf, nullStreamId); 12 | expectRevert_Null(callData); 13 | } 14 | 15 | function test_RevertGiven_Paused() external givenNotNull { 16 | bytes memory callData = abi.encodeCall(flow.depletionTimeOf, defaultStreamId); 17 | expectRevert_Paused(callData); 18 | } 19 | 20 | function test_RevertGiven_BalanceZero() external givenNotNull givenNotPaused { 21 | vm.expectRevert(abi.encodeWithSelector(Errors.SablierFlow_StreamBalanceZero.selector, defaultStreamId)); 22 | flow.depletionTimeOf(defaultStreamId); 23 | } 24 | 25 | function test_GivenUncoveredDebt() external givenNotNull givenNotPaused givenBalanceNotZero { 26 | uint256 depletionTimestamp = WARP_SOLVENCY_PERIOD + 1; 27 | vm.warp({ newTimestamp: depletionTimestamp }); 28 | 29 | // Check that uncovered debt is greater than 0. 30 | assertGt(flow.uncoveredDebtOf(defaultStreamId), 0); 31 | 32 | // It should return 0. 33 | uint256 actualDepletionTime = flow.depletionTimeOf(defaultStreamId); 34 | assertEq(actualDepletionTime, 0, "depletion time"); 35 | } 36 | 37 | modifier givenNoUncoveredDebt() { 38 | _; 39 | } 40 | 41 | function test_WhenExactDivision() external givenNotNull givenNotPaused givenBalanceNotZero givenNoUncoveredDebt { 42 | // Create a stream with a rate per second such that the deposit amount produces no remainder when divided by the 43 | // rate per second. 44 | UD21x18 rps = UD21x18.wrap(2e18); 45 | uint256 streamId = createDefaultStream(rps, usdc); 46 | depositDefaultAmount(streamId); 47 | uint256 solvencyPeriod = DEPOSIT_AMOUNT_18D / rps.unwrap(); 48 | 49 | // It should return the time at which the total debt exceeds the balance. 50 | uint40 actualDepletionTime = uint40(flow.depletionTimeOf(streamId)); 51 | uint40 exptectedDepletionTime = ONE_MONTH_SINCE_START + uint40(solvencyPeriod + 1); 52 | assertEq(actualDepletionTime, exptectedDepletionTime, "depletion time"); 53 | } 54 | 55 | function test_WhenNotExactDivision() 56 | external 57 | givenNotNull 58 | givenNotPaused 59 | givenBalanceNotZero 60 | givenNoUncoveredDebt 61 | { 62 | // It should return the time at which the total debt exceeds the balance. 63 | uint40 actualDepletionTime = uint40(flow.depletionTimeOf(defaultStreamId)); 64 | uint256 expectedDepletionTime = WARP_SOLVENCY_PERIOD + 1; 65 | assertEq(actualDepletionTime, expectedDepletionTime, "depletion time"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/integration/concrete/depletion-time-of/depletionTimeOf.tree: -------------------------------------------------------------------------------- 1 | DepletionTimeOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given paused 6 | │ └── it should revert 7 | └── given not paused 8 | ├── given balance zero 9 | │ └── it should revert 10 | └── given balance not zero 11 | ├── given uncovered debt 12 | │ └── it should return 0 13 | └── given no uncovered debt 14 | ├── when exact division 15 | │ └── it should return the time at which the total debt exceeds the balance 16 | └── when not exact division 17 | └── it should return the time at which the total debt exceeds the balance 18 | -------------------------------------------------------------------------------- /tests/integration/concrete/deposit-and-pause/depositAndPause.tree: -------------------------------------------------------------------------------- 1 | DepositAndPause_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given paused 9 | │ └── it should revert 10 | └── given not paused 11 | ├── when caller not sender 12 | │ ├── when caller recipient 13 | │ │ └── it should revert 14 | │ └── when caller malicious third party 15 | │ └── it should revert 16 | └── when caller sender 17 | ├── it should update the stream balance 18 | ├── it should perform the ERC20 transfer 19 | ├── it should pause the stream 20 | ├── it should set rate per second to 0 21 | ├── it should update the snapshot debt 22 | └── it should emit 1 {Transfer}, 1 {DepositFlowStream}, 1 {PauseFlowStream}, 1 {MetadataUpdate} events 23 | -------------------------------------------------------------------------------- /tests/integration/concrete/deposit-via-broker/depositViaBroker.tree: -------------------------------------------------------------------------------- 1 | DepositViaBroker_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given voided 9 | │ └── it should revert 10 | └── given not voided 11 | ├── when sender not match 12 | │ └── it should revert 13 | └── when sender matches 14 | ├── when recipient not match 15 | │ └── it should revert 16 | └── when recipient matches 17 | ├── when broker fee greater than max fee 18 | │ └── it should revert 19 | └── when broker fee not greater than max fee 20 | ├── when broke address zero 21 | │ └── it should revert 22 | └── when broker address not zero 23 | ├── when total amount zero 24 | │ └── it should revert 25 | └── when total amount not zero 26 | ├── when token misses ERC20 return 27 | │ └── it should make the deposit 28 | └── when token not miss ERC20 return 29 | ├── given token has 18 decimals 30 | │ ├── it should update the stream balance 31 | │ ├── it should perform the ERC20 transfers 32 | │ └── it should emit 2 {Transfer}, 1 {DepositFlowStream}, 1 {MetadataUpdate} events 33 | └── given token not have 18 decimals 34 | ├── it should update the stream balance 35 | ├── it should perform the ERC20 transfers 36 | └── it should emit 2 {Transfer}, 1 {DepositFlowStream}, 1 {MetadataUpdate} events 37 | -------------------------------------------------------------------------------- /tests/integration/concrete/deposit/deposit.tree: -------------------------------------------------------------------------------- 1 | Deposit_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given voided 9 | │ └── it should revert 10 | └── given not voided 11 | ├── when sender not match 12 | │ └── it should revert 13 | └── when sender matches 14 | ├── when recipient not match 15 | │ └── it should revert 16 | └── when recipient matches 17 | ├── when deposit amount zero 18 | │ └── it should revert 19 | └── when deposit amount not zero 20 | ├── when token misses ERC20 return 21 | │ └── it should make the deposit 22 | └── when token not miss ERC20 return 23 | ├── given token has 18 decimals 24 | │ ├── it should update the stream balance 25 | │ ├── it should increase the aggregate amount 26 | │ ├── it should perform the ERC20 transfer 27 | │ └── it should emit 1 {Transfer}, 1 {DepositFlowStream}, 1 {MetadataUpdate} events 28 | └── given token not have 18 decimals 29 | ├── it should update the stream balance 30 | ├── it should increase the aggregate amount 31 | ├── it should perform the ERC20 transfer 32 | └── it should emit 1 {Transfer}, 1 {DepositFlowStream}, 1 {MetadataUpdate} events 33 | 34 | -------------------------------------------------------------------------------- /tests/integration/concrete/getters/getters.tree: -------------------------------------------------------------------------------- 1 | Getters_Integration_Concrete_Test::getBalance 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given zero 6 | │ └── it should return zero value 7 | └── given not zero 8 | └── it should return non-zero value 9 | 10 | Getters_Integration_Concrete_Test::getRatePerSecond 11 | ├── given null 12 | │ └── it should revert 13 | └── given not null 14 | ├── given zero 15 | │ └── it should return zero value 16 | └── given not zero 17 | └── it should return non-zero value 18 | 19 | Getters_Integration_Concrete_Test::getRecipient 20 | ├── given null 21 | │ └── it should revert 22 | └── given not null 23 | └── it should return the correct recipient 24 | 25 | Getters_Integration_Concrete_Test::getSender 26 | ├── given null 27 | │ └── it should revert 28 | └── given not null 29 | └── it should return the correct sender 30 | 31 | Getters_Integration_Concrete_Test::getSnapshotDebtScaled 32 | ├── given null 33 | │ └── it should revert 34 | └── given not null 35 | ├── given zero 36 | │ └── it should return zero value 37 | └── given not zero 38 | └── it should return non-zero value 39 | 40 | Getters_Integration_Concrete_Test::getSnapshotTime 41 | ├── given null 42 | │ └── it should revert 43 | └── given not null 44 | └── it should return the correct snapshot time 45 | 46 | Getters_Integration_Concrete_Test::getStream 47 | ├── given null 48 | │ └── it should revert 49 | └── given not null 50 | └── it should return the stream 51 | 52 | Getters_Integration_Concrete_Test::getTokenDecimals 53 | ├── given null 54 | │ └── it should revert 55 | └── given not null 56 | └── it should return token decimals 57 | 58 | Getters_Integration_Concrete_Test::isPaused 59 | ├── given null 60 | │ └── it should revert 61 | └── given not null 62 | ├── given true 63 | │ └── it should return true 64 | └── given not true 65 | └── it should return false 66 | 67 | Getters_Integration_Concrete_Test::isStream 68 | ├── given null 69 | │ └── it should return false 70 | └── given not null 71 | └── it should return true 72 | 73 | Getters_Integration_Concrete_Test::isTransferable 74 | ├── given null 75 | │ └── it should revert 76 | └── given not null 77 | ├── given true 78 | │ └── it should return true 79 | └── given false 80 | └── it should return false 81 | 82 | Getters_Integration_Concrete_Test::isVoided 83 | ├── given null 84 | │ └── it should revert 85 | └── given not null 86 | ├── given true 87 | │ └── it should return true 88 | └── given false 89 | └── it should return false -------------------------------------------------------------------------------- /tests/integration/concrete/ongoing-debt-of/ongoingDebtScaledOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 5 | 6 | contract OngoingDebtScaledOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 7 | function test_RevertGiven_Null() external { 8 | bytes memory callData = abi.encodeCall(flow.ongoingDebtScaledOf, nullStreamId); 9 | expectRevert_Null(callData); 10 | } 11 | 12 | function test_GivenPaused() external givenNotNull { 13 | flow.pause(defaultStreamId); 14 | 15 | // It should return zero. 16 | uint256 ongoingDebtScaled = flow.ongoingDebtScaledOf(defaultStreamId); 17 | assertEq(ongoingDebtScaled, 0, "ongoing debt"); 18 | } 19 | 20 | function test_WhenSnapshotTimeInPresent() external givenNotNull givenNotPaused { 21 | // Take snapshot. 22 | updateSnapshot(defaultStreamId); 23 | 24 | // It should return zero. 25 | uint256 ongoingDebtScaled = flow.ongoingDebtScaledOf(defaultStreamId); 26 | assertEq(ongoingDebtScaled, 0, "ongoing debt"); 27 | } 28 | 29 | function test_WhenSnapshotTimeInPast() external view givenNotNull givenNotPaused { 30 | // It should return the correct ongoing debt. 31 | uint256 ongoingDebtScaled = flow.ongoingDebtScaledOf(defaultStreamId); 32 | assertEq(ongoingDebtScaled, ONE_MONTH_DEBT_18D, "ongoing debt"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/integration/concrete/ongoing-debt-of/ongoingDebtScaledOf.tree: -------------------------------------------------------------------------------- 1 | OngoingDebtScaledOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given paused 6 | │ └── it should return zero 7 | └── given not paused 8 | ├── when snapshot time in present 9 | │ └── it should return zero 10 | └── when snapshot time in past 11 | └── it should return the correct ongoing debt 12 | -------------------------------------------------------------------------------- /tests/integration/concrete/pause/pause.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 10 | 11 | contract Pause_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 12 | function test_RevertWhen_DelegateCall() external { 13 | bytes memory callData = abi.encodeCall(flow.pause, (defaultStreamId)); 14 | expectRevert_DelegateCall(callData); 15 | } 16 | 17 | function test_RevertGiven_Null() external whenNoDelegateCall { 18 | bytes memory callData = abi.encodeCall(flow.pause, (nullStreamId)); 19 | expectRevert_Null(callData); 20 | } 21 | 22 | function test_RevertGiven_Paused() external whenNoDelegateCall givenNotNull { 23 | bytes memory callData = abi.encodeCall(flow.pause, (defaultStreamId)); 24 | expectRevert_Paused(callData); 25 | } 26 | 27 | function test_RevertWhen_CallerRecipient() 28 | external 29 | whenNoDelegateCall 30 | givenNotNull 31 | givenNotPaused 32 | whenCallerNotSender 33 | { 34 | bytes memory callData = abi.encodeCall(flow.pause, (defaultStreamId)); 35 | expectRevert_CallerRecipient(callData); 36 | } 37 | 38 | function test_RevertWhen_CallerMaliciousThirdParty() 39 | external 40 | whenNoDelegateCall 41 | givenNotNull 42 | givenNotPaused 43 | whenCallerNotSender 44 | { 45 | bytes memory callData = abi.encodeCall(flow.pause, (defaultStreamId)); 46 | expectRevert_CallerMaliciousThirdParty(callData); 47 | } 48 | 49 | function test_GivenUncoveredDebt() external whenNoDelegateCall givenNotNull givenNotPaused whenCallerSender { 50 | // Check that uncovered debt is greater than zero. 51 | assertGt(flow.uncoveredDebtOf(defaultStreamId), 0, "uncovered debt"); 52 | 53 | // It should pause the stream. 54 | _test_Pause(); 55 | } 56 | 57 | function test_GivenNoUncoveredDebt() external whenNoDelegateCall givenNotNull givenNotPaused whenCallerSender { 58 | // Make deposit to repay uncovered debt. 59 | depositToDefaultStream(); 60 | 61 | // Check that uncovered debt is zero. 62 | assertEq(flow.uncoveredDebtOf(defaultStreamId), 0, "uncovered debt"); 63 | 64 | // It should pause the stream. 65 | _test_Pause(); 66 | } 67 | 68 | function _test_Pause() private { 69 | // It should emit 1 {PauseFlowStream}, 1 {MetadataUpdate} events. 70 | vm.expectEmit({ emitter: address(flow) }); 71 | emit ISablierFlow.PauseFlowStream({ 72 | streamId: defaultStreamId, 73 | sender: users.sender, 74 | recipient: users.recipient, 75 | totalDebt: flow.totalDebtOf(defaultStreamId) 76 | }); 77 | 78 | vm.expectEmit({ emitter: address(flow) }); 79 | emit IERC4906.MetadataUpdate({ _tokenId: defaultStreamId }); 80 | 81 | flow.pause(defaultStreamId); 82 | 83 | // It should pause the stream. 84 | assertTrue(flow.isPaused(defaultStreamId), "is paused"); 85 | 86 | // It should set the rate per second to zero. 87 | UD21x18 actualRatePerSecond = flow.getRatePerSecond(defaultStreamId); 88 | assertEq(actualRatePerSecond, 0, "rate per second"); 89 | 90 | // It should update the snapshot debt. 91 | uint256 actualSnapshotDebtScaled = flow.getSnapshotDebtScaled(defaultStreamId); 92 | assertEq(actualSnapshotDebtScaled, ONE_MONTH_DEBT_18D, "snapshot debt"); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/integration/concrete/pause/pause.tree: -------------------------------------------------------------------------------- 1 | Pause_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given paused 9 | │ └── it should revert 10 | └── given not paused 11 | ├── when caller not sender 12 | │ ├── when caller recipient 13 | │ │ └── it should revert 14 | │ └── when caller malicious third party 15 | │ └── it should revert 16 | └── when caller sender 17 | ├── given uncovered debt 18 | │ ├── it should pause the stream 19 | │ ├── it should set the rate per second to zero 20 | │ ├── it should update the snapshot debt 21 | │ └──it should emit 1 {PauseFlowStream}, 1 {MetadataUpdate} events 22 | └── given no uncovered debt 23 | ├── it should pause the stream 24 | ├── it should set the rate per second to zero 25 | ├── it should update the snapshot debt 26 | └── it should emit 1 {PauseFlowStream}, 1 {MetadataUpdate} events 27 | -------------------------------------------------------------------------------- /tests/integration/concrete/payable/payable.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22 <0.9.0; 3 | 4 | import { ud21x18 } from "@prb/math/src/UD21x18.sol"; 5 | 6 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 7 | 8 | contract Payable_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 9 | function setUp() public override { 10 | Shared_Integration_Concrete_Test.setUp(); 11 | depositToDefaultStream(); 12 | 13 | vm.warp({ newTimestamp: ONE_MONTH_SINCE_START }); 14 | 15 | // Make the sender the caller. 16 | resetPrank({ msgSender: users.sender }); 17 | } 18 | 19 | function test_AdjustRatePerSecondWhenETHValueNotZero() external { 20 | flow.adjustRatePerSecond{ value: FEE }(defaultStreamId, ud21x18(RATE_PER_SECOND_U128 + 1)); 21 | } 22 | 23 | function test_BatchWhenETHValueNotZero() external { 24 | bytes[] memory calls = new bytes[](0); 25 | flow.batch{ value: FEE }(calls); 26 | } 27 | 28 | function test_CreateWhenETHValueNotZero() external { 29 | flow.create{ value: FEE }(users.sender, users.recipient, RATE_PER_SECOND, usdc, TRANSFERABLE); 30 | } 31 | 32 | function test_CreateAndDepositWhenETHValueNotZero() external { 33 | flow.createAndDeposit{ value: FEE }( 34 | users.sender, users.recipient, RATE_PER_SECOND, usdc, TRANSFERABLE, DEPOSIT_AMOUNT_6D 35 | ); 36 | } 37 | 38 | function test_DepositWhenETHValueNotZero() external { 39 | flow.deposit{ value: FEE }(defaultStreamId, DEPOSIT_AMOUNT_6D, users.sender, users.recipient); 40 | } 41 | 42 | function test_DepositAndPauseWhenETHValueNotZero() external { 43 | flow.depositAndPause{ value: FEE }(defaultStreamId, DEPOSIT_AMOUNT_6D); 44 | } 45 | 46 | function test_DepositViaBrokerWhenETHValueNotZero() external { 47 | flow.depositViaBroker{ value: FEE }( 48 | defaultStreamId, DEPOSIT_AMOUNT_6D, users.sender, users.recipient, defaultBroker 49 | ); 50 | } 51 | 52 | function test_PauseWhenETHValueNotZero() external { 53 | flow.pause{ value: FEE }(defaultStreamId); 54 | } 55 | 56 | function test_RefundWhenETHValueNotZero() external { 57 | flow.refund{ value: FEE }(defaultStreamId, REFUND_AMOUNT_6D); 58 | } 59 | 60 | function test_RefundAndPauseWhenETHValueNotZero() external { 61 | flow.refundAndPause{ value: FEE }(defaultStreamId, REFUND_AMOUNT_6D); 62 | } 63 | 64 | function test_RefundMaxWhenETHValueNotZero() external { 65 | flow.refundMax{ value: FEE }(defaultStreamId); 66 | } 67 | 68 | function test_RestartWhenETHValueNotZero() external { 69 | flow.pause(defaultStreamId); 70 | flow.restart{ value: FEE }(defaultStreamId, RATE_PER_SECOND); 71 | } 72 | 73 | function test_RestartAndDepositWhenETHValueNotZero() external { 74 | flow.pause(defaultStreamId); 75 | flow.restartAndDeposit{ value: FEE }(defaultStreamId, RATE_PER_SECOND, DEPOSIT_AMOUNT_6D); 76 | } 77 | 78 | function test_VoidWhenETHValueNotZero() external { 79 | flow.void{ value: FEE }(defaultStreamId); 80 | } 81 | 82 | function test_WithdrawWhenETHValueNotZero() external { 83 | flow.withdraw{ value: FEE }(defaultStreamId, users.recipient, WITHDRAW_AMOUNT_6D); 84 | } 85 | 86 | function test_WithdrawMaxWhenETHValueNotZero() external { 87 | flow.withdrawMax{ value: FEE }(defaultStreamId, users.recipient); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/integration/concrete/payable/payable.tree: -------------------------------------------------------------------------------- 1 | Payable_Integration_Concrete_Test::adjustRatePerSecond 2 | └── when ETH value not zero 3 | └── it should make the call 4 | 5 | Payable_Integration_Concrete_Test::batch 6 | └── when ETH value not zero 7 | └── it should make the call 8 | 9 | Payable_Integration_Concrete_Test::create 10 | └── when ETH value not zero 11 | └── it should make the call 12 | 13 | Payable_Integration_Concrete_Test::createAndDeposit 14 | └── when ETH value not zero 15 | └── it should make the call 16 | 17 | Payable_Integration_Concrete_Test::deposit 18 | └── when ETH value not zero 19 | └── it should make the call 20 | 21 | Payable_Integration_Concrete_Test::depositAndPause 22 | └── when ETH value not zero 23 | └── it should make the call 24 | 25 | Payable_Integration_Concrete_Test::depositViaBroker 26 | └── when ETH value not zero 27 | └── it should make the call 28 | 29 | Payable_Integration_Concrete_Test::pause 30 | └── when ETH value not zero 31 | └── it should make the call 32 | 33 | Payable_Integration_Concrete_Test::refund 34 | └── when ETH value not zero 35 | └── it should make the call 36 | 37 | Payable_Integration_Concrete_Test::refundAndPause 38 | └── when ETH value not zero 39 | └── it should make the call 40 | 41 | Payable_Integration_Concrete_Test::refundMax 42 | └── when ETH value not zero 43 | └── it should make the call 44 | 45 | Payable_Integration_Concrete_Test::restart 46 | └── when ETH value not zero 47 | └── it should make the call 48 | 49 | Payable_Integration_Concrete_Test::restartAndDeposit 50 | └── when ETH value not zero 51 | └── it should make the call 52 | 53 | Payable_Integration_Concrete_Test::void 54 | └── when ETH value not zero 55 | └── it should make the call 56 | 57 | Payable_Integration_Concrete_Test::withdraw 58 | └── when ETH value not zero 59 | └── it should make the call 60 | 61 | Payable_Integration_Concrete_Test::withdrawMax 62 | └── when ETH value not zero 63 | └── it should make the call 64 | -------------------------------------------------------------------------------- /tests/integration/concrete/recover/recover.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import { ISablierFlowBase } from "src/interfaces/ISablierFlowBase.sol"; 6 | import { Errors } from "src/libraries/Errors.sol"; 7 | import { Shared_Integration_Concrete_Test } from "./../Concrete.t.sol"; 8 | 9 | contract Recover_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 10 | uint256 internal surplusAmount = 1e6; 11 | 12 | function setUp() public override { 13 | Shared_Integration_Concrete_Test.setUp(); 14 | 15 | // Increase the flow contract balance in order to have a surplus. 16 | deal({ token: address(usdc), to: address(flow), give: surplusAmount }); 17 | } 18 | 19 | function test_RevertWhen_CallerNotAdmin() external { 20 | resetPrank({ msgSender: users.eve }); 21 | vm.expectRevert(abi.encodeWithSelector(Errors.CallerNotAdmin.selector, users.admin, users.eve)); 22 | flow.recover(usdc, users.eve); 23 | } 24 | 25 | function test_RevertWhen_TokenBalanceNotExceedAggregateAmount() external whenCallerAdmin { 26 | // Using dai token for this test because it has zero surplus. 27 | vm.expectRevert(abi.encodeWithSelector(Errors.SablierFlowBase_SurplusZero.selector, dai)); 28 | flow.recover(dai, users.admin); 29 | } 30 | 31 | function test_WhenTokenBalanceExceedAggregateAmount() external whenCallerAdmin { 32 | assertEq(usdc.balanceOf(address(flow)), surplusAmount + flow.aggregateBalance(usdc)); 33 | 34 | // It should emit {Recover} and {Transfer} events. 35 | vm.expectEmit({ emitter: address(usdc) }); 36 | emit IERC20.Transfer({ from: address(flow), to: users.admin, value: surplusAmount }); 37 | vm.expectEmit({ emitter: address(flow) }); 38 | emit ISablierFlowBase.Recover(users.admin, usdc, users.admin, surplusAmount); 39 | 40 | // Recover the surplus. 41 | flow.recover(usdc, users.admin); 42 | 43 | // It should lead to token balance same as aggregate amount. 44 | assertEq(usdc.balanceOf(address(flow)), flow.aggregateBalance(usdc)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/integration/concrete/recover/recover.tree: -------------------------------------------------------------------------------- 1 | Recover_Integration_Concrete_Test 2 | ├── when caller not admin 3 | │ └── it should revert 4 | └── when caller admin 5 | ├── when token balance not exceed aggregate amount 6 | │ └── it should revert 7 | └── when token balance exceed aggregate amount 8 | ├── it should transfer the surplus to provided address 9 | ├── it should emit {Recover} and {Transfer} events 10 | └── it should lead to token balance same as aggregate amount 11 | -------------------------------------------------------------------------------- /tests/integration/concrete/refund-and-pause/refundAndPause.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 7 | 8 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 9 | 10 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 11 | 12 | contract RefundAndPause_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 13 | function setUp() public override { 14 | Shared_Integration_Concrete_Test.setUp(); 15 | 16 | depositToDefaultStream(); 17 | } 18 | 19 | function test_RevertWhen_DelegateCall() external { 20 | bytes memory callData = abi.encodeCall(flow.refundAndPause, (defaultStreamId, REFUND_AMOUNT_6D)); 21 | expectRevert_DelegateCall(callData); 22 | } 23 | 24 | function test_RevertGiven_Null() external whenNoDelegateCall { 25 | bytes memory callData = abi.encodeCall(flow.refundAndPause, (nullStreamId, REFUND_AMOUNT_6D)); 26 | expectRevert_Null(callData); 27 | } 28 | 29 | function test_RevertGiven_Paused() external whenNoDelegateCall givenNotNull { 30 | bytes memory callData = abi.encodeCall(flow.refundAndPause, (defaultStreamId, REFUND_AMOUNT_6D)); 31 | expectRevert_Paused(callData); 32 | } 33 | 34 | function test_RevertWhen_CallerRecipient() 35 | external 36 | whenNoDelegateCall 37 | givenNotNull 38 | givenNotPaused 39 | whenCallerNotSender 40 | { 41 | bytes memory callData = abi.encodeCall(flow.refundAndPause, (defaultStreamId, REFUND_AMOUNT_6D)); 42 | expectRevert_CallerRecipient(callData); 43 | } 44 | 45 | function test_RevertWhen_CallerMaliciousThirdParty() 46 | external 47 | whenNoDelegateCall 48 | givenNotNull 49 | givenNotPaused 50 | whenCallerNotSender 51 | { 52 | bytes memory callData = abi.encodeCall(flow.refundAndPause, (defaultStreamId, REFUND_AMOUNT_6D)); 53 | expectRevert_CallerMaliciousThirdParty(callData); 54 | } 55 | 56 | function test_WhenCallerSender() external whenNoDelegateCall givenNotNull givenNotPaused { 57 | // It should emit 1 {Transfer}, 1 {RefundFromFlowStream}, 1 {PauseFlowStream}, 1 {MetadataUpdate} events 58 | vm.expectEmit({ emitter: address(usdc) }); 59 | emit IERC20.Transfer({ from: address(flow), to: users.sender, value: REFUND_AMOUNT_6D }); 60 | 61 | vm.expectEmit({ emitter: address(flow) }); 62 | emit ISablierFlow.RefundFromFlowStream({ 63 | streamId: defaultStreamId, 64 | sender: users.sender, 65 | amount: REFUND_AMOUNT_6D 66 | }); 67 | 68 | vm.expectEmit({ emitter: address(flow) }); 69 | emit ISablierFlow.PauseFlowStream({ 70 | streamId: defaultStreamId, 71 | sender: users.sender, 72 | recipient: users.recipient, 73 | totalDebt: flow.totalDebtOf(defaultStreamId) 74 | }); 75 | 76 | vm.expectEmit({ emitter: address(flow) }); 77 | emit IERC4906.MetadataUpdate({ _tokenId: defaultStreamId }); 78 | 79 | // It should perform the ERC-20 transfer 80 | expectCallToTransfer({ token: usdc, to: users.sender, amount: REFUND_AMOUNT_6D }); 81 | 82 | flow.refundAndPause(defaultStreamId, REFUND_AMOUNT_6D); 83 | 84 | // It should update the stream balance 85 | uint128 actualStreamBalance = flow.getBalance(defaultStreamId); 86 | uint128 expectedStreamBalance = DEPOSIT_AMOUNT_6D - REFUND_AMOUNT_6D; 87 | assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); 88 | 89 | // It should pause the stream 90 | assertTrue(flow.isPaused(defaultStreamId), "is paused"); 91 | 92 | // It should set the rate per second to 0 93 | UD21x18 actualRatePerSecond = flow.getRatePerSecond(defaultStreamId); 94 | assertEq(actualRatePerSecond, 0, "rate per second"); 95 | 96 | // It should update the snapshot debt 97 | uint256 actualSnapshotDebtScaled = flow.getSnapshotDebtScaled(defaultStreamId); 98 | assertEq(actualSnapshotDebtScaled, ONE_MONTH_DEBT_18D, "snapshot debt"); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/integration/concrete/refund-and-pause/refundAndPause.tree: -------------------------------------------------------------------------------- 1 | RefundAndPause_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given paused 9 | │ └── it should revert 10 | └── given not paused 11 | ├── when caller not sender 12 | │ ├── when caller recipient 13 | │ │ └── it should revert 14 | │ └── when caller malicious third party 15 | │ └── it should revert 16 | └── when caller sender 17 | ├── it should update the stream balance 18 | ├── it should perform the ERC20 transfer 19 | ├── it should pause the stream 20 | ├── it should set rate per second to 0 21 | ├── it should update the snapshot debt 22 | ├── it should emit 1 {Transfer}, 1 {RefundFromFlowStream}, 1 {PauseFlowStream}, 1 {MetadataUpdate} events 23 | └── it should return the transfer amount 24 | -------------------------------------------------------------------------------- /tests/integration/concrete/refund-max/refundMax.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 10 | 11 | contract RefundMax_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 12 | function setUp() public override { 13 | Shared_Integration_Concrete_Test.setUp(); 14 | 15 | // Deposit to the default stream. 16 | depositToDefaultStream(); 17 | } 18 | 19 | function test_RevertWhen_DelegateCall() external { 20 | bytes memory callData = abi.encodeCall(flow.refundMax, (defaultStreamId)); 21 | expectRevert_DelegateCall(callData); 22 | } 23 | 24 | function test_RevertGiven_Null() external whenNoDelegateCall { 25 | bytes memory callData = abi.encodeCall(flow.refundMax, (nullStreamId)); 26 | expectRevert_Null(callData); 27 | } 28 | 29 | function test_RevertWhen_CallerRecipient() external whenNoDelegateCall givenNotNull whenCallerNotSender { 30 | bytes memory callData = abi.encodeCall(flow.refundMax, (defaultStreamId)); 31 | expectRevert_CallerRecipient(callData); 32 | } 33 | 34 | function test_RevertWhen_CallerMaliciousThirdParty() external whenNoDelegateCall givenNotNull whenCallerNotSender { 35 | bytes memory callData = abi.encodeCall(flow.refundMax, (defaultStreamId)); 36 | expectRevert_CallerMaliciousThirdParty(callData); 37 | } 38 | 39 | function test_GivenPaused() external whenNoDelegateCall givenNotNull whenCallerSender { 40 | flow.pause(defaultStreamId); 41 | 42 | // It should make the refund. 43 | _test_RefundMax({ streamId: defaultStreamId, token: usdc, depositedAmount: DEPOSIT_AMOUNT_6D }); 44 | } 45 | 46 | function test_GivenNotPaused() external whenNoDelegateCall givenNotNull whenCallerSender { 47 | // It should make the refund. 48 | _test_RefundMax({ streamId: defaultStreamId, token: usdc, depositedAmount: DEPOSIT_AMOUNT_6D }); 49 | } 50 | 51 | function _test_RefundMax(uint256 streamId, IERC20 token, uint128 depositedAmount) private { 52 | uint256 previousAggregateAmount = flow.aggregateBalance(token); 53 | uint128 refundableAmount = flow.refundableAmountOf(streamId); 54 | 55 | // It should emit 1 {Transfer}, 1 {RefundFromFlowStream}, 1 {MetadataUpdate} events. 56 | vm.expectEmit({ emitter: address(token) }); 57 | emit IERC20.Transfer({ from: address(flow), to: users.sender, value: refundableAmount }); 58 | 59 | vm.expectEmit({ emitter: address(flow) }); 60 | emit ISablierFlow.RefundFromFlowStream({ streamId: streamId, sender: users.sender, amount: refundableAmount }); 61 | 62 | vm.expectEmit({ emitter: address(flow) }); 63 | emit IERC4906.MetadataUpdate({ _tokenId: streamId }); 64 | 65 | // It should perform the ERC-20 transfer. 66 | expectCallToTransfer({ token: token, to: users.sender, amount: refundableAmount }); 67 | flow.refundMax(streamId); 68 | 69 | // It should update the stream balance. 70 | uint128 actualStreamBalance = flow.getBalance(streamId); 71 | uint128 expectedStreamBalance = depositedAmount - refundableAmount; 72 | assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); 73 | 74 | // It should decrease the aggregate amount. 75 | assertEq(flow.aggregateBalance(token), previousAggregateAmount - refundableAmount, "aggregate amount"); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/integration/concrete/refund-max/refundMax.tree: -------------------------------------------------------------------------------- 1 | RefundMax_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── when caller not sender 9 | │ ├── when caller recipient 10 | │ │ └── it should revert 11 | │ └── when caller malicious third party 12 | │ └── it should revert 13 | └── when caller sender 14 | ├── given paused 15 | │ └── it should make the refund 16 | └── given not paused 17 | ├── it should make the refund 18 | ├── it should update the stream balance 19 | ├── it should decrease the aggregate amount 20 | ├── it should perform the ERC20 transfer 21 | └── it should emit 1 {Transfer}, 1 {RefundFromFlowStream}, 1 {MetadataUpdate} event 22 | -------------------------------------------------------------------------------- /tests/integration/concrete/refund/refund.tree: -------------------------------------------------------------------------------- 1 | Refund_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── when caller not sender 9 | │ ├── when caller recipient 10 | │ │ └── it should revert 11 | │ └── when caller malicious third party 12 | │ └── it should revert 13 | └── when caller sender 14 | ├── when refund amount zero 15 | │ └── it should revert 16 | └── when refund amount not zero 17 | ├── when over refund 18 | │ └── it should revert 19 | └── when no over refund 20 | ├── given paused 21 | │ └── it should make the refund 22 | └── given not paused 23 | ├── when token misses ERC20 return 24 | │ └── it should make the refund 25 | └── when token not miss ERC20 return 26 | ├── given token not have 18 decimals 27 | │ ├── it should make the refund 28 | │ ├── it should update the stream balance 29 | │ ├── it should decrease the aggregate amount 30 | │ ├── it should perform the ERC20 transfer 31 | │ └── it should emit 1 {Transfer}, 1 {RefundFromFlowStream}, 1 {MetadataUpdate} event 32 | └── given token has 18 decimals 33 | ├── it should make the refund 34 | ├── it should update the stream balance 35 | ├── it should decrease the aggregate amount 36 | ├── it should perform the ERC20 transfer 37 | └── it should emit 1 {Transfer}, 1 {RefundFromFlowStream}, 1 {MetadataUpdate} event 38 | -------------------------------------------------------------------------------- /tests/integration/concrete/refundable-amount-of/refundableAmountOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 5 | 6 | contract RefundableAmountOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 7 | function test_RevertGiven_Null() external { 8 | bytes memory callData = abi.encodeCall(flow.refundableAmountOf, nullStreamId); 9 | expectRevert_Null(callData); 10 | } 11 | 12 | function test_GivenBalanceZero() external view givenNotNull { 13 | // It should return zero. 14 | uint128 actualRefundableAmount = flow.refundableAmountOf(defaultStreamId); 15 | assertEq(actualRefundableAmount, 0, "refundable amount"); 16 | } 17 | 18 | function test_GivenPaused() external givenNotNull givenBalanceNotZero { 19 | // Pause the stream. 20 | flow.pause(defaultStreamId); 21 | 22 | // It should return the correct refundable amount. 23 | uint128 actualRefundableAmount = flow.refundableAmountOf(defaultStreamId); 24 | assertEq(actualRefundableAmount, ONE_MONTH_REFUNDABLE_AMOUNT_6D, "refundable amount"); 25 | } 26 | 27 | function test_WhenTotalDebtExceedsBalance() external givenNotNull givenBalanceNotZero givenNotPaused { 28 | // Simulate the passage of time until debt becomes uncovered. 29 | vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD }); 30 | 31 | // It should return zero. 32 | uint128 actualRefundableAmount = flow.refundableAmountOf(defaultStreamId); 33 | assertEq(actualRefundableAmount, 0, "refundable amount"); 34 | } 35 | 36 | function test_WhenTotalDebtNotExceedBalance() external givenNotNull givenBalanceNotZero givenNotPaused { 37 | // It should return the correct refundable amount. 38 | uint128 actualRefundableAmount = flow.refundableAmountOf(defaultStreamId); 39 | assertEq(actualRefundableAmount, ONE_MONTH_REFUNDABLE_AMOUNT_6D, "refundable amount"); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/integration/concrete/refundable-amount-of/refundableAmountOf.tree: -------------------------------------------------------------------------------- 1 | RefundableAmountOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given balance zero 6 | │ └── it should return zero 7 | └── given balance not zero 8 | ├── given paused 9 | │ └── it should return correct refundable amount 10 | └── given not paused 11 | ├── when total debt exceeds balance 12 | │ └── it should return zero 13 | └── when total debt not exceed balance 14 | └── it should return correct refundable amount 15 | -------------------------------------------------------------------------------- /tests/integration/concrete/restart-and-deposit/restartAndDeposit.tree: -------------------------------------------------------------------------------- 1 | RestartAndDeposit_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given voided 9 | │ └── it should revert 10 | └── given not voided 11 | ├── when caller not sender 12 | │ ├── when caller recipient 13 | │ │ └── it should revert 14 | │ └── when caller malicious third party 15 | │ └── it should revert 16 | └── when caller sender 17 | ├── it should restart the stream 18 | ├── it should update the rate per second 19 | ├── it should update snapshot time 20 | ├── it should update the stream balance 21 | ├── it should perform the ERC20 transfer 22 | └── it should emit 1 {RestartFlowStream}, 1 {Transfer}, 1 {DepositFlowStream} and 1 {MetadataUpdate} events 23 | -------------------------------------------------------------------------------- /tests/integration/concrete/restart/restart.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | import { Errors } from "src/libraries/Errors.sol"; 9 | 10 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 11 | 12 | contract Restart_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 13 | function setUp() public override { 14 | Shared_Integration_Concrete_Test.setUp(); 15 | 16 | // Pause the stream for this test. 17 | flow.pause({ streamId: defaultStreamId }); 18 | } 19 | 20 | function test_RevertWhen_DelegateCall() external { 21 | bytes memory callData = abi.encodeCall(flow.restart, (defaultStreamId, RATE_PER_SECOND)); 22 | expectRevert_DelegateCall(callData); 23 | } 24 | 25 | function test_RevertGiven_Null() external whenNoDelegateCall { 26 | bytes memory callData = abi.encodeCall(flow.restart, (nullStreamId, RATE_PER_SECOND)); 27 | expectRevert_Null(callData); 28 | } 29 | 30 | function test_RevertGiven_Voided() external whenNoDelegateCall givenNotNull { 31 | bytes memory callData = abi.encodeCall(flow.restart, (defaultStreamId, RATE_PER_SECOND)); 32 | expectRevert_Voided(callData); 33 | } 34 | 35 | function test_RevertWhen_CallerRecipient() 36 | external 37 | whenNoDelegateCall 38 | givenNotNull 39 | givenNotVoided 40 | whenCallerNotSender 41 | { 42 | bytes memory callData = abi.encodeCall(flow.restart, (defaultStreamId, RATE_PER_SECOND)); 43 | expectRevert_CallerRecipient(callData); 44 | } 45 | 46 | function test_RevertWhen_CallerMaliciousThirdParty() 47 | external 48 | whenNoDelegateCall 49 | givenNotNull 50 | givenNotVoided 51 | whenCallerNotSender 52 | { 53 | bytes memory callData = abi.encodeCall(flow.restart, (defaultStreamId, RATE_PER_SECOND)); 54 | expectRevert_CallerMaliciousThirdParty(callData); 55 | } 56 | 57 | function test_RevertGiven_NotPaused() external whenNoDelegateCall givenNotNull givenNotVoided whenCallerSender { 58 | uint256 streamId = createDefaultStream(); 59 | 60 | vm.expectRevert(abi.encodeWithSelector(Errors.SablierFlow_StreamNotPaused.selector, streamId)); 61 | flow.restart({ streamId: streamId, ratePerSecond: RATE_PER_SECOND }); 62 | } 63 | 64 | function test_RevertWhen_NewRatePerSecondZero() 65 | external 66 | whenNoDelegateCall 67 | givenNotNull 68 | givenNotVoided 69 | whenCallerSender 70 | givenPaused 71 | { 72 | vm.expectRevert( 73 | abi.encodeWithSelector(Errors.SablierFlow_RatePerSecondNotDifferent.selector, defaultStreamId, ud21x18(0)) 74 | ); 75 | flow.restart({ streamId: defaultStreamId, ratePerSecond: ud21x18(0) }); 76 | } 77 | 78 | function test_WhenNewRatePerSecondNotZero() 79 | external 80 | whenNoDelegateCall 81 | givenNotNull 82 | givenNotVoided 83 | whenCallerSender 84 | givenPaused 85 | { 86 | // It should emit 1 {RestartFlowStream}, 1 {MetadataUpdate} event. 87 | vm.expectEmit({ emitter: address(flow) }); 88 | emit ISablierFlow.RestartFlowStream({ 89 | streamId: defaultStreamId, 90 | sender: users.sender, 91 | ratePerSecond: RATE_PER_SECOND 92 | }); 93 | 94 | vm.expectEmit({ emitter: address(flow) }); 95 | emit IERC4906.MetadataUpdate({ _tokenId: defaultStreamId }); 96 | 97 | flow.restart({ streamId: defaultStreamId, ratePerSecond: RATE_PER_SECOND }); 98 | 99 | bool isPaused = flow.isPaused(defaultStreamId); 100 | 101 | // It should restart the stream. 102 | assertFalse(isPaused); 103 | 104 | // It should update rate per second. 105 | UD21x18 actualRatePerSecond = flow.getRatePerSecond(defaultStreamId); 106 | assertEq(actualRatePerSecond, RATE_PER_SECOND, "ratePerSecond"); 107 | 108 | // It should update snapshot time. 109 | uint40 actualSnapshotTime = flow.getSnapshotTime(defaultStreamId); 110 | assertEq(actualSnapshotTime, getBlockTimestamp(), "snapshotTime"); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/integration/concrete/restart/restart.tree: -------------------------------------------------------------------------------- 1 | Restart_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given voided 9 | │ └── it should revert 10 | └── given not voided 11 | ├── when caller not sender 12 | │ ├── when caller recipient 13 | │ │ └── it should revert 14 | │ └── when caller malicious third party 15 | │ └── it should revert 16 | └── when caller sender 17 | ├── given not paused 18 | │ └── it should revert 19 | └── given paused 20 | ├── when new rate per second zero 21 | │ └── it should revert 22 | └── when new rate per second not zero 23 | ├── it should restart the stream 24 | ├── it should update rate per second 25 | ├── it should update snapshot time 26 | └── it should emit 1 {RestartFlowStream}, 1 {MetadataUpdate} event 27 | -------------------------------------------------------------------------------- /tests/integration/concrete/set-nft-descriptor/setNFTDescriptor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { FlowNFTDescriptor } from "src/FlowNFTDescriptor.sol"; 6 | import { ISablierFlowBase } from "src/interfaces/ISablierFlowBase.sol"; 7 | import { Errors } from "src/libraries/Errors.sol"; 8 | import { Shared_Integration_Concrete_Test } from "./../Concrete.t.sol"; 9 | 10 | contract SetNFTDescriptor_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 11 | function test_RevertWhen_CallerNotAdmin() external { 12 | resetPrank({ msgSender: users.eve }); 13 | vm.expectRevert(abi.encodeWithSelector(Errors.CallerNotAdmin.selector, users.admin, users.eve)); 14 | flow.setNFTDescriptor(FlowNFTDescriptor(users.eve)); 15 | } 16 | 17 | function test_WhenNewAndOldNFTDescriptorsAreSame() external whenCallerAdmin { 18 | // It should emit 1 {SetNFTDescriptor} and 1 {BatchMetadataUpdate} events 19 | vm.expectEmit({ emitter: address(flow) }); 20 | emit ISablierFlowBase.SetNFTDescriptor(users.admin, nftDescriptor, nftDescriptor); 21 | vm.expectEmit({ emitter: address(flow) }); 22 | emit IERC4906.BatchMetadataUpdate({ _fromTokenId: 1, _toTokenId: flow.nextStreamId() - 1 }); 23 | 24 | // It should re-set the NFT descriptor 25 | flow.setNFTDescriptor(nftDescriptor); 26 | vm.expectCall(address(nftDescriptor), abi.encodeCall(FlowNFTDescriptor.tokenURI, (flow, 1))); 27 | flow.tokenURI(defaultStreamId); 28 | } 29 | 30 | function test_WhenNewAndOldNFTDescriptorsAreNotSame() external whenCallerAdmin { 31 | // Deploy another NFT descriptor. 32 | FlowNFTDescriptor newNFTDescriptor = new FlowNFTDescriptor(); 33 | 34 | // It should emit 1 {SetNFTDescriptor} and 1 {BatchMetadataUpdate} events 35 | vm.expectEmit({ emitter: address(flow) }); 36 | emit ISablierFlowBase.SetNFTDescriptor(users.admin, nftDescriptor, newNFTDescriptor); 37 | vm.expectEmit({ emitter: address(flow) }); 38 | emit IERC4906.BatchMetadataUpdate({ _fromTokenId: 1, _toTokenId: flow.nextStreamId() - 1 }); 39 | 40 | // It should set the new NFT descriptor 41 | flow.setNFTDescriptor(newNFTDescriptor); 42 | address actualNFTDescriptor = address(flow.nftDescriptor()); 43 | address expectedNFTDescriptor = address(newNFTDescriptor); 44 | assertEq(actualNFTDescriptor, expectedNFTDescriptor, "nftDescriptor"); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/integration/concrete/set-nft-descriptor/setNFTDescriptor.tree: -------------------------------------------------------------------------------- 1 | SetNFTDescriptor_Integration_Concrete_Test 2 | ├── when caller not admin 3 | │ └── it should revert 4 | └── when caller admin 5 | ├── when new and old NFT descriptors are same 6 | │ ├── it should re-set the NFT descriptor 7 | │ └── it should emit 1 {SetNFTDescriptor} and 1 {BatchMetadataUpdate} events 8 | └── when new and old NFT descriptors are not same 9 | ├── it should set the new NFT descriptor 10 | └── it should emit 1 {SetNFTDescriptor} and 1 {BatchMetadataUpdate} events 11 | -------------------------------------------------------------------------------- /tests/integration/concrete/set-protocol-fee/setProtocolFee.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { UD60x18, UNIT } from "@prb/math/src/UD60x18.sol"; 6 | 7 | import { ISablierFlowBase } from "src/interfaces/ISablierFlowBase.sol"; 8 | import { Errors } from "src/libraries/Errors.sol"; 9 | 10 | import { Shared_Integration_Concrete_Test } from "./../Concrete.t.sol"; 11 | 12 | contract SetProtocolFee_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 13 | function test_RevertWhen_CallerNotAdmin() external { 14 | resetPrank({ msgSender: users.eve }); 15 | vm.expectRevert(abi.encodeWithSelector(Errors.CallerNotAdmin.selector, users.admin, users.eve)); 16 | flow.setProtocolFee(tokenWithProtocolFee, PROTOCOL_FEE); 17 | } 18 | 19 | function test_RevertWhen_NewProtocolFeeExceedsMaxFee() external whenCallerAdmin { 20 | UD60x18 newProtocolFee = MAX_FEE + UNIT; 21 | vm.expectRevert( 22 | abi.encodeWithSelector(Errors.SablierFlowBase_ProtocolFeeTooHigh.selector, newProtocolFee, MAX_FEE) 23 | ); 24 | flow.setProtocolFee(tokenWithProtocolFee, newProtocolFee); 25 | } 26 | 27 | modifier whenNewProtocolFeeNotExceedMaxFee() { 28 | _; 29 | } 30 | 31 | function test_WhenNewAndOldProtocolFeeAreSame() external whenCallerAdmin whenNewProtocolFeeNotExceedMaxFee { 32 | // It should emit {SetProtocolFee} and {BatchMetadataUpdate} events. 33 | vm.expectEmit({ emitter: address(flow) }); 34 | emit ISablierFlowBase.SetProtocolFee(users.admin, tokenWithProtocolFee, PROTOCOL_FEE, PROTOCOL_FEE); 35 | vm.expectEmit({ emitter: address(flow) }); 36 | emit IERC4906.BatchMetadataUpdate({ _fromTokenId: 1, _toTokenId: flow.nextStreamId() - 1 }); 37 | 38 | flow.setProtocolFee(tokenWithProtocolFee, PROTOCOL_FEE); 39 | 40 | // It should re-set the protocol fee. 41 | assertEq(flow.protocolFee(tokenWithProtocolFee), PROTOCOL_FEE); 42 | } 43 | 44 | function test_WhenNewAndOldProtocolFeeAreNotSame() external whenCallerAdmin whenNewProtocolFeeNotExceedMaxFee { 45 | UD60x18 newProtocolFee = PROTOCOL_FEE + UD60x18.wrap(0.01e18); 46 | 47 | // It should emit {SetProtocolFee} and {BatchMetadataUpdate} events. 48 | vm.expectEmit({ emitter: address(flow) }); 49 | emit ISablierFlowBase.SetProtocolFee(users.admin, tokenWithProtocolFee, PROTOCOL_FEE, newProtocolFee); 50 | vm.expectEmit({ emitter: address(flow) }); 51 | emit IERC4906.BatchMetadataUpdate({ _fromTokenId: 1, _toTokenId: flow.nextStreamId() - 1 }); 52 | 53 | flow.setProtocolFee(tokenWithProtocolFee, newProtocolFee); 54 | 55 | // It should set the protocol fee. 56 | assertEq(flow.protocolFee(tokenWithProtocolFee), newProtocolFee); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/integration/concrete/set-protocol-fee/setProtocolFee.tree: -------------------------------------------------------------------------------- 1 | SetProtocolFee_Integration_Concrete_Test 2 | ├── when caller not admin 3 | │ └── it should revert 4 | └── when caller admin 5 | ├── when new protocol fee exceeds max fee 6 | │ └── it should revert 7 | └── when new protocol fee not exceed max fee 8 | ├── when new and old protocol fee are same 9 | │ ├── it should re-set the protocol fee 10 | │ └── it should emit {SetProtocolFee} and {BatchMetadataUpdate} events 11 | └── when new and old protocol fee are not same 12 | ├── it should set the new protocol fee 13 | └── it should emit {SetProtocolFee} and {BatchMetadataUpdate} events 14 | -------------------------------------------------------------------------------- /tests/integration/concrete/status-of/statusOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Flow } from "src/types/DataTypes.sol"; 5 | 6 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 7 | 8 | contract StatusOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 9 | function setUp() public override { 10 | Shared_Integration_Concrete_Test.setUp(); 11 | 12 | depositToDefaultStream(); 13 | } 14 | 15 | function test_RevertGiven_Null() external { 16 | bytes memory callData = abi.encodeCall(flow.statusOf, nullStreamId); 17 | expectRevert_Null(callData); 18 | } 19 | 20 | function test_GivenVoided() external givenNotNull { 21 | // Simulate the passage of time to accumulate uncovered debt for one month. 22 | vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD + ONE_MONTH }); 23 | flow.void(defaultStreamId); 24 | 25 | // it should return VOIDED 26 | uint8 actualStatus = uint8(flow.statusOf(defaultStreamId)); 27 | uint8 expectedStatus = uint8(Flow.Status.VOIDED); 28 | assertEq(actualStatus, expectedStatus); 29 | } 30 | 31 | function test_GivenPausedAndNoUncoveredDebt() external givenNotNull { 32 | flow.pause(defaultStreamId); 33 | 34 | // it should return PAUSED_SOLVENT 35 | uint8 actualStatus = uint8(flow.statusOf(defaultStreamId)); 36 | uint8 expectedStatus = uint8(Flow.Status.PAUSED_SOLVENT); 37 | assertEq(actualStatus, expectedStatus); 38 | } 39 | 40 | function test_GivenPausedAndUncoveredDebt() external givenNotNull { 41 | vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD + 1 }); 42 | flow.pause(defaultStreamId); 43 | 44 | // it should return PAUSED_INSOLVENT 45 | uint8 actualStatus = uint8(flow.statusOf(defaultStreamId)); 46 | uint8 expectedStatus = uint8(Flow.Status.PAUSED_INSOLVENT); 47 | assertEq(actualStatus, expectedStatus); 48 | } 49 | 50 | function test_GivenStreamingAndNoUncoveredDebt() external view givenNotNull { 51 | // it should return STREAMING_SOLVENT 52 | uint8 actualStatus = uint8(flow.statusOf(defaultStreamId)); 53 | uint8 expectedStatus = uint8(Flow.Status.STREAMING_SOLVENT); 54 | assertEq(actualStatus, expectedStatus); 55 | } 56 | 57 | function test_GivenStreamingAndUncoveredDebt() external givenNotNull { 58 | // it should return STREAMING_INSOLVENT 59 | vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD + 1 }); 60 | 61 | // it should return STREAMING_INSOLVENT 62 | uint8 actualStatus = uint8(flow.statusOf(defaultStreamId)); 63 | uint8 expectedStatus = uint8(Flow.Status.STREAMING_INSOLVENT); 64 | assertEq(actualStatus, expectedStatus); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/integration/concrete/status-of/statusOf.tree: -------------------------------------------------------------------------------- 1 | StatusOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given voided 6 | │ └── it should return VOIDED 7 | ├── given paused and no uncovered debt 8 | │ └── it should return PAUSED_SOLVENT 9 | ├── given paused and uncovered debt 10 | │ └── it should return PAUSED_INSOLVENT 11 | ├── given streaming and no uncovered debt 12 | │ └── it should return STREAMING_SOLVENT 13 | └── given streaming and uncovered debt 14 | └── it should return STREAMING_INSOLVENT 15 | -------------------------------------------------------------------------------- /tests/integration/concrete/token-uri/tokenURI.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC721Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; 5 | 6 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 7 | 8 | contract TokenURI_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 9 | function test_RevertGiven_NFTNotExist() external { 10 | vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, nullStreamId)); 11 | flow.tokenURI(nullStreamId); 12 | } 13 | 14 | function test_GivenNFTExists() external view { 15 | // It should return the correct token URI 16 | string memory actualURI = flow.tokenURI(defaultStreamId); 17 | // solhint-disable max-line-length,quotes 18 | string memory expectedURI = 19 | "data:application/json;base64,eyJkZXNjcmlwdGlvbiI6ICJUaGlzIE5GVCByZXByZXNlbnRzIGEgcGF5bWVudCBzdHJlYW0gaW4gU2FibGllciBGbG93IiwiZXh0ZXJuYWxfdXJsIjogImh0dHBzOi8vc2FibGllci5jb20iLCJuYW1lIjogIlNhYmxpZXIgRmxvdyIsImltYWdlIjogImRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QjNhV1IwYUQwaU5UQXdJaUJvWldsbmFIUTlJalV3TUNJZ2MzUjViR1U5SW1KaFkydG5jbTkxYm1RdFkyOXNiM0k2SUNNeE5ERTJNVVk3SWlCNGJXeHVjejBpYUhSMGNEb3ZMM2QzZHk1M015NXZjbWN2TWpBd01DOXpkbWNpSUhacFpYZENiM2c5SWpJd0lDMDBNREFnTWpBd0lERXdNREFpUGp4d1lYUm9JR2xrUFNKTWIyZHZJaUJtYVd4c1BTSWpabVptSWlCbWFXeHNMVzl3WVdOcGRIazlJakVpSUdROUltMHhNek11TlRVNUxERXlOQzR3TXpSakxTNHdNVE1zTWk0ME1USXRNUzR3TlRrc05DNDRORGd0TWk0NU1qTXNOaTQwTURJdE1pNDFOVGdzTVM0NE1Ua3ROUzR4Tmpnc015NDBNemt0Tnk0NE9EZ3NOQzQ1T1RZdE1UUXVORFFzT0M0eU5qSXRNekV1TURRM0xERXlMalUyTlMwME55NDJOelFzTVRJdU5UWTVMVGd1T0RVNExqQXpOaTB4Tnk0NE16Z3RNUzR5TnpJdE1qWXVNekk0TFRNdU5qWXpMVGt1T0RBMkxUSXVOelkyTFRFNUxqQTROeTAzTGpFeE15MHlOeTQxTmpJdE1USXVOemM0TFRFekxqZzBNaTA0TGpBeU5TdzVMalEyT0MweU9DNDJNRFlzTVRZdU1UVXpMVE0xTGpJMk5XZ3dZekl1TURNMUxURXVPRE00TERRdU1qVXlMVE11TlRRMkxEWXVORFl6TFRVdU1qSTBhREJqTmk0ME1qa3ROUzQyTlRVc01UWXVNakU0TFRJdU9ETTFMREl3TGpNMU9DdzBMakUzTERRdU1UUXpMRFV1TURVM0xEZ3VPREUyTERrdU5qUTVMREV6TGpreUxERXpMamN6TkdndU1ETTNZelV1TnpNMkxEWXVORFl4TERFMUxqTTFOeTB5TGpJMU15dzVMak00TFRndU5EZ3NNQ3d3TFRNdU5URTFMVE11TlRFMUxUTXVOVEUxTFRNdU5URTFMVEV4TGpRNUxURXhMalEzT0MwMU1pNDJOVFl0TlRJdU5qWTBMVFkwTGpnek55MDJOQzQ0TXpkc0xqQTBPUzB1TURNM1l5MHhMamN5TlMweExqWXdOaTB5TGpjeE9TMHpMamcwTnkweUxqYzFNUzAyTGpJd05HZ3dZeTB1TURRMkxUSXVNemMxTERFdU1EWXlMVFF1TlRneUxESXVOekkyTFRZdU1qSTVhREJzTGpFNE5TMHVNVFE0YURCakxqQTVPUzB1TURZeUxDNHlNakl0TGpFME9Dd3VNemN0TGpJMU9XZ3dZekl1TURZdE1TNHpOaklzTXk0NU5URXRNaTQyTWpFc05pNHdORFF0TXk0NE5ESkROVGN1TnpZekxUTXVORGN6TERrM0xqYzJMVEl1TXpReExERXlPQzQyTXpjc01UZ3VNek15WXpFMkxqWTNNU3c1TGprME5pMHlOaTR6TkRRc05UUXVPREV6TFRNNExqWTFNU3cwTUM0eE9Ua3ROaTR5T1RrdE5pNHdPVFl0TVRndU1EWXpMVEUzTGpjME15MHhPUzQyTmpndE1UZ3VPREV4TFRZdU1ERTJMVFF1TURRM0xURXpMakEyTVN3MExqYzNOaTAzTGpjMU1pdzVMamMxTVd3Mk9DNHlOVFFzTmpndU16Y3hZekV1TnpJMExERXVOakF4TERJdU56RTBMRE11T0RRc01pNDNNemdzTmk0eE9USmFJaUIwY21GdWMyWnZjbTA5SW5OallXeGxLREV1TlN3Z01TNDFLU0lnTHo0OEwzTjJaejQ9In0="; 20 | assertEq(actualURI, expectedURI, "tokenURI"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/integration/concrete/token-uri/tokenURI.tree: -------------------------------------------------------------------------------- 1 | TokenURI_Integration_Concrete_Test 2 | ├── given NFT not exist 3 | │ └── it should revert 4 | └── given NFT exists 5 | └── it should return the correct token URI 6 | -------------------------------------------------------------------------------- /tests/integration/concrete/total-debt-of/totalDebtOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { ud21x18 } from "@prb/math/src/UD21x18.sol"; 5 | 6 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 7 | 8 | contract TotalDebtOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 9 | function test_RevertGiven_Null() external { 10 | bytes memory callData = abi.encodeCall(flow.totalDebtOf, nullStreamId); 11 | expectRevert_Null(callData); 12 | } 13 | 14 | function test_GivenPaused() external givenNotNull { 15 | flow.pause(defaultStreamId); 16 | 17 | assertEq( 18 | flow.totalDebtOf(defaultStreamId), 19 | getDescaledAmount(flow.getSnapshotDebtScaled(defaultStreamId), 6), 20 | "total debt" 21 | ); 22 | } 23 | 24 | function test_WhenCurrentTimeEqualsSnapshotTime() external givenNotNull givenNotPaused { 25 | // Set the snapshot time to the current time by changing rate per second. 26 | flow.adjustRatePerSecond(defaultStreamId, ud21x18(RATE_PER_SECOND_U128 * 2)); 27 | 28 | assertEq( 29 | flow.totalDebtOf(defaultStreamId), 30 | getDescaledAmount(flow.getSnapshotDebtScaled(defaultStreamId), 6), 31 | "total debt" 32 | ); 33 | } 34 | 35 | function test_WhenCurrentTimeGreaterThanSnapshotTime() external view givenNotNull givenNotPaused { 36 | uint256 actualTotalDebt = flow.totalDebtOf(defaultStreamId); 37 | uint256 expectedTotalDebt = getDescaledAmount( 38 | flow.getSnapshotDebtScaled(defaultStreamId) + flow.ongoingDebtScaledOf(defaultStreamId), 6 39 | ); 40 | 41 | assertEq(actualTotalDebt, expectedTotalDebt, "total debt"); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/integration/concrete/total-debt-of/totalDebtOf.tree: -------------------------------------------------------------------------------- 1 | TotalDebtOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── given paused 6 | │ └── it should return snapshot debt 7 | └── given not paused 8 | ├── when current time equals snapshot time 9 | │ └── it should return snapshot debt 10 | └── when current time greater than snapshot time 11 | └── it should return the sum of snapshot debt and ongoing debt 12 | -------------------------------------------------------------------------------- /tests/integration/concrete/transfer-from/transferFrom.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 6 | 7 | import { Errors } from "src/libraries/Errors.sol"; 8 | 9 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 10 | 11 | contract TransferFrom_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 12 | function setUp() public virtual override { 13 | Shared_Integration_Concrete_Test.setUp(); 14 | 15 | // Prank the recipient for this test. 16 | resetPrank({ msgSender: users.recipient }); 17 | } 18 | 19 | function test_RevertGiven_StreamNotTransferable() external { 20 | // Create a non-transferrable stream. 21 | uint256 notTransferableStreamId = flow.create({ 22 | sender: users.sender, 23 | recipient: users.recipient, 24 | ratePerSecond: RATE_PER_SECOND, 25 | token: dai, 26 | transferable: false 27 | }); 28 | 29 | vm.expectRevert( 30 | abi.encodeWithSelector(Errors.SablierFlowBase_NotTransferable.selector, notTransferableStreamId) 31 | ); 32 | flow.transferFrom({ from: users.recipient, to: users.eve, tokenId: notTransferableStreamId }); 33 | } 34 | 35 | function test_GivenStreamTransferable() external { 36 | // It should emit 1 {Transfer} and 1 {MetadataUpdate} event. 37 | vm.expectEmit({ emitter: address(flow) }); 38 | emit IERC721.Transfer({ from: users.recipient, to: users.sender, tokenId: defaultStreamId }); 39 | 40 | vm.expectEmit({ emitter: address(flow) }); 41 | emit IERC4906.MetadataUpdate({ _tokenId: defaultStreamId }); 42 | 43 | flow.transferFrom({ from: users.recipient, to: users.sender, tokenId: defaultStreamId }); 44 | 45 | // It should transfer the NFT. 46 | address actualRecipient = flow.getRecipient(defaultStreamId); 47 | address expectedRecipient = users.sender; 48 | assertEq(actualRecipient, expectedRecipient, "recipient"); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/integration/concrete/transfer-from/transferFrom.tree: -------------------------------------------------------------------------------- 1 | TransferFrom_Integration_Concrete_Test 2 | ├── given stream not transferable 3 | │ └── it should revert 4 | └── given stream transferable 5 | ├── it should transfer the NFT 6 | └── it should emit 1 {Transfer} and 1 {MetadataUpdate} event 7 | -------------------------------------------------------------------------------- /tests/integration/concrete/uncovered-debt-of/uncoveredDebtOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 5 | 6 | contract UncoveredDebtOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 7 | function setUp() public override { 8 | Shared_Integration_Concrete_Test.setUp(); 9 | 10 | // Deposit into the stream. 11 | depositToDefaultStream(); 12 | } 13 | 14 | function test_RevertGiven_Null() external { 15 | bytes memory callData = abi.encodeCall(flow.uncoveredDebtOf, nullStreamId); 16 | expectRevert_Null(callData); 17 | } 18 | 19 | function test_WhenTotalDebtNotExceedBalance() external view givenNotNull { 20 | // It should return zero. 21 | uint256 actualUncoveredDebt = flow.uncoveredDebtOf(defaultStreamId); 22 | assertEq(actualUncoveredDebt, 0, "uncovered debt"); 23 | } 24 | 25 | function test_WhenTotalDebtExceedsBalance() external givenNotNull { 26 | // Simulate the passage of time to accumulate uncovered debt for one month. 27 | vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD + ONE_MONTH }); 28 | 29 | uint256 totalStreamed = getDescaledAmount(RATE_PER_SECOND_U128 * (SOLVENCY_PERIOD + ONE_MONTH), 6); 30 | 31 | // It should return non-zero value. 32 | uint256 actualUncoveredDebt = flow.uncoveredDebtOf(defaultStreamId); 33 | uint256 expectedUncoveredDebt = totalStreamed - DEPOSIT_AMOUNT_6D; 34 | assertEq(actualUncoveredDebt, expectedUncoveredDebt, "uncovered debt"); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/integration/concrete/uncovered-debt-of/uncoveredDebtOf.tree: -------------------------------------------------------------------------------- 1 | UncoveredDebtOf_Integration_Concrete_Test 2 | ├── given null 3 | │ └── it should revert 4 | └── given not null 5 | ├── when total debt not exceed balance 6 | │ └── it should return zero 7 | └── when total debt exceeds balance 8 | └── it should return non-zero value 9 | -------------------------------------------------------------------------------- /tests/integration/concrete/void/void.tree: -------------------------------------------------------------------------------- 1 | Void_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given voided 9 | │ └── it should revert 10 | └── given not voided 11 | ├── when caller not authorized 12 | │ └── it should revert 13 | └── when caller authorized 14 | ├── given stream has no uncovered debt 15 | │ ├── it should void the stream 16 | │ ├── it should set the rate per second to zero 17 | │ └── it should not change the total debt 18 | └── given stream has uncovered debt 19 | ├── when caller sender 20 | │ └── it should void the stream 21 | ├── when caller approved third party 22 | │ └── it should void the stream 23 | └── when caller recipient 24 | ├── it should set the rate per second to zero 25 | ├── it should void the stream 26 | ├── it should pause the stream 27 | ├── it should update the total debt to stream balance 28 | └── it should emit 1 {VoidFlowStream}, 1 {MetadataUpdate} events 29 | -------------------------------------------------------------------------------- /tests/integration/concrete/withdraw-max/withdrawMax.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 10 | 11 | contract WithdrawMax_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 12 | function setUp() public override { 13 | Shared_Integration_Concrete_Test.setUp(); 14 | 15 | // Deposit to the default stream. 16 | depositToDefaultStream(); 17 | } 18 | 19 | function test_RevertWhen_DelegateCall() external { 20 | bytes memory callData = abi.encodeCall(flow.withdrawMax, (defaultStreamId, users.recipient)); 21 | expectRevert_DelegateCall(callData); 22 | } 23 | 24 | function test_RevertGiven_Null() external whenNoDelegateCall { 25 | bytes memory callData = abi.encodeCall(flow.withdrawMax, (nullStreamId, users.recipient)); 26 | expectRevert_Null(callData); 27 | } 28 | 29 | function test_GivenPaused() external whenNoDelegateCall givenNotNull { 30 | // Pause the stream. 31 | flow.pause(defaultStreamId); 32 | 33 | // Withdraw the maximum amount. 34 | _test_WithdrawMax(); 35 | } 36 | 37 | function test_GivenNotPaused() external whenNoDelegateCall givenNotNull { 38 | // Withdraw the maximum amount. 39 | _test_WithdrawMax(); 40 | } 41 | 42 | function _test_WithdrawMax() private { 43 | vars.expectedWithdrawAmount = ONE_MONTH_DEBT_6D; 44 | vars.previousAggregateAmount = flow.aggregateBalance(usdc); 45 | 46 | // It should emit 1 {Transfer}, 1 {WithdrawFromFlowStream} and 1 {MetadataUpdated} events. 47 | vm.expectEmit({ emitter: address(usdc) }); 48 | emit IERC20.Transfer({ from: address(flow), to: users.recipient, value: vars.expectedWithdrawAmount }); 49 | 50 | vm.expectEmit({ emitter: address(flow) }); 51 | emit ISablierFlow.WithdrawFromFlowStream({ 52 | streamId: defaultStreamId, 53 | to: users.recipient, 54 | token: IERC20(address(usdc)), 55 | caller: users.sender, 56 | protocolFeeAmount: 0, 57 | withdrawAmount: vars.expectedWithdrawAmount 58 | }); 59 | 60 | vm.expectEmit({ emitter: address(flow) }); 61 | emit IERC4906.MetadataUpdate({ _tokenId: defaultStreamId }); 62 | 63 | // It should perform the ERC-20 transfer. 64 | expectCallToTransfer({ token: usdc, to: users.recipient, amount: vars.expectedWithdrawAmount }); 65 | 66 | (vars.actualWithdrawnAmount, vars.actualProtocolFeeAmount) = flow.withdrawMax(defaultStreamId, users.recipient); 67 | 68 | // It should update the stream balance. 69 | vars.actualStreamBalance = flow.getBalance(defaultStreamId); 70 | vars.expectedStreamBalance = DEPOSIT_AMOUNT_6D - ONE_MONTH_DEBT_6D; 71 | assertEq(vars.actualStreamBalance, vars.expectedStreamBalance, "stream balance"); 72 | 73 | // It should set the snapshot debt to zero. 74 | vars.actualSnapshotDebtScaled = flow.getSnapshotDebtScaled(defaultStreamId); 75 | assertEq(vars.actualSnapshotDebtScaled, 0, "snapshot debt"); 76 | 77 | if (flow.getRatePerSecond(defaultStreamId).unwrap() > 0) { 78 | // It should update snapshot time. 79 | vars.actualSnapshotTime = flow.getSnapshotTime(defaultStreamId); 80 | assertEq(vars.actualSnapshotTime, getBlockTimestamp(), "snapshot time"); 81 | } 82 | 83 | // It should return the actual withdrawn amount. 84 | assertEq(vars.actualWithdrawnAmount, vars.expectedWithdrawAmount, "withdrawn amount"); 85 | assertEq(vars.actualProtocolFeeAmount, 0, "protocol fee amount"); 86 | 87 | // It should decrease the aggregate amount. 88 | assertEq( 89 | flow.aggregateBalance(usdc), vars.previousAggregateAmount - vars.expectedWithdrawAmount, "aggregate amount" 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/integration/concrete/withdraw-max/withdrawMax.tree: -------------------------------------------------------------------------------- 1 | WithdrawMax_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── given paused 9 | │ ├── it should update snapshot time 10 | │ ├── it should set the snapshot debt to zero 11 | │ ├── it should update the stream balance 12 | │ ├── it should perform the ERC20 transfer 13 | │ ├── it should emit 1 {Transfer}, 1 {WithdrawFromFlowStream} and 1 {MetadataUpdated} events 14 | │ └── it should return the transfer amount 15 | └── given not paused 16 | ├── it should update snapshot time 17 | ├── it should decrease the snapshot debt by the covered debt 18 | ├── it should update the stream balance 19 | ├── it should reduce the aggregate amount by the withdrawn amount 20 | ├── it should perform the ERC20 transfer 21 | ├── it should emit 1 {Transfer}, 1 {WithdrawFromFlowStream} and 1 {MetadataUpdated} events 22 | └── it should return the transfer amount 23 | -------------------------------------------------------------------------------- /tests/integration/concrete/withdraw/withdraw.tree: -------------------------------------------------------------------------------- 1 | Withdraw_Integration_Concrete_Test 2 | ├── when delegate call 3 | │ └── it should revert 4 | └── when no delegate call 5 | ├── given null 6 | │ └── it should revert 7 | └── given not null 8 | ├── when amount zero 9 | │ └── it should revert 10 | └── when amount not zero 11 | ├── when withdrawal address zero 12 | │ └── it should revert 13 | └── when withdrawal address not zero 14 | ├── when withdrawal address not owner 15 | │ ├── when caller sender 16 | │ │ └── it should revert 17 | │ ├── when caller unknown 18 | │ │ └── it should revert 19 | │ └── when caller recipient 20 | │ └── it should withdraw 21 | └── when withdrawal address owner 22 | └── when authorized caller 23 | ├── given balance not exceed total debt 24 | │ ├── when amount exceeds balance 25 | │ │ └── it should revert 26 | │ └── when amount not exceed balance 27 | │ └── it should withdraw 28 | └── given balance exceeds total debt 29 | ├── when amount greater than total debt 30 | │ └── it should revert 31 | ├── when amount equals total debt 32 | │ ├── it should make the withdrawal 33 | │ ├── it should update snapshot debt to zero 34 | │ └── it should update snapshot time to current time 35 | └── when amount less than total debt 36 | ├── when amount less than snapshot debt 37 | │ ├── it should make the withdrawal 38 | │ ├── it should reduce snapshot debt by amount withdrawn 39 | │ └── it should not update snapshot time 40 | ├── when amount equals snapshot debt 41 | │ ├── it should make the withdrawal 42 | │ ├── it should update snapshot debt to zero 43 | │ └── it should not update snapshot time 44 | └── when amount greater than snapshot debt 45 | ├── given protocol fee not zero 46 | │ ├── it should update the protocol revenue 47 | │ └── it should withdraw the net amount 48 | └── given protocol fee zero 49 | ├── given token has 18 decimals 50 | │ └── it should make the withdrawal 51 | └── given token not have 18 decimals 52 | ├── it should make the withdrawal 53 | ├── it should reduce the stream balance by the withdrawn amount 54 | ├── it should reduce the aggregate amount by the withdrawn amount 55 | ├── it should set snapshot debt to difference between total debt and amount withdrawn 56 | ├── it should update snapshot time to current time 57 | ├── it should emit 1 {Transfer}, 1 {WithdrawFromFlowStream} and 1 {MetadataUpdated} events 58 | └── it should return the withdrawn amount 59 | -------------------------------------------------------------------------------- /tests/integration/concrete/withdrawable-amount-of/withdrawableAmountOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; 5 | 6 | contract WithdrawableAmountOf_Integration_Concrete_Test is Shared_Integration_Concrete_Test { 7 | function test_WithdrawableAmountOf() external givenNotNull givenBalanceNotZero { 8 | // Deposit into stream. 9 | depositToDefaultStream(); 10 | 11 | // Simulate one month of streaming. 12 | vm.warp({ newTimestamp: ONE_MONTH_SINCE_START }); 13 | 14 | // It should return the correct withdrawable amount. 15 | uint128 withdrawableAmount = flow.withdrawableAmountOf(defaultStreamId); 16 | assertEq(withdrawableAmount, ONE_MONTH_DEBT_6D, "withdrawable amount"); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/integration/fuzz/Fuzz.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | 6 | import { Base_Test } from "../../Base.t.sol"; 7 | import { Integration_Test } from "../Integration.t.sol"; 8 | 9 | abstract contract Shared_Integration_Fuzz_Test is Integration_Test { 10 | IERC20 internal token; 11 | uint128 internal depositedAmount; 12 | 13 | /*////////////////////////////////////////////////////////////////////////// 14 | FIXTURES 15 | //////////////////////////////////////////////////////////////////////////*/ 16 | 17 | // 40% of fuzz tests will load input parameters from the below fixtures. 18 | address[4] public fixtureCaller = [users.sender, users.recipient, users.operator, users.eve]; 19 | uint256[19] public fixtureStreamId; 20 | 21 | /*////////////////////////////////////////////////////////////////////////// 22 | SET-UP 23 | //////////////////////////////////////////////////////////////////////////*/ 24 | 25 | function setUp() public override { 26 | // Base setup is used because stream created and time warp by Integration setup are not required. 27 | Base_Test.setUp(); 28 | 29 | // Create streams with all possible decimals. 30 | _setupStreamsWithAllDecimals(); 31 | } 32 | 33 | /*////////////////////////////////////////////////////////////////////////// 34 | HELPERS 35 | //////////////////////////////////////////////////////////////////////////*/ 36 | 37 | /// @dev An internal function to fuzz the stream id and decimals based on whether the stream ID exists or not. 38 | /// 39 | /// @param streamId The stream ID to fuzz. 40 | /// @param decimals The decimals to fuzz. 41 | /// 42 | /// @return uint256 The fuzzed stream ID of either a stream picked from the fixture or a new stream. 43 | /// @return uint8 The fuzzed decimals. 44 | /// @return uint128 The fuzzed deposit amount. 45 | function useFuzzedStreamOrCreate(uint256 streamId, uint8 decimals) internal returns (uint256, uint8, uint128) { 46 | // Check if stream id is picked from the fixtures. 47 | if (!flow.isStream(streamId)) { 48 | // If not, create a new stream. 49 | decimals = boundUint8(decimals, 0, 18); 50 | 51 | // Create stream. 52 | (token, streamId) = createTokenAndStream(decimals); 53 | 54 | // Hash the next stream ID and the decimal to generate a seed. 55 | uint128 amountSeed = uint128(uint256(keccak256(abi.encodePacked(flow.nextStreamId(), decimals)))); 56 | // Bound the amount between a realistic range. 57 | uint128 amount = boundUint128(amountSeed, 1e18, 200_000e18); 58 | uint128 depositAmount = uint128(getDescaledAmount(amount, decimals)); 59 | 60 | // Deposit into the stream. 61 | deposit(streamId, depositAmount); 62 | 63 | return (streamId, decimals, depositAmount); 64 | } 65 | 66 | token = flow.getToken(streamId); 67 | decimals = flow.getTokenDecimals(streamId); 68 | return (streamId, decimals, getDefaultDepositAmount(decimals)); 69 | } 70 | 71 | /// @dev Helper function to return the address of either recipient or operator depending on the value of `timeJump`. 72 | /// This function is used to prank the caller in {withdraw}, {withdrawMax} and {void} calls. 73 | function useRecipientOrOperator(uint256 streamId, uint40 timeJump) internal returns (address) { 74 | if (timeJump % 2 != 0) { 75 | return users.recipient; 76 | } else { 77 | resetPrank({ msgSender: users.recipient }); 78 | flow.approve({ to: users.operator, tokenId: streamId }); 79 | return users.operator; 80 | } 81 | } 82 | 83 | function _setupStreamsWithAllDecimals() private { 84 | for (uint8 decimal; decimal < 19; ++decimal) { 85 | // Create token, create stream and deposit. 86 | (token, fixtureStreamId[decimal]) = createTokenAndStream(decimal); 87 | 88 | depositDefaultAmount(fixtureStreamId[decimal]); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/integration/fuzz/coveredDebtOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 5 | 6 | contract CoveredDebtOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 7 | /// @dev It should return the expected value. 8 | /// 9 | /// Given enough runs, all of the following scenarios should be fuzzed: 10 | /// - Multiple paused streams, each with different token decimals and rps. 11 | /// - Multiple points in time prior to depletion period. 12 | function testFuzz_PreDepletion_Paused( 13 | uint256 streamId, 14 | uint40 warpTimestamp, 15 | uint8 decimals 16 | ) 17 | external 18 | givenNotNull 19 | { 20 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 21 | 22 | // Bound the time jump so that it is less than the depletion timestamp. 23 | warpTimestamp = boundUint40(warpTimestamp, getBlockTimestamp(), uint40(flow.depletionTimeOf(streamId)) - 1); 24 | 25 | // Simulate the passage of time. 26 | vm.warp({ newTimestamp: warpTimestamp }); 27 | 28 | uint256 expectedCoveredDebt = flow.coveredDebtOf(streamId); 29 | 30 | // Pause the stream. 31 | flow.pause(streamId); 32 | 33 | // Simulate the passage of time. 34 | vm.warp({ newTimestamp: boundUint40(warpTimestamp, getBlockTimestamp() + 1, UINT40_MAX) }); 35 | 36 | // Assert that the covered debt did not change. 37 | uint256 actualCoveredDebt = flow.coveredDebtOf(streamId); 38 | assertEq(actualCoveredDebt, expectedCoveredDebt); 39 | } 40 | 41 | /// @dev It should return the expected value. 42 | /// 43 | /// Given enough runs, all of the following scenarios should be fuzzed: 44 | /// - Multiple non-paused streams, each with different token decimals and rps. 45 | /// - Multiple points in time prior to depletion period. 46 | function testFuzz_PreDepletion( 47 | uint256 streamId, 48 | uint40 warpTimestamp, 49 | uint8 decimals 50 | ) 51 | external 52 | givenNotNull 53 | givenNotPaused 54 | { 55 | (streamId, decimals,) = useFuzzedStreamOrCreate(streamId, decimals); 56 | 57 | // Bound the time jump so that it is less than the depletion timestamp. 58 | warpTimestamp = boundUint40(warpTimestamp, getBlockTimestamp(), uint40(flow.depletionTimeOf(streamId)) - 1); 59 | 60 | // Simulate the passage of time. 61 | vm.warp({ newTimestamp: warpTimestamp }); 62 | 63 | uint128 ratePerSecond = flow.getRatePerSecond(streamId).unwrap(); 64 | 65 | // Assert that the covered debt equals the ongoing debt. 66 | uint256 actualCoveredDebt = flow.coveredDebtOf(streamId); 67 | uint256 expectedCoveredDebt = getDescaledAmount(ratePerSecond * (warpTimestamp - OCT_1_2024), decimals); 68 | assertEq(actualCoveredDebt, expectedCoveredDebt); 69 | } 70 | 71 | /// @dev It should return the stream balance which is also same as the deposited amount, 72 | /// denoted in token's decimals. 73 | /// 74 | /// Given enough runs, all of the following scenarios should be fuzzed: 75 | /// - Multiple streams, each with different token decimals and rps. 76 | /// - Multiple points in time post depletion period. 77 | function testFuzz_PostDepletion(uint256 streamId, uint40 warpTimestamp, uint8 decimals) external givenNotNull { 78 | (streamId,, depositedAmount) = useFuzzedStreamOrCreate(streamId, decimals); 79 | 80 | // Bound the time jump so it is greater than depletion timestamp. 81 | warpTimestamp = boundUint40(warpTimestamp, uint40(flow.depletionTimeOf(streamId)) + 1, UINT40_MAX); 82 | 83 | // Simulate the passage of time. 84 | vm.warp({ newTimestamp: warpTimestamp }); 85 | 86 | // Assert that the covered debt equals the stream balance. 87 | uint256 actualCoveredDebt = flow.coveredDebtOf(streamId); 88 | assertEq(actualCoveredDebt, flow.getBalance(streamId), "covered debt vs stream balance"); 89 | 90 | // Assert that the covered debt is same as the deposited amount. 91 | assertEq(actualCoveredDebt, depositedAmount, "covered debt vs deposited amount"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/integration/fuzz/create.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 6 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 7 | 8 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 9 | 10 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 11 | 12 | contract Create_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 13 | /// @dev Checklist: 14 | /// - It should create the stream. 15 | /// - It should bump the next stream ID. 16 | /// - It should mint the NFT. 17 | /// - It should emit the following events: {Transfer}, {MetadataUpdate}, {CreateFlowStream} 18 | /// 19 | /// Given enough runs, all of the following scenarios should be fuzzed: 20 | /// - Multiple non-zero values for the sender and recipient. 21 | /// - Multiple values for the rate per second. 22 | /// - Multiple values for token decimals less than or equal to 18. 23 | /// - Both transferable and non-transferable streams. 24 | function testFuzz_Create( 25 | address recipient, 26 | address sender, 27 | UD21x18 ratePerSecond, 28 | uint8 decimals, 29 | bool transferable 30 | ) 31 | external 32 | whenNoDelegateCall 33 | { 34 | // Check the sender and recipient are not zero. 35 | vm.assume(sender != address(0) && recipient != address(0)); 36 | 37 | // Bound the variables. 38 | decimals = boundUint8(decimals, 0, 18); 39 | 40 | // Create a new token. 41 | token = createToken(decimals); 42 | 43 | uint256 expectedStreamId = flow.nextStreamId(); 44 | 45 | // Expect the relevant events to be emitted. 46 | vm.expectEmit({ emitter: address(flow) }); 47 | emit IERC721.Transfer({ from: address(0), to: recipient, tokenId: expectedStreamId }); 48 | 49 | vm.expectEmit({ emitter: address(flow) }); 50 | emit IERC4906.MetadataUpdate({ _tokenId: expectedStreamId }); 51 | 52 | vm.expectEmit({ emitter: address(flow) }); 53 | emit ISablierFlow.CreateFlowStream({ 54 | streamId: expectedStreamId, 55 | sender: sender, 56 | recipient: recipient, 57 | ratePerSecond: ratePerSecond, 58 | token: token, 59 | transferable: transferable 60 | }); 61 | 62 | // Create the stream. 63 | uint256 actualStreamId = flow.create({ 64 | sender: sender, 65 | recipient: recipient, 66 | ratePerSecond: ratePerSecond, 67 | token: token, 68 | transferable: transferable 69 | }); 70 | 71 | // Assert stream's initial states. This is the only place testing for state's getter functions. 72 | assertEq(flow.getBalance(actualStreamId), 0); 73 | assertEq(flow.getSnapshotTime(actualStreamId), getBlockTimestamp()); 74 | assertEq(flow.getRatePerSecond(actualStreamId), ratePerSecond); 75 | assertEq(flow.getRecipient(actualStreamId), recipient); 76 | assertEq(flow.getSnapshotDebtScaled(actualStreamId), 0); 77 | assertEq(flow.getSender(actualStreamId), sender); 78 | assertEq(flow.getToken(actualStreamId), token); 79 | assertEq(flow.getTokenDecimals(actualStreamId), decimals); 80 | assertEq(flow.isStream(actualStreamId), true); 81 | assertEq(flow.isTransferable(actualStreamId), transferable); 82 | 83 | if (flow.getRatePerSecond(actualStreamId).unwrap() == 0) { 84 | assertEq(flow.isPaused(actualStreamId), true); 85 | } else { 86 | assertEq(flow.isPaused(actualStreamId), false); 87 | } 88 | 89 | // Assert that the next stream ID has been bumped. 90 | uint256 actualNextStreamId = flow.nextStreamId(); 91 | uint256 expectedNextStreamId = expectedStreamId + 1; 92 | assertEq(actualNextStreamId, expectedNextStreamId, "nextStreamId"); 93 | 94 | // Assert that the minted NFT has the correct owner. 95 | address actualNFTOwner = flow.ownerOf({ tokenId: expectedStreamId }); 96 | address expectedNFTOwner = recipient; 97 | assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/integration/fuzz/depletionTimeOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 5 | 6 | contract DepletionTimeOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 7 | /// @dev Checklist: 8 | /// - It should return a non-zero value if the current time is less than the depletion timestamp. 9 | /// - It should return 0 if the current time is equal to or greater than the depletion timestamp. 10 | /// 11 | /// Given enough runs, all of the following scenarios should be fuzzed: 12 | /// - Multiple streams, each with different rate per second and decimals. 13 | function testFuzz_DepletionTimeOf( 14 | uint256 streamId, 15 | uint8 decimals 16 | ) 17 | external 18 | givenNotNull 19 | givenPaused 20 | givenBalanceNotZero 21 | { 22 | (streamId, decimals,) = useFuzzedStreamOrCreate(streamId, decimals); 23 | 24 | // Calculate the solvency period based on the stream deposit. 25 | uint256 solvencyPeriod = 26 | getScaledAmount(flow.getBalance(streamId) + 1, decimals) / flow.getRatePerSecond(streamId).unwrap(); 27 | uint256 carry = 28 | getScaledAmount(flow.getBalance(streamId) + 1, decimals) % flow.getRatePerSecond(streamId).unwrap(); 29 | 30 | // Assert that depletion time equals expected value. 31 | uint256 actualDepletionTime = flow.depletionTimeOf(streamId); 32 | uint256 expectedDepletionTime = carry > 0 ? OCT_1_2024 + solvencyPeriod + 1 : OCT_1_2024 + solvencyPeriod; 33 | assertEq(actualDepletionTime, expectedDepletionTime, "depletion time"); 34 | 35 | // Warp time to 1 second before the depletion timestamp. 36 | vm.warp({ newTimestamp: actualDepletionTime - 1 }); 37 | // Assert that total debt does not exceed the stream balance before depletion time. 38 | assertLe( 39 | flow.totalDebtOf(streamId), flow.getBalance(streamId), "pre-depletion period: total debt exceeds balance" 40 | ); 41 | assertLe(flow.depletionTimeOf(streamId), getBlockTimestamp() + 1, "depletion time 1 second in future"); 42 | 43 | // Warp time to the depletion timestamp. 44 | vm.warp({ newTimestamp: actualDepletionTime }); 45 | // Assert that total debt exceeds the stream balance at depletion time. 46 | assertGt( 47 | flow.totalDebtOf(streamId), 48 | flow.getBalance(streamId), 49 | "at depletion time: total debt does not exceed balance" 50 | ); 51 | assertEq(flow.depletionTimeOf(streamId), 0, "non-zero depletion time at depletion timestamp"); 52 | 53 | // Warp time to 1 second after the depletion timestamp. 54 | vm.warp({ newTimestamp: actualDepletionTime + 1 }); 55 | // Assert that total debt exceeds the stream balance after depletion time. 56 | assertGt( 57 | flow.totalDebtOf(streamId), 58 | flow.getBalance(streamId), 59 | "post-depletion time: total debt does not exceed balance" 60 | ); 61 | assertEq(flow.depletionTimeOf(streamId), 0, "non-zero depletion time after depletion timestamp"); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/integration/fuzz/deposit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 10 | 11 | contract Deposit_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 12 | /// @dev Checklist: 13 | /// - It should deposit token into a stream. 40% runs should load streams from fixtures. 14 | /// - It should emit the following events: {Transfer}, {MetadataUpdate}, {DepositFlowStream} 15 | /// 16 | /// Given enough runs, all of the following scenarios should be fuzzed: 17 | /// - Multiple non-zero values for callers. 18 | /// - Multiple non-zero values for deposit amount. 19 | /// - Multiple streams to deposit into, each with different token decimals and rps. 20 | /// - Multiple points in time. 21 | function testFuzz_Deposit( 22 | address caller, 23 | uint256 streamId, 24 | uint128 depositAmount, 25 | uint40 timeJump, 26 | uint8 decimals 27 | ) 28 | external 29 | whenNoDelegateCall 30 | givenNotNull 31 | { 32 | vm.assume(caller != address(0) && caller != address(flow)); 33 | 34 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 35 | 36 | // Following variables are used during assertions. 37 | uint256 initialAggregateAmount = flow.aggregateBalance(token); 38 | uint256 initialTokenBalance = token.balanceOf(address(flow)); 39 | uint128 initialStreamBalance = flow.getBalance(streamId); 40 | 41 | // Bound the deposit amount to avoid overflow. 42 | depositAmount = boundDepositAmount(depositAmount, initialStreamBalance, decimals); 43 | 44 | // Bound the time jump to provide a realistic time frame. 45 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 46 | 47 | // Change prank to caller and deal some tokens to him. 48 | deal({ token: address(token), to: caller, give: depositAmount }); 49 | resetPrank(caller); 50 | 51 | // Approve the flow contract to spend the token. 52 | token.approve(address(flow), depositAmount); 53 | 54 | // Simulate the passage of time. 55 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 56 | 57 | // Expect the relevant events to be emitted. 58 | vm.expectEmit({ emitter: address(token) }); 59 | emit IERC20.Transfer({ from: caller, to: address(flow), value: depositAmount }); 60 | 61 | vm.expectEmit({ emitter: address(flow) }); 62 | emit ISablierFlow.DepositFlowStream({ streamId: streamId, funder: caller, amount: depositAmount }); 63 | 64 | vm.expectEmit({ emitter: address(flow) }); 65 | emit IERC4906.MetadataUpdate({ _tokenId: streamId }); 66 | 67 | // It should perform the ERC-20 transfer. 68 | expectCallToTransferFrom({ token: token, from: caller, to: address(flow), amount: depositAmount }); 69 | 70 | // Make the deposit. 71 | flow.deposit(streamId, depositAmount, users.sender, users.recipient); 72 | 73 | // Assert that the token balance of stream has been updated. 74 | uint256 actualTokenBalance = token.balanceOf(address(flow)); 75 | uint256 expectedTokenBalance = initialTokenBalance + depositAmount; 76 | assertEq(actualTokenBalance, expectedTokenBalance, "token balanceOf"); 77 | 78 | // Assert that stored balance in stream has been updated. 79 | uint256 actualStreamBalance = flow.getBalance(streamId); 80 | uint256 expectedStreamBalance = initialStreamBalance + depositAmount; 81 | assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); 82 | 83 | // Assert that aggregate amount has been updated. 84 | uint256 actualAggregateAmount = flow.aggregateBalance(token); 85 | uint256 expectedAggregateAmount = initialAggregateAmount + depositAmount; 86 | assertEq(actualAggregateAmount, expectedAggregateAmount, "aggregate amount"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/integration/fuzz/ongoingDebtScaledOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 5 | 6 | contract OngoingDebtScaledOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 7 | /// @dev It should return the expected value. 8 | /// 9 | /// Given enough runs, all of the following scenarios should be fuzzed: 10 | /// - Multiple paused streams, each with different token decimals and rps. 11 | /// - Multiple points in time. 12 | function testFuzz_Paused(uint256 streamId, uint40 timeJump, uint8 decimals) external givenNotNull { 13 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 14 | 15 | // Bound the time jump to provide a realistic time frame. 16 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 17 | 18 | // Simulate the passage of time. 19 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 20 | 21 | // Pause the stream. 22 | flow.pause(streamId); 23 | 24 | uint256 expectedOngoingDebtScaled = flow.ongoingDebtScaledOf(streamId); 25 | 26 | // Simulate the passage of time after pause. 27 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 28 | 29 | // Assert that the ongoing debt did not change. 30 | uint256 actualOngoingDebtScaled = flow.ongoingDebtScaledOf(streamId); 31 | assertEq(actualOngoingDebtScaled, expectedOngoingDebtScaled, "ongoing debt"); 32 | } 33 | 34 | /// @dev It should return 0. 35 | /// 36 | /// Given enough runs, all of the following scenarios should be fuzzed: 37 | /// - Multiple non-paused streams, each with different token decimals and rps. 38 | function testFuzz_EqualSnapshotTime( 39 | uint256 streamId, 40 | uint40 timeJump, 41 | uint8 decimals 42 | ) 43 | external 44 | givenNotNull 45 | givenNotPaused 46 | { 47 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 48 | 49 | // Bound the time jump to provide a realistic time frame. 50 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 51 | 52 | // Simulate the passage of time. 53 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 54 | 55 | // Take snapshot. 56 | updateSnapshot(streamId); 57 | 58 | // Assert that ongoing debt is zero. 59 | uint256 actualOngoingDebtScaled = flow.ongoingDebtScaledOf(streamId); 60 | assertEq(actualOngoingDebtScaled, 0, "ongoing debt"); 61 | } 62 | 63 | /// @dev It should return the ongoing debt. 64 | /// 65 | /// Given enough runs, all of the following scenarios should be fuzzed: 66 | /// - Multiple non-paused streams, each with different token decimals and rps. 67 | /// - Multiple points in time after the value of snapshotTime. 68 | function testFuzz_OngoingDebtScaledOf( 69 | uint256 streamId, 70 | uint40 timeJump, 71 | uint8 decimals 72 | ) 73 | external 74 | givenNotNull 75 | givenNotPaused 76 | { 77 | (streamId, decimals,) = useFuzzedStreamOrCreate(streamId, decimals); 78 | 79 | // Take snapshot. 80 | updateSnapshot(streamId); 81 | 82 | // Bound the time jump to provide a realistic time frame. 83 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 84 | 85 | // Simulate the passage of time. 86 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 87 | 88 | uint128 ratePerSecond = flow.getRatePerSecond(streamId).unwrap(); 89 | 90 | // Assert that the ongoing debt equals the expected value. 91 | uint256 actualOngoingDebtScaled = flow.ongoingDebtScaledOf(streamId); 92 | uint256 expectedOngoingDebtScaled = ratePerSecond * timeJump; 93 | assertEq(actualOngoingDebtScaled, expectedOngoingDebtScaled, "ongoing debt"); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/integration/fuzz/pause.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | 6 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 7 | import { Errors } from "src/libraries/Errors.sol"; 8 | 9 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 10 | 11 | contract Pause_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 12 | /// @dev It should revert. 13 | /// 14 | /// Given enough runs, all of the following scenarios should be fuzzed: 15 | /// - Multiple paused streams, each with different token decimals and rps. 16 | /// - Multiple points in time. 17 | function testFuzz_RevertGiven_Paused( 18 | uint256 streamId, 19 | uint40 timeJump, 20 | uint8 decimals 21 | ) 22 | external 23 | whenNoDelegateCall 24 | givenNotNull 25 | { 26 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 27 | 28 | // Make the stream paused. 29 | flow.pause(streamId); 30 | 31 | // Bound the time jump to provide a realistic time frame. 32 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 33 | 34 | // Simulate the passage of time. 35 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 36 | 37 | // Expect the relevant error. 38 | vm.expectRevert(abi.encodeWithSelector(Errors.SablierFlow_StreamPaused.selector, streamId)); 39 | 40 | // Pause the stream. 41 | flow.pause(streamId); 42 | } 43 | 44 | /// @dev Checklist: 45 | /// - It should pause the stream. 46 | /// - It should set rate per second to 0. 47 | /// - It should emit the following events: {MetadataUpdate}, {PauseFlowStream} 48 | /// 49 | /// Given enough runs, all of the following scenarios should be fuzzed: 50 | /// - Multiple non-paused streams, each with different token decimals and rps. 51 | /// - Multiple points in time. 52 | function testFuzz_Pause( 53 | uint256 streamId, 54 | uint40 timeJump, 55 | uint8 decimals 56 | ) 57 | external 58 | whenNoDelegateCall 59 | givenNotNull 60 | givenNotPaused 61 | { 62 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 63 | 64 | // Bound the time jump to provide a realistic time frame. 65 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 66 | 67 | // Simulate the passage of time. 68 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 69 | 70 | // Expect the relevant events to be emitted. 71 | vm.expectEmit({ emitter: address(flow) }); 72 | emit ISablierFlow.PauseFlowStream({ 73 | streamId: streamId, 74 | sender: users.sender, 75 | recipient: users.recipient, 76 | totalDebt: flow.totalDebtOf(streamId) 77 | }); 78 | 79 | vm.expectEmit({ emitter: address(flow) }); 80 | emit IERC4906.MetadataUpdate({ _tokenId: streamId }); 81 | 82 | // Pause the stream. 83 | flow.pause(streamId); 84 | 85 | // Assert that the stream is paused. 86 | assertTrue(flow.isPaused(streamId), "paused"); 87 | 88 | assertEq(flow.ongoingDebtScaledOf(streamId), 0, "ongoing debt"); 89 | 90 | // Assert that the rate per second is 0. 91 | assertEq(flow.getRatePerSecond(streamId), 0, "rate per second"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/integration/fuzz/refundMax.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 10 | 11 | contract RefundMax_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 12 | /// @dev Checklist: 13 | /// - It should refund the refundable amount of tokens from a stream. 14 | /// - It should emit the following events: {Transfer}, {MetadataUpdate}, {RefundFromFlowStream} 15 | /// 16 | /// Given enough runs, all of the following scenarios should be fuzzed: 17 | /// - Multiple streams to refund from, each with different token decimals and rate per second. 18 | /// - Multiple points in time prior to depletion period. 19 | function testFuzz_RefundMax( 20 | uint256 streamId, 21 | uint40 timeJump, 22 | uint8 decimals 23 | ) 24 | external 25 | whenNoDelegateCall 26 | givenNotNull 27 | { 28 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 29 | 30 | // Bound the time jump so that it is less than the depletion timestamp. 31 | uint40 depletionPeriod = uint40(flow.depletionTimeOf(streamId)); 32 | timeJump = boundUint40(timeJump, getBlockTimestamp(), depletionPeriod - 1); 33 | 34 | // Simulate the passage of time. 35 | vm.warp({ newTimestamp: timeJump }); 36 | 37 | uint128 refundableAmount = flow.refundableAmountOf(streamId); 38 | 39 | // Ensure refundable amount is not zero. It could be zero for a small time range upto the depletion time due to 40 | // precision error. 41 | vm.assume(refundableAmount != 0); 42 | 43 | // Following variables are used during assertions. 44 | uint256 initialAggregateAmount = flow.aggregateBalance(token); 45 | uint256 initialTokenBalance = token.balanceOf(address(flow)); 46 | uint128 initialStreamBalance = flow.getBalance(streamId); 47 | 48 | // Expect the relevant events to be emitted. 49 | vm.expectEmit({ emitter: address(token) }); 50 | emit IERC20.Transfer({ from: address(flow), to: users.sender, value: refundableAmount }); 51 | 52 | vm.expectEmit({ emitter: address(flow) }); 53 | emit ISablierFlow.RefundFromFlowStream({ streamId: streamId, sender: users.sender, amount: refundableAmount }); 54 | 55 | vm.expectEmit({ emitter: address(flow) }); 56 | emit IERC4906.MetadataUpdate({ _tokenId: streamId }); 57 | 58 | // Request the maximum refund. 59 | flow.refundMax(streamId); 60 | 61 | // Assert that the token balance of stream has been updated. 62 | uint256 actualTokenBalance = token.balanceOf(address(flow)); 63 | uint256 expectedTokenBalance = initialTokenBalance - refundableAmount; 64 | assertEq(actualTokenBalance, expectedTokenBalance, "token balanceOf"); 65 | 66 | // Assert that stored balance in stream has been updated. 67 | uint256 actualStreamBalance = flow.getBalance(streamId); 68 | uint256 expectedStreamBalance = initialStreamBalance - refundableAmount; 69 | assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); 70 | 71 | // Assert that the aggregate amount has been updated. 72 | uint256 actualAggregateAmount = flow.aggregateBalance(token); 73 | uint256 expectedAggregateAmount = initialAggregateAmount - refundableAmount; 74 | assertEq(actualAggregateAmount, expectedAggregateAmount, "aggregate amount"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/integration/fuzz/refundableAmountOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 5 | 6 | contract RefundableAmountOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 7 | /// @dev It should return the refundable amount equal to the deposited amount, denoted in token's decimals. 8 | /// 9 | /// Given enough runs, all of the following scenarios should be fuzzed: 10 | /// - Multiple paused streams, each with different token decimals and rps. 11 | /// - Multiple points in time prior to depletion period. 12 | function testFuzz_PreDepletion_Paused( 13 | uint256 streamId, 14 | uint40 warpTimestamp, 15 | uint8 decimals 16 | ) 17 | external 18 | givenNotNull 19 | { 20 | (streamId,, depositedAmount) = useFuzzedStreamOrCreate(streamId, decimals); 21 | 22 | uint40 depletionPeriod = uint40(flow.depletionTimeOf(streamId)); 23 | 24 | // Pause the stream. 25 | flow.pause(streamId); 26 | 27 | uint128 previousStreamBalance = flow.getBalance(streamId); 28 | 29 | // Bound the time jump so that it is less than the depletion timestamp. 30 | warpTimestamp = boundUint40(warpTimestamp, getBlockTimestamp(), depletionPeriod - 1); 31 | 32 | // Simulate the passage of time. 33 | vm.warp({ newTimestamp: warpTimestamp }); 34 | 35 | // Assert that the refundable amount equals the stream balance before the time warp. 36 | uint128 actualRefundableAmount = flow.refundableAmountOf(streamId); 37 | assertEq(actualRefundableAmount, previousStreamBalance); 38 | 39 | // Assert that the refundable amount is same as the deposited amount. 40 | assertEq(actualRefundableAmount, depositedAmount); 41 | } 42 | 43 | /// @dev It should return the refundable amount equal to the deposited amount minus streamed amount. 44 | /// 45 | /// Given enough runs, all of the following scenarios should be fuzzed: 46 | /// - Multiple non-paused streams, each with different token decimals and rps. 47 | /// - Multiple points in time prior to depletion period. 48 | function testFuzz_PreDepletion( 49 | uint256 streamId, 50 | uint40 warpTimestamp, 51 | uint8 decimals 52 | ) 53 | external 54 | givenNotNull 55 | givenNotPaused 56 | { 57 | (streamId, decimals, depositedAmount) = useFuzzedStreamOrCreate(streamId, decimals); 58 | 59 | // Bound the time jump so that it is less than the depletion timestamp. 60 | warpTimestamp = boundUint40(warpTimestamp, getBlockTimestamp(), uint40(flow.depletionTimeOf(streamId)) - 1); 61 | 62 | // Simulate the passage of time. 63 | vm.warp({ newTimestamp: warpTimestamp }); 64 | 65 | uint128 ratePerSecond = flow.getRatePerSecond(streamId).unwrap(); 66 | 67 | // Assert that the refundable amount same as the deposited amount minus streamed amount. 68 | uint256 actualRefundableAmount = flow.refundableAmountOf(streamId); 69 | uint256 expectedRefundableAmount = 70 | depositedAmount - getDescaledAmount(ratePerSecond * (warpTimestamp - OCT_1_2024), decimals); 71 | assertEq(actualRefundableAmount, expectedRefundableAmount); 72 | } 73 | 74 | /// @dev It should return the zero value for refundable amount. 75 | /// 76 | /// Given enough runs, all of the following scenarios should be fuzzed: 77 | /// - Multiple streams, each with different token decimals and rps. 78 | /// - Multiple points in time post depletion period. 79 | function testFuzz_PostDepletion(uint256 streamId, uint40 warpTimestamp, uint8 decimals) external givenNotNull { 80 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 81 | 82 | // Bound the time jump so it is greater than depletion timestamp. 83 | uint40 depletionPeriod = uint40(flow.depletionTimeOf(streamId)); 84 | warpTimestamp = boundUint40(warpTimestamp, depletionPeriod + 1, UINT40_MAX); 85 | 86 | // Simulate the passage of time. 87 | vm.warp({ newTimestamp: warpTimestamp }); 88 | 89 | // Assert that the refundable amount is zero. 90 | uint256 actualRefundableAmount = flow.refundableAmountOf(streamId); 91 | assertEq(actualRefundableAmount, 0); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/integration/fuzz/restart.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; 5 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | 9 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 10 | 11 | contract Restart_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 12 | /// @dev Checklist: 13 | /// - It should restart the stream. 14 | /// - It should update rate per second. 15 | /// - It should update snapshot time. 16 | /// - It should emit the following events: {MetadataUpdate}, {RestartFlowStream} 17 | /// 18 | /// Given enough runs, all of the following scenarios should be fuzzed: 19 | /// - Multiple paused streams. 20 | /// - Multiple points in time. 21 | function testFuzz_Restart( 22 | uint256 streamId, 23 | UD21x18 ratePerSecond, 24 | uint40 timeJump, 25 | uint8 decimals 26 | ) 27 | external 28 | whenNoDelegateCall 29 | givenNotNull 30 | { 31 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 32 | 33 | ratePerSecond = boundRatePerSecond(ratePerSecond); 34 | 35 | // Pause the stream. 36 | flow.pause(streamId); 37 | 38 | // Bound the time jump to provide a realistic time frame. 39 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 40 | 41 | uint40 warpTimestamp = getBlockTimestamp() + timeJump; 42 | 43 | // Simulate the passage of time. 44 | vm.warp({ newTimestamp: warpTimestamp }); 45 | 46 | // Expect the relevant events to be emitted. 47 | vm.expectEmit({ emitter: address(flow) }); 48 | emit ISablierFlow.RestartFlowStream({ streamId: streamId, sender: users.sender, ratePerSecond: ratePerSecond }); 49 | 50 | vm.expectEmit({ emitter: address(flow) }); 51 | emit IERC4906.MetadataUpdate({ _tokenId: streamId }); 52 | 53 | // Restart the stream. 54 | flow.restart(streamId, ratePerSecond); 55 | 56 | // It should restart the stream. 57 | assertFalse(flow.isPaused(streamId), "isPaused"); 58 | 59 | // It should update rate per second. 60 | UD21x18 actualRatePerSecond = flow.getRatePerSecond(streamId); 61 | assertEq(actualRatePerSecond, ratePerSecond, "ratePerSecond"); 62 | 63 | // It should update snapshot time. 64 | uint40 actualSnapshotTime = flow.getSnapshotTime(streamId); 65 | assertEq(actualSnapshotTime, warpTimestamp, "snapshotTime"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/integration/fuzz/totalDebtOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 5 | 6 | contract TotalDebtOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 7 | /// @dev It should return expected value. 8 | /// 9 | /// Given enough runs, all of the following scenarios should be fuzzed: 10 | /// - Multiple paused streams, each with different token decimals and rps. 11 | /// - Multiple points in time. It includes pre-depletion and post-depletion. 12 | function testFuzz_Paused(uint256 streamId, uint40 timeJump, uint8 decimals) external givenNotNull { 13 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 14 | 15 | // Bound the time jump to provide a realistic time frame. 16 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 17 | 18 | // Simulate the passage of time. 19 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 20 | 21 | // Pause the stream. 22 | flow.pause(streamId); 23 | 24 | uint256 expectedTotalDebt = flow.totalDebtOf(streamId); 25 | 26 | // Simulate the passage of time after pause. 27 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 28 | 29 | // Assert that total debt is zero. 30 | uint256 actualTotalDebt = flow.totalDebtOf(streamId); 31 | assertEq(actualTotalDebt, expectedTotalDebt, "total debt"); 32 | } 33 | 34 | /// @dev It should return the ongoing debt until that moment. 35 | /// 36 | /// Given enough runs, all of the following scenarios should be fuzzed: 37 | /// - Multiple non-paused streams, each with different token decimals and rps. 38 | /// - Multiple points in time. It includes pre-depletion and post-depletion. 39 | function testFuzz_TotalDebtOf( 40 | uint256 streamId, 41 | uint40 timeJump, 42 | uint8 decimals 43 | ) 44 | external 45 | givenNotNull 46 | givenNotPaused 47 | { 48 | (streamId, decimals,) = useFuzzedStreamOrCreate(streamId, decimals); 49 | 50 | // Bound the time jump to provide a realistic time frame. 51 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 52 | 53 | // Simulate the passage of time. 54 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 55 | 56 | uint128 ratePerSecond = flow.getRatePerSecond(streamId).unwrap(); 57 | 58 | // Assert that total debt is the ongoing debt. 59 | uint256 actualTotalDebt = flow.totalDebtOf(streamId); 60 | uint256 expectedTotalDebt = getDescaledAmount(ratePerSecond * timeJump, decimals); 61 | assertEq(actualTotalDebt, expectedTotalDebt, "total debt"); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/integration/fuzz/uncoveredDebtOf.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol"; 5 | 6 | contract UncoveredDebtOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test { 7 | /// @dev It should return the same uncovered debt for paused streams. 8 | /// 9 | /// Given enough runs, all of the following scenarios should be fuzzed: 10 | /// - Multiple paused streams, each with different rate per second and decimals. 11 | /// - Multiple points in time, both pre-depletion and post-depletion. 12 | function testFuzz_Paused(uint256 streamId, uint40 timeJump, uint8 decimals) external givenNotNull { 13 | (streamId,,) = useFuzzedStreamOrCreate(streamId, decimals); 14 | 15 | // Bound the time jump to provide a realistic time frame. 16 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 17 | 18 | // Simulate the passage of time. 19 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 20 | 21 | // Pause the stream. 22 | flow.pause(streamId); 23 | 24 | uint256 expectedUncoveredDebt = flow.uncoveredDebtOf(streamId); 25 | 26 | // Simulate the passage of time after pause. 27 | vm.warp({ newTimestamp: getBlockTimestamp() + timeJump }); 28 | 29 | // Assert that uncovered debt equals expected value. 30 | uint256 actualUncoveredDebt = flow.uncoveredDebtOf(streamId); 31 | assertEq(actualUncoveredDebt, expectedUncoveredDebt, "uncovered debt"); 32 | } 33 | 34 | /// @dev Checklist: 35 | /// - It should return 0 if the current time is less than the depletion time. 36 | /// - It should return the difference between total debt and stream balance if the current time is greater than the 37 | /// depletion time. 38 | /// 39 | /// Given enough runs, all of the following scenarios should be fuzzed: 40 | /// - Multiple non-paused streams, each with different rate per second and decimals. 41 | /// - Multiple points in time, both pre-depletion and post-depletion. 42 | function testFuzz_UncoveredDebtOf( 43 | uint256 streamId, 44 | uint40 timeJump, 45 | uint8 decimals 46 | ) 47 | external 48 | givenNotNull 49 | givenNotPaused 50 | { 51 | (streamId,, depositedAmount) = useFuzzedStreamOrCreate(streamId, decimals); 52 | 53 | uint128 balance = flow.getBalance(streamId); 54 | uint40 depletionTime = uint40(flow.depletionTimeOf(streamId)); 55 | 56 | // Bound the time jump to provide a realistic time frame. 57 | timeJump = boundUint40(timeJump, 0 seconds, 100 weeks); 58 | 59 | uint40 warpTimestamp = getBlockTimestamp() + timeJump; 60 | 61 | // Simulate the passage of time. 62 | vm.warp({ newTimestamp: warpTimestamp }); 63 | 64 | // Assert that the uncovered debt equals expected value. 65 | uint256 actualUncoveredDebt = flow.uncoveredDebtOf(streamId); 66 | uint256 expectedUncoveredDebt; 67 | if (warpTimestamp > depletionTime) { 68 | expectedUncoveredDebt = flow.totalDebtOf(streamId) - balance; 69 | } else { 70 | expectedUncoveredDebt = 0; 71 | } 72 | 73 | // Assert that the uncovered debt is the same as the expected value. 74 | assertEq(actualUncoveredDebt, expectedUncoveredDebt); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/invariant/handlers/BaseHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { StdCheats } from "forge-std/src/StdCheats.sol"; 5 | 6 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 7 | 8 | import { Utils } from "../../utils/Utils.sol"; 9 | import { FlowStore } from "../stores/FlowStore.sol"; 10 | 11 | /// @notice Base contract with common logic needed by all handler contracts. 12 | abstract contract BaseHandler is StdCheats, Utils { 13 | /*////////////////////////////////////////////////////////////////////////// 14 | VARIABLES 15 | //////////////////////////////////////////////////////////////////////////*/ 16 | 17 | /// @dev Maximum number of streams that can be created during an invariant campaign. 18 | uint256 internal constant MAX_STREAM_COUNT = 100; 19 | 20 | /// @dev Maps function names and the number of times they have been called by the stream ID. 21 | mapping(uint256 streamId => mapping(string func => uint256 calls)) public calls; 22 | 23 | /// @dev The total number of calls made to a specific function. 24 | mapping(string func => uint256 calls) public totalCalls; 25 | 26 | /*////////////////////////////////////////////////////////////////////////// 27 | TEST CONTRACTS 28 | //////////////////////////////////////////////////////////////////////////*/ 29 | 30 | ISablierFlow public flow; 31 | FlowStore public flowStore; 32 | 33 | /*////////////////////////////////////////////////////////////////////////// 34 | CONSTRUCTOR 35 | //////////////////////////////////////////////////////////////////////////*/ 36 | 37 | constructor(FlowStore flowStore_, ISablierFlow flow_) { 38 | flowStore = flowStore_; 39 | flow = flow_; 40 | } 41 | 42 | /*////////////////////////////////////////////////////////////////////////// 43 | MODIFIERS 44 | //////////////////////////////////////////////////////////////////////////*/ 45 | 46 | /// @dev Simulates the passage of time. The time jump is kept under 40 days to prevent the streamed amount 47 | /// from becoming excessively large. 48 | /// @param timeJump A fuzzed value for time warps. 49 | modifier adjustTimestamp(uint256 timeJump) { 50 | vm.assume(timeJump < 40 days); 51 | vm.warp(getBlockTimestamp() + timeJump); 52 | _; 53 | } 54 | 55 | /// @dev Records a function call for instrumentation purposes. 56 | modifier instrument(uint256 streamId, string memory functionName) { 57 | if (streamId > 0) { 58 | calls[streamId][functionName]++; 59 | } 60 | totalCalls[functionName]++; 61 | _; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/invariant/handlers/FlowAdminHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; 5 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 6 | import { UD60x18 } from "@prb/math/src/UD60x18.sol"; 7 | import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; 8 | import { FlowStore } from "./../stores/FlowStore.sol"; 9 | import { BaseHandler } from "./BaseHandler.sol"; 10 | 11 | contract FlowAdminHandler is BaseHandler { 12 | IERC20 internal currentToken; 13 | 14 | /*////////////////////////////////////////////////////////////////////////// 15 | MODIFIERS 16 | //////////////////////////////////////////////////////////////////////////*/ 17 | 18 | /// @dev Since all admin-related functions are rarely called compared to core flow functionalities, 19 | /// we limit the number of calls to 10. 20 | modifier limitNumberOfCalls(string memory name) { 21 | vm.assume(totalCalls[name] < 10); 22 | _; 23 | } 24 | 25 | modifier setCallerAdmin() { 26 | resetPrank(flow.admin()); 27 | _; 28 | } 29 | 30 | modifier useFuzzedToken(uint256 tokenIndex) { 31 | IERC20[] memory tokens = flowStore.getTokens(); 32 | vm.assume(tokenIndex < tokens.length); 33 | currentToken = tokens[tokenIndex]; 34 | _; 35 | } 36 | 37 | /*////////////////////////////////////////////////////////////////////////// 38 | CONSTRUCTOR 39 | //////////////////////////////////////////////////////////////////////////*/ 40 | 41 | constructor(FlowStore flowStore_, ISablierFlow flow_) BaseHandler(flowStore_, flow_) { } 42 | 43 | /*////////////////////////////////////////////////////////////////////////// 44 | SABLIER-FLOW-BASE 45 | //////////////////////////////////////////////////////////////////////////*/ 46 | 47 | function collectProtocolRevenue(uint256 tokenIndex) 48 | external 49 | limitNumberOfCalls("collectProtocolRevenue") 50 | instrument(0, "collectProtocolRevenue") 51 | useFuzzedToken(tokenIndex) 52 | setCallerAdmin 53 | { 54 | vm.assume(flow.protocolRevenue(currentToken) > 0); 55 | 56 | flow.collectProtocolRevenue(currentToken, flow.admin()); 57 | } 58 | 59 | /// @dev Function to increase the flow contract balance for the fuzzed token. 60 | function randomTransfer(uint256 tokenIndex, uint256 amount) external useFuzzedToken(tokenIndex) { 61 | vm.assume(amount > 0 && amount < 100e18); 62 | amount *= 10 ** IERC20Metadata(address(currentToken)).decimals(); 63 | 64 | deal({ token: address(currentToken), to: address(flow), give: currentToken.balanceOf(address(flow)) + amount }); 65 | } 66 | 67 | function recover(uint256 tokenIndex) 68 | external 69 | limitNumberOfCalls("recover") 70 | instrument(0, "recover") 71 | useFuzzedToken(tokenIndex) 72 | setCallerAdmin 73 | { 74 | vm.assume(currentToken.balanceOf(address(flow)) > flow.aggregateBalance(currentToken)); 75 | 76 | flow.recover(currentToken, flow.admin()); 77 | } 78 | 79 | function setProtocolFee( 80 | uint256 tokenIndex, 81 | UD60x18 newProtocolFee 82 | ) 83 | external 84 | limitNumberOfCalls("setProtocolFee") 85 | instrument(0, "setProtocolFee") 86 | useFuzzedToken(tokenIndex) 87 | setCallerAdmin 88 | { 89 | vm.assume(newProtocolFee.lt(MAX_FEE)); 90 | 91 | flow.setProtocolFee(currentToken, newProtocolFee); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/mocks/ERC20MissingReturn.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | // solhint-disable reason-string 3 | pragma solidity >=0.8.22; 4 | 5 | /// @notice An implementation of ERC-20 that does not return a boolean in {transfer} and {transferFrom}. 6 | /// @dev See https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca/. 7 | contract ERC20MissingReturn { 8 | uint8 public decimals; 9 | string public name; 10 | string public symbol; 11 | uint256 public totalSupply; 12 | 13 | mapping(address owner => mapping(address spender => uint256 allowance)) internal _allowances; 14 | mapping(address account => uint256 balance) internal _balances; 15 | 16 | event Transfer(address indexed from, address indexed to, uint256 value); 17 | 18 | event Approval(address indexed owner, address indexed spender, uint256 value); 19 | 20 | constructor(string memory name_, string memory symbol_, uint8 decimals_) { 21 | name = name_; 22 | symbol = symbol_; 23 | decimals = decimals_; 24 | } 25 | 26 | function allowance(address owner, address spender) public view returns (uint256) { 27 | return _allowances[owner][spender]; 28 | } 29 | 30 | function balanceOf(address account) public view returns (uint256) { 31 | return _balances[account]; 32 | } 33 | 34 | function approve(address spender, uint256 value) public returns (bool) { 35 | _approve(msg.sender, spender, value); 36 | return true; 37 | } 38 | 39 | function burn(address holder, uint256 amount) public { 40 | require(holder != address(0), "ERC20: burn from the zero address"); 41 | require(_balances[holder] >= amount, "ERC20: burn amount exceeds balance"); 42 | _balances[holder] -= amount; 43 | totalSupply -= amount; 44 | emit Transfer(holder, address(0), amount); 45 | } 46 | 47 | function mint(address beneficiary, uint256 amount) public { 48 | require(beneficiary != address(0), "ERC20: mint to the zero address"); 49 | _balances[beneficiary] += amount; 50 | totalSupply += amount; 51 | emit Transfer(address(0), beneficiary, amount); 52 | } 53 | 54 | function _approve(address owner, address spender, uint256 value) internal virtual { 55 | require(owner != address(0), "ERC20: approve from the zero address"); 56 | require(spender != address(0), "ERC20: approve to the zero address"); 57 | _allowances[owner][spender] = value; 58 | emit Approval(owner, spender, value); 59 | } 60 | 61 | /// @dev This function does not return a value, although the ERC-20 standard mandates that it should. 62 | function transfer(address to, uint256 amount) public { 63 | _transfer(msg.sender, to, amount); 64 | } 65 | 66 | /// @dev This function does not return a value, although the ERC-20 standard mandates that it should. 67 | function transferFrom(address from, address to, uint256 amount) public { 68 | require(_allowances[from][msg.sender] >= amount, "ERC20: insufficient allowance"); 69 | _transfer(from, to, amount); 70 | _approve(from, msg.sender, _allowances[from][msg.sender] - amount); 71 | } 72 | 73 | function _transfer(address from, address to, uint256 amount) internal virtual { 74 | require(from != address(0), "ERC20: transfer from the zero address"); 75 | require(to != address(0), "ERC20: transfer to the zero address"); 76 | require(_balances[from] >= amount); // no revert message because this case is tested in {Batch} 77 | _balances[from] = _balances[from] - amount; 78 | _balances[to] = _balances[to] + amount; 79 | emit Transfer(from, to, amount); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/mocks/ERC20Mock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract ERC20Mock is ERC20 { 7 | uint8 internal immutable DECIMAL; 8 | 9 | constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { 10 | DECIMAL = decimals_; 11 | } 12 | 13 | function decimals() public view override returns (uint8) { 14 | return DECIMAL; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/mocks/Receive.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22 <0.9.0; 3 | 4 | contract ContractWithoutReceive { } 5 | 6 | contract ContractWithReceive { 7 | receive() external payable { } 8 | } 9 | -------------------------------------------------------------------------------- /tests/utils/.npmignore: -------------------------------------------------------------------------------- 1 | *.t.sol 2 | -------------------------------------------------------------------------------- /tests/utils/Assertions.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import { PRBMathAssertions } from "@prb/math/test/utils/Assertions.sol"; 6 | 7 | import { Flow } from "../../src/types/DataTypes.sol"; 8 | 9 | abstract contract Assertions is PRBMathAssertions { 10 | /*////////////////////////////////////////////////////////////////////////// 11 | ASSERTIONS 12 | //////////////////////////////////////////////////////////////////////////*/ 13 | 14 | /// @dev Compares two {IERC20} values. 15 | function assertEq(IERC20 a, IERC20 b) internal pure { 16 | assertEq(address(a), address(b)); 17 | } 18 | 19 | /// @dev Compares two {IERC20} values. 20 | function assertEq(IERC20 a, IERC20 b, string memory err) internal pure { 21 | assertEq(address(a), address(b), err); 22 | } 23 | 24 | /// @dev Compares two {Flow.Stream} struct entities. 25 | function assertEq(Flow.Stream memory a, Flow.Stream memory b) internal pure { 26 | assertEq(a.ratePerSecond, b.ratePerSecond, "ratePerSecond"); 27 | assertEq(a.balance, b.balance, "balance"); 28 | assertEq(a.snapshotTime, b.snapshotTime, "snapshotTime"); 29 | assertEq(a.isStream, b.isStream, "isStream"); 30 | assertEq(a.isTransferable, b.isTransferable, "isTransferable"); 31 | assertEq(a.isVoided, b.isVoided, "isVoided"); 32 | assertEq(a.snapshotDebtScaled, b.snapshotDebtScaled, "snapshotDebtScaled"); 33 | assertEq(a.sender, b.sender, "sender"); 34 | assertEq(a.token, b.token, "token"); 35 | assertEq(a.tokenDecimals, b.tokenDecimals, "tokenDecimals"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/utils/Constants.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 5 | import { UD60x18, ud } from "@prb/math/src/UD60x18.sol"; 6 | 7 | abstract contract Constants { 8 | // Amounts 9 | uint128 internal constant DEPOSIT_AMOUNT_18D = 50_000e18; 10 | uint128 internal constant DEPOSIT_AMOUNT_6D = 50_000e6; 11 | uint256 internal constant FEE = 0.001e18; 12 | uint128 internal constant REFUND_AMOUNT_18D = 10_000e18; 13 | uint128 internal constant REFUND_AMOUNT_6D = 10_000e6; 14 | uint128 internal constant TOTAL_AMOUNT_WITH_BROKER_FEE_18D = DEPOSIT_AMOUNT_18D + BROKER_FEE_AMOUNT_18D; 15 | uint128 internal constant TOTAL_AMOUNT_WITH_BROKER_FEE_6D = DEPOSIT_AMOUNT_6D + BROKER_FEE_AMOUNT_6D; 16 | uint128 internal constant TRANSFER_VALUE = 50_000; 17 | uint128 internal constant WITHDRAW_AMOUNT_18D = 500e18; 18 | uint128 internal constant WITHDRAW_AMOUNT_6D = 500e6; 19 | 20 | // Fees 21 | UD60x18 internal constant BROKER_FEE = UD60x18.wrap(0.01e18); // 1% 22 | uint128 internal constant BROKER_FEE_AMOUNT_18D = 505.050505050505050505e18; // 1% of total amount 23 | uint128 internal constant BROKER_FEE_AMOUNT_6D = 505.050505e6; // 1% of total amount 24 | UD60x18 internal constant MAX_FEE = UD60x18.wrap(0.1e18); // 10% 25 | UD60x18 internal constant PROTOCOL_FEE = UD60x18.wrap(0.01e18); // 1% 26 | uint128 internal immutable PROTOCOL_FEE_AMOUNT_18D = ud(WITHDRAW_AMOUNT_18D).mul(PROTOCOL_FEE).intoUint128(); 27 | uint128 internal immutable PROTOCOL_FEE_AMOUNT_6D = ud(WITHDRAW_AMOUNT_6D).mul(PROTOCOL_FEE).intoUint128(); 28 | 29 | // Max value 30 | uint128 internal constant UINT128_MAX = type(uint128).max; 31 | uint40 internal constant UINT40_MAX = type(uint40).max; 32 | 33 | // Misc 34 | uint8 internal constant DECIMALS = 6; 35 | UD21x18 internal constant RATE_PER_SECOND = UD21x18.wrap(0.001e18); // 86.4 daily 36 | uint128 internal constant RATE_PER_SECOND_U128 = 0.001e18; // 86.4 daily 37 | uint256 internal constant SCALE_FACTOR = 10 ** 12; 38 | bool internal constant TRANSFERABLE = true; 39 | 40 | // Streaming amounts 41 | uint128 internal constant ONE_MONTH_DEBT_6D = 2592e6; // 86.4 * 30 42 | uint128 internal constant ONE_MONTH_DEBT_18D = 2592e18; // 86.4 * 30 43 | uint128 internal constant ONE_MONTH_REFUNDABLE_AMOUNT_6D = DEPOSIT_AMOUNT_6D - ONE_MONTH_DEBT_6D; 44 | 45 | // Time 46 | uint40 internal constant OCT_1_2024 = 1_727_740_800; 47 | uint40 internal constant ONE_MONTH = 30 days; // "30/360" convention 48 | uint40 internal constant ONE_MONTH_SINCE_START = OCT_1_2024 + ONE_MONTH; 49 | // Solvency period is 49999999.999999 seconds. 50 | uint40 internal constant SOLVENCY_PERIOD = uint40(DEPOSIT_AMOUNT_18D / RATE_PER_SECOND_U128); // ~578 days 51 | // The following variable represents the timestamp at which the stream depletes all its balance. 52 | uint40 internal constant WARP_SOLVENCY_PERIOD = OCT_1_2024 + SOLVENCY_PERIOD; 53 | uint40 internal constant WITHDRAW_TIME = OCT_1_2024 + 2_500_000; 54 | } 55 | -------------------------------------------------------------------------------- /tests/utils/Types.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | struct Users { 5 | // Default protocol admin. 6 | address payable admin; 7 | // Default stream broker. 8 | address payable broker; 9 | // Malicious user. 10 | address payable eve; 11 | // Default NFT operator. 12 | address payable operator; 13 | // Default stream recipient. 14 | address payable recipient; 15 | // Default stream sender. 16 | address payable sender; 17 | } 18 | -------------------------------------------------------------------------------- /tests/utils/Utils.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity >=0.8.22; 3 | 4 | import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; 5 | import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | import { PRBMathUtils } from "@prb/math/test/utils/Utils.sol"; 7 | import { CommonBase } from "forge-std/src/Base.sol"; 8 | import { SafeCastLib } from "solady/src/utils/SafeCastLib.sol"; 9 | import { Constants } from "./Constants.sol"; 10 | 11 | abstract contract Utils is CommonBase, Constants, PRBMathUtils { 12 | using SafeCastLib for uint256; 13 | 14 | /// @dev Bound deposit amount to avoid overflow. 15 | function boundDepositAmount( 16 | uint128 amount, 17 | uint128 balance, 18 | uint8 decimals 19 | ) 20 | internal 21 | pure 22 | returns (uint128 depositAmount) 23 | { 24 | uint128 maxDepositAmount = (UINT128_MAX - balance); 25 | if (decimals < 18) { 26 | maxDepositAmount = maxDepositAmount / uint128(10 ** (18 - decimals)); 27 | } 28 | 29 | depositAmount = boundUint128(amount, 1, maxDepositAmount - 1); 30 | } 31 | 32 | /// @dev Bounds the rate per second between a realistic range i.e. for USDC [$50/month $5000/month]. 33 | function boundRatePerSecond(UD21x18 ratePerSecond) internal pure returns (UD21x18) { 34 | return ud21x18(boundUint128(ratePerSecond.unwrap(), 0.00002e18, 0.002e18)); 35 | } 36 | 37 | /// @dev Bounds a `uint128` number. 38 | function boundUint128(uint128 x, uint128 min, uint128 max) internal pure returns (uint128) { 39 | return uint128(_bound(uint256(x), uint256(min), uint256(max))); 40 | } 41 | 42 | /// @dev Bounds a `uint40` number. 43 | function boundUint40(uint40 x, uint40 min, uint40 max) internal pure returns (uint40) { 44 | return uint40(_bound(uint256(x), uint256(min), uint256(max))); 45 | } 46 | 47 | /// @dev Bounds a `uint8` number. 48 | function boundUint8(uint8 x, uint8 min, uint8 max) internal pure returns (uint8) { 49 | return uint8(_bound(uint256(x), uint256(min), uint256(max))); 50 | } 51 | 52 | /// @dev Retrieves the current block timestamp as an `uint40`. 53 | function getBlockTimestamp() internal view returns (uint40) { 54 | return uint40(block.timestamp); 55 | } 56 | 57 | /// @dev Calculates the default deposit amount using `TRANSFER_VALUE` and `decimals`. 58 | function getDefaultDepositAmount(uint8 decimals) internal pure returns (uint128 depositAmount) { 59 | return TRANSFER_VALUE * (10 ** decimals).toUint128(); 60 | } 61 | 62 | /// @dev Descales the amount to denote it in token's decimals. 63 | function getDescaledAmount(uint256 amount, uint8 decimals) internal pure returns (uint256) { 64 | if (decimals == 18) { 65 | return amount; 66 | } 67 | 68 | uint256 scaleFactor = (10 ** (18 - decimals)); 69 | return amount / scaleFactor; 70 | } 71 | 72 | /// @dev Scales the amount to denote it in 18 decimals. 73 | function getScaledAmount(uint256 amount, uint8 decimals) internal pure returns (uint256) { 74 | if (decimals == 18) { 75 | return amount; 76 | } 77 | 78 | uint256 scaleFactor = (10 ** (18 - decimals)); 79 | return amount * scaleFactor; 80 | } 81 | 82 | /// @dev Checks if the Foundry profile is "benchmark". 83 | function isBenchmarkProfile() internal view returns (bool) { 84 | string memory profile = vm.envOr({ name: "FOUNDRY_PROFILE", defaultValue: string("default") }); 85 | return Strings.equal(profile, "benchmark"); 86 | } 87 | 88 | /// @dev Checks if the Foundry profile is "test-optimized". 89 | function isTestOptimizedProfile() internal view returns (bool) { 90 | string memory profile = vm.envOr({ name: "FOUNDRY_PROFILE", defaultValue: string("default") }); 91 | return Strings.equal(profile, "test-optimized"); 92 | } 93 | 94 | /// @dev Stops the active prank and sets a new one. 95 | function resetPrank(address msgSender) internal { 96 | vm.stopPrank(); 97 | vm.startPrank(msgSender); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/utils/Vars.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity >=0.8.22; 3 | 4 | import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import { UD21x18 } from "@prb/math/src/UD21x18.sol"; 6 | 7 | /// @dev A struct to hold the variables in case a test throws stack too deep error. 8 | struct Vars { 9 | uint128 protocolFeeAmount; 10 | IERC20 token; 11 | // previous values. 12 | uint256 previousAggregateAmount; 13 | uint256 previousOngoingDebtScaled; 14 | uint40 previousSnapshotTime; 15 | uint128 previousStreamBalance; 16 | uint256 previousTokenBalance; 17 | uint256 previousTotalDebt; 18 | // actual values. 19 | uint256 actualAggregateAmount; 20 | uint128 actualProtocolFeeAmount; 21 | uint128 actualProtocolRevenue; 22 | UD21x18 actualRatePerSecond; 23 | uint256 actualSnapshotDebtScaled; 24 | uint40 actualSnapshotTime; 25 | uint128 actualStreamBalance; 26 | uint256 actualStreamId; 27 | uint256 actualTokenBalance; 28 | uint256 actualTotalDebt; 29 | uint128 actualWithdrawnAmount; 30 | // expected values. 31 | uint256 expectedAggregateAmount; 32 | uint128 expectedProtocolFeeAmount; 33 | uint128 expectedProtocolRevenue; 34 | UD21x18 expectedRatePerSecond; 35 | uint256 expectedSnapshotDebtScaled; 36 | uint40 expectedSnapshotTime; 37 | uint128 expectedStreamBalance; 38 | uint256 expectedStreamId; 39 | uint256 expectedTokenBalance; 40 | uint256 expectedTotalDebt; 41 | uint128 expectedWithdrawAmount; 42 | } 43 | --------------------------------------------------------------------------------