diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45a10eb..4225f4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,15 +95,16 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 - - name: Install Rust - uses: actions-rs/toolchain@v1 + - name: Install scopelint + uses: engineerd/configurator@v0.0.8 with: - profile: minimal - toolchain: nightly - override: true - - - name: Install scopelint (beta) - run: cargo install --git https://github.com/ScopeLift/scopelint.git --branch beta-release + name: scopelint + repo: ScopeLift/scopelint + fromGitHubReleases: true + version: latest + pathInArchive: scopelint-x86_64-linux/scopelint + urlTemplate: https://github.com/ScopeLift/scopelint/releases/download/{{version}}/scopelint-x86_64-linux.tar.xz + token: ${{ secrets.GITHUB_TOKEN }} - name: Check formatting run: | diff --git a/.gitignore b/.gitignore index 2036134..d6b2891 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Compiler files cache/ out/ +zkout/ # Ignores development broadcast logs !/broadcast diff --git a/README.md b/README.md index ed5d5da..686ed74 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,96 @@ -# ScopeLift Foundry Template +# ZKsync Fee Flow -An opinionated template for [Foundry](https://github.com/foundry-rs/foundry) projects. - -_**Please read the full README before using this template.**_ - -- [Usage](#usage) -- [Overview](#overview) - - [`foundry.toml`](#foundrytoml) - - [CI](#ci) - - [Test Structure](#test-structure) -- [Configuration](#configuration) - - [Coverage](#coverage) - - [Slither](#slither) - - [GitHub Code Scanning](#github-code-scanning) - -## Usage - -To use this template, use one of the below approaches: - -1. Run `forge init --template ScopeLift/foundry-template` in an empty directory. -2. Click [here](https://github.com/ScopeLift/foundry-template/generate) to generate a new repository from this template. -3. Click the "Use this template" button from this repo's [home page](https://github.com/ScopeLift/foundry-template). - -It's also recommend to install [scopelint](https://github.com/ScopeLift/scopelint), which is used in CI. -You can run this locally with `scopelint fmt` and `scopelint check`. -Note that these are supersets of `forge fmt` and `forge fmt --check`, so you do not need to run those forge commands when using scopelint. +A system for managing fee token auctions and distribution on ZKsync Era. ## Overview -This template is designed to be a simple but powerful configuration for Foundry projects, that aims to help you follow Solidity and Foundry [best practices](https://book.getfoundry.sh/tutorials/best-practices) -Writing secure contracts is hard, so it ships with strict defaults that you can loosen as needed. - -### `foundry.toml` - -The `foundry.toml` config file comes with: - -- A `fmt` configuration. -- `default`, `lite`, and `ci` profiles. - -Both of these can of course be modified. -The `default` and `ci` profiles use the same solc build settings, which are intended to be the production settings, but the `ci` profile is configured to run deeper fuzz and invariant tests. -The `lite` profile turns the optimizer off, which is useful for speeding up compilation times during development. - -It's recommended to keep the solidity configuration of the `default` and `ci` profiles in sync, to avoid accidentally deploying contracts with suboptimal configuration settings when running `forge script`. -This means you can change the solc settings in the `default` profile and the `lite` profile, but never for the `ci` profile. - -Note that the `foundry.toml` file is formatted using [Taplo](https://taplo.tamasfe.dev/) via `scopelint fmt`. - -### CI - -Robust CI is also included, with a GitHub Actions workflow that does the following: +This repository contains two main contracts: -- Runs tests with the `ci` profile. -- Verifies contracts are within the [size limit](https://eips.ethereum.org/EIPS/eip-170) of 24576 bytes. -- Runs `forge coverage` and verifies a minimum coverage threshold is met. -- Runs `slither`, integrated with GitHub's [code scanning](https://docs.github.com/en/code-security/code-scanning). See the [Configuration](#configuration) section to learn more. +- **FeeFlow**: A fixed-price auction contract where bidders exchange ZK tokens for accumulated fee assets. +- **Splitter**: A contract that splits received ZK tokens between burning and distributing to configured recipients. -The CI also runs [scopelint](https://github.com/ScopeLift/scopelint) to verify formatting and best practices: +## Architecture -- Checks that Solidity and TOML files have been formatted. - - Solidity checks use the `foundry.toml` config. - - Currently the TOML formatting cannot be customized. -- Validates test names follow a convention of `test(Fork)?(Fuzz)?_(Revert(If_|When_){1})?\w{1,}`. [^naming-convention] -- Validates constants and immutables are in `ALL_CAPS`. -- Validates internal functions in `src/` start with a leading underscore. -- Validates function names and visibility in forge scripts to 1 public `run` method per script. [^script-abi] - -Note that the foundry-toolchain GitHub Action will cache RPC responses in CI by default, and it will also update the cache when you update your fork tests. - -### Test Structure - -The test structure is configured to follow recommended [best practices](https://book.getfoundry.sh/tutorials/best-practices). -It's strongly recommended to read that document, as it covers a range of aspects. -Consequently, the test structure is as follows: - -- The core protocol deploy script is `script/Deploy.sol`. - This deploys the contracts and saves their addresses to storage variables. -- The tests inherit from this deploy script and execute `Deploy.run()` in their `setUp` method. - This has the effect of running all tests against your deploy script, giving confidence that your deploy script is correct. -- Each test contract serves as `describe` block to unit test a function, e.g. `contract Increment` to test the `increment` function. - -## Configuration +``` + ┌─────────────┐ + │ Claimer │ + └──────┬──────┘ + │ ZK tokens + ▼ + ┌─────────────┐ + │ FeeFlow │ ◄── Fee tokens accumulate here + └──────┬──────┘ + │ ZK tokens + ▼ + ┌─────────────┐ + │ Splitter │ + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + 🔥 Burn Distributor1 Distributor2 +``` -After creating a new repository from this template, make sure to set any desired [branch protections](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches) on your repo. +## Installation -### Coverage +```bash +# Clone the repository +git clone https://github.com/ScopeLift/zksync-fee-flow.git +cd zksync-fee-flow -The [`ci.yml`](.github/workflows/ci.yml) has `coverage` configured by default, and contains comments explaining how to modify the configuration. -It uses: -The [lcov] CLI tool to filter out the `test/` and `script/` folders from the coverage report. +# Install dependencies +forge install +``` -- The [romeovs/lcov-reporter-action](https://github.com/romeovs/lcov-reporter-action) action to post a detailed coverage report to the PR. Subsequent commits on the same branch will automatically delete stale coverage comments and post new ones. -- The [zgosalvez/github-actions-report-lcov](https://github.com/zgosalvez/github-actions-report-lcov) action to fail coverage if a minimum coverage threshold is not met. +## Building -Be aware of foundry's current coverage limitations: +```bash +forge build +``` -- You cannot filter files/folders from `forge` directly, so `lcov` is used to do this. -- `forge coverage` always runs with the optimizer off and without via-ir, so if you need either of these to compile you will not be able to run coverage. +## Testing -Remember not to optimize for coverage, but to optimize for [well thought-out tests](https://book.getfoundry.sh/tutorials/best-practices?highlight=coverage#best-practices-1). +This project uses [foundry-zksync](https://github.com/matter-labs/foundry-zksync) for ZKsync Era compatibility. -### Slither +### Tests -In [`ci.yml`](.github/workflows/ci.yml), you'll notice Slither is configured as follows: +Run unit and integration tests (mock-based, fast): -```yml -slither-args: --filter-paths "./lib|./test" --exclude naming-convention,solc-version +```bash +forge test ``` -This means Slither is not run on the `lib` or `test` folders, and the [`naming-convention`](https://github.com/crytic/slither/wiki/Detector-Documentation#conformance-to-solidity-naming-conventions) and [solc-version](https://github.com/crytic/slither/wiki/Detector-Documentation#incorrect-versions-of-solidity) checks are disabled. - -This `slither-args` field is where you can change the Slither configuration for your project, and the defaults above can of course be changed. - -Notice that Slither will run against `script/` by default. -Carefully written and tested scripts are key to ensuring complex deployment and scripting pipelines execute as planned, but you are free to disable Slither checks on the scripts folder if it feels like overkill for your use case. +### Test Profiles -For more information on configuration Slither, see [the documentation](https://github.com/crytic/slither/wiki/Usage). For more information on configuring the slither action, see the [slither-action](https://github.com/crytic/slither-action) repo. +- `default`: Standard test runs +- `lite`: Optimizer disabled for faster compilation during development +- `ci`: Extended fuzz/invariant runs for CI -### GitHub Code Scanning +```bash +# Run with lite profile for faster iteration +FOUNDRY_PROFILE=lite forge test -As mentioned, the Slither CI step is integrated with GitHub's [code scanning](https://docs.github.com/en/code-security/code-scanning) feature. -This means when your jobs execute, you'll see two related checks: - -1. `CI / slither-analyze` -2. `Code scanning results / Slither` +# Run with CI profile for thorough testing +FOUNDRY_PROFILE=ci forge test +``` -The first check is the actual Slither analysis. -You'll notice in the [`ci.yml`](.github/workflows/ci.yml) file that this check has a configuration of `fail-on: none`. -This means this step will _never_ fail CI, no matter how many findings there are or what their severity is. -Instead, this check outputs the findings to a SARIF file[^sarif] to be used in the next check. +## Formatting -The second check is the GitHub code scanning check. -The `slither-analyze` job uploads the SARIF report to GitHub, which is then analyzed by GitHub's code scanning feature in this step. -This is the check that will fail CI if there are Slither findings. +This project uses [scopelint](https://github.com/ScopeLift/scopelint) for formatting: -By default when you create a repository, only alerts with the severity level of `Error` will cause a pull request check failure, and checks will succeed with alerts of lower severities. -However, you can [configure](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#defining-the-severities-causing-pull-request-check-failure) which level of slither results cause PR check failures. +```bash +scopelint fmt # Format files +scopelint check # Check formatting +``` -It's recommended to conservatively set the failure level to `Any` to start, and to reduce the failure level if you are unable to sufficiently tune Slither or find it to be too noisy. +## Deployment -Findings are shown directly on the PR, as well as in your repo's "Security" tab, under the "Code scanning" section. -Alerts that are dismissed are remembered by GitHub, and will not be shown again on future PRs. +The deployment script is located at `script/Deploy.s.sol`. It deploys both Splitter and FeeFlow contracts with UUPS proxies. -Note that code scanning integration [only works](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/setting-up-code-scanning-for-a-repository) for public repos, or private repos with GitHub Enterprise Cloud and a license for GitHub Advanced Security. -If you have a private repo and don't want to purchase a license, the best option is probably to: +## Security -- Remove the `Upload SARIF file` step from CI. -- Change the `Run Slither` step to `fail-on` whichever level you like, and remove the `sarif` output. -- Use [triage mode](https://github.com/crytic/slither/wiki/Usage#triage-mode) locally and commit the resulting `slither.db.json` file, and make sure CI has access to that file. +Please report any security issues to security@matterlabs.dev. -[^naming-convention]: - A rigorous test naming convention is important for ensuring that tests are easy to understand and maintain, while also making filtering much easier. - For example, one benefit is filtering out all reverting tests when generating gas reports. +## License -[^script-abi]: Limiting scripts to a single public method makes it easier to understand a script's purpose, and facilitates composability of simple, atomic scripts. -[^sarif]: - [SARIF](https://sarifweb.azurewebsites.net/) (Static Analysis Results Interchange Format) is an industry standard for static analysis results. - You can read learn more about SARIF [here](https://github.com/microsoft/sarif-tutorials) and read about GitHub's SARIF support [here](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning). +MIT diff --git a/foundry.toml b/foundry.toml index 86b1a95..268e9d5 100644 --- a/foundry.toml +++ b/foundry.toml @@ -19,6 +19,9 @@ # Speed up compilation and tests during development. optimizer = false +[rpc_endpoints] + zksync = "${ZKSYNC_MAINNET_RPC_URL}" + [fmt] bracket_spacing = false int_types = "long" diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 8bdf10d..a2fdb19 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,8 +1,169 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {Script} from "forge-std/Script.sol"; +import {FeeFlow} from "src/FeeFlow.sol"; +import {Splitter} from "src/Splitter.sol"; +import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; +import {ERC1967Proxy} from "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +/// @title Deploy +/// @notice Deploys FeeFlow and Splitter behind ERC1967 proxies. +/// @dev Environment variables: +/// - `DEPLOYER_PRIVATE_KEY` (required) +/// - `EXPECTED_CHAIN_ID` (optional; if set, reverts when `block.chainid` mismatches) +/// - `ADMIN` (optional; defaults to deployer address) +/// - `EMERGENCY_ADMIN` (optional; defaults to `ADMIN`) +/// - `BID_TOKEN` (required) +/// - `MIN_BID_THRESHOLD` (required) +/// - `BID_THRESHOLD` (required; must be >= `MIN_BID_THRESHOLD`) +/// - `SPLITTER_BURN_BPS` (optional; defaults to 10_000) +/// - `CLAIMABLE_TOKENS` (optional; comma-separated list of ERC20 addresses) +/// - `DISTRIBUTOR_RECIPIENTS` (optional; comma-separated list of addresses) +/// - `DISTRIBUTOR_WEIGHTS` (optional; comma-separated list of uint256 weights; same length as +/// `DISTRIBUTOR_RECIPIENTS`) contract Deploy is Script { - function run() public {} + error Deploy_InvalidChainId(uint256 expected, uint256 actual); + error Deploy_BidThresholdBelowMin(uint256 minBidThreshold, uint256 bidThreshold); + error Deploy_InvalidBurnBps(uint256 burnBps); + error Deploy_DistributorLengthMismatch(uint256 recipientsLength, uint256 weightsLength); + error Deploy_DistributorWeightZero(uint256 index); + + struct DeploymentParams { + address admin; + address emergencyAdmin; + IERC20Burnable bidToken; + uint256 minBidThreshold; + uint256 bidThreshold; + uint256 burnBps; + Splitter.DistributorConfig[] distributors; + IERC20[] claimableTokens; + } + + struct DeploymentResult { + address splitterImplementation; + address splitterProxy; + address feeFlowImplementation; + address feeFlowProxy; + } + + /// @notice Entrypoint for `forge script`. + /// @dev Loads config from env and deploys all contracts in one broadcast. + function run() public returns (DeploymentResult memory _result) { + uint256 _deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + uint256 _expectedChainId = vm.envOr("EXPECTED_CHAIN_ID", uint256(0)); + if (_expectedChainId != 0 && block.chainid != _expectedChainId) { + revert Deploy_InvalidChainId(_expectedChainId, block.chainid); + } + + DeploymentParams memory _params = _getDeploymentParams(_deployerPrivateKey); + + vm.startBroadcast(_deployerPrivateKey); + _result = _deploy(_params); + vm.stopBroadcast(); + } + + /// @notice Build deployment params from environment variables. + function _getDeploymentParams(uint256 _deployerPrivateKey) + internal + view + returns (DeploymentParams memory) + { + address _deployer = vm.addr(_deployerPrivateKey); + address _admin = vm.envOr("ADMIN", _deployer); + + address _bidToken = vm.envAddress("BID_TOKEN"); + uint256 _minBidThreshold = vm.envUint("MIN_BID_THRESHOLD"); + uint256 _bidThreshold = vm.envUint("BID_THRESHOLD"); + uint256 _burnBps = vm.envOr("SPLITTER_BURN_BPS", uint256(10_000)); + + if (_bidThreshold < _minBidThreshold) { + revert Deploy_BidThresholdBelowMin(_minBidThreshold, _bidThreshold); + } + if (_burnBps > 10_000) revert Deploy_InvalidBurnBps(_burnBps); + + return DeploymentParams({ + admin: _admin, + emergencyAdmin: vm.envOr("EMERGENCY_ADMIN", _admin), + bidToken: IERC20Burnable(_bidToken), + minBidThreshold: _minBidThreshold, + bidThreshold: _bidThreshold, + burnBps: _burnBps, + distributors: _distributorsFromEnv(), + claimableTokens: _claimableTokensFromEnv() + }); + } + + /// @notice Deploys the Splitter and FeeFlow contracts with proxies. + function _deploy(DeploymentParams memory _params) + internal + returns (DeploymentResult memory _result) + { + Splitter _splitterImplementation = new Splitter(); + ERC1967Proxy _splitterProxy = new ERC1967Proxy( + address(_splitterImplementation), + abi.encodeCall( + Splitter.initialize, + ( + _params.admin, + _params.emergencyAdmin, + _params.bidToken, + _params.burnBps, + _params.distributors + ) + ) + ); + + FeeFlow _feeFlowImplementation = new FeeFlow(); + ERC1967Proxy _feeFlowProxy = new ERC1967Proxy( + address(_feeFlowImplementation), + abi.encodeCall( + FeeFlow.initialize, + ( + _params.admin, + _params.emergencyAdmin, + IERC20(address(_params.bidToken)), + _params.minBidThreshold, + _params.bidThreshold, + address(_splitterProxy), + _params.claimableTokens + ) + ) + ); + + return DeploymentResult({ + splitterImplementation: address(_splitterImplementation), + splitterProxy: address(_splitterProxy), + feeFlowImplementation: address(_feeFlowImplementation), + feeFlowProxy: address(_feeFlowProxy) + }); + } + + function _claimableTokensFromEnv() internal view returns (IERC20[] memory claimableTokens) { + address[] memory _claimableTokenAddresses = vm.envOr("CLAIMABLE_TOKENS", ",", new address[](0)); + claimableTokens = new IERC20[](_claimableTokenAddresses.length); + for (uint256 _i; _i < _claimableTokenAddresses.length; ++_i) { + claimableTokens[_i] = IERC20(_claimableTokenAddresses[_i]); + } + } + + function _distributorsFromEnv() internal view returns (Splitter.DistributorConfig[] memory) { + address[] memory _recipients = vm.envOr("DISTRIBUTOR_RECIPIENTS", ",", new address[](0)); + uint256[] memory _weights = vm.envOr("DISTRIBUTOR_WEIGHTS", ",", new uint256[](0)); + + if (_recipients.length != _weights.length) { + revert Deploy_DistributorLengthMismatch(_recipients.length, _weights.length); + } + + Splitter.DistributorConfig[] memory _distributors = + new Splitter.DistributorConfig[](_recipients.length); + for (uint256 _i; _i < _recipients.length; ++_i) { + if (_weights[_i] == 0) revert Deploy_DistributorWeightZero(_i); + _distributors[_i] = + Splitter.DistributorConfig({recipient: _recipients[_i], weight: uint96(_weights[_i])}); + } + return _distributors; + } } diff --git a/src/FeeFlow.sol b/src/FeeFlow.sol index e3ed542..6f69f99 100644 --- a/src/FeeFlow.sol +++ b/src/FeeFlow.sol @@ -9,6 +9,7 @@ import { } from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISplitter} from "src/interfaces/ISplitter.sol"; /// @title FeeFlow /// @author ScopeLift @@ -212,6 +213,7 @@ contract FeeFlow is AccessControlUpgradeable, UUPSUpgradeable { uint256 _bidAmount = $._bidThreshold; $._bidToken.safeTransferFrom(msg.sender, $._destination, _bidAmount); + ISplitter($._destination).split(); for (uint256 _i = 0; _i < _claimRequests.length; _i++) { IERC20 _token = _claimRequests[_i].token; diff --git a/src/interfaces/ISplitter.sol b/src/interfaces/ISplitter.sol new file mode 100644 index 0000000..0e8d351 --- /dev/null +++ b/src/interfaces/ISplitter.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +/// @notice Interface for the Splitter contract. +interface ISplitter { + /// @notice Splits the contract's token balance between burning and distributors. + function split() external; +} diff --git a/test/FeeFlow.integration.t.sol b/test/FeeFlow.integration.t.sol new file mode 100644 index 0000000..dbef389 --- /dev/null +++ b/test/FeeFlow.integration.t.sol @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {FeeFlow} from "src/FeeFlow.sol"; +import {Splitter} from "src/Splitter.sol"; +import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; +import {ERC1967Proxy} from "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {ERC20BurnableMock} from "test/mocks/ERC20BurnableMock.sol"; +import {ERC20Mock} from "test/mocks/ERC20Mock.sol"; + +/// @title FeeFlow Integration Tests +/// @notice Tests for FeeFlow and Splitter working together. +/// @dev These tests use mock tokens to verify the full integration flow. +contract FeeFlowIntegrationTest is Test { + FeeFlow internal feeFlow; + Splitter internal splitter; + ERC20BurnableMock internal bidToken; + ERC20Mock internal feeToken; + + address internal admin; + address internal emergencyAdmin; + address internal distributor1; + address internal distributor2; + address internal claimer; + + uint256 internal constant MIN_BID_THRESHOLD = 100; + uint256 internal constant BID_THRESHOLD = 1000 ether; + + function setUp() public { + admin = makeAddr("admin"); + emergencyAdmin = makeAddr("emergencyAdmin"); + distributor1 = makeAddr("distributor1"); + distributor2 = makeAddr("distributor2"); + claimer = makeAddr("claimer"); + + // Deploy bid token (ZK token equivalent) + bidToken = new ERC20BurnableMock(); + + // Deploy fee token + feeToken = new ERC20Mock("Fee Token", "FEE"); + + // Deploy Splitter with 50% burn and two distributors with equal weights + Splitter.DistributorConfig[] memory _distributors = new Splitter.DistributorConfig[](2); + _distributors[0] = Splitter.DistributorConfig({recipient: distributor1, weight: 50}); + _distributors[1] = Splitter.DistributorConfig({recipient: distributor2, weight: 50}); + + Splitter _splitterImpl = new Splitter(); + ERC1967Proxy _splitterProxy = new ERC1967Proxy( + address(_splitterImpl), + abi.encodeCall( + Splitter.initialize, + (admin, emergencyAdmin, IERC20Burnable(address(bidToken)), 5000, _distributors) + ) + ); + splitter = Splitter(address(_splitterProxy)); + + // Deploy FeeFlow with Splitter as destination + IERC20[] memory _claimableTokens = new IERC20[](1); + _claimableTokens[0] = IERC20(address(feeToken)); + + FeeFlow _feeFlowImpl = new FeeFlow(); + ERC1967Proxy _feeFlowProxy = new ERC1967Proxy( + address(_feeFlowImpl), + abi.encodeCall( + FeeFlow.initialize, + ( + admin, + emergencyAdmin, + IERC20(address(bidToken)), + MIN_BID_THRESHOLD, + BID_THRESHOLD, + address(splitter), + _claimableTokens + ) + ) + ); + feeFlow = FeeFlow(address(_feeFlowProxy)); + } + + function test_FullFlow_ClaimTriggersSplitAndDistribution() public { + // Mint fee tokens to FeeFlow + uint256 _feeAmount = 500 ether; + feeToken.mint(address(feeFlow), _feeAmount); + + // Mint and approve bid tokens for claimer + bidToken.mint(claimer, BID_THRESHOLD); + vm.prank(claimer); + bidToken.approve(address(feeFlow), BID_THRESHOLD); + + // Record initial state + uint256 _bidTokenSupplyBefore = bidToken.totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](1); + _claimRequests[0] = + FeeFlow.ClaimRequest({token: IERC20(address(feeToken)), minAmountRequested: _feeAmount}); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // Verify fee tokens went to claimer + assertEq(feeToken.balanceOf(claimer), _feeAmount); + assertEq(feeToken.balanceOf(address(feeFlow)), 0); + + // Verify bid tokens were split: 50% burned, 50% distributed + uint256 _expectedBurn = BID_THRESHOLD / 2; + uint256 _expectedPerDistributor = BID_THRESHOLD / 4; // 25% each + + // Total supply decreased by burn amount + assertEq(bidToken.totalSupply(), _bidTokenSupplyBefore - _expectedBurn); + + // Distributors received their shares + assertEq(bidToken.balanceOf(distributor1), _expectedPerDistributor); + assertEq(bidToken.balanceOf(distributor2), _expectedPerDistributor); + + // Splitter has zero balance + assertEq(bidToken.balanceOf(address(splitter)), 0); + + // Claimer has zero bid tokens left + assertEq(bidToken.balanceOf(claimer), 0); + } + + function test_FullFlow_OneHundredPercentBurn() public { + // Reconfigure splitter to 100% burn (no distributors) + Splitter.DistributorConfig[] memory _emptyDistributors = new Splitter.DistributorConfig[](0); + vm.prank(admin); + splitter.setDistributors(_emptyDistributors); + + // Mint fee tokens to FeeFlow + uint256 _feeAmount = 500 ether; + feeToken.mint(address(feeFlow), _feeAmount); + + // Mint and approve bid tokens for claimer + bidToken.mint(claimer, BID_THRESHOLD); + vm.prank(claimer); + bidToken.approve(address(feeFlow), BID_THRESHOLD); + + uint256 _bidTokenSupplyBefore = bidToken.totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](1); + _claimRequests[0] = + FeeFlow.ClaimRequest({token: IERC20(address(feeToken)), minAmountRequested: _feeAmount}); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // All bid tokens burned + assertEq(bidToken.totalSupply(), _bidTokenSupplyBefore - BID_THRESHOLD); + assertEq(bidToken.balanceOf(address(splitter)), 0); + } + + function test_FullFlow_ZeroPercentBurn() public { + // Reconfigure splitter to 0% burn (all to distributors) + vm.prank(admin); + splitter.setBurnPercentage(0); + + // Mint fee tokens to FeeFlow + uint256 _feeAmount = 500 ether; + feeToken.mint(address(feeFlow), _feeAmount); + + // Mint and approve bid tokens for claimer + bidToken.mint(claimer, BID_THRESHOLD); + vm.prank(claimer); + bidToken.approve(address(feeFlow), BID_THRESHOLD); + + uint256 _bidTokenSupplyBefore = bidToken.totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](1); + _claimRequests[0] = + FeeFlow.ClaimRequest({token: IERC20(address(feeToken)), minAmountRequested: _feeAmount}); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // No burn, all distributed + assertEq(bidToken.totalSupply(), _bidTokenSupplyBefore); + assertEq(bidToken.balanceOf(distributor1), BID_THRESHOLD / 2); + assertEq(bidToken.balanceOf(distributor2), BID_THRESHOLD / 2); + assertEq(bidToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_FullFlow_VariableBurnAndWeights( + uint256 _burnBps, + uint256 _weight1, + uint256 _weight2, + uint256 _feeAmount + ) public { + _burnBps = bound(_burnBps, 0, 10_000); + _weight1 = bound(_weight1, 1, type(uint96).max / 2); + _weight2 = bound(_weight2, 1, type(uint96).max / 2); + _feeAmount = bound(_feeAmount, 1, type(uint128).max); + + // Reconfigure splitter + Splitter.DistributorConfig[] memory _distributors = new Splitter.DistributorConfig[](2); + _distributors[0] = + Splitter.DistributorConfig({recipient: distributor1, weight: uint96(_weight1)}); + _distributors[1] = + Splitter.DistributorConfig({recipient: distributor2, weight: uint96(_weight2)}); + vm.prank(admin); + splitter.setDistributors(_distributors); + vm.prank(admin); + splitter.setBurnPercentage(_burnBps); + + // Mint fee tokens to FeeFlow + feeToken.mint(address(feeFlow), _feeAmount); + + // Mint and approve bid tokens for claimer + bidToken.mint(claimer, BID_THRESHOLD); + vm.prank(claimer); + bidToken.approve(address(feeFlow), BID_THRESHOLD); + + uint256 _bidTokenSupplyBefore = bidToken.totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](1); + _claimRequests[0] = + FeeFlow.ClaimRequest({token: IERC20(address(feeToken)), minAmountRequested: _feeAmount}); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // Calculate expected distribution + uint256 _burnAmount = (BID_THRESHOLD * _burnBps) / 10_000; + uint256 _distributeAmount = BID_THRESHOLD - _burnAmount; + uint256 _totalWeight = _weight1 + _weight2; + uint256 _expectedShare1 = (_distributeAmount * _weight1) / _totalWeight; + uint256 _expectedShare2 = (_distributeAmount * _weight2) / _totalWeight; + uint256 _dust = _distributeAmount - _expectedShare1 - _expectedShare2; + uint256 _totalBurned = _burnAmount + _dust; + + // Verify + assertEq(feeToken.balanceOf(claimer), _feeAmount); + assertEq(bidToken.totalSupply(), _bidTokenSupplyBefore - _totalBurned); + assertEq(bidToken.balanceOf(distributor1), _expectedShare1); + assertEq(bidToken.balanceOf(distributor2), _expectedShare2); + assertEq(bidToken.balanceOf(address(splitter)), 0); + } + + function test_FullFlow_MultipleClaims() public { + // First claim + feeToken.mint(address(feeFlow), 500 ether); + bidToken.mint(claimer, BID_THRESHOLD); + vm.prank(claimer); + bidToken.approve(address(feeFlow), BID_THRESHOLD); + + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](1); + _claimRequests[0] = + FeeFlow.ClaimRequest({token: IERC20(address(feeToken)), minAmountRequested: 500 ether}); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + uint256 _dist1AfterFirst = bidToken.balanceOf(distributor1); + uint256 _dist2AfterFirst = bidToken.balanceOf(distributor2); + + // Second claim + feeToken.mint(address(feeFlow), 300 ether); + bidToken.mint(claimer, BID_THRESHOLD); + vm.prank(claimer); + bidToken.approve(address(feeFlow), BID_THRESHOLD); + + _claimRequests[0] = + FeeFlow.ClaimRequest({token: IERC20(address(feeToken)), minAmountRequested: 300 ether}); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // Distributors should have accumulated from both claims + assertEq(bidToken.balanceOf(distributor1), _dist1AfterFirst * 2); + assertEq(bidToken.balanceOf(distributor2), _dist2AfterFirst * 2); + assertEq(bidToken.balanceOf(address(splitter)), 0); + } +} diff --git a/test/FeeFlow.t.sol b/test/FeeFlow.t.sol index 7a70178..4f89630 100644 --- a/test/FeeFlow.t.sol +++ b/test/FeeFlow.t.sol @@ -9,13 +9,14 @@ import {ERC20Mock} from "openzeppelin-contracts/contracts/mocks/token/ERC20Mock. import { Initializable } from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {MockSplitter} from "test/mocks/MockSplitter.sol"; contract FeeFlowTest is Test { FeeFlow internal feeFlow; address internal admin; address internal emergencyAdmin; ERC20Mock internal bidToken; - address internal destination; + MockSplitter internal destination; uint256 internal minBidThreshold = 100; uint256 internal initialBidThreshold = 1000; @@ -50,7 +51,7 @@ contract FeeFlowTest is Test { function setUp() public virtual { admin = makeAddr("admin"); emergencyAdmin = makeAddr("emergencyAdmin"); - destination = makeAddr("destination"); + destination = new MockSplitter(); bidToken = new ERC20Mock(); IERC20[] memory _claimableTokens = new IERC20[](0); feeFlow = _deployFeeFlow( @@ -59,7 +60,7 @@ contract FeeFlowTest is Test { IERC20(address(bidToken)), minBidThreshold, initialBidThreshold, - destination, + address(destination), _claimableTokens ); } @@ -145,7 +146,7 @@ contract Initialize is FeeFlowTest { IERC20(address(bidToken)), minBidThreshold, initialBidThreshold, - destination, + address(destination), _claimableTokens ); @@ -293,7 +294,7 @@ contract Initialize is FeeFlowTest { IERC20(address(bidToken)), minBidThreshold, initialBidThreshold, - destination, + address(destination), _claimableTokens ); } @@ -308,7 +309,7 @@ contract Initialize is FeeFlowTest { IERC20(address(bidToken)), minBidThreshold, initialBidThreshold, - destination, + address(destination), _claimableTokens ); } @@ -327,7 +328,7 @@ contract Initialize is FeeFlowTest { IERC20(address(bidToken)), minBidThreshold, initialBidThreshold, - destination, + address(destination), _claimableTokens ); } @@ -545,7 +546,7 @@ contract Claim is FeeFlowTest { function testFuzz_TransfersBidTokenToDestination(address _claimer, uint256 _threshold) public { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != destination); + vm.assume(_claimer != address(0) && _claimer != address(destination)); _threshold = _boundThreshold(_threshold); vm.prank(admin); @@ -557,7 +558,7 @@ contract Claim is FeeFlowTest { vm.prank(_claimer); feeFlow.claim(_claimRequests); - assertEq(bidToken.balanceOf(destination), _threshold); + assertEq(bidToken.balanceOf(address(destination)), _threshold); assertEq(bidToken.balanceOf(_claimer), 0); } @@ -569,7 +570,9 @@ contract Claim is FeeFlowTest { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes address(feeFlow): see testFuzz_WhenClaimerIsFeeFlow_FeeTokensRemainInContract // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != address(feeFlow) && _claimer != destination); + vm.assume( + _claimer != address(0) && _claimer != address(feeFlow) && _claimer != address(destination) + ); _threshold = _boundThreshold(_threshold); _feeAmount = _boundFeeAmount(_feeAmount); @@ -602,7 +605,9 @@ contract Claim is FeeFlowTest { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes address(feeFlow): see testFuzz_WhenClaimerIsFeeFlow_FeeTokensRemainInContract // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != address(feeFlow) && _claimer != destination); + vm.assume( + _claimer != address(0) && _claimer != address(feeFlow) && _claimer != address(destination) + ); _threshold = _boundThreshold(_threshold); _feeAmount1 = _boundFeeAmount(_feeAmount1); _feeAmount2 = _boundFeeAmount(_feeAmount2); @@ -635,7 +640,7 @@ contract Claim is FeeFlowTest { function testFuzz_EmitsClaimedEvent(address _claimer, uint256 _threshold) public { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != destination); + vm.assume(_claimer != address(0) && _claimer != address(destination)); _threshold = _boundThreshold(_threshold); vm.prank(admin); @@ -689,7 +694,9 @@ contract Claim is FeeFlowTest { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes address(feeFlow): see testFuzz_WhenClaimerIsFeeFlow_FeeTokensRemainInContract // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != address(feeFlow) && _claimer != destination); + vm.assume( + _claimer != address(0) && _claimer != address(feeFlow) && _claimer != address(destination) + ); _threshold = _boundThreshold(_threshold); _feeAmount = _boundFeeAmount(_feeAmount); // minAmountRequested can be equal to or less than feeAmount (covers both cases) @@ -731,26 +738,28 @@ contract Claim is FeeFlowTest { _whitelistToken(IERC20(address(_feeToken))); - _mintAndApproveBidToken(destination, _threshold); + _mintAndApproveBidToken(address(destination), _threshold); FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](1); _claimRequests[0] = FeeFlow.ClaimRequest({token: IERC20(address(_feeToken)), minAmountRequested: _feeAmount}); - vm.prank(destination); + vm.prank(address(destination)); feeFlow.claim(_claimRequests); // Bid tokens transferred from destination to destination (net zero change) - assertEq(bidToken.balanceOf(destination), _threshold); + assertEq(bidToken.balanceOf(address(destination)), _threshold); // Fee tokens transferred to destination (the claimer) - assertEq(_feeToken.balanceOf(destination), _feeAmount); + assertEq(_feeToken.balanceOf(address(destination)), _feeAmount); } function testFuzz_RevertWhen_FeeTokenBalanceIsZero(address _claimer, uint256 _threshold) public { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes address(feeFlow): see testFuzz_WhenClaimerIsFeeFlow_FeeTokensRemainInContract // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != address(feeFlow) && _claimer != destination); + vm.assume( + _claimer != address(0) && _claimer != address(feeFlow) && _claimer != address(destination) + ); _threshold = _boundThreshold(_threshold); vm.prank(admin); @@ -774,7 +783,7 @@ contract Claim is FeeFlowTest { function testFuzz_RevertWhen_FeeTokenIsBidToken(address _claimer, uint256 _threshold) public { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != destination); + vm.assume(_claimer != address(0) && _claimer != address(destination)); _threshold = _boundThreshold(_threshold); vm.prank(admin); @@ -800,7 +809,9 @@ contract Claim is FeeFlowTest { // Excludes address(0): see test_RevertWhen_ClaimerIsZeroAddress // Excludes address(feeFlow): see testFuzz_WhenClaimerIsFeeFlow_FeeTokensRemainInContract // Excludes destination: see testFuzz_WhenClaimerIsDestination_BidTokensStayAtDestination - vm.assume(_claimer != address(0) && _claimer != address(feeFlow) && _claimer != destination); + vm.assume( + _claimer != address(0) && _claimer != address(feeFlow) && _claimer != address(destination) + ); _threshold = _boundThreshold(_threshold); _feeAmount = bound(_feeAmount, 0, type(uint128).max); _minAmount = bound(_minAmount, _feeAmount + 1, type(uint256).max); @@ -854,7 +865,9 @@ contract Claim is FeeFlowTest { uint256 _threshold, uint256 _feeAmount ) public { - vm.assume(_claimer != address(0) && _claimer != address(feeFlow) && _claimer != destination); + vm.assume( + _claimer != address(0) && _claimer != address(feeFlow) && _claimer != address(destination) + ); _threshold = _boundThreshold(_threshold); _feeAmount = _boundFeeAmount(_feeAmount); @@ -876,6 +889,48 @@ contract Claim is FeeFlowTest { vm.expectRevert(FeeFlow.FeeFlow_TokenNotClaimable.selector); feeFlow.claim(_claimRequests); } + + function testFuzz_CallsSplitOnDestination(address _claimer, uint256 _threshold) public { + vm.assume(_claimer != address(0) && _claimer != address(destination)); + _threshold = _boundThreshold(_threshold); + + vm.prank(admin); + feeFlow.setBidThreshold(_threshold); + + _mintAndApproveBidToken(_claimer, _threshold); + + assertEq(destination.splitCallCount(), 0); + + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](0); + vm.prank(_claimer); + feeFlow.claim(_claimRequests); + + assertEq(destination.splitCallCount(), 1); + } + + function testFuzz_CallsSplitOnEachClaim(address _claimer, uint256 _threshold, uint8 _numClaims) + public + { + vm.assume(_claimer != address(0) && _claimer != address(destination)); + // Bound threshold lower to avoid overflow when minting multiple times + _threshold = bound(_threshold, minBidThreshold, type(uint128).max / 10); + _numClaims = uint8(bound(_numClaims, 1, 10)); + + vm.prank(admin); + feeFlow.setBidThreshold(_threshold); + + assertEq(destination.splitCallCount(), 0); + + for (uint256 _i = 0; _i < _numClaims; _i++) { + _mintAndApproveBidToken(_claimer, _threshold); + + FeeFlow.ClaimRequest[] memory _claimRequests = new FeeFlow.ClaimRequest[](0); + vm.prank(_claimer); + feeFlow.claim(_claimRequests); + } + + assertEq(destination.splitCallCount(), _numClaims); + } } contract Upgrade is FeeFlowTest { @@ -975,7 +1030,7 @@ contract Recover is FeeFlowTest { vm.prank(emergencyAdmin); vm.expectRevert(FeeFlow.FeeFlow_Unauthorized.selector); - feeFlow.recover(IERC20(address(_token)), destination, 1000); + feeFlow.recover(IERC20(address(_token)), address(destination), 1000); } function testFuzz_RevertWhen_InsufficientBalance(address _to, uint256 _balance, uint256 _amount) diff --git a/test/mocks/ERC20BurnableMock.sol b/test/mocks/ERC20BurnableMock.sol index 80ef8f2..91f8ded 100644 --- a/test/mocks/ERC20BurnableMock.sol +++ b/test/mocks/ERC20BurnableMock.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.30; import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +/// @dev Mock ERC20 with burn functionality for testing. contract ERC20BurnableMock is ERC20 { constructor() ERC20("Mock Token", "MOCK") {} diff --git a/test/mocks/ERC20Mock.sol b/test/mocks/ERC20Mock.sol new file mode 100644 index 0000000..54b7d88 --- /dev/null +++ b/test/mocks/ERC20Mock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; + +/// @dev Mock ERC20 without burn functionality for fee tokens. +contract ERC20Mock is ERC20 { + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {} + + function mint(address _to, uint256 _amount) external { + _mint(_to, _amount); + } +} diff --git a/test/mocks/MockSplitter.sol b/test/mocks/MockSplitter.sol new file mode 100644 index 0000000..1cca10f --- /dev/null +++ b/test/mocks/MockSplitter.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ISplitter} from "src/interfaces/ISplitter.sol"; + +/// @dev Mock Splitter that tracks split() calls for testing. +contract MockSplitter is ISplitter { + uint256 public splitCallCount; + + function split() external override { + splitCallCount++; + } +}