-
Notifications
You must be signed in to change notification settings - Fork 3.9k
test: custom gas token invariants #17489
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
agusduha
merged 15 commits into
ethereum-optimism:sc-feat/custom-gas-token-rebase
from
defi-wonderland:chore/pr-cgt-invariants
Sep 25, 2025
Merged
Changes from 10 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
180ddbc
test(inv): setup and total sup inv
simon-something 2d536c5
test(inv): accounting invariants
simon-something c0ffee3
chore: doc
simon-something 3988861
fix: import in OptimismPortal2CGT test and pre-pr
0xniha 2c6ed20
fix(cgt): add missing native asset amount (#543)
0xniha afb8076
fix: l2 genesis pipeline (#554)
agusduha c13a4f4
fix: add nativeAssetLiquidityAmount in e2e apply test (#555)
0xniha e5f9a7f
fix: failing test
hexshire c4ad819
chore: doc
simon-something ed26bde
Merge branch 'sc-feat/custom-gas-token-rebase' into chore/pr-cgt-inva…
simon-something 1c1debe
Merge branch 'sc-feat/custom-gas-token-rebase' into chore/pr-cgt-inva…
hexshire 1f7d6fe
chore: remove unused imports
hexshire be16b0b
Merge pull request #591 from defi-wonderland/chore/pr-cgt-invariants-…
hexshire a64e1d6
chore: fix semgrep
hexshire d1e3076
chore: change error name
hexshire 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
There are no files selected for viewing
256 changes: 256 additions & 0 deletions
256
packages/contracts-bedrock/test/invariants/CustomGasToken.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,256 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity 0.8.15; | ||
|
|
||
| // Testing | ||
| import { StdUtils } from "forge-std/Test.sol"; | ||
| import { Vm } from "forge-std/Vm.sol"; | ||
| import { CommonTest } from "test/setup/CommonTest.sol"; | ||
|
|
||
| // Libraries | ||
| import { Predeploys } from "src/libraries/Predeploys.sol"; | ||
|
|
||
| // Contracts | ||
| import { LiquidityController } from "src/L2/LiquidityController.sol"; | ||
| import { NativeAssetLiquidity } from "src/L2/NativeAssetLiquidity.sol"; | ||
| import { ILiquidityController } from "interfaces/L2/ILiquidityController.sol"; | ||
| import { INativeAssetLiquidity } from "interfaces/L2/INativeAssetLiquidity.sol"; | ||
| import { IProxy } from "interfaces/universal/IProxy.sol"; | ||
| import { DeployUtils } from "scripts/libraries/DeployUtils.sol"; | ||
| import { IProxyAdmin } from "interfaces/universal/IProxyAdmin.sol"; | ||
|
|
||
| /// @title CGT_Minter | ||
| /// @notice An actor with the minter role (can mint and burn) | ||
| contract LiquidityController_Minter is StdUtils { | ||
| /// @notice The Vm contract. | ||
| Vm internal vm; | ||
|
|
||
| /// @notice The LiquidityController contract. | ||
| ILiquidityController internal liquidityController; | ||
|
|
||
| /// @notice The RandomActor contract. | ||
| RandomActor internal randomActor; | ||
|
|
||
| /// @notice Ghost accounting | ||
| uint256 public totalAmountMinted; | ||
| uint256 public totalAmountBurned; | ||
| bool public deltaBalanceAndMint; // NativeAssetLiquidity balance change != amount minted? | ||
| bool public deltaBalanceAndBurn; // NativeAssetLiquidity balance change != amount burned? | ||
|
|
||
| /// @param _vm The Vm contract. | ||
| /// @param _liquidityController The LiquidityController contract. | ||
| /// @param _randomActor The RandomActor contract. | ||
| constructor(Vm _vm, ILiquidityController _liquidityController, RandomActor _randomActor) { | ||
| vm = _vm; | ||
| liquidityController = _liquidityController; | ||
| randomActor = _randomActor; | ||
| } | ||
|
|
||
| /// @notice Mint custom gas token to the random actor. | ||
| /// @param _amount The amount of CGT to mint. | ||
| /// @dev Accounting invariants are leveraging the balance difference between pre and post-condition | ||
| function mint(uint256 _amount) public { | ||
| // precondition: nil - update ghost variables | ||
| uint256 _preBalance = payable(Predeploys.NATIVE_ASSET_LIQUIDITY).balance; | ||
|
|
||
| // action: mint to the random actor | ||
| liquidityController.mint(address(randomActor), _amount); | ||
|
|
||
| // postcondition: is the NativeAssetLiquidity contract's balance changed by an amount different than minted? | ||
| deltaBalanceAndMint = _amount != (_preBalance - uint256(payable(Predeploys.NATIVE_ASSET_LIQUIDITY).balance)); | ||
| totalAmountMinted += _amount; | ||
| } | ||
|
|
||
| /// @notice Burn custom gas token. | ||
| /// @param _amount The amount of CGT to burn, which is bounded to the actor's balance (avoid trivial revert) | ||
| /// @dev Accounting invariant are leveraging the balance difference between pre and post-condition | ||
| function burn(uint256 _amount) public { | ||
| // precondition: amount to burn has an upper bound (this contract's balance) | ||
| _amount = bound(_amount, 0, address(this).balance); | ||
| uint256 _preBalance = payable(Predeploys.NATIVE_ASSET_LIQUIDITY).balance; | ||
|
|
||
| // action: burn _amount | ||
| liquidityController.burn{ value: _amount }(); | ||
|
|
||
| // postcondition: update ghost variables by tracking an accounting difference | ||
| deltaBalanceAndBurn = _preBalance + _amount != uint256(payable(Predeploys.NATIVE_ASSET_LIQUIDITY).balance); | ||
| totalAmountBurned += _amount; | ||
| } | ||
|
|
||
| /// @dev Receive needed to receive CGT from the random actor | ||
| receive() external payable { } | ||
| } | ||
|
|
||
| /// @notice An actor which funds the NativeAssetLiquidity contract | ||
| /// @dev There is no underlying access control to this | ||
| contract NativeAssetLiquidity_Fundooor is StdUtils { | ||
| /// @notice The Vm contract. | ||
| Vm internal vm; | ||
|
|
||
| /// @notice The NativeAssetLiquidity contract. | ||
| INativeAssetLiquidity internal nativeAssetLiquidity; | ||
|
|
||
| /// @notice Ghost accounting | ||
| uint256 public totalAmountFunded; | ||
|
|
||
| /// @param _vm The Vm contract. | ||
| constructor(Vm _vm) { | ||
| vm = _vm; | ||
| nativeAssetLiquidity = INativeAssetLiquidity(Predeploys.NATIVE_ASSET_LIQUIDITY); | ||
| } | ||
|
|
||
| /// @notice Wrap fund() calls on the NativeAssetLiquidity contract. | ||
| /// @param _amount The amount of CGT to fund. | ||
| /// @dev The amount is bounded to the actor's balance (avoid trivial revert) | ||
| function fund(uint256 _amount) public { | ||
| // precondition: amount to fund has an upper bound (this contract's balance) + ghost accounting | ||
| _amount = bound(_amount, 0, address(this).balance); | ||
|
|
||
| // action: fund _amount | ||
| nativeAssetLiquidity.fund{ value: _amount }(); | ||
|
|
||
| // postcondition: nil here (in the invariant tests) | ||
| // update ghost variables | ||
| totalAmountFunded += _amount; | ||
| } | ||
|
|
||
| receive() external payable { } | ||
| } | ||
|
|
||
| /// @notice actor which receives fund and send them to either the minter or the funder actor, | ||
| /// keeping a closed loop (no vm.deal). It receive() function always revert, to insure mint()/safeSend is | ||
| /// always successfully sending the CGT. | ||
| contract RandomActor is StdUtils { | ||
| Vm internal vm; | ||
| address internal liquidityController_Minter; | ||
| address internal nativeAssetLiquidity_Fundooor; | ||
|
|
||
| /// @notice Flag to indicate if the actor has been called via receive() | ||
| bool public hasBeenCalled = false; | ||
|
|
||
| /// @notice Initialize the addresses of the minter and funder actors. | ||
| /// @param _liquidityController_Minter The address of the minter actor. | ||
| /// @param _nativeAssetLiquidity_Fundooor The address of the funder actor. | ||
| /// @dev This function selector is excluded from the invariant tests | ||
| function initAddresses(address _liquidityController_Minter, address _nativeAssetLiquidity_Fundooor) public { | ||
| liquidityController_Minter = _liquidityController_Minter; | ||
| nativeAssetLiquidity_Fundooor = _nativeAssetLiquidity_Fundooor; | ||
| } | ||
|
|
||
| /// @notice Send CGT to the minter actor. | ||
| /// @param _amount The amount of CGT to send. | ||
| /// @dev The amount is bounded to the actor's balance (avoid trivial revert) | ||
| function sendCGTtoMinter(uint256 _amount) public { | ||
| // precondition: amount to send has an upper bound (this contract's balance) | ||
| uint256 _amountToSend = bound(_amount, 0, address(this).balance); | ||
|
|
||
| // action: send _amountToSend to the minter actor | ||
| (bool success,) = payable(address(liquidityController_Minter)).call{ value: _amountToSend }(""); | ||
|
|
||
| // postcondition: the call must succeed (test suite sanity check) | ||
| require(success); | ||
| } | ||
|
|
||
| /// @notice Send CGT to the funder actor. | ||
| /// @param _amount The amount of CGT to send. | ||
| /// @dev The amount is bounded to the actor's balance (avoid trivial revert) | ||
| function sendCGTtoFunder(uint256 _amount) public { | ||
| // precondition: amount to send has an upper bound (this contract's balance) | ||
| uint256 _amountToSend = bound(_amount, 0, address(this).balance); | ||
|
|
||
| // action: send _amountToSend to the funder actor | ||
| (bool success,) = payable(address(nativeAssetLiquidity_Fundooor)).call{ value: _amountToSend }(""); | ||
|
|
||
| // postcondition: the call must succeed (test suite sanity check) | ||
| require(success); | ||
| } | ||
|
|
||
| /// @dev We track if the SafeSend triggers a logic on the receiver via a ghost variable | ||
| receive() external payable { | ||
| hasBeenCalled = true; | ||
| } | ||
|
|
||
| fallback() external payable { | ||
| hasBeenCalled = true; | ||
| } | ||
| } | ||
|
|
||
| /// @title ETHLiquidity_MintBurn_Invariant | ||
| /// @notice Invariant that checks that the NativeAssetLiquidity contract's balance is always equal | ||
| /// to the sum of the initial supply, the deposits, the funds, and minus the withdrawals. | ||
| /// NAL Balance = Initial Supply + Deposits + Funds - Withdrawals | ||
| contract CustomGasToken_Invariants is CommonTest { | ||
hexshire marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| /// @notice Starting balance of the contract - arbitrary value (cf Config change) | ||
| uint256 internal constant STARTING_BALANCE = type(uint248).max / 5; | ||
|
|
||
| LiquidityController_Minter internal actor_minter; | ||
| NativeAssetLiquidity_Fundooor internal actor_funder; | ||
| RandomActor internal randomActor; | ||
|
|
||
| /// @notice Test setup. | ||
| function setUp() public override { | ||
| enableCustomGasToken(); | ||
| super.setUp(); | ||
|
|
||
| randomActor = new RandomActor(); | ||
| actor_funder = new NativeAssetLiquidity_Fundooor(vm); | ||
| actor_minter = new LiquidityController_Minter(vm, liquidityController, randomActor); | ||
|
|
||
| // Initialize the addresses of the minter and funder actors | ||
| randomActor.initAddresses(address(actor_minter), address(actor_funder)); | ||
|
|
||
| // Authorize the minter actor (simple access control in unit tests) | ||
| vm.prank(IProxyAdmin(Predeploys.PROXY_ADMIN).owner()); | ||
| liquidityController.authorizeMinter(address(actor_minter)); | ||
|
|
||
| // Create the initial supply | ||
| vm.deal(address(nativeAssetLiquidity), STARTING_BALANCE); | ||
|
|
||
| // Set the target contract. | ||
| targetContract(address(actor_minter)); | ||
| targetContract(address(actor_funder)); | ||
|
|
||
| // Set the target selectors (exclude the initAddresses function) | ||
| bytes4[] memory selectors = new bytes4[](2); | ||
| selectors[0] = RandomActor.sendCGTtoMinter.selector; | ||
| selectors[1] = RandomActor.sendCGTtoFunder.selector; | ||
| FuzzSelector memory selector = FuzzSelector({ addr: address(randomActor), selectors: selectors }); | ||
| targetSelector(selector); | ||
| } | ||
|
|
||
| /// @notice Invariant that checks that the NativeAssetLiquidity contract's balance is always equal | ||
| /// to the sum of the initial supply, the deposits, the funds, and minus the withdrawals. | ||
| /// NAL Balance = Initial Supply + Deposits + Funds - Withdrawals | ||
| /// @dev liquidityController.burn() calls deposit, liquidityController.mint() calls withdraw | ||
| function invariant_supplyConservation() public view { | ||
| assertEq( | ||
| address(nativeAssetLiquidity).balance, | ||
| STARTING_BALANCE + actor_funder.totalAmountFunded() + actor_minter.totalAmountBurned() | ||
| - actor_minter.totalAmountMinted(), | ||
| "NativeAssetLiquidity balance is not equal to the sum of the initial supply, the deposits, the funds, and minus the withdrawals" | ||
| ); | ||
| } | ||
|
|
||
| /// @notice Invariant that checks that the minted amount is equal to the withdrawn amount | ||
| /// @dev Checks if the amount minted equals the amount transferred *outside* the NativeAssetLiquidity contract | ||
| function invariant_mintedEqualsWithdrawn() public view { | ||
| assertFalse(actor_minter.deltaBalanceAndMint(), "Minted amount is not equal to the withdrawn amount"); | ||
| } | ||
|
|
||
| /// @notice Invariant that checks that the burned amount is equal to the deposited amount | ||
| /// @dev Checks if the amount burned equals the amount transferred *to* the NativeAssetLiquidity contract | ||
| function invariant_burnedEqualsDeposited() public view { | ||
| assertFalse(actor_minter.deltaBalanceAndBurn(), "Burned amount is not equal to the deposited amount"); | ||
| } | ||
|
|
||
| /// @notice Invariant that checks that the LiquidityController contract's balance is always 0 | ||
| /// @dev Checks if the LiquidityController there is no CGT being trapped in the LiquidityController contract | ||
| function invariant_noDustLiquidityController() public view { | ||
| assertEq(address(liquidityController).balance, 0, "LiquidityController balance is not 0"); | ||
| } | ||
|
|
||
| /// @notice Invariant that checks that the mint function never calls back to the RandomActor contract | ||
| /// @dev Checks if the mint function never calls back to the RandomActor contract (test SafeSend) | ||
| function invariant_mintNeverCallsBack() public view { | ||
| assertFalse(randomActor.hasBeenCalled(), "RandomActor receive() function has been triggered"); | ||
| } | ||
| } | ||
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.