From 34821861610a47dfacd8b9a27e864439fe1d6fbc Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:34:34 -0800 Subject: [PATCH 1/3] feat: add Splitter contract with burn/distribute and FeeFlow integration --- .github/workflows/ci.yml | 19 +- .gitignore | 1 + README.md | 203 ++++++++----------- foundry.toml | 3 + script/Deploy.s.sol | 73 +++++++ src/FeeFlow.sol | 2 + src/interfaces/ISplitter.sol | 8 + test/FeeFlow.fork.t.sol | 191 ++++++++++++++++++ test/FeeFlow.integration.t.sol | 279 +++++++++++++++++++++++++++ test/FeeFlow.t.sol | 99 +++++++--- test/helpers/IntegrationTestBase.sol | 107 ++++++++++ test/mocks/ERC20BurnableMock.sol | 1 + test/mocks/ERC20Mock.sol | 13 ++ test/mocks/MockSplitter.sol | 13 ++ 14 files changed, 868 insertions(+), 144 deletions(-) create mode 100644 src/interfaces/ISplitter.sol create mode 100644 test/FeeFlow.fork.t.sol create mode 100644 test/FeeFlow.integration.t.sol create mode 100644 test/helpers/IntegrationTestBase.sol create mode 100644 test/mocks/ERC20Mock.sol create mode 100644 test/mocks/MockSplitter.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45a10eb..186bde1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,22 @@ jobs: uses: dutterbutter/foundry-zksync-toolchain@v1 - name: Run tests - run: forge test + run: forge test --no-match-contract FeeFlowForkTest + + fork-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Install Foundry + uses: dutterbutter/foundry-zksync-toolchain@v1 + + - name: Run fork tests + env: + ZKSYNC_MAINNET_RPC_URL: ${{ secrets.ZKSYNC_MAINNET_RPC_URL }} + run: forge test --match-contract FeeFlowForkTest --zksync coverage: runs-on: ubuntu-latest @@ -52,7 +67,7 @@ jobs: uses: dutterbutter/foundry-zksync-toolchain@v1 - name: Run coverage - run: forge coverage --report summary --report lcov + run: forge coverage --no-match-contract FeeFlowForkTest --report summary --report lcov # To ignore coverage for certain directories modify the paths in this step as needed. The # below default ignores coverage results for the test and script directories. Alternatively, 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..1b3908c 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,122 @@ -# 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. +This repository contains two main contracts: -Note that the `foundry.toml` file is formatted using [Taplo](https://taplo.tamasfe.dev/) via `scopelint fmt`. +- **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. -### CI +## Architecture -Robust CI is also included, with a GitHub Actions workflow that does the following: +``` + ┌─────────────┐ + │ Claimer │ + └──────┬──────┘ + │ ZK tokens + ▼ + ┌─────────────┐ + │ FeeFlow │ ◄── Fee tokens accumulate here + └──────┬──────┘ + │ ZK tokens + ▼ + ┌─────────────┐ + │ Splitter │ + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + 🔥 Burn Distributor1 Distributor2 +``` -- 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. +## Installation -The CI also runs [scopelint](https://github.com/ScopeLift/scopelint) to verify formatting and best practices: +```bash +# Clone the repository +git clone https://github.com/ScopeLift/zksync-fee-flow.git +cd zksync-fee-flow -- 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] +# Install dependencies +forge install +``` -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. +## Building -### Test Structure +```bash +forge build +``` -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: +## Testing -- 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. +This project uses [foundry-zksync](https://github.com/matter-labs/foundry-zksync) for ZKsync Era compatibility. -## Configuration +### Unit Tests -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. +Run unit and integration tests (mock-based, fast): -### Coverage +```bash +forge test +``` -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. +### Fork Tests -- 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. +Fork tests run against ZKsync Era mainnet with the real ZK token. These require: -Be aware of foundry's current coverage limitations: +1. A `.env` file with your RPC URL: + ``` + ZKSYNC_MAINNET_RPC_URL=https://your-zksync-rpc-url + ``` -- 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. +2. The `--zksync` flag for foundry-zksync: + ```bash + source .env && forge test --match-contract FeeFlowForkTest --zksync + ``` -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). +### Running All Tests -### Slither +Due to a limitation in foundry-zksync, fork tests and unit tests must be run separately: -In [`ci.yml`](.github/workflows/ci.yml), you'll notice Slither is configured as follows: +```bash +# Unit tests (no --zksync flag needed) +forge test --no-match-contract FeeFlowForkTest -```yml -slither-args: --filter-paths "./lib|./test" --exclude naming-convention,solc-version +# Fork tests (requires --zksync flag) +source .env && forge test --match-contract FeeFlowForkTest --zksync ``` -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. +### Test Profiles -This `slither-args` field is where you can change the Slither configuration for your project, and the defaults above can of course be changed. +- `default`: Standard test runs +- `lite`: Optimizer disabled for faster compilation during development +- `ci`: Extended fuzz/invariant runs for CI -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. +```bash +# Run with lite profile for faster iteration +FOUNDRY_PROFILE=lite forge test -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. - -### GitHub Code Scanning - -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..08f48f8 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -2,7 +2,80 @@ 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 Deployment script for FeeFlow and Splitter contracts. contract Deploy is Script { + struct DeploymentParams { + address admin; + address emergencyAdmin; + IERC20Burnable zkToken; + uint256 minBidThreshold; + uint256 bidThreshold; + uint256 burnBps; + Splitter.DistributorConfig[] distributors; + IERC20[] claimableTokens; + } + + struct DeploymentResult { + Splitter splitterImpl; + Splitter splitter; + FeeFlow feeFlowImpl; + FeeFlow feeFlow; + } + + /// @notice Deploys the Splitter and FeeFlow contracts with proxies. + /// @param _params The deployment parameters. + /// @return _result The deployed contract instances. + function deploy(DeploymentParams memory _params) + public + returns (DeploymentResult memory _result) + { + // Deploy Splitter implementation + _result.splitterImpl = new Splitter(); + + // Deploy Splitter proxy + ERC1967Proxy _splitterProxy = new ERC1967Proxy( + address(_result.splitterImpl), + abi.encodeCall( + Splitter.initialize, + ( + _params.admin, + _params.emergencyAdmin, + _params.zkToken, + _params.burnBps, + _params.distributors + ) + ) + ); + _result.splitter = Splitter(address(_splitterProxy)); + + // Deploy FeeFlow implementation + _result.feeFlowImpl = new FeeFlow(); + + // Deploy FeeFlow proxy + ERC1967Proxy _feeFlowProxy = new ERC1967Proxy( + address(_result.feeFlowImpl), + abi.encodeCall( + FeeFlow.initialize, + ( + _params.admin, + _params.emergencyAdmin, + IERC20(address(_params.zkToken)), + _params.minBidThreshold, + _params.bidThreshold, + address(_result.splitter), + _params.claimableTokens + ) + ) + ); + _result.feeFlow = FeeFlow(address(_feeFlowProxy)); + } + function run() public {} } diff --git a/src/FeeFlow.sol b/src/FeeFlow.sol index 013ecab..a76a0ad 100644 --- a/src/FeeFlow.sol +++ b/src/FeeFlow.sol @@ -9,6 +9,7 @@ import { } from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "lib/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.fork.t.sol b/test/FeeFlow.fork.t.sol new file mode 100644 index 0000000..7357821 --- /dev/null +++ b/test/FeeFlow.fork.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {IntegrationTestBase} from "test/helpers/IntegrationTestBase.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 {ERC20Mock} from "test/mocks/ERC20Mock.sol"; + +/// @title FeeFlow Fork Tests +/// @notice Fork tests against ZKsync Era mainnet with real ZK token. +contract FeeFlowForkTest is IntegrationTestBase { + ERC20Mock internal feeToken; + + function setUp() public override { + super.setUp(); + + // Deploy a mock fee token for testing claims + feeToken = new ERC20Mock("Fee Token", "FEE"); + + // Add fee token to claimable tokens + vm.prank(admin); + feeFlow.setClaimableToken(IERC20(address(feeToken)), true); + } + + function test_Fork_FullFlow_ClaimTriggersSplitAndBurn() public { + // Mint fee tokens to FeeFlow + uint256 _feeAmount = 500 ether; + feeToken.mint(address(feeFlow), _feeAmount); + + // Setup claimer with ZK tokens + _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); + + // Record initial state + uint256 _zkSupplyBefore = IERC20(address(ZK_TOKEN)).totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = + _createClaimRequest(IERC20(address(feeToken)), _feeAmount); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // Verify fee tokens went to claimer + assertEq(feeToken.balanceOf(claimer), _feeAmount, "Claimer should receive fee tokens"); + assertEq(feeToken.balanceOf(address(feeFlow)), 0, "FeeFlow should have no fee tokens left"); + + // Verify ZK tokens were split: 50% burned, 50% distributed + uint256 _expectedBurn = DEFAULT_BID_THRESHOLD / 2; + uint256 _expectedPerDistributor = DEFAULT_BID_THRESHOLD / 4; // 25% each + + // Total supply decreased by burn amount (real ZK token burn) + assertEq( + IERC20(address(ZK_TOKEN)).totalSupply(), + _zkSupplyBefore - _expectedBurn, + "ZK total supply should decrease by burn amount" + ); + + // Distributors received their shares + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(distributor1), + _expectedPerDistributor, + "Distributor1 should receive 25%" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(distributor2), + _expectedPerDistributor, + "Distributor2 should receive 25%" + ); + + // Splitter has zero balance + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" + ); + + // Claimer has zero ZK tokens left + assertEq(IERC20(address(ZK_TOKEN)).balanceOf(claimer), 0, "Claimer should have no ZK left"); + } + + function test_Fork_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); + + // Setup claimer + _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); + + uint256 _zkSupplyBefore = IERC20(address(ZK_TOKEN)).totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = + _createClaimRequest(IERC20(address(feeToken)), _feeAmount); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // All ZK tokens burned + assertEq( + IERC20(address(ZK_TOKEN)).totalSupply(), + _zkSupplyBefore - DEFAULT_BID_THRESHOLD, + "All bid tokens should be burned" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" + ); + } + + function test_Fork_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); + + // Setup claimer + _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); + + uint256 _zkSupplyBefore = IERC20(address(ZK_TOKEN)).totalSupply(); + + // Claimer claims fee tokens + FeeFlow.ClaimRequest[] memory _claimRequests = + _createClaimRequest(IERC20(address(feeToken)), _feeAmount); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // No burn, all distributed + assertEq( + IERC20(address(ZK_TOKEN)).totalSupply(), _zkSupplyBefore, "Total supply should not change" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(distributor1), + DEFAULT_BID_THRESHOLD / 2, + "Distributor1 should receive 50%" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(distributor2), + DEFAULT_BID_THRESHOLD / 2, + "Distributor2 should receive 50%" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" + ); + } + + function test_Fork_FullFlow_MultipleClaims() public { + // First claim + feeToken.mint(address(feeFlow), 500 ether); + _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); + + FeeFlow.ClaimRequest[] memory _claimRequests = + _createClaimRequest(IERC20(address(feeToken)), 500 ether); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + uint256 _dist1AfterFirst = IERC20(address(ZK_TOKEN)).balanceOf(distributor1); + uint256 _dist2AfterFirst = IERC20(address(ZK_TOKEN)).balanceOf(distributor2); + + // Second claim + feeToken.mint(address(feeFlow), 300 ether); + _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); + + _claimRequests = _createClaimRequest(IERC20(address(feeToken)), 300 ether); + + vm.prank(claimer); + feeFlow.claim(_claimRequests); + + // Distributors should have accumulated from both claims + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(distributor1), + _dist1AfterFirst * 2, + "Distributor1 should accumulate from both claims" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(distributor2), + _dist2AfterFirst * 2, + "Distributor2 should accumulate from both claims" + ); + assertEq( + IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" + ); + } +} diff --git a/test/FeeFlow.integration.t.sol b/test/FeeFlow.integration.t.sol new file mode 100644 index 0000000..bb79f8b --- /dev/null +++ b/test/FeeFlow.integration.t.sol @@ -0,0 +1,279 @@ +// 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. +/// For fork tests against ZKsync Era mainnet, use foundry-zksync: +/// https://github.com/matter-labs/foundry-zksync +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 f105e4e..ad885eb 100644 --- a/test/FeeFlow.t.sol +++ b/test/FeeFlow.t.sol @@ -9,13 +9,14 @@ import {ERC20Mock} from "lib/openzeppelin-contracts/contracts/mocks/token/ERC20M import { Initializable } from "lib/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/helpers/IntegrationTestBase.sol b/test/helpers/IntegrationTestBase.sol new file mode 100644 index 0000000..649e772 --- /dev/null +++ b/test/helpers/IntegrationTestBase.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {Deploy} from "script/Deploy.s.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"; + +/// @title IntegrationTestBase +/// @notice Base contract for fork integration tests. +/// @dev Inherits from Deploy to reuse deployment logic. +abstract contract IntegrationTestBase is Test, Deploy { + /// @notice ZK token address on ZKsync Era mainnet. + IERC20Burnable public constant ZK_TOKEN = + IERC20Burnable(0x5A7d6b2F92C77FAD6CCaBd7EE0624E64907Eaf3E); + + /// @notice Default bid threshold for tests. + uint256 public constant DEFAULT_BID_THRESHOLD = 1000 ether; + + /// @notice Default minimum bid threshold for tests. + uint256 public constant DEFAULT_MIN_BID_THRESHOLD = 100; + + /// @notice Default burn percentage in basis points (50%). + uint256 public constant DEFAULT_BURN_BPS = 5000; + + FeeFlow public feeFlow; + Splitter public splitter; + + address public admin; + address public emergencyAdmin; + address public distributor1; + address public distributor2; + address public claimer; + + uint256 public forkId; + + /// @notice Sets up the fork and deploys contracts. + function setUp() public virtual { + // Create fork + forkId = vm.createSelectFork(vm.envString("ZKSYNC_MAINNET_RPC_URL")); + + // Setup test accounts + admin = makeAddr("admin"); + emergencyAdmin = makeAddr("emergencyAdmin"); + distributor1 = makeAddr("distributor1"); + distributor2 = makeAddr("distributor2"); + claimer = makeAddr("claimer"); + + // Deploy contracts + _deployContracts(); + } + + /// @notice Deploys FeeFlow and Splitter with default configuration. + function _deployContracts() internal virtual { + Splitter.DistributorConfig[] memory _distributors = new Splitter.DistributorConfig[](2); + _distributors[0] = Splitter.DistributorConfig({recipient: distributor1, weight: 50}); + _distributors[1] = Splitter.DistributorConfig({recipient: distributor2, weight: 50}); + + IERC20[] memory _claimableTokens = new IERC20[](0); + + DeploymentParams memory _params = DeploymentParams({ + admin: admin, + emergencyAdmin: emergencyAdmin, + zkToken: ZK_TOKEN, + minBidThreshold: DEFAULT_MIN_BID_THRESHOLD, + bidThreshold: DEFAULT_BID_THRESHOLD, + burnBps: DEFAULT_BURN_BPS, + distributors: _distributors, + claimableTokens: _claimableTokens + }); + + DeploymentResult memory _result = deploy(_params); + feeFlow = _result.feeFlow; + splitter = _result.splitter; + } + + /// @notice Deals ZK tokens to an address. + /// @param _to The address to deal tokens to. + /// @param _amount The amount of tokens to deal. + function _dealZkTokens(address _to, uint256 _amount) internal { + deal(address(ZK_TOKEN), _to, _amount); + } + + /// @notice Sets up a claimer with ZK tokens and approval. + /// @param _claimer The claimer address. + /// @param _amount The amount of tokens to give and approve. + function _setupClaimer(address _claimer, uint256 _amount) internal { + _dealZkTokens(_claimer, _amount); + vm.prank(_claimer); + IERC20(address(ZK_TOKEN)).approve(address(feeFlow), _amount); + } + + /// @notice Creates a claim request array for a single token. + /// @param _token The token to claim. + /// @param _minAmount The minimum amount to request. + /// @return _requests The claim request array. + function _createClaimRequest(IERC20 _token, uint256 _minAmount) + internal + pure + returns (FeeFlow.ClaimRequest[] memory _requests) + { + _requests = new FeeFlow.ClaimRequest[](1); + _requests[0] = FeeFlow.ClaimRequest({token: _token, minAmountRequested: _minAmount}); + } +} diff --git a/test/mocks/ERC20BurnableMock.sol b/test/mocks/ERC20BurnableMock.sol index ae9a4a4..ff7fa6a 100644 --- a/test/mocks/ERC20BurnableMock.sol +++ b/test/mocks/ERC20BurnableMock.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.30; import {ERC20} from "lib/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++; + } +} From 085b69166db53820bd2c541c7238af97ff3cef1c Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:29:29 -0800 Subject: [PATCH 2/3] chore/remove-fork-tests --- .github/workflows/ci.yml | 19 +-- README.md | 28 +--- test/FeeFlow.fork.t.sol | 191 --------------------------- test/FeeFlow.integration.t.sol | 2 - test/helpers/IntegrationTestBase.sol | 107 --------------- 5 files changed, 3 insertions(+), 344 deletions(-) delete mode 100644 test/FeeFlow.fork.t.sol delete mode 100644 test/helpers/IntegrationTestBase.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 186bde1..45a10eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,22 +37,7 @@ jobs: uses: dutterbutter/foundry-zksync-toolchain@v1 - name: Run tests - run: forge test --no-match-contract FeeFlowForkTest - - fork-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Install Foundry - uses: dutterbutter/foundry-zksync-toolchain@v1 - - - name: Run fork tests - env: - ZKSYNC_MAINNET_RPC_URL: ${{ secrets.ZKSYNC_MAINNET_RPC_URL }} - run: forge test --match-contract FeeFlowForkTest --zksync + run: forge test coverage: runs-on: ubuntu-latest @@ -67,7 +52,7 @@ jobs: uses: dutterbutter/foundry-zksync-toolchain@v1 - name: Run coverage - run: forge coverage --no-match-contract FeeFlowForkTest --report summary --report lcov + run: forge coverage --report summary --report lcov # To ignore coverage for certain directories modify the paths in this step as needed. The # below default ignores coverage results for the test and script directories. Alternatively, diff --git a/README.md b/README.md index 1b3908c..686ed74 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ forge build This project uses [foundry-zksync](https://github.com/matter-labs/foundry-zksync) for ZKsync Era compatibility. -### Unit Tests +### Tests Run unit and integration tests (mock-based, fast): @@ -60,32 +60,6 @@ Run unit and integration tests (mock-based, fast): forge test ``` -### Fork Tests - -Fork tests run against ZKsync Era mainnet with the real ZK token. These require: - -1. A `.env` file with your RPC URL: - ``` - ZKSYNC_MAINNET_RPC_URL=https://your-zksync-rpc-url - ``` - -2. The `--zksync` flag for foundry-zksync: - ```bash - source .env && forge test --match-contract FeeFlowForkTest --zksync - ``` - -### Running All Tests - -Due to a limitation in foundry-zksync, fork tests and unit tests must be run separately: - -```bash -# Unit tests (no --zksync flag needed) -forge test --no-match-contract FeeFlowForkTest - -# Fork tests (requires --zksync flag) -source .env && forge test --match-contract FeeFlowForkTest --zksync -``` - ### Test Profiles - `default`: Standard test runs diff --git a/test/FeeFlow.fork.t.sol b/test/FeeFlow.fork.t.sol deleted file mode 100644 index 7357821..0000000 --- a/test/FeeFlow.fork.t.sol +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.30; - -import {IntegrationTestBase} from "test/helpers/IntegrationTestBase.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 {ERC20Mock} from "test/mocks/ERC20Mock.sol"; - -/// @title FeeFlow Fork Tests -/// @notice Fork tests against ZKsync Era mainnet with real ZK token. -contract FeeFlowForkTest is IntegrationTestBase { - ERC20Mock internal feeToken; - - function setUp() public override { - super.setUp(); - - // Deploy a mock fee token for testing claims - feeToken = new ERC20Mock("Fee Token", "FEE"); - - // Add fee token to claimable tokens - vm.prank(admin); - feeFlow.setClaimableToken(IERC20(address(feeToken)), true); - } - - function test_Fork_FullFlow_ClaimTriggersSplitAndBurn() public { - // Mint fee tokens to FeeFlow - uint256 _feeAmount = 500 ether; - feeToken.mint(address(feeFlow), _feeAmount); - - // Setup claimer with ZK tokens - _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); - - // Record initial state - uint256 _zkSupplyBefore = IERC20(address(ZK_TOKEN)).totalSupply(); - - // Claimer claims fee tokens - FeeFlow.ClaimRequest[] memory _claimRequests = - _createClaimRequest(IERC20(address(feeToken)), _feeAmount); - - vm.prank(claimer); - feeFlow.claim(_claimRequests); - - // Verify fee tokens went to claimer - assertEq(feeToken.balanceOf(claimer), _feeAmount, "Claimer should receive fee tokens"); - assertEq(feeToken.balanceOf(address(feeFlow)), 0, "FeeFlow should have no fee tokens left"); - - // Verify ZK tokens were split: 50% burned, 50% distributed - uint256 _expectedBurn = DEFAULT_BID_THRESHOLD / 2; - uint256 _expectedPerDistributor = DEFAULT_BID_THRESHOLD / 4; // 25% each - - // Total supply decreased by burn amount (real ZK token burn) - assertEq( - IERC20(address(ZK_TOKEN)).totalSupply(), - _zkSupplyBefore - _expectedBurn, - "ZK total supply should decrease by burn amount" - ); - - // Distributors received their shares - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(distributor1), - _expectedPerDistributor, - "Distributor1 should receive 25%" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(distributor2), - _expectedPerDistributor, - "Distributor2 should receive 25%" - ); - - // Splitter has zero balance - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" - ); - - // Claimer has zero ZK tokens left - assertEq(IERC20(address(ZK_TOKEN)).balanceOf(claimer), 0, "Claimer should have no ZK left"); - } - - function test_Fork_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); - - // Setup claimer - _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); - - uint256 _zkSupplyBefore = IERC20(address(ZK_TOKEN)).totalSupply(); - - // Claimer claims fee tokens - FeeFlow.ClaimRequest[] memory _claimRequests = - _createClaimRequest(IERC20(address(feeToken)), _feeAmount); - - vm.prank(claimer); - feeFlow.claim(_claimRequests); - - // All ZK tokens burned - assertEq( - IERC20(address(ZK_TOKEN)).totalSupply(), - _zkSupplyBefore - DEFAULT_BID_THRESHOLD, - "All bid tokens should be burned" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" - ); - } - - function test_Fork_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); - - // Setup claimer - _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); - - uint256 _zkSupplyBefore = IERC20(address(ZK_TOKEN)).totalSupply(); - - // Claimer claims fee tokens - FeeFlow.ClaimRequest[] memory _claimRequests = - _createClaimRequest(IERC20(address(feeToken)), _feeAmount); - - vm.prank(claimer); - feeFlow.claim(_claimRequests); - - // No burn, all distributed - assertEq( - IERC20(address(ZK_TOKEN)).totalSupply(), _zkSupplyBefore, "Total supply should not change" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(distributor1), - DEFAULT_BID_THRESHOLD / 2, - "Distributor1 should receive 50%" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(distributor2), - DEFAULT_BID_THRESHOLD / 2, - "Distributor2 should receive 50%" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" - ); - } - - function test_Fork_FullFlow_MultipleClaims() public { - // First claim - feeToken.mint(address(feeFlow), 500 ether); - _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); - - FeeFlow.ClaimRequest[] memory _claimRequests = - _createClaimRequest(IERC20(address(feeToken)), 500 ether); - - vm.prank(claimer); - feeFlow.claim(_claimRequests); - - uint256 _dist1AfterFirst = IERC20(address(ZK_TOKEN)).balanceOf(distributor1); - uint256 _dist2AfterFirst = IERC20(address(ZK_TOKEN)).balanceOf(distributor2); - - // Second claim - feeToken.mint(address(feeFlow), 300 ether); - _setupClaimer(claimer, DEFAULT_BID_THRESHOLD); - - _claimRequests = _createClaimRequest(IERC20(address(feeToken)), 300 ether); - - vm.prank(claimer); - feeFlow.claim(_claimRequests); - - // Distributors should have accumulated from both claims - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(distributor1), - _dist1AfterFirst * 2, - "Distributor1 should accumulate from both claims" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(distributor2), - _dist2AfterFirst * 2, - "Distributor2 should accumulate from both claims" - ); - assertEq( - IERC20(address(ZK_TOKEN)).balanceOf(address(splitter)), 0, "Splitter should have no ZK left" - ); - } -} diff --git a/test/FeeFlow.integration.t.sol b/test/FeeFlow.integration.t.sol index bb79f8b..dbef389 100644 --- a/test/FeeFlow.integration.t.sol +++ b/test/FeeFlow.integration.t.sol @@ -13,8 +13,6 @@ 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. -/// For fork tests against ZKsync Era mainnet, use foundry-zksync: -/// https://github.com/matter-labs/foundry-zksync contract FeeFlowIntegrationTest is Test { FeeFlow internal feeFlow; Splitter internal splitter; diff --git a/test/helpers/IntegrationTestBase.sol b/test/helpers/IntegrationTestBase.sol deleted file mode 100644 index 649e772..0000000 --- a/test/helpers/IntegrationTestBase.sol +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.30; - -import {Test} from "forge-std/Test.sol"; -import {Deploy} from "script/Deploy.s.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"; - -/// @title IntegrationTestBase -/// @notice Base contract for fork integration tests. -/// @dev Inherits from Deploy to reuse deployment logic. -abstract contract IntegrationTestBase is Test, Deploy { - /// @notice ZK token address on ZKsync Era mainnet. - IERC20Burnable public constant ZK_TOKEN = - IERC20Burnable(0x5A7d6b2F92C77FAD6CCaBd7EE0624E64907Eaf3E); - - /// @notice Default bid threshold for tests. - uint256 public constant DEFAULT_BID_THRESHOLD = 1000 ether; - - /// @notice Default minimum bid threshold for tests. - uint256 public constant DEFAULT_MIN_BID_THRESHOLD = 100; - - /// @notice Default burn percentage in basis points (50%). - uint256 public constant DEFAULT_BURN_BPS = 5000; - - FeeFlow public feeFlow; - Splitter public splitter; - - address public admin; - address public emergencyAdmin; - address public distributor1; - address public distributor2; - address public claimer; - - uint256 public forkId; - - /// @notice Sets up the fork and deploys contracts. - function setUp() public virtual { - // Create fork - forkId = vm.createSelectFork(vm.envString("ZKSYNC_MAINNET_RPC_URL")); - - // Setup test accounts - admin = makeAddr("admin"); - emergencyAdmin = makeAddr("emergencyAdmin"); - distributor1 = makeAddr("distributor1"); - distributor2 = makeAddr("distributor2"); - claimer = makeAddr("claimer"); - - // Deploy contracts - _deployContracts(); - } - - /// @notice Deploys FeeFlow and Splitter with default configuration. - function _deployContracts() internal virtual { - Splitter.DistributorConfig[] memory _distributors = new Splitter.DistributorConfig[](2); - _distributors[0] = Splitter.DistributorConfig({recipient: distributor1, weight: 50}); - _distributors[1] = Splitter.DistributorConfig({recipient: distributor2, weight: 50}); - - IERC20[] memory _claimableTokens = new IERC20[](0); - - DeploymentParams memory _params = DeploymentParams({ - admin: admin, - emergencyAdmin: emergencyAdmin, - zkToken: ZK_TOKEN, - minBidThreshold: DEFAULT_MIN_BID_THRESHOLD, - bidThreshold: DEFAULT_BID_THRESHOLD, - burnBps: DEFAULT_BURN_BPS, - distributors: _distributors, - claimableTokens: _claimableTokens - }); - - DeploymentResult memory _result = deploy(_params); - feeFlow = _result.feeFlow; - splitter = _result.splitter; - } - - /// @notice Deals ZK tokens to an address. - /// @param _to The address to deal tokens to. - /// @param _amount The amount of tokens to deal. - function _dealZkTokens(address _to, uint256 _amount) internal { - deal(address(ZK_TOKEN), _to, _amount); - } - - /// @notice Sets up a claimer with ZK tokens and approval. - /// @param _claimer The claimer address. - /// @param _amount The amount of tokens to give and approve. - function _setupClaimer(address _claimer, uint256 _amount) internal { - _dealZkTokens(_claimer, _amount); - vm.prank(_claimer); - IERC20(address(ZK_TOKEN)).approve(address(feeFlow), _amount); - } - - /// @notice Creates a claim request array for a single token. - /// @param _token The token to claim. - /// @param _minAmount The minimum amount to request. - /// @return _requests The claim request array. - function _createClaimRequest(IERC20 _token, uint256 _minAmount) - internal - pure - returns (FeeFlow.ClaimRequest[] memory _requests) - { - _requests = new FeeFlow.ClaimRequest[](1); - _requests[0] = FeeFlow.ClaimRequest({token: _token, minAmountRequested: _minAmount}); - } -} From 8d3315bad210d93372688c4cb4042db72a44bf93 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:33:57 -0800 Subject: [PATCH 3/3] chore/deploy-script --- script/Deploy.s.sol | 142 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 27 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 08f48f8..a2fdb19 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {Script} from "forge-std/Script.sol"; @@ -9,12 +9,31 @@ import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; import {ERC1967Proxy} from "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; /// @title Deploy -/// @notice Deployment script for FeeFlow and Splitter contracts. +/// @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 { + 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 zkToken; + IERC20Burnable bidToken; uint256 minBidThreshold; uint256 bidThreshold; uint256 burnBps; @@ -23,59 +42,128 @@ contract Deploy is Script { } struct DeploymentResult { - Splitter splitterImpl; - Splitter splitter; - FeeFlow feeFlowImpl; - FeeFlow feeFlow; + 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. - /// @param _params The deployment parameters. - /// @return _result The deployed contract instances. - function deploy(DeploymentParams memory _params) - public + function _deploy(DeploymentParams memory _params) + internal returns (DeploymentResult memory _result) { - // Deploy Splitter implementation - _result.splitterImpl = new Splitter(); - - // Deploy Splitter proxy + Splitter _splitterImplementation = new Splitter(); ERC1967Proxy _splitterProxy = new ERC1967Proxy( - address(_result.splitterImpl), + address(_splitterImplementation), abi.encodeCall( Splitter.initialize, ( _params.admin, _params.emergencyAdmin, - _params.zkToken, + _params.bidToken, _params.burnBps, _params.distributors ) ) ); - _result.splitter = Splitter(address(_splitterProxy)); - // Deploy FeeFlow implementation - _result.feeFlowImpl = new FeeFlow(); - - // Deploy FeeFlow proxy + FeeFlow _feeFlowImplementation = new FeeFlow(); ERC1967Proxy _feeFlowProxy = new ERC1967Proxy( - address(_result.feeFlowImpl), + address(_feeFlowImplementation), abi.encodeCall( FeeFlow.initialize, ( _params.admin, _params.emergencyAdmin, - IERC20(address(_params.zkToken)), + IERC20(address(_params.bidToken)), _params.minBidThreshold, _params.bidThreshold, - address(_result.splitter), + address(_splitterProxy), _params.claimableTokens ) ) ); - _result.feeFlow = FeeFlow(address(_feeFlowProxy)); + + return DeploymentResult({ + splitterImplementation: address(_splitterImplementation), + splitterProxy: address(_splitterProxy), + feeFlowImplementation: address(_feeFlowImplementation), + feeFlowProxy: address(_feeFlowProxy) + }); } - function run() public {} + 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; + } }