forked from ethereum-optimism/optimism
-
Notifications
You must be signed in to change notification settings - Fork 3
feat: invariants and tests #595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
0xDiscotech
merged 11 commits into
sc-feat/fee-splitter-system
from
sc-test/fee-splitter-invariants
Sep 26, 2025
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
c0ffee0
feat: invariants and tests
simon-something c0ffeec
chore: typo
simon-something c0ffee9
chore: typo
simon-something c0ffee9
feat: vault setMinAmount handler
simon-something c0ffee5
chore: fix vault idx
simon-something c0ffee5
fix: upper bound for gross rev share overflow
simon-something c0ffee6
fix: handler
simon-something c0ffeec
feat: l1withdrawer handler and inv
simon-something c0ffee4
feat: bias calls toward zero in setMinAmount
simon-something c0ffee5
chore: typo
simon-something c0ffee1
feat: match error selectors
simon-something File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
325 changes: 325 additions & 0 deletions
325
packages/contracts-bedrock/test/invariants/FeeSplit.t.sol
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,325 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.15; | ||
|
|
||
| import { StdUtils } from "forge-std/StdUtils.sol"; | ||
| import { Vm } from "forge-std/Vm.sol"; | ||
| import { StdInvariant } from "forge-std/StdInvariant.sol"; | ||
| import { InvariantTest } from "test/invariants/InvariantTest.sol"; | ||
| import { CommonTest } from "test/setup/CommonTest.sol"; | ||
| import { IFeeVault } from "interfaces/L2/IFeeVault.sol"; | ||
| import { IProxyAdmin } from "interfaces/universal/IProxyAdmin.sol"; | ||
| import { Predeploys } from "src/libraries/Predeploys.sol"; | ||
| import { IFeeSplitter } from "interfaces/L2/IFeeSplitter.sol"; | ||
| import { IL1Withdrawer } from "interfaces/L2/IL1Withdrawer.sol"; | ||
| import { ISuperchainRevSharesCalculator } from "interfaces/L2/ISuperchainRevSharesCalculator.sol"; | ||
|
|
||
| /// @notice A struct to keep track of the state when a disburse call fails | ||
| struct DisburseFailureState { | ||
| uint256 sequencerFeeVaultBalance; | ||
| uint256 sequencerFeeVaultMinWithdrawalAmount; | ||
| uint256 baseFeeVaultBalance; | ||
| uint256 baseFeeVaultMinWithdrawalAmount; | ||
| uint256 l1FeeVaultBalance; | ||
| uint256 l1FeeVaultMinWithdrawalAmount; | ||
| uint256 operatorFeeVaultBalance; | ||
| uint256 operatorFeeVaultMinWithdrawalAmount; | ||
| uint256 attemptTimestamp; | ||
| bytes reason; | ||
| } | ||
|
|
||
| /// @title Handler to call the disburseFees function | ||
| contract FeeSplitter_Disburser is StdUtils { | ||
| /// @notice Vm instance | ||
| Vm internal vm; | ||
|
|
||
| /// @notice FeeSplitter contract | ||
| IFeeSplitter public feeSplitter; | ||
|
|
||
| IL1Withdrawer public l1Withdrawer; | ||
|
|
||
| /// @notice Flag to track if a disburseFees() call failed | ||
| bool public txFailed; | ||
|
|
||
| /// @notice Keep track of the balances and timestamp of a failed disbursement | ||
| DisburseFailureState internal failureState; | ||
|
|
||
| /// @notice Aggregate of the vault balances disbursed | ||
| uint256 public ghost_grossRevenueDisbursed; | ||
|
|
||
| /// @notice Keep track of the last aggregated fee disbursed | ||
| uint256 public ghost_lastDisbursementAmount; | ||
|
|
||
| /// @notice Keep track of the l1withdrawer should have withdrawn | ||
| bool public l1withdrawerShouldHaveWithdrawn; | ||
|
|
||
| constructor(Vm _vm, IFeeSplitter _feeSplitter, IL1Withdrawer _l1Withdrawer) { | ||
| vm = _vm; | ||
| feeSplitter = _feeSplitter; | ||
| l1Withdrawer = _l1Withdrawer; | ||
| } | ||
|
|
||
| /// @notice Get the failure state (convenience, to keep the struct) | ||
| function getFailureState() external view returns (DisburseFailureState memory failureState_) { | ||
| failureState_ = failureState; | ||
| } | ||
|
|
||
| /// @notice handler for FeeSplitter.disburseFees() | ||
| /// @dev It update important ghost var, in both success and failure cases: | ||
| /// - success: update the overall amount disbursed (ie add the sum of the vaults balances before disbursement) | ||
| /// - failure: update the failure state (all vault balances and the current timestamp) | ||
| function disburse() public { | ||
| uint256 _sequencerFees = address(Predeploys.SEQUENCER_FEE_WALLET).balance; | ||
| uint256 _baseFees = address(Predeploys.BASE_FEE_VAULT).balance; | ||
| uint256 _l1Fees = address(Predeploys.L1_FEE_VAULT).balance; | ||
| uint256 _operatorFees = address(Predeploys.OPERATOR_FEE_VAULT).balance; | ||
| uint256 _aggregateVaultsBalances = _sequencerFees + _baseFees + _l1Fees + _operatorFees; | ||
|
|
||
| uint256 _l1withdrawerBalanceBeforeDisbursement = address(l1Withdrawer).balance; | ||
|
|
||
| try feeSplitter.disburseFees() { | ||
| // reset the fail flags | ||
| txFailed = false; | ||
| delete failureState; | ||
|
|
||
| // Check if the l1withdrawer should have been triggered and empty its balance | ||
| uint256 _amountToL1Withdrawer = feeSplitter.sharesCalculator().getRecipientsAndAmounts( | ||
| _sequencerFees, _baseFees, _operatorFees, _l1Fees | ||
| )[0].amount; | ||
|
|
||
| if ( | ||
| _l1withdrawerBalanceBeforeDisbursement + _amountToL1Withdrawer | ||
| >= IL1Withdrawer(payable(l1Withdrawer)).minWithdrawalAmount() | ||
| ) { | ||
| l1withdrawerShouldHaveWithdrawn = true; | ||
| } else { | ||
| l1withdrawerShouldHaveWithdrawn = false; | ||
| } | ||
|
|
||
| ghost_grossRevenueDisbursed += _aggregateVaultsBalances; | ||
| } catch (bytes memory _reason) { | ||
| // keep track of the failing state | ||
| txFailed = true; | ||
| failureState.sequencerFeeVaultBalance = address(Predeploys.SEQUENCER_FEE_WALLET).balance; | ||
| failureState.sequencerFeeVaultMinWithdrawalAmount = | ||
| IFeeVault(payable(Predeploys.SEQUENCER_FEE_WALLET)).minWithdrawalAmount(); | ||
| failureState.baseFeeVaultBalance = address(Predeploys.BASE_FEE_VAULT).balance; | ||
| failureState.baseFeeVaultMinWithdrawalAmount = | ||
| IFeeVault(payable(Predeploys.BASE_FEE_VAULT)).minWithdrawalAmount(); | ||
| failureState.l1FeeVaultBalance = address(Predeploys.L1_FEE_VAULT).balance; | ||
| failureState.l1FeeVaultMinWithdrawalAmount = | ||
| IFeeVault(payable(Predeploys.L1_FEE_VAULT)).minWithdrawalAmount(); | ||
| failureState.operatorFeeVaultBalance = address(Predeploys.OPERATOR_FEE_VAULT).balance; | ||
| failureState.operatorFeeVaultMinWithdrawalAmount = | ||
| IFeeVault(payable(Predeploys.OPERATOR_FEE_VAULT)).minWithdrawalAmount(); | ||
| failureState.attemptTimestamp = block.timestamp; | ||
| failureState.reason = _reason; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// @title Handler to set arbitrary preconditions (balance and block timestamp) | ||
| contract FeeSplitter_Preconditions is CommonTest { | ||
| /// @notice modify the min amount to withdraw from a vault | ||
| /// @dev We include the case where min amount is 0 (ie no minimum to withdraw) | ||
| /// @param _minAmount The seed of the min amount to withdraw from a vault | ||
| /// @param _vaultIndex The seed of the vault's index to set the min amount to withdraw from | ||
| function setMinAmount(uint256 _minAmount, uint256 _vaultIndex) public { | ||
| _vaultIndex = bound(_vaultIndex, 0, 3); | ||
|
|
||
| vm.prank(IProxyAdmin(Predeploys.PROXY_ADMIN).owner()); | ||
|
|
||
| if (_vaultIndex == 0) { | ||
| IFeeVault(payable(Predeploys.SEQUENCER_FEE_WALLET)).setMinWithdrawalAmount(_minAmount); | ||
| } else if (_vaultIndex == 1) { | ||
| IFeeVault(payable(Predeploys.BASE_FEE_VAULT)).setMinWithdrawalAmount(_minAmount); | ||
| } else if (_vaultIndex == 2) { | ||
| IFeeVault(payable(Predeploys.L1_FEE_VAULT)).setMinWithdrawalAmount(_minAmount); | ||
| } else if (_vaultIndex == 3) { | ||
| IFeeVault(payable(Predeploys.OPERATOR_FEE_VAULT)).setMinWithdrawalAmount(_minAmount); | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| /// @notice Warp the block timestamp | ||
| /// @param _seconds The seed of the seconds to warp the block timestamp by | ||
| function warp(uint256 _seconds) public { | ||
| _seconds = bound(_seconds, 0, 10 days); | ||
| vm.warp(block.timestamp + _seconds); | ||
| } | ||
|
|
||
| /// @notice Add collected fee to a vault | ||
| /// @param _amount The seed of amount to add to the vault | ||
| /// @param _vaultIndex The seed of the vault's index to add the fee to | ||
| /// @dev The net and gross revenue have an upper bound to avoid overflows in the shares calculator | ||
| function addCollectedFeeToVault(uint256 _amount, uint256 _vaultIndex) public { | ||
| _vaultIndex = bound(_vaultIndex, 0, 3); | ||
| _amount = bound(_amount, 0, 100 ether); | ||
|
|
||
| if (_vaultIndex == 0) { | ||
| vm.deal(address(Predeploys.SEQUENCER_FEE_WALLET), _amount); | ||
| } else if (_vaultIndex == 1) { | ||
| vm.deal(address(Predeploys.BASE_FEE_VAULT), _amount); | ||
| } else if (_vaultIndex == 2) { | ||
| vm.deal(address(Predeploys.L1_FEE_VAULT), _amount); | ||
| } else if (_vaultIndex == 3) { | ||
| vm.deal(address(Predeploys.OPERATOR_FEE_VAULT), _amount); | ||
| } | ||
|
simon-something marked this conversation as resolved.
|
||
| } | ||
| } | ||
| /// @title Handler to set the call distribution bias for setMinAmount | ||
| /// @notice This bias the distribution of calls to setMinAmount, favoring 75% of the calls to set the min amount to 0 | ||
| /// as it is the "vanilla" case. | ||
| /// @dev See https://getfoundry.sh/forge/advanced-testing/invariant-testing#function-call-probability-distribution | ||
| /// We favor this over a single wrapper with a seed to branch to keep distinct edges/selectors while using a corpus | ||
|
|
||
| contract FeeSplitter_CallDistributionBias is FeeSplitter_Preconditions { | ||
| uint256 public amountZeroCalls; | ||
| uint256 public amountNotZeroCalls; | ||
|
|
||
| function setMinAmountZero1(uint256 _vaultIndex) public { | ||
| amountZeroCalls++; | ||
| setMinAmount(0, _vaultIndex); | ||
| } | ||
|
|
||
| function setMinAmountZero2(uint256 _vaultIndex) public { | ||
| amountZeroCalls++; | ||
| setMinAmount(0, _vaultIndex); | ||
| } | ||
|
|
||
| function setMinAmountZero3(uint256 _vaultIndex) public { | ||
| amountZeroCalls++; | ||
| setMinAmount(0, _vaultIndex); | ||
| } | ||
|
|
||
| function setMinAmountNotZero(uint256 _minAmount, uint256 _vaultIndex) public { | ||
| amountNotZeroCalls++; | ||
| setMinAmount(_minAmount, _vaultIndex); | ||
| } | ||
| } | ||
|
|
||
| /// @title Invariants for the FeeSplitter | ||
| /// @notice The invariants tested are: | ||
| /// - no dust accumulation in the FeeSplitter | ||
| /// - total disbursed fees should always be equal to the sum of the vault balances before disbursement | ||
| /// - disburseFees can only revert if either one of the vault has a balance below it's minimum withdrawal amount | ||
| /// or if the disbursement interval has not been reached yet | ||
| /// @dev These invariants are covering the system formed by: | ||
| /// FeeSplitter, 4 FeeVault's, SuperchainRevSharesCalculator, L1Withdrawer | ||
| contract FeeSplitter_Invariant is CommonTest { | ||
| /// @notice Handler for disbursing fees | ||
| FeeSplitter_Disburser public disburser; | ||
|
|
||
| /// @notice Handler to set test preconditions | ||
| FeeSplitter_Preconditions public preconditions; | ||
|
|
||
| /// @notice Handler to set the call distribution bias | ||
| FeeSplitter_CallDistributionBias public callDistributionBias; | ||
|
|
||
| /// @notice Setup: enable the revenue share, deploy handlers and target them. | ||
| function setUp() public override { | ||
| super.enableRevenueShare(); | ||
| super.setUp(); | ||
|
|
||
| disburser = new FeeSplitter_Disburser(vm, feeSplitter, l1Withdrawer); | ||
| preconditions = new FeeSplitter_Preconditions(); | ||
| callDistributionBias = new FeeSplitter_CallDistributionBias(); | ||
|
|
||
| targetContract(address(disburser)); | ||
|
|
||
| targetContract(address(callDistributionBias)); | ||
| bytes4[] memory selectors = new bytes4[](4); | ||
| selectors[0] = FeeSplitter_CallDistributionBias.setMinAmountZero1.selector; | ||
| selectors[1] = FeeSplitter_CallDistributionBias.setMinAmountZero2.selector; | ||
| selectors[2] = FeeSplitter_CallDistributionBias.setMinAmountZero3.selector; | ||
| selectors[3] = FeeSplitter_CallDistributionBias.setMinAmountNotZero.selector; | ||
| targetSelector(FuzzSelector({ addr: address(callDistributionBias), selectors: selectors })); | ||
|
|
||
| targetContract(address(preconditions)); | ||
| selectors = new bytes4[](2); | ||
| selectors[0] = FeeSplitter_Preconditions.warp.selector; | ||
| selectors[1] = FeeSplitter_Preconditions.addCollectedFeeToVault.selector; | ||
| targetSelector(FuzzSelector({ addr: address(preconditions), selectors: selectors })); | ||
| } | ||
|
|
||
| /// @notice Invariant: The fee splitter balance should always be 0 | ||
| /// @dev This invariant doesn't account for direct forced transfers (eg selfdestruct) | ||
| function invariant_noDust() external view { | ||
| assertEq(address(disburser.feeSplitter()).balance, 0); | ||
| } | ||
|
|
||
| /// @notice Invariant: The l1withdrawer should always transfer its whole balance if it reaches the threshold | ||
| function invariant_l1withdrawerWithdrawn() external view { | ||
| if (disburser.l1withdrawerShouldHaveWithdrawn()) { | ||
| assertEq(address(l1Withdrawer).balance, 0); | ||
| } | ||
| } | ||
|
|
||
| /// @notice Invariant: The total disbursed fees should always be equal to the sum of the vault balances before | ||
| /// disbursement | ||
| /// @dev This invariant can also be expressed as "disburseFees is only successful if all vaults can transfer the | ||
| /// fee/all or nothing (0 threshold is accepted)" as, otherwise, some funds would still be in the vaults, | ||
| /// invalidating the equality | ||
| function invariant_balanceConservation() external view { | ||
| assertEq( | ||
| disburser.ghost_grossRevenueDisbursed(), | ||
| address(l1Withdrawer).balance + Predeploys.L2_TO_L1_MESSAGE_PASSER.balance | ||
| + address(chainFeesRecipient).balance | ||
| ); | ||
| } | ||
|
simon-something marked this conversation as resolved.
|
||
|
|
||
| /// @notice Invariants: these are revert invariants, disburseFees can only revert if either one of the vault | ||
| /// has a balance below it's minimum withdrawal amount (no other revert conditions are possible for the vault) | ||
| /// or if the disbursement interval has not been reached yet (this is making the assumption the recipient are | ||
| /// NOT reverting when receiving the fees). | ||
| /// @dev This invariant is also testing the "no partial disbursement", as the previous one. | ||
| function invariant_disburseReverts() external view { | ||
| if (disburser.txFailed()) { | ||
| DisburseFailureState memory _failureState = disburser.getFailureState(); | ||
|
|
||
| uint256 _grossRevenue = _failureState.sequencerFeeVaultBalance + _failureState.baseFeeVaultBalance | ||
| + _failureState.l1FeeVaultBalance + _failureState.operatorFeeVaultBalance; | ||
|
|
||
| // either one of the vaults is below the minimum withdrawal amount | ||
| bool _vaultBelowMinimum = ( | ||
| _failureState.sequencerFeeVaultBalance < _failureState.sequencerFeeVaultMinWithdrawalAmount | ||
| || _failureState.baseFeeVaultBalance < _failureState.baseFeeVaultMinWithdrawalAmount | ||
| || _failureState.l1FeeVaultBalance < _failureState.l1FeeVaultMinWithdrawalAmount | ||
| || _failureState.operatorFeeVaultBalance < _failureState.operatorFeeVaultMinWithdrawalAmount | ||
| ) | ||
| && keccak256(_failureState.reason) | ||
| == keccak256( | ||
| abi.encodeWithSignature( | ||
| "Error(string)", "FeeVault: withdrawal amount must be greater than minimum withdrawal amount" | ||
| ) | ||
| ); | ||
|
|
||
| // not enough time since last disbursement | ||
| bool _tooEarly = _failureState.attemptTimestamp | ||
| < disburser.feeSplitter().lastDisbursementTime() + disburser.feeSplitter().feeDisbursementInterval() | ||
| && bytes4(_failureState.reason) == IFeeSplitter.FeeSplitter_DisbursementIntervalNotReached.selector; | ||
|
|
||
| // no revenue at all | ||
| bool _noRevenue = | ||
| _grossRevenue == 0 && bytes4(_failureState.reason) == IFeeSplitter.FeeSplitter_NoFeesCollected.selector; | ||
|
|
||
| // rounding down error in the shares calculator | ||
| bool _noSharesCalculator = (_grossRevenue * 250) < 10000 | ||
| && bytes4(_failureState.reason) == ISuperchainRevSharesCalculator.SharesCalculator_ZeroGrossShare.selector; | ||
|
|
||
| assertTrue(_vaultBelowMinimum || _tooEarly || _noRevenue || _noSharesCalculator); | ||
| } | ||
| } | ||
|
|
||
| /// @notice After invariant: log the call distribution bias | ||
| /// @dev This could be an assertion, but only works significant with big calldepth (or keeping a counter accross all | ||
| /// runs, which needs a file to write to) | ||
| function afterInvariant() external { | ||
| uint256 _totalCalls = callDistributionBias.amountZeroCalls() + callDistributionBias.amountNotZeroCalls(); | ||
|
|
||
| if (_totalCalls > 0) { | ||
| emit log_named_uint( | ||
| "% of calls setting the min amount to zero in this run: ", | ||
| callDistributionBias.amountZeroCalls() * 100 / _totalCalls | ||
| ); | ||
| } | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.