diff --git a/.github/workflows/medusa.yml b/.github/workflows/medusa.yml new file mode 100644 index 000000000000..f4676c7ba487 --- /dev/null +++ b/.github/workflows/medusa.yml @@ -0,0 +1,29 @@ +name: CI + +on: [push] + +jobs: + medusa-tests: + name: Medusa Test + runs-on: ubuntu-latest + container: ghcr.io/defi-wonderland/eth-security-toolbox-ci:dev + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Node.js 16.x + uses: actions/setup-node@master + with: + node-version: 16.x + cache: yarn + + - name: Install dependencies + working-directory: ./packages/contracts-bedrock + run: yarn --frozen-lockfile --network-concurrency 1 + + - name: Run Medusa + working-directory: ./packages/contracts-bedrock + run: medusa fuzz --test-limit 200000 diff --git a/packages/contracts-bedrock/.env.example b/packages/contracts-bedrock/.env.example index 54d631791b4f..083ae7f7f46f 100644 --- a/packages/contracts-bedrock/.env.example +++ b/packages/contracts-bedrock/.env.example @@ -8,6 +8,10 @@ PRIVATE_KEY_DEPLOYER= TENDERLY_PROJECT= TENDERLY_USERNAME= +# RPC +ETHEREUM_MAINNET_RPC= +LOCAL_RPC= + # The following settings are useful for manually testing the migration scripts. @@ -22,3 +26,4 @@ DISABLE_LIVE_DEPLOYER=true # Sets the deployer's key to match the first default hardhat account PRIVATE_KEY_DEPLOYER=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +PRIVATE_KEY_PROXY_ADMIN= diff --git a/packages/contracts-bedrock/contracts/L1/L1StandardBridge.sol b/packages/contracts-bedrock/contracts/L1/L1StandardBridge.sol index 3cfb1bba02df..4cf243ad5286 100644 --- a/packages/contracts-bedrock/contracts/L1/L1StandardBridge.sol +++ b/packages/contracts-bedrock/contracts/L1/L1StandardBridge.sol @@ -1,6 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; +// Libraries +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +// Interfaces +import { IBalanceClaimer } from "./interfaces/winddown/IBalanceClaimer.sol"; +import { IErc20BalanceWithdrawer } from "./interfaces/winddown/IErc20BalanceWithdrawer.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + import { Predeploys } from "../libraries/Predeploys.sol"; import { StandardBridge } from "../universal/StandardBridge.sol"; import { Semver } from "../universal/Semver.sol"; @@ -17,7 +25,9 @@ import { Semver } from "../universal/Semver.sol"; * of some token types that may not be properly supported by this contract include, but are * not limited to: tokens with transfer fees, rebasing tokens, and tokens with blocklists. */ -contract L1StandardBridge is StandardBridge, Semver { +contract L1StandardBridge is StandardBridge, Semver, IErc20BalanceWithdrawer { + using SafeERC20 for IERC20; + /** * @custom:legacy * @notice Emitted whenever a deposit of ETH from L1 into L2 is initiated. @@ -90,15 +100,20 @@ contract L1StandardBridge is StandardBridge, Semver { bytes extraData ); + /// @inheritdoc IErc20BalanceWithdrawer + address public immutable BALANCE_CLAIMER; + /** - * @custom:semver 1.1.0 + * @custom:semver 1.2.0 * * @param _messenger Address of the L1CrossDomainMessenger. */ - constructor(address payable _messenger) - Semver(1, 1, 0) + constructor(address payable _messenger, address _balanceClaimer) + Semver(1, 2, 0) StandardBridge(_messenger, payable(Predeploys.L2_STANDARD_BRIDGE)) - {} + { + BALANCE_CLAIMER = _balanceClaimer; + } /** * @notice Allows EOAs to bridge ETH by sending directly to the bridge. @@ -244,6 +259,17 @@ contract L1StandardBridge is StandardBridge, Semver { finalizeBridgeERC20(_l1Token, _l2Token, _from, _to, _amount, _extraData); } + /// @inheritdoc IErc20BalanceWithdrawer + function withdrawErc20Balance(address _user, Erc20BalanceClaim[] calldata _erc20Claim) external { + if (msg.sender != BALANCE_CLAIMER) { + revert CallerNotBalanceClaimer(); + } + + for (uint256 _i; _i < _erc20Claim.length; _i++) { + IERC20(_erc20Claim[_i].token).safeTransfer(_user, _erc20Claim[_i].balance); + } + } + /** * @custom:legacy * @notice Retrieves the access of the corresponding L2 bridge contract. diff --git a/packages/contracts-bedrock/contracts/L1/OptimismPortal.sol b/packages/contracts-bedrock/contracts/L1/OptimismPortal.sol index eb5a0141fff4..626b0081164c 100644 --- a/packages/contracts-bedrock/contracts/L1/OptimismPortal.sol +++ b/packages/contracts-bedrock/contracts/L1/OptimismPortal.sol @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; +// Interfaces +import { IEthBalanceWithdrawer } from "./interfaces/winddown/IEthBalanceWithdrawer.sol"; +import { IBalanceClaimer } from "./interfaces/winddown/IBalanceClaimer.sol"; + import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import { SafeCall } from "../libraries/SafeCall.sol"; import { L2OutputOracle } from "./L2OutputOracle.sol"; @@ -20,7 +24,7 @@ import { Semver } from "../universal/Semver.sol"; * and L2. Messages sent directly to the OptimismPortal have no form of replayability. * Users are encouraged to use the L1CrossDomainMessenger for a higher-level interface. */ -contract OptimismPortal is Initializable, ResourceMetering, Semver { +contract OptimismPortal is Initializable, ResourceMetering, Semver, IEthBalanceWithdrawer { /** * @notice Represents a proven withdrawal. * @@ -82,6 +86,9 @@ contract OptimismPortal is Initializable, ResourceMetering, Semver { */ bool public paused; + /// @inheritdoc IEthBalanceWithdrawer + address public immutable BALANCE_CLAIMER; + /** * @notice Emitted when a transaction is deposited from L1 to L2. The parameters of this event * are read by the rollup node and used to derive deposit transactions on L2. @@ -140,22 +147,25 @@ contract OptimismPortal is Initializable, ResourceMetering, Semver { } /** - * @custom:semver 1.6.0 + * @custom:semver 1.7.0 * * @param _l2Oracle Address of the L2OutputOracle contract. * @param _guardian Address that can pause deposits and withdrawals. * @param _paused Sets the contract's pausability state. * @param _config Address of the SystemConfig contract. + * @param _balanceClaimer Address of the BalanceClaimer contract. */ constructor( L2OutputOracle _l2Oracle, address _guardian, bool _paused, - SystemConfig _config - ) Semver(1, 6, 0) { + SystemConfig _config, + address _balanceClaimer + ) Semver(1, 7, 0) { L2_ORACLE = _l2Oracle; GUARDIAN = _guardian; SYSTEM_CONFIG = _config; + BALANCE_CLAIMER = _balanceClaimer; initialize(_paused); } @@ -482,6 +492,15 @@ contract OptimismPortal is Initializable, ResourceMetering, Semver { emit TransactionDeposited(from, _to, DEPOSIT_VERSION, opaqueData); } + /// @inheritdoc IEthBalanceWithdrawer + function withdrawEthBalance(address _user, uint256 _ethClaim) external { + if (msg.sender != BALANCE_CLAIMER) revert CallerNotBalanceClaimer(); + (bool success,) = _user.call{value: _ethClaim}(""); + if (!success) { + revert IEthBalanceWithdrawer.EthTransferFailed(); + } + } + /** * @notice Determine if a given output is finalized. Reverts if the call to * L2_ORACLE.getL2Output reverts. Returns a boolean otherwise. diff --git a/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IBalanceClaimer.sol b/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IBalanceClaimer.sol new file mode 100644 index 000000000000..57662a83ee42 --- /dev/null +++ b/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IBalanceClaimer.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { IEthBalanceWithdrawer } from "./IEthBalanceWithdrawer.sol"; +import { IErc20BalanceWithdrawer } from "./IErc20BalanceWithdrawer.sol"; + + +/** + * @title IBalanceClaimer + * @notice Interface for the BalanceClaimer contract + */ +interface IBalanceClaimer { + /** + * @notice Emitted when a user claims their balance + * @param user The user who claimed their balance + * @param ethBalance The eth balance of the user + * @param erc20TokenBalances The ERC20 token balances of the user + */ + event BalanceClaimed( + address indexed user, + uint256 ethBalance, + IErc20BalanceWithdrawer.Erc20BalanceClaim[] erc20TokenBalances + ); + + /// @notice Thrown when the user has no balance to claim + error NoBalanceToClaim(); + + /// @notice Thrown when the merkle root is invalid + error InvalidMerkleRoot(); + + /// @notice the root of the merkle tree + function ROOT() external view returns (bytes32); + + /// @notice OptimismPortal ethBalanceWithdrawer contract + function ETH_BALANCE_WITHDRAWER() external view returns (IEthBalanceWithdrawer); + + /// @notice erc20BalanceWithdrawer contract + function ERC20_BALANCE_WITHDRAWER() external view returns (IErc20BalanceWithdrawer); + + /// @notice return users who claimed their balances + function claimed(address) external view returns (bool); + + /** + * @notice Claims the tokens for the user + * @param _proof The merkle proof + * @param _user The user address + * @param _ethBalance The eth balance of the user + * @param _erc20Claim The ERC20 tokens balances of the user + */ + function claim( + bytes32[] calldata _proof, + address _user, + uint256 _ethBalance, + IErc20BalanceWithdrawer.Erc20BalanceClaim[] calldata _erc20Claim + ) external; + + /** + * @notice Checks if the user can claim the tokens + * @param _proof The merkle proof + * @param _user The user address + * @param _ethBalance The eth balance of the user + * @param _erc20Claim The ERC20 tokens balances of the user + * @return _canClaimTokens True if the user can claim the tokens + */ + function canClaim( + bytes32[] calldata _proof, + address _user, + uint256 _ethBalance, + IErc20BalanceWithdrawer.Erc20BalanceClaim[] calldata _erc20Claim + ) external view returns (bool _canClaimTokens); +} diff --git a/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IErc20BalanceWithdrawer.sol b/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IErc20BalanceWithdrawer.sol new file mode 100644 index 000000000000..4fc6d0756418 --- /dev/null +++ b/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IErc20BalanceWithdrawer.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { IBalanceClaimer } from "./IBalanceClaimer.sol"; + +/** + * @title IErc20BalanceWithdrawer + * @notice Interface for the Erc20BalanceWithdrawer contract + */ +interface IErc20BalanceWithdrawer { + /** + * @notice Struct for ERC20 balance claim + * @param token The ERC20 token address + * @param balance The balance of the user + */ + struct Erc20BalanceClaim { + address token; + uint256 balance; + } + + /// @notice Thrown when the caller is not the BalanceClaimer contract + error CallerNotBalanceClaimer(); + + /** + * @notice Withdraws the ERC20 balance to the user. + * @param _user Address of the user. + * @param _erc20Claim Array of Erc20BalanceClaim structs containing the token address + */ + function withdrawErc20Balance(address _user, Erc20BalanceClaim[] calldata _erc20Claim) + external; + + /** + * @notice Address of the balance claimer contract. + * @dev This contract is responsible for claiming the ERC20 balances of the bridge. + */ + function BALANCE_CLAIMER() external view returns (address); +} diff --git a/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IEthBalanceWithdrawer.sol b/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IEthBalanceWithdrawer.sol new file mode 100644 index 000000000000..7e78e9944ed3 --- /dev/null +++ b/packages/contracts-bedrock/contracts/L1/interfaces/winddown/IEthBalanceWithdrawer.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { IBalanceClaimer } from "./IBalanceClaimer.sol"; + +/** + * @title IEthBalanceWithdrawer + * @notice Interface for the EthBalanceWithdrawer contract + */ +interface IEthBalanceWithdrawer { + /// @notice Thrown when the caller is not the BalanceClaimer contract + error CallerNotBalanceClaimer(); + + /// @notice Thrown when the eth transfer fails + error EthTransferFailed(); + + /** + * @notice Withdraws the ETH balance to the user. + * @param _user Address of the user. + * @param _ethClaim Amount of ETH to withdraw. + * @dev This function is only callable by the BalanceClaimer contract. + */ + function withdrawEthBalance(address _user, uint256 _ethClaim) external; + + /** + * @notice Address of the BalanceClaimer contract. + * @dev This contract is responsible for claiming the ETH balances of the OptimismPortal. + */ + function BALANCE_CLAIMER() external view returns (address); +} \ No newline at end of file diff --git a/packages/contracts-bedrock/contracts/L1/winddown/BalanceClaimer.sol b/packages/contracts-bedrock/contracts/L1/winddown/BalanceClaimer.sol new file mode 100644 index 000000000000..b4b208f13ad6 --- /dev/null +++ b/packages/contracts-bedrock/contracts/L1/winddown/BalanceClaimer.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Libraries +import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +// Interfaces +import { IEthBalanceWithdrawer } from "../interfaces/winddown/IEthBalanceWithdrawer.sol"; +import { IErc20BalanceWithdrawer } from "../interfaces/winddown/IErc20BalanceWithdrawer.sol"; +import { IBalanceClaimer } from "../interfaces/winddown/IBalanceClaimer.sol"; +import { Semver } from "../../universal/Semver.sol"; + +/** + * @custom:proxied + * @notice Contract that allows users to claim and withdraw their eth and erc20 balances + */ +contract BalanceClaimer is Semver, IBalanceClaimer { + /// @inheritdoc IBalanceClaimer + bytes32 public immutable ROOT; + + /// @inheritdoc IBalanceClaimer + IEthBalanceWithdrawer public immutable ETH_BALANCE_WITHDRAWER; + + /// @inheritdoc IBalanceClaimer + IErc20BalanceWithdrawer public immutable ERC20_BALANCE_WITHDRAWER; + + /// @inheritdoc IBalanceClaimer + mapping(address => bool) public claimed; + + /** + * @custom:semver 1.0.0 + * @param _ethBalanceWithdrawer The EthBalanceWithdrawer address + * @param _erc20BalanceWithdrawer The Erc20BalanceWithdrawer address + * @param _root The root of the merkle tree + */ + constructor(address _ethBalanceWithdrawer, address _erc20BalanceWithdrawer, bytes32 _root) Semver(1, 0, 0) { + if (_root == 0) revert InvalidMerkleRoot(); + ETH_BALANCE_WITHDRAWER = IEthBalanceWithdrawer(_ethBalanceWithdrawer); + ERC20_BALANCE_WITHDRAWER = IErc20BalanceWithdrawer(_erc20BalanceWithdrawer); + ROOT = _root; + } + + /// @inheritdoc IBalanceClaimer + function claim( + bytes32[] calldata _proof, + address _user, + uint256 _ethBalance, + IErc20BalanceWithdrawer.Erc20BalanceClaim[] calldata _erc20Claim + ) external { + if (!canClaim(_proof, _user, _ethBalance, _erc20Claim)) revert NoBalanceToClaim(); + claimed[_user] = true; + + if (_erc20Claim.length != 0) { + ERC20_BALANCE_WITHDRAWER.withdrawErc20Balance(_user, _erc20Claim); + } + + if (_ethBalance != 0) { + ETH_BALANCE_WITHDRAWER.withdrawEthBalance(_user, _ethBalance); + } + + emit BalanceClaimed({user: _user, ethBalance: _ethBalance, erc20TokenBalances: _erc20Claim}); + } + + /// @inheritdoc IBalanceClaimer + function canClaim( + bytes32[] calldata _proof, + address _user, + uint256 _ethBalance, + IErc20BalanceWithdrawer.Erc20BalanceClaim[] calldata _erc20Claim + ) public view returns (bool _canClaimTokens) { + if (claimed[_user]) return false; + + bytes32 _leaf = keccak256(bytes.concat(keccak256(abi.encode(_user, _ethBalance, _erc20Claim)))); + + _canClaimTokens = MerkleProof.verify(_proof, ROOT, _leaf); + } +} diff --git a/packages/contracts-bedrock/contracts/echidna/FuzzOptimismPortal.sol b/packages/contracts-bedrock/contracts/echidna/FuzzOptimismPortal.sol index a62f31d481e4..8021ab6cb9c8 100644 --- a/packages/contracts-bedrock/contracts/echidna/FuzzOptimismPortal.sol +++ b/packages/contracts-bedrock/contracts/echidna/FuzzOptimismPortal.sol @@ -28,7 +28,8 @@ contract EchidnaFuzzOptimismPortal { _l2Oracle: L2OutputOracle(address(0)), _guardian: address(0), _paused: false, - _config: systemConfig + _config: systemConfig, + _balanceClaimer: address(0) }); } diff --git a/packages/contracts-bedrock/contracts/test/BondManager.t.sol b/packages/contracts-bedrock/contracts/test/BondManager.t.sol index 8cae49c28fa7..994cbbe4d842 100644 --- a/packages/contracts-bedrock/contracts/test/BondManager.t.sol +++ b/packages/contracts-bedrock/contracts/test/BondManager.t.sol @@ -325,7 +325,7 @@ contract BondManager_Test is Test { unchecked { vm.assume(block.timestamp + minClaimHold > minClaimHold); } - assumeNoPrecompiles(owner); + assumeNotPrecompile(owner); // Post the bond vm.deal(address(this), amount); diff --git a/packages/contracts-bedrock/contracts/test/CommonTest.t.sol b/packages/contracts-bedrock/contracts/test/CommonTest.t.sol index fadb09398231..c718decfbfd0 100644 --- a/packages/contracts-bedrock/contracts/test/CommonTest.t.sol +++ b/packages/contracts-bedrock/contracts/test/CommonTest.t.sol @@ -31,6 +31,7 @@ import { LegacyMintableERC20 } from "../legacy/LegacyMintableERC20.sol"; import { SystemConfig } from "../L1/SystemConfig.sol"; import { ResourceMetering } from "../L1/ResourceMetering.sol"; import { Constants } from "../libraries/Constants.sol"; +import { IBalanceClaimer, BalanceClaimer } from "../L1/winddown/BalanceClaimer.sol"; contract CommonTest is Test { address alice = address(128); @@ -157,7 +158,20 @@ contract L2OutputOracle_Initializer is CommonTest { } } -contract Portal_Initializer is L2OutputOracle_Initializer { +contract BalanceClaimer_Initializer is L2OutputOracle_Initializer { + IBalanceClaimer balanceClaimerProxy; + BalanceClaimer balanceClaimerImpl; + + function setUp() public virtual override { + super.setUp(); + Proxy proxy = new Proxy(multisig); + // The Balance Claimer is initialized with the Merkle root and when L1StandardBridge and OptimismPortal are deployed + balanceClaimerProxy = IBalanceClaimer(address(proxy)); + vm.label(address(balanceClaimerProxy), "BalanceClaimerProxy"); + } +} + +contract Portal_Initializer is BalanceClaimer_Initializer { // Test target OptimismPortal internal opImpl; OptimismPortal internal op; @@ -189,7 +203,8 @@ contract Portal_Initializer is L2OutputOracle_Initializer { _l2Oracle: oracle, _guardian: guardian, _paused: true, - _config: systemConfig + _config: systemConfig, + _balanceClaimer: address(balanceClaimerProxy) }); Proxy proxy = new Proxy(multisig); @@ -389,7 +404,8 @@ contract Bridge_Initializer is Messenger_Initializer { abi.encode(true) ); vm.startPrank(multisig); - proxy.setCode(address(new L1StandardBridge(payable(address(L1Messenger)))).code); + address impl = address(new L1StandardBridge(payable(address(L1Messenger)), address(balanceClaimerProxy))); + proxy.setCode(impl.code); vm.clearMockedCalls(); address L1Bridge_Impl = proxy.getImplementation(); vm.stopPrank(); diff --git a/packages/contracts-bedrock/contracts/test/L1StandardBridge.t.sol b/packages/contracts-bedrock/contracts/test/L1StandardBridge.t.sol index 6bb07baf1a92..10011c08265d 100644 --- a/packages/contracts-bedrock/contracts/test/L1StandardBridge.t.sol +++ b/packages/contracts-bedrock/contracts/test/L1StandardBridge.t.sol @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; +// Interfaces +import { IErc20BalanceWithdrawer } from "../L1/interfaces/winddown/IErc20BalanceWithdrawer.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + import { Bridge_Initializer } from "./CommonTest.t.sol"; import { StandardBridge } from "../universal/StandardBridge.sol"; import { OptimismPortal } from "../L1/OptimismPortal.sol"; @@ -17,7 +21,8 @@ contract L1StandardBridge_Getter_Test is Bridge_Initializer { assert(L1Bridge.OTHER_BRIDGE() == L2Bridge); assert(L1Bridge.messenger() == L1Messenger); assert(L1Bridge.MESSENGER() == L1Messenger); - assertEq(L1Bridge.version(), "1.1.0"); + assertEq(L1Bridge.version(), "1.2.0"); + assertEq(address(L1Bridge.BALANCE_CLAIMER()), address(balanceClaimerProxy)); } } @@ -28,6 +33,8 @@ contract L1StandardBridge_Initialize_Test is Bridge_Initializer { assertEq(address(L1Bridge.OTHER_BRIDGE()), Predeploys.L2_STANDARD_BRIDGE); assertEq(address(L2Bridge), Predeploys.L2_STANDARD_BRIDGE); + + assertEq(address(L1Bridge.BALANCE_CLAIMER()), address(balanceClaimerProxy)); } } @@ -720,3 +727,79 @@ contract L1StandardBridge_FinalizeBridgeETH_TestFail is Bridge_Initializer { L1Bridge.finalizeBridgeETH{ value: 100 }(alice, messenger, 100, hex""); } } + +contract L1StandardBridge_WithdrawErc20Balance_Test is Bridge_Initializer { + using stdStorage for StdStorage; + + /// @dev Mocks the tokens, set the expects the calls and returns the balances array parameter + function _mockTokensExpectCallsAndGetBalancesArray( + address _user, + bool _setExpectCall, + IErc20BalanceWithdrawer.Erc20BalanceClaim[10] memory _fuzzBalances + ) + internal + returns (IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _balances) + { + uint8 _claimArraySize; + for (uint256 _i; _i < _fuzzBalances.length; _i++) { + assumeNotPrecompile(_fuzzBalances[_i].token); + if (_fuzzBalances[_i].balance > 0) { + _claimArraySize++; + vm.mockCall( + _fuzzBalances[_i].token, + abi.encodeWithSelector(IERC20.transfer.selector, _user, _fuzzBalances[_i].balance), + abi.encode(true) + ); + if (_setExpectCall) { + vm.expectCall( + _fuzzBalances[_i].token, + abi.encodeWithSelector(IERC20.transfer.selector, _user, _fuzzBalances[_i].balance) + ); + } + } + } + + _balances = new IErc20BalanceWithdrawer.Erc20BalanceClaim[](_claimArraySize); + + uint256 _balancesIndex; + for (uint256 _i; _i < _fuzzBalances.length; _i++) { + if (_fuzzBalances[_i].balance > 0) { + _balances[_balancesIndex] = _fuzzBalances[_i]; + _balancesIndex++; + } + } + } + + /// @dev Tests that withdrawing ERC20 balances succeeds. + function testFuzz_withdrawErc20Balance_succeeds( + address _user, + IErc20BalanceWithdrawer.Erc20BalanceClaim[10] memory _fuzzBalances + ) + external + { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _balances = + _mockTokensExpectCallsAndGetBalancesArray(_user, true, _fuzzBalances); + + vm.prank(address(L1Bridge.BALANCE_CLAIMER())); + IErc20BalanceWithdrawer(address(L1Bridge)).withdrawErc20Balance(_user, _balances); + } + + /// @dev Tests that withdrawing ERC20 balances reverts if the caller is not the balance claimer. + function testFuzz_withdrawErc20Balance_reverts( + address _user, + address _notBalanceClaimer, + IErc20BalanceWithdrawer.Erc20BalanceClaim[10] memory _fuzzBalances + ) + external + { + // calling from unauthorized address + vm.assume(_notBalanceClaimer != address(L1Bridge.BALANCE_CLAIMER())); + + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _balances = + _mockTokensExpectCallsAndGetBalancesArray(_user, false, _fuzzBalances); + + vm.prank(_notBalanceClaimer); + vm.expectRevert(IErc20BalanceWithdrawer.CallerNotBalanceClaimer.selector); + IErc20BalanceWithdrawer(address(L1Bridge)).withdrawErc20Balance(_user, _balances); + } +} diff --git a/packages/contracts-bedrock/contracts/test/OptimismPortal.t.sol b/packages/contracts-bedrock/contracts/test/OptimismPortal.t.sol index de9f97736eb9..784623358b79 100644 --- a/packages/contracts-bedrock/contracts/test/OptimismPortal.t.sol +++ b/packages/contracts-bedrock/contracts/test/OptimismPortal.t.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; +// Interfaces +import { IEthBalanceWithdrawer } from "../L1/interfaces/winddown/IEthBalanceWithdrawer.sol"; + import { stdError } from "forge-std/Test.sol"; import { Portal_Initializer, CommonTest, NextImpl } from "./CommonTest.t.sol"; import { AddressAliasHelper } from "../vendor/AddressAliasHelper.sol"; @@ -11,6 +14,9 @@ import { Hashing } from "../libraries/Hashing.sol"; import { Proxy } from "../universal/Proxy.sol"; import { ResourceMetering } from "../L1/ResourceMetering.sol"; +import { Proxy } from "../universal/Proxy.sol"; + + contract OptimismPortal_Test is Portal_Initializer { event Paused(address); event Unpaused(address); @@ -21,6 +27,11 @@ contract OptimismPortal_Test is Portal_Initializer { assertEq(op.paused(), false); } + function test_initialize_succeeds() external { + assertEq(address(op.BALANCE_CLAIMER()), address(balanceClaimerProxy)); + assertEq(op.paused(), false); + } + /** * @notice The OptimismPortal can be paused by the GUARDIAN */ @@ -1232,3 +1243,44 @@ contract OptimismPortalResourceFuzz_Test is Portal_Initializer { }); } } + +contract OptimismPortal_WithdrawEthBalance_Test is Portal_Initializer { + /// @dev Check if an address is a contract + function _isContract(address _addr) internal view returns (bool) { + uint256 _size; + assembly { + _size := extcodesize(_addr) + } + return _size > 0; + } + + /// @dev Tests that `withdrawEthBalance` succeeds when the balance claimer is the caller. + function testFuzz_withdrawEthBalance_succeeds(address _user, uint256 _balance) external { + vm.assume(!_isContract(_user)); + vm.deal(address(op), _balance); + + vm.prank(address(op.BALANCE_CLAIMER())); + op.withdrawEthBalance(_user, _balance); + + assertEq(address(op).balance, 0); + assertEq(address(_user).balance, _balance); + } + + /// @dev Tests that `withdrawEthBalance` reverts when the balance claimer is not the caller. + function testFuzz_withdrawEthBalance_reverts_notBalanceClaimer( + address _user, + address _notBalanceClaimer, + uint256 _balance + ) + external + { + vm.assume(_notBalanceClaimer != address(op.BALANCE_CLAIMER())); + vm.assume(!_isContract(_user)); + vm.deal(address(op), _balance); + + vm.expectRevert(IEthBalanceWithdrawer.CallerNotBalanceClaimer.selector); + + vm.prank(_notBalanceClaimer); + op.withdrawEthBalance(_user, _balance); + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/contracts/test/SafeCall.t.sol b/packages/contracts-bedrock/contracts/test/SafeCall.t.sol index f5bb61b8fb27..c644697d11c5 100644 --- a/packages/contracts-bedrock/contracts/test/SafeCall.t.sol +++ b/packages/contracts-bedrock/contracts/test/SafeCall.t.sol @@ -14,7 +14,7 @@ contract SafeCall_Test is CommonTest { vm.assume(from.balance == 0); vm.assume(to.balance == 0); // no precompiles (mainnet) - assumeNoPrecompiles(to, 1); + assumeNotPrecompile(to, 1); // don't call the vm vm.assume(to != address(vm)); vm.assume(from != address(vm)); @@ -54,7 +54,7 @@ contract SafeCall_Test is CommonTest { vm.assume(from.balance == 0); vm.assume(to.balance == 0); // no precompiles (mainnet) - assumeNoPrecompiles(to, 1); + assumeNotPrecompile(to, 1); // don't call the vm vm.assume(to != address(vm)); vm.assume(from != address(vm)); @@ -94,7 +94,7 @@ contract SafeCall_Test is CommonTest { vm.assume(from.balance == 0); vm.assume(to.balance == 0); // no precompiles (mainnet) - assumeNoPrecompiles(to, 1); + assumeNotPrecompile(to, 1); // don't call the vm vm.assume(to != address(vm)); vm.assume(from != address(vm)); diff --git a/packages/contracts-bedrock/contracts/test/invariants/PROPERTIES.md b/packages/contracts-bedrock/contracts/test/invariants/PROPERTIES.md new file mode 100644 index 000000000000..acce7e3ffbe3 --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/invariants/PROPERTIES.md @@ -0,0 +1,52 @@ +# Scope + +- OptimismPortal's withdrawEthBalance function: contracts/L1/OptimismPortal.sol:504 +- L1StandardBridge's withdrawErc20Balance function: contracts/L1/L1StandardBridge.sol:272 +- BalanceClaimer contract: contracts/L1/winddown/BalanceClaimer.sol + +# Properties + +| Id | Properties | Type | Checked | +| --- | --------------------------------------------------- | ------------ | --- | +| 1 | a valid claim should be redeemable once | State transition | [x] | +| 2 | a valid claim should not be redeemable more than once | State transition | [x] | +| 3 | a user should be set as claimed when they process a claim | State transition | [x] | +| 4 | an invalid claim should not be redeemable | State transition | [x] | +| 5 | for each token, token.balanceOf(L1StandardBridge) == initialBalance - sum of claims | High-level | [x] | +| 6 | OptimismPortal.balance == initialBalance - sum of claims | High-level | [x] | + + +## testing methodology +The fact that the state root is not writeable in the lifetime of the contract is cool from a design standpoint, but that also means the root has to be generated, and the valid claims chosen, before it makes sense to call any other handler. + +As a first approach, we've meta-programmed a solidity source file with a hard-coded set of valid claims, which can be refreshed by calling `./generate-random-tree.sh`. +This is not ideal, as all fuzzing runs are going to run on the same merkle tree instead of letting the fuzzer explore new ones. Some alternatives are described below: + +### mutate the state root +Idea for this is to initialize the BalanceClaimer in the campaign constructor with either + +- [ ] an empty state root (for ...purity? ie allowing the fuzzer choose the inputs with the greatest variability) +- [ ] pre-filled state root (to cover code faster) and set of valid claims, with the downside of calls creating + +and have handlers to _add_ valid claims to the set, overwriting the state root + +This has the downside of being dissimilar to the actual production usage in a very crucial way, but also the invariant we would be breaking (the state root not changing) can be easily enforced by the compiler (ie: make the field immutable), and the upside of exploring a lot of possible trees in a simpler way + +### use a modifier to ensure the first call of the sequence initializes a state root +this would involve +- [ ] not creating the balanceClaimer in the constructor +- [ ] have a modifier (and an extra param of fuzzed input in every handler/property check) which will be used to initialize the state root on the first call +- [ ] have all handlers afterwards only process claims (valid or not, obviously) and not create new ones + +This has the upside of being identical to the production setup, but would yield uglier code and potentially have worse pseudorandom input since we would be having all the state as fields of structs in arrays + +# nice to haves +- [ ] use tokens' actual bytecode in the fuzzing campaign +- [ ] use a full uint256 for the range of the amounts in merkle tree + - [ ] use bigger number in script + - [ ] handle fails caused by insufficient balances +- [ ] create a larger share of claims with incomplete list of tokens or zero eth +- [ ] handlers for withdraw{Erc20,Eth}Balance methods + - [ ] guided + - [ ] unguided + diff --git a/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/FuzzTest.t.sol b/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/FuzzTest.t.sol new file mode 100644 index 000000000000..1295b58f1170 --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/FuzzTest.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.15; + +import {BalanceClaimerGuidedHandlers} from "./handlers/guided/BalanceClaimer.t.sol"; +import {BalanceClaimerUnguidedHandlers} from "./handlers/unguided/BalanceClaimer.t.sol"; +import {BalanceClaimerProperties} from "./properties/BalanceClaimer.t.sol"; + +contract FuzzTest is BalanceClaimerGuidedHandlers, BalanceClaimerUnguidedHandlers, BalanceClaimerProperties {} diff --git a/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/generate-random-tree.sh b/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/generate-random-tree.sh new file mode 100755 index 000000000000..fd0b1530f878 --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/generate-random-tree.sh @@ -0,0 +1,54 @@ +#!/bin/sh + +claims_file=./setup/ClaimList.t.sol +claims_amount=100 +users_amount=100 + +random_int() { + # 2^ 64 - 2 , max range for shuf, == 18e18, not ideal. + echo "$(shuf -n 1 -i 0-18446744073709551614)" +} + +cat - > "$claims_file" <> "$claims_file" + +done + + +cat - >> "$claims_file" < bool) internal ghost_claimed; + mapping(bytes32 => bool) internal ghost_claimInTree; + // only used as dynamic array + address[] private _tokens; + // only used as dynamic array + uint256[] private _amounts; + + constructor() { + for (uint256 i = 0; i < randomClaims.length; i++) { + ClaimEntry memory rawClaim = randomClaims[i]; + if (rawClaim.daiAmount > 0) { + _tokens.push(address(supportedTokens[0])); + _amounts.push(rawClaim.daiAmount); + } + if (rawClaim.gtcAmount > 0) { + _tokens.push(address(supportedTokens[1])); + _amounts.push(rawClaim.gtcAmount); + } + if (rawClaim.usdtAmount > 0) { + _tokens.push(address(supportedTokens[2])); + _amounts.push(rawClaim.usdtAmount); + } + if (rawClaim.usdcAmount > 0) { + _tokens.push(address(supportedTokens[3])); + _amounts.push(rawClaim.usdcAmount); + } + Claim memory claim = Claim({ + user: rawClaim.recipient, + ethAmount: rawClaim.ethAmount, + tokens: _tokens, + tokenAmounts: _amounts + }); + delete _amounts; + delete _tokens; + ghost_validClaims.push(claim); + leaves.push(_hashClaim(claim)); + } + tree = generateMerkleTree(leaves); + } + + function _hashClaim(Claim memory claim) internal pure returns (bytes32) { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory erc20Claims = + new IErc20BalanceWithdrawer.Erc20BalanceClaim[](claim.tokens.length); + for (uint256 i = 0; i < claim.tokens.length; i++) { + erc20Claims[i].token = claim.tokens[i]; + erc20Claims[i].balance = claim.tokenAmounts[i]; + } + return keccak256(bytes.concat(keccak256(abi.encode(claim.user, claim.ethAmount, erc20Claims)))); + } + + function _hashClaim(address user, uint256 ethAmount, IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory erc20Claims) + internal + pure + returns (bytes32) + { + return keccak256(bytes.concat(keccak256(abi.encode(user, ethAmount, erc20Claims)))); + } + + function _claimToErc20ClaimArray(Claim memory claim) + internal + pure + returns (IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory) + { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory erc20Claims = + new IErc20BalanceWithdrawer.Erc20BalanceClaim[](claim.tokens.length); + for (uint256 i = 0; i < claim.tokens.length; i++) { + erc20Claims[i].token = claim.tokens[i]; + erc20Claims[i].balance = claim.tokenAmounts[i]; + } + return erc20Claims; + } +} diff --git a/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/setup/Tokens.t.sol b/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/setup/Tokens.t.sol new file mode 100644 index 000000000000..ef591636b8d7 --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/invariants/balance-claimer/setup/Tokens.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.15; + +import {MockERC20} from "forge-std/mocks/MockERC20.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +contract FuzzERC20 is MockERC20 { + function mint(address _to, uint256 _amount) public { + _mint(_to, _amount); + } +} + +contract Tokens { + uint8 internal constant TOKENS = 4; + uint256 internal constant INITIAL_BALANCE = 100000e18; + IERC20[] internal supportedTokens; + + mapping(address => uint256) internal ghost_claimedTokens; + uint256 internal ghost_claimedEther; + + constructor() { + for (uint256 i = 0; i < TOKENS; i++) { + // TODO: use bytecode from production tokens + FuzzERC20 token = new FuzzERC20(); + // TODO: use 6 decimals for usdt + token.initialize("name", "symbol", 18); + supportedTokens.push(token); + } + } +} diff --git a/packages/contracts-bedrock/contracts/test/libraries/MerkleTreeGenerator.t.sol b/packages/contracts-bedrock/contracts/test/libraries/MerkleTreeGenerator.t.sol new file mode 100644 index 000000000000..4c0c36f3d5a6 --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/libraries/MerkleTreeGenerator.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +/** + * Test helper contract to generate Merkle trees and proofs. + */ +contract MerkleTreeGenerator { + function generateMerkleTree(bytes32[] memory leaves) public pure returns (bytes32[] memory) { + require(leaves.length > 0, "Expected non-zero number of leaves"); + + bytes32[] memory tree = new bytes32[](2 * leaves.length - 1); + + for (uint256 i = 0; i < leaves.length; i++) { + tree[tree.length - 1 - i] = leaves[i]; + } + + for (int256 i = int256(tree.length - 1 - leaves.length); i >= 0; i--) { + tree[uint256(i)] = hashPair(tree[leftChildIndex(uint256(i))], tree[rightChildIndex(uint256(i))]); + } + + return tree; + } + + function hashPair(bytes32 left, bytes32 right) internal pure returns (bytes32) { + return left < right ? keccak256(bytes.concat(left, right)) : keccak256(bytes.concat(right, left)); + } + + function leftChildIndex(uint256 i) internal pure returns (uint256) { + return 2 * i + 1; + } + + function rightChildIndex(uint256 i) internal pure returns (uint256) { + return 2 * i + 2; + } + + function getProof(bytes32[] memory tree, uint256 index) public pure returns (bytes32[] memory) { + checkLeafNode(tree, index); + + bytes32[] memory proof; + while (index > 0) { + proof = concatenate(proof, tree[siblingIndex(index)]); + index = parentIndex(index); + } + return proof; + } + + function checkLeafNode(bytes32[] memory tree, uint256 index) internal pure { + require(index < tree.length, "Invalid leaf index"); + } + + function siblingIndex(uint256 index) internal pure returns (uint256) { + if (index % 2 == 0) { + return index - 1; + } else { + return index + 1; + } + } + + function parentIndex(uint256 index) internal pure returns (uint256) { + return (index - 1) / 2; + } + + function concatenate(bytes32[] memory a, bytes32 b) internal pure returns (bytes32[] memory) { + bytes32[] memory concatenated = new bytes32[](a.length + 1); + for (uint256 i = 0; i < a.length; i++) { + concatenated[i] = a[i]; + } + concatenated[a.length] = b; + return concatenated; + } + + function getIndex(bytes32[] memory tree, bytes32 leaf) public pure returns (uint256) { + for (uint256 i = 0; i < tree.length; i++) { + if (tree[i] == leaf) { + return i; + } + } + revert("Leaf not found"); + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/contracts/test/winddown/integration/BalanceClaimer.t.sol b/packages/contracts-bedrock/contracts/test/winddown/integration/BalanceClaimer.t.sol new file mode 100644 index 000000000000..9b1c0fe2cfc7 --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/winddown/integration/BalanceClaimer.t.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Libraries +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +// Testing +import { stdStorage, StdStorage } from "forge-std/Test.sol"; +import { Bridge_Initializer } from "../../CommonTest.t.sol"; +import { MerkleTreeGenerator } from "../../libraries/MerkleTreeGenerator.t.sol"; + +// Contracts +import { BalanceClaimer } from "../../../L1/winddown/BalanceClaimer.sol"; +import { Proxy } from "../../../universal/Proxy.sol"; + +// Interfaces +import { IBalanceClaimer } from "../../../L1/interfaces/winddown/IBalanceClaimer.sol"; +import { IErc20BalanceWithdrawer } from "../../../L1/interfaces/winddown/IErc20BalanceWithdrawer.sol"; +import { IEthBalanceWithdrawer } from "../../../L1/interfaces/winddown/IEthBalanceWithdrawer.sol"; + +contract BalanceClaimerIntegration_Test is Bridge_Initializer { + using stdStorage for StdStorage; + + MerkleTreeGenerator merkleTreeGenerator = new MerkleTreeGenerator(); + + address aliceClaimer = makeAddr("aliceClaimer"); + address bobClaimer = makeAddr("bobClaimer"); + address charlieClaimer = makeAddr("charlieClaimer"); + + address token1 = address(new ERC20("token1", "TK1")); + address token2 = address(new ERC20("token2", "TK2")); + address token3 = address(new ERC20("token3", "TK3")); + + ClaimParams aliceClaimParams; + ClaimParams bobClaimParams; + ClaimParams charlieClaimParams; + + bytes32[] leaves; + bytes32[] tree; + + struct ClaimParams { + address user; + uint256 ethBalance; + IErc20BalanceWithdrawer.Erc20BalanceClaim[] erc20TokenBalances; + } + + function setUp() public override { + super.setUp(); + + // The Balance Claimer is deployed with the Merkle root and when L1StandardBridge and OptimismPortal are deployed + balanceClaimerImpl = new BalanceClaimer({ + _ethBalanceWithdrawer: address(op), + _erc20BalanceWithdrawer: address(L1Bridge), + _root: keccak256("mockRoot") + }); + + vm.prank(multisig); + Proxy(payable(address(balanceClaimerProxy))).upgradeTo(address(balanceClaimerImpl)); + + merkleTreeGenerator = new MerkleTreeGenerator(); + + aliceClaimParams.user = aliceClaimer; + aliceClaimParams.ethBalance = 100; + + bobClaimParams.user = bobClaimer; + bobClaimParams.ethBalance = 200; + + charlieClaimParams.user = charlieClaimer; + charlieClaimParams.ethBalance = 300; + + aliceClaimParams.erc20TokenBalances.push(IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: token2, balance: 100 })); + + bobClaimParams.erc20TokenBalances.push(IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: token1, balance: 200 })); + + bobClaimParams.erc20TokenBalances.push(IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: token3, balance: 300 })); + + charlieClaimParams.erc20TokenBalances.push( + IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: token1, balance: 400 }) + ); + + charlieClaimParams.erc20TokenBalances.push( + IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: token2, balance: 500 }) + ); + + charlieClaimParams.erc20TokenBalances.push( + IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: token3, balance: 600 }) + ); + + ClaimParams[] memory _claimParams = new ClaimParams[](3); + + _claimParams[0] = aliceClaimParams; + _claimParams[1] = bobClaimParams; + _claimParams[2] = charlieClaimParams; + + leaves = _getLeaves(_claimParams); + tree = _mockRoot(leaves); + + deal(token1, address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER()), 600); + deal(token2, address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER()), 600); + deal(token3, address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER()), 900); + vm.deal(address(balanceClaimerProxy.ETH_BALANCE_WITHDRAWER()), 600); + } + + /// @dev Get the leaves for the merkle tree + function _getLeaves(ClaimParams[] memory _claimParams) internal pure returns (bytes32[] memory _leaves) { + _leaves = new bytes32[](_claimParams.length); + for (uint256 _i; _i < _claimParams.length; _i++) { + _leaves[_i] = keccak256( + bytes.concat( + keccak256( + abi.encode( + _claimParams[_i].user, _claimParams[_i].ethBalance, _claimParams[_i].erc20TokenBalances + ) + ) + ) + ); + } + } + + /// @dev Generates the merkle tree, mock the root and set it in the storage + function _mockRoot(bytes32[] memory _leaves) internal returns (bytes32[] memory _tree) { + _tree = merkleTreeGenerator.generateMerkleTree(_leaves); + bytes32 _root = _tree[0]; + + balanceClaimerImpl = new BalanceClaimer({ + _ethBalanceWithdrawer: address(op), + _erc20BalanceWithdrawer: address(L1Bridge), + _root: _root + }); + vm.prank(multisig); + Proxy(payable(address(balanceClaimerProxy))).upgradeTo(address(balanceClaimerImpl)); + } + + /// @dev Test that the claim function succeeds + function test_claim_succeeds() external { + bytes32[] memory _aliceClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[0])); + balanceClaimerProxy.claim( + _aliceClaimerProofs, aliceClaimParams.user, aliceClaimParams.ethBalance, aliceClaimParams.erc20TokenBalances + ); + + bytes32[] memory _bobClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[1])); + balanceClaimerProxy.claim( + _bobClaimerProofs, bobClaimParams.user, bobClaimParams.ethBalance, bobClaimParams.erc20TokenBalances + ); + + bytes32[] memory _charlieClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[2])); + balanceClaimerProxy.claim( + _charlieClaimerProofs, + charlieClaimParams.user, + charlieClaimParams.ethBalance, + charlieClaimParams.erc20TokenBalances + ); + + // Assertions + assertEq(address(balanceClaimerProxy.ETH_BALANCE_WITHDRAWER()).balance, 0); + assertEq(ERC20(token1).balanceOf(address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER())), 0); + assertEq(ERC20(token2).balanceOf(address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER())), 0); + assertEq(ERC20(token3).balanceOf(address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER())), 0); + + assertEq(aliceClaimer.balance, aliceClaimParams.ethBalance); + assertEq(ERC20(token2).balanceOf(aliceClaimer), aliceClaimParams.erc20TokenBalances[0].balance); + + assertEq(bobClaimer.balance, bobClaimParams.ethBalance); + assertEq(ERC20(token1).balanceOf(bobClaimer), bobClaimParams.erc20TokenBalances[0].balance); + assertEq(ERC20(token3).balanceOf(bobClaimer), bobClaimParams.erc20TokenBalances[1].balance); + + assertEq(charlieClaimer.balance, charlieClaimParams.ethBalance); + assertEq(ERC20(token1).balanceOf(charlieClaimer), charlieClaimParams.erc20TokenBalances[0].balance); + assertEq(ERC20(token2).balanceOf(charlieClaimer), charlieClaimParams.erc20TokenBalances[1].balance); + assertEq(ERC20(token3).balanceOf(charlieClaimer), charlieClaimParams.erc20TokenBalances[2].balance); + } + + /// @dev Test that the claim function reverts when the user is invalid + function test_claim_reverts_InvalidUser() external { + bytes32[] memory _aliceClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[0])); + + vm.expectRevert(IBalanceClaimer.NoBalanceToClaim.selector); + + // using charlie user instead of alice + balanceClaimerProxy.claim( + _aliceClaimerProofs, + charlieClaimParams.user, + aliceClaimParams.ethBalance, + aliceClaimParams.erc20TokenBalances + ); + } + + /// @dev Test that the claim function reverts when the proof is invalid + function test_claim_reverts_InvalidProof() external { + // using bob proofs instead of alice + bytes32[] memory _aliceClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[1])); + + vm.expectRevert(IBalanceClaimer.NoBalanceToClaim.selector); + balanceClaimerProxy.claim( + _aliceClaimerProofs, aliceClaimParams.user, aliceClaimParams.ethBalance, aliceClaimParams.erc20TokenBalances + ); + } + + /// @dev Test that the claim function reverts when the eth balance is invalid + function test_claim_reverts_InvalidEthBalance() external { + bytes32[] memory _aliceClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[0])); + + // using charlie eth balance instead of alice + vm.expectRevert(IBalanceClaimer.NoBalanceToClaim.selector); + balanceClaimerProxy.claim( + _aliceClaimerProofs, + aliceClaimParams.user, + charlieClaimParams.ethBalance, + aliceClaimParams.erc20TokenBalances + ); + } + + /// @dev Test that the claim function reverts when the erc20 balance is invalid + function test_claim_reverts_InvalidErc20Balance() external { + // using bob proofs instead of alice + bytes32[] memory _aliceClaimerProofs = + merkleTreeGenerator.getProof(tree, merkleTreeGenerator.getIndex(tree, leaves[0])); + + vm.expectRevert(IBalanceClaimer.NoBalanceToClaim.selector); + + // using bob erc20 balance instead of alice + balanceClaimerProxy.claim( + _aliceClaimerProofs, aliceClaimParams.user, aliceClaimParams.ethBalance, bobClaimParams.erc20TokenBalances + ); + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/contracts/test/winddown/unit/BalanceClaimer.t.sol b/packages/contracts-bedrock/contracts/test/winddown/unit/BalanceClaimer.t.sol new file mode 100644 index 000000000000..861885dcf45e --- /dev/null +++ b/packages/contracts-bedrock/contracts/test/winddown/unit/BalanceClaimer.t.sol @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// libraries +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +// Testing +import { stdStorage, StdStorage } from "forge-std/Test.sol"; +import { BalanceClaimer_Initializer } from "../../CommonTest.t.sol"; +import { MerkleTreeGenerator } from "../../libraries/MerkleTreeGenerator.t.sol"; + +// Contracts +import { BalanceClaimer } from "../../../L1/winddown/BalanceClaimer.sol"; +import { Proxy } from "../../../universal/Proxy.sol"; + +// Interfaces +import { IBalanceClaimer } from "../../../L1/interfaces/winddown/IBalanceClaimer.sol"; +import { IErc20BalanceWithdrawer } from "../../../L1/interfaces/winddown/IErc20BalanceWithdrawer.sol"; +import { IEthBalanceWithdrawer } from "../../../L1/interfaces/winddown/IEthBalanceWithdrawer.sol"; + +contract BalanceClaimer_TestBase is BalanceClaimer_Initializer { + address mockOptimismPortal = makeAddr("mockOptimismPortal"); + address mockL1StandardBridge = makeAddr("mockL1StandardBridge"); + bytes32 mockRoot = keccak256("mockRoot"); + + function setUp() public virtual override { + super.setUp(); + + vm.prank(multisig); + balanceClaimerImpl = new BalanceClaimer({ + _ethBalanceWithdrawer: address(mockOptimismPortal), + _erc20BalanceWithdrawer: address(mockL1StandardBridge), + _root: mockRoot + }); + + vm.prank(multisig); + Proxy(payable(address(balanceClaimerProxy))).upgradeTo(address(balanceClaimerImpl)); + } + +} + +contract BalanceClaimer_Constructor_Test is BalanceClaimer_TestBase { + + /// @dev Test that the constructor sets the correct values. + function test_constructor_succeeds() external { + assertEq(balanceClaimerProxy.ROOT(), mockRoot); + assertEq(address(balanceClaimerProxy.ETH_BALANCE_WITHDRAWER()), mockOptimismPortal); + assertEq(address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER()), mockL1StandardBridge); + } +} + +contract BalanceClaimer_Test is BalanceClaimer_TestBase { + using stdStorage for StdStorage; + + struct ClaimData { + uint256 ethBalance; + uint256 balanceToken1; + uint256 balanceToken2; + uint256 balanceToken3; + } + + MerkleTreeGenerator merkleTreeGenerator; + + address _alice = makeAddr("alice"); + address _bob = makeAddr("bob"); + address _charlie = makeAddr("charlie"); + + address _token1 = makeAddr("token1"); + address _token2 = makeAddr("token2"); + address _token3 = makeAddr("token3"); + + address[] _users; + + function setUp() public override { + super.setUp(); + merkleTreeGenerator = new MerkleTreeGenerator(); + _users = new address[](3); + _users[0] = _alice; + _users[1] = _bob; + _users[2] = _charlie; + } + + /// @dev Get the erc20 token balances for the user + function _getErc20TokenBalances( + uint256 _balanceToken1, + uint256 _balanceToken2, + uint256 _balanceToken3 + ) + internal + view + returns (IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _erc20Claim) + { + uint8 _length; + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _auxErc20TokenBalances = + new IErc20BalanceWithdrawer.Erc20BalanceClaim[](3); + + if (_balanceToken1 > 0) { + _length++; + _auxErc20TokenBalances[0] = + IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: _token1, balance: _balanceToken1 }); + } + if (_balanceToken2 > 0) { + _length++; + _auxErc20TokenBalances[1] = + IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: _token2, balance: _balanceToken2 }); + } + if (_balanceToken3 > 0) { + _length++; + _auxErc20TokenBalances[2] = + IErc20BalanceWithdrawer.Erc20BalanceClaim({ token: _token3, balance: _balanceToken3 }); + } + + _erc20Claim = new IErc20BalanceWithdrawer.Erc20BalanceClaim[](_length); + uint256 _index; + for (uint256 _i = 0; _i < _auxErc20TokenBalances.length; _i++) { + if (_auxErc20TokenBalances[_i].balance > 0) { + _erc20Claim[_index] = _auxErc20TokenBalances[_i]; + _index++; + } + } + } + + /// @dev Get the leaves for the merkle tree + function _getLeaves(ClaimData[3] memory _claimData) internal view returns (bytes32[] memory _leaves) { + _leaves = new bytes32[](_claimData.length); + for (uint256 _i; _i < _claimData.length; _i++) { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _erc20Claim = _getErc20TokenBalances( + _claimData[_i].balanceToken1, _claimData[_i].balanceToken2, _claimData[_i].balanceToken3 + ); + _leaves[_i] = keccak256( + bytes.concat(keccak256(abi.encode(_users[_i], _claimData[_i].ethBalance, _erc20Claim))) + ); + } + } + + /// @dev Generates the merkle tree, mock the root and set it in the storage + function _mockRoot(bytes32[] memory _leaves) internal returns (bytes32[] memory _tree) { + _tree = merkleTreeGenerator.generateMerkleTree(_leaves); + bytes32 _root = _tree[0]; + + balanceClaimerImpl = new BalanceClaimer({ + _ethBalanceWithdrawer: address(mockOptimismPortal), + _erc20BalanceWithdrawer: address(mockL1StandardBridge), + _root: _root + }); + vm.prank(multisig); + Proxy(payable(address(balanceClaimerProxy))).upgradeTo(address(balanceClaimerImpl)); + } + + /// @dev Mock the erc20 balance withdraw call and set the expect call if at least one balance is greater than 0 + function _mockErc20BalanceWithdrawCallAndSetExpectCall( + address _user, + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _erc20Claim + ) + internal + { + bool _called; + for (uint256 _i = 0; _i < _erc20Claim.length; _i++) { + if (_erc20Claim[_i].balance > 0) { + _called = true; + break; + } + } + if (!_called) { + return; + } + vm.mockCall( + address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER()), + abi.encodeWithSelector(IErc20BalanceWithdrawer.withdrawErc20Balance.selector, _user, _erc20Claim), + abi.encode(true) + ); + + vm.expectCall( + address(balanceClaimerProxy.ERC20_BALANCE_WITHDRAWER()), + abi.encodeWithSelector(IErc20BalanceWithdrawer.withdrawErc20Balance.selector, _user, _erc20Claim) + ); + } + + /// @dev Mock the eth balance withdraw call and set the expect call if the balance is greater than 0 + function _mockEthBalanceWithdrawCallAndSetExpectCall(address _user, uint256 _ethBalance) internal { + if (_ethBalance == 0) { + return; + } + vm.mockCall( + address(balanceClaimerProxy.ETH_BALANCE_WITHDRAWER()), + abi.encodeWithSelector(IEthBalanceWithdrawer.withdrawEthBalance.selector, _user, _ethBalance), + abi.encode(true) + ); + + vm.expectCall( + address(balanceClaimerProxy.ETH_BALANCE_WITHDRAWER()), + abi.encodeWithSelector(IEthBalanceWithdrawer.withdrawEthBalance.selector, _user, _ethBalance) + ); + } +} + +contract BalanceClaimer_CanClaim_Test is BalanceClaimer_Test { + /// @dev Test that the canClaim function returns true when the user is a legit claimer. + function testFuzz_canClaim_returnsTrue(ClaimData[3] memory _claimData) external { + bytes32[] memory _leaves = _getLeaves(_claimData); + + bytes32[] memory _tree = _mockRoot(_leaves); + + for (uint256 _i = 0; _i < _claimData.length; _i++) { + bool _canClaim = balanceClaimerProxy.canClaim( + merkleTreeGenerator.getProof(_tree, merkleTreeGenerator.getIndex(_tree, _leaves[_i])), + _users[_i], + _claimData[_i].ethBalance, + _getErc20TokenBalances( + _claimData[_i].balanceToken1, _claimData[_i].balanceToken2, _claimData[_i].balanceToken3 + ) + ); + assertTrue(_canClaim); + } + } + + /// @dev Test that the canClaim function returns false when the user is not a legit claimer. + function testFuzz_canClaim_returnsFalse(ClaimData[3] memory _claimData) external { + bytes32[] memory _leaves = _getLeaves(_claimData); + + bytes32[] memory _tree = _mockRoot(_leaves); + + for (uint256 _i = 0; _i < _claimData.length; _i++) { + bool _canClaim = balanceClaimerProxy.canClaim( + merkleTreeGenerator.getProof(_tree, merkleTreeGenerator.getIndex(_tree, _leaves[_i])), + makeAddr("random"), + _claimData[_i].ethBalance, + _getErc20TokenBalances( + _claimData[_i].balanceToken1, _claimData[_i].balanceToken2, _claimData[_i].balanceToken3 + ) + ); + assertFalse(_canClaim); + } + } +} + +contract BalanceClaimer_Claim_Test is BalanceClaimer_Test { + event BalanceClaimed( + address indexed user, uint256 ethBalance, IErc20BalanceWithdrawer.Erc20BalanceClaim[] erc20TokenBalances + ); + + /// @dev Test that the canClaim function returns true when the user is a legit claimer. + function testFuzz_claim_succeeds(ClaimData[3] memory _claimData) external { + bytes32[] memory _leaves = _getLeaves(_claimData); + + bytes32[] memory _tree = _mockRoot(_leaves); + + for (uint256 _i = 0; _i < _claimData.length; _i++) { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _erc20Claim = _getErc20TokenBalances( + _claimData[_i].balanceToken1, _claimData[_i].balanceToken2, _claimData[_i].balanceToken3 + ); + _mockErc20BalanceWithdrawCallAndSetExpectCall(_users[_i], _erc20Claim); + _mockEthBalanceWithdrawCallAndSetExpectCall(_users[_i], _claimData[_i].ethBalance); + + vm.expectEmit(address(balanceClaimerProxy)); + emit BalanceClaimed(_users[_i], _claimData[_i].ethBalance, _erc20Claim); + + balanceClaimerProxy.claim( + merkleTreeGenerator.getProof(_tree, merkleTreeGenerator.getIndex(_tree, _leaves[_i])), + _users[_i], + _claimData[_i].ethBalance, + _erc20Claim + ); + assertTrue(balanceClaimerProxy.claimed(_users[_i])); + } + } + + /// @dev Test that the claim function reverts when the user is not a legit claimer. + function testFuzz_claim_reverts(ClaimData[3] memory _claimData) external { + bytes32[] memory _leaves = _getLeaves(_claimData); + + bytes32[] memory _tree = _mockRoot(_leaves); + + for (uint256 _i = 0; _i < _claimData.length; _i++) { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _erc20Claim = _getErc20TokenBalances( + _claimData[_i].balanceToken1, _claimData[_i].balanceToken2, _claimData[_i].balanceToken3 + ); + bytes32[] memory _proof = + merkleTreeGenerator.getProof(_tree, merkleTreeGenerator.getIndex(_tree, _leaves[_i])); + + vm.expectRevert(IBalanceClaimer.NoBalanceToClaim.selector); + balanceClaimerProxy.claim(_proof, makeAddr("random"), _claimData[_i].ethBalance, _erc20Claim); + } + } + + /// @dev Test that the canClaim function can be only called once when the user is a legit claimer. + function testFuzz_claimTwice_reverts(ClaimData[3] memory _claimData) external { + bytes32[] memory _leaves = _getLeaves(_claimData); + + bytes32[] memory _tree = _mockRoot(_leaves); + + for (uint256 _i = 0; _i < _claimData.length; _i++) { + IErc20BalanceWithdrawer.Erc20BalanceClaim[] memory _erc20Claim = _getErc20TokenBalances( + _claimData[_i].balanceToken1, _claimData[_i].balanceToken2, _claimData[_i].balanceToken3 + ); + bytes32[] memory _proof = + merkleTreeGenerator.getProof(_tree, merkleTreeGenerator.getIndex(_tree, _leaves[_i])); + _mockErc20BalanceWithdrawCallAndSetExpectCall(_users[_i], _erc20Claim); + _mockEthBalanceWithdrawCallAndSetExpectCall(_users[_i], _claimData[_i].ethBalance); + + balanceClaimerProxy.claim(_proof, _users[_i], _claimData[_i].ethBalance, _erc20Claim); + assertTrue(balanceClaimerProxy.claimed(_users[_i])); + + vm.expectRevert(IBalanceClaimer.NoBalanceToClaim.selector); + balanceClaimerProxy.claim(_proof, _users[_i], _claimData[_i].ethBalance, _erc20Claim); + } + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/foundry.toml b/packages/contracts-bedrock/foundry.toml index 5c812b95828d..3b04c12b8cd4 100644 --- a/packages/contracts-bedrock/foundry.toml +++ b/packages/contracts-bedrock/foundry.toml @@ -24,8 +24,17 @@ fs_permissions = [ { 'access'='read-write', 'path'='./.resource-metering.csv' }, ] +[profile.medusa] +src='contracts/test/invariants/balance-claimer' +test='contracts/test/invariants/balance-claimer' +script='contracts/test/invariants/balance-claimer' + [profile.ci] fuzz_runs = 512 [profile.echidna] bytecode_hash = 'ipfs' + +[rpc_endpoints] +ethereum_mainnet = "${ETHEREUM_MAINNET_RPC}" +local = "${LOCAL_RPC}" diff --git a/packages/contracts-bedrock/medusa.json b/packages/contracts-bedrock/medusa.json new file mode 100644 index 000000000000..ede7245d9619 --- /dev/null +++ b/packages/contracts-bedrock/medusa.json @@ -0,0 +1,89 @@ +{ + "fuzzing": { + "workers": 10, + "workerResetLimit": 50, + "timeout": 0, + "testLimit": 0, + "shrinkLimit": 5000, + "callSequenceLength": 100, + "corpusDirectory": "corpus", + "coverageEnabled": true, + "coverageFormats": [ + "html", + "lcov" + ], + "targetContracts": ["FuzzTest"], + "predeployedContracts": {}, + "targetContractsBalances": [], + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": [ + "0x10000", + "0x20000", + "0x30000" + ], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 125000000, + "transactionGasLimit": 12500000, + "testing": { + "stopOnFailedTest": false, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "panicCodeConfig": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": false, + "testPrefixes": [ + "property_" + ] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": [ + "optimize_" + ] + }, + "targetFunctionSignatures": [], + "excludeFunctionSignatures": [] + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": false + }, + "skipAccountChecks": true + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": ".", + "solcVersion": "", + "exportDirectory": "", + "args": ["--foundry-compile-all", "--foundry-out-directory", "artifacts"] + } + }, + "logging": { + "level": "info", + "logDirectory": "", + "noColor": false + } +} diff --git a/packages/contracts-bedrock/package.json b/packages/contracts-bedrock/package.json index 5204e3a87cda..a81ac2fea050 100644 --- a/packages/contracts-bedrock/package.json +++ b/packages/contracts-bedrock/package.json @@ -25,6 +25,8 @@ "autogen:artifacts": "ts-node scripts/generate-artifacts.ts", "autogen:invariant-docs": "ts-node scripts/invariant-doc-gen.ts", "deploy": "hardhat deploy", + "upgrade-prod:winddown": "bash -c 'source .env && forge script scripts/winddown-upgrade/prod/Winddown.s.sol:WinddownUpgrade --rpc-url $ETHEREUM_MAINNET_RPC --broadcast'", + "upgrade-local:winddown": "bash -c 'source .env && forge script scripts/winddown-upgrade/local/Winddown.s.sol:WinddownUpgrade --rpc-url $LOCAL_RPC --unlocked --broadcast'", "test": "yarn build:differential && yarn build:fuzz && forge test", "coverage": "yarn build:differential && yarn build:fuzz && forge coverage", "coverage:lcov": "yarn build:differential && yarn build:fuzz && forge coverage --report lcov", @@ -44,6 +46,7 @@ "lint:fix": "yarn lint:contracts:fix && yarn lint:ts:fix", "lint": "yarn lint:fix && yarn lint:check", "typechain": "typechain --target ethers-v5 --out-dir dist/types --glob 'artifacts/!(build-info)/**/+([a-zA-Z0-9_]).json'", + "medusa": "FOUNDRY_PROFILE=medusa medusa fuzz", "echidna:aliasing": "echidna-test --contract EchidnaFuzzAddressAliasing --config ./echidna.yaml .", "echidna:burn:gas": "echidna-test --contract EchidnaFuzzBurnGas --config ./echidna.yaml .", "echidna:burn:eth": "echidna-test --contract EchidnaFuzzBurnEth --config ./echidna.yaml .", @@ -79,7 +82,7 @@ "dotenv": "^16.0.0", "ds-test": "https://github.com/dapphub/ds-test.git#9310e879db8ba3ea6d5c6489a579118fd264a3f5", "ethereum-waffle": "^3.0.0", - "forge-std": "https://github.com/foundry-rs/forge-std.git#46264e9788017fc74f9f58b7efa0bc6e1df6d410", + "forge-std": "https://github.com/foundry-rs/forge-std.git#v1.9.4", "glob": "^7.1.6", "hardhat": "^2.9.6", "hardhat-deploy": "^0.11.4", diff --git a/packages/contracts-bedrock/scripts/winddown-upgrade/WinddownConstants.sol b/packages/contracts-bedrock/scripts/winddown-upgrade/WinddownConstants.sol new file mode 100644 index 000000000000..9f434915ea01 --- /dev/null +++ b/packages/contracts-bedrock/scripts/winddown-upgrade/WinddownConstants.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +library WinddownConstants { + address constant L1_STANDARD_BRIDGE_PROXY = 0xD0204B9527C1bA7bD765Fa5CCD9355d38338272b; + address constant OPTIMISM_PORTAL_PROXY = 0xb26Fd985c5959bBB382BAFdD0b879E149e48116c; + + // OptimismPortal constructor parameters + address constant L2_ORACLE = 0xA38d0c4E6319F9045F20318BA5f04CDe94208608; + address constant GUARDIAN = 0x39E13D1AB040F6EA58CE19998edCe01B3C365f84; + address constant SYSTEM_CONFIG = 0x7Df716EAD1d83a2BF35B416B7BC84bd0700357C9; + + // L1StandardBridge constructor parameters + address constant MESSENGER = 0x97BAf688E5d0465E149d1d5B497Ca99392a6760e; + + // TODO: Set the correct merkle root + bytes32 constant MERKLE_ROOT; +} \ No newline at end of file diff --git a/packages/contracts-bedrock/scripts/winddown-upgrade/local/Winddown.s.sol b/packages/contracts-bedrock/scripts/winddown-upgrade/local/Winddown.s.sol new file mode 100644 index 000000000000..29134f31c8d3 --- /dev/null +++ b/packages/contracts-bedrock/scripts/winddown-upgrade/local/Winddown.s.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { console } from "forge-std/console.sol"; +import { Script } from "forge-std/Script.sol"; + +import { Proxy } from "contracts/universal/Proxy.sol"; +import { L1ChugSplashProxy } from "contracts/legacy/L1ChugSplashProxy.sol"; +import { WinddownConstants } from "../WinddownConstants.sol"; + + +import { IBalanceClaimer, BalanceClaimer } from "contracts/L1/winddown/BalanceClaimer.sol"; +import { L1StandardBridge } from "contracts/L1/L1StandardBridge.sol"; +import { OptimismPortal } from "contracts/L1/OptimismPortal.sol"; + +import { L2OutputOracle } from "../../../contracts/L1/L2OutputOracle.sol"; +import { SystemConfig } from "../../../contracts/L1/SystemConfig.sol"; + +contract WinddownUpgrade is Script { + bytes32 internal constant OWNER_KEY = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + + function run() public { + uint256 _deployerPk = vm.envUint("PRIVATE_KEY_PROXY_ADMIN"); + address _deployer = vm.addr(_deployerPk); + + // Get the proxies for L1StandardBridge and OptimismPortal + L1ChugSplashProxy l1StandardBridgeProxy = L1ChugSplashProxy(payable(address(WinddownConstants.L1_STANDARD_BRIDGE_PROXY))); + Proxy optimismPortalProxy = Proxy(payable(address(WinddownConstants.OPTIMISM_PORTAL_PROXY))); + + vm.startBroadcast(_deployer); + + // Deploy BalanceClaimer proxy + Proxy balanceClaimerProxy = new Proxy(_deployer); + + console.log("BalanceClaimer proxy deployed at: ", address(balanceClaimerProxy)); + + // Deploy BalanceClaimer implementation + BalanceClaimer balanceClaimerImpl = new BalanceClaimer({ + _ethBalanceWithdrawer: address(optimismPortalProxy), + _erc20BalanceWithdrawer: address(l1StandardBridgeProxy), + _root: WinddownConstants.MERKLE_ROOT + }); + + // Set BalanceClaimer implementation + balanceClaimerProxy.upgradeTo(address(balanceClaimerImpl)); + + // BalanceClaimer assertions + assert(address(BalanceClaimer(address(balanceClaimerProxy)).ETH_BALANCE_WITHDRAWER()) == address(optimismPortalProxy)); + assert(address(BalanceClaimer(address(balanceClaimerProxy)).ERC20_BALANCE_WITHDRAWER()) == address(l1StandardBridgeProxy)); + assert(BalanceClaimer(address(balanceClaimerProxy)).ROOT() == WinddownConstants.MERKLE_ROOT); + + vm.stopBroadcast(); + + // Get the admin address of the OptimismPortal + bytes32 storageData = vm.load(address(optimismPortalProxy), OWNER_KEY); + address adminAddress = address(uint160(uint256(storageData))); + + vm.startBroadcast(adminAddress); + + // Deploy OptimismPortal implementation + OptimismPortal opPortalImpl = new OptimismPortal({ + _l2Oracle: L2OutputOracle(WinddownConstants.L2_ORACLE), + _guardian: WinddownConstants.GUARDIAN, + _paused: true, + _config: SystemConfig(WinddownConstants.SYSTEM_CONFIG), + _balanceClaimer: address(balanceClaimerProxy) + }); + + // Upgrade OptimismPortal + optimismPortalProxy.upgradeTo( + address(opPortalImpl) + ); + + // OptimismPortal assertions + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).L2_ORACLE()) == WinddownConstants.L2_ORACLE); + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).GUARDIAN()) == WinddownConstants.GUARDIAN); + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).SYSTEM_CONFIG()) == WinddownConstants.SYSTEM_CONFIG); + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).BALANCE_CLAIMER()) == address(balanceClaimerProxy)); + // No assertion for pause since it's set in the initializer and setting true or false in the new implementation constructor parameter is idempotent + + vm.stopBroadcast(); + + // Get the admin address of the L1StandardBridge + storageData = vm.load(address(optimismPortalProxy), OWNER_KEY); + adminAddress = address(uint160(uint256(storageData))); + + vm.startBroadcast(adminAddress); + + // Deploy L1StandardBridge implementation + L1StandardBridge l1StandardBridgeImpl = new L1StandardBridge({ + _messenger: payable(WinddownConstants.MESSENGER), + _balanceClaimer: address(balanceClaimerProxy) + }); + + // Upgrade L1StandardBridge + l1StandardBridgeProxy.setCode(address(l1StandardBridgeImpl).code); + + // L1StandardBridge assertions + assert(address(L1StandardBridge(payable(address(l1StandardBridgeProxy))).BALANCE_CLAIMER()) == address(balanceClaimerProxy)); + assert(address(L1StandardBridge(payable(address(l1StandardBridgeProxy))).MESSENGER()) == WinddownConstants.MESSENGER); + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/scripts/winddown-upgrade/prod/Winddown.s.sol b/packages/contracts-bedrock/scripts/winddown-upgrade/prod/Winddown.s.sol new file mode 100644 index 000000000000..37734ee846f6 --- /dev/null +++ b/packages/contracts-bedrock/scripts/winddown-upgrade/prod/Winddown.s.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { console } from "forge-std/console.sol"; +import { Script } from "forge-std/Script.sol"; + +import { Proxy } from "contracts/universal/Proxy.sol"; +import { L1ChugSplashProxy } from "contracts/legacy/L1ChugSplashProxy.sol"; +import { WinddownConstants } from "../WinddownConstants.sol"; + +import { IBalanceClaimer, BalanceClaimer } from "contracts/L1/winddown/BalanceClaimer.sol"; +import { L1StandardBridge } from "contracts/L1/L1StandardBridge.sol"; +import { OptimismPortal } from "contracts/L1/OptimismPortal.sol"; + +import { L2OutputOracle } from "../../../contracts/L1/L2OutputOracle.sol"; +import { SystemConfig } from "../../../contracts/L1/SystemConfig.sol"; + +contract WinddownUpgrade is Script { + function run() public { + uint256 _deployerPk = vm.envUint("PRIVATE_KEY_PROXY_ADMIN"); + address _deployer = vm.addr(_deployerPk); + + // Get the proxies for L1StandardBridge and OptimismPortal + L1ChugSplashProxy l1StandardBridgeProxy = L1ChugSplashProxy(payable(address(WinddownConstants.L1_STANDARD_BRIDGE_PROXY))); + Proxy optimismPortalProxy = Proxy(payable(address(WinddownConstants.OPTIMISM_PORTAL_PROXY))); + + vm.startBroadcast(_deployer); + + // Deploy BalanceClaimer proxy + Proxy balanceClaimerProxy = new Proxy(_deployer); + + // Deploy BalanceClaimer implementation + BalanceClaimer balanceClaimerImpl = new BalanceClaimer({ + _ethBalanceWithdrawer: address(optimismPortalProxy), + _erc20BalanceWithdrawer: address(l1StandardBridgeProxy), + _root: WinddownConstants.MERKLE_ROOT + }); + + // Set BalanceClaimer implementation + balanceClaimerProxy.upgradeTo(address(balanceClaimerImpl)); + + // BalanceClaimer assertions + assert(address(BalanceClaimer(address(balanceClaimerProxy)).ETH_BALANCE_WITHDRAWER()) == address(optimismPortalProxy)); + assert(address(BalanceClaimer(address(balanceClaimerProxy)).ERC20_BALANCE_WITHDRAWER()) == address(l1StandardBridgeProxy)); + assert(BalanceClaimer(address(balanceClaimerProxy)).ROOT() == WinddownConstants.MERKLE_ROOT); + + // Deploy OptimismPortal implementation + OptimismPortal opPortalImpl = new OptimismPortal({ + _l2Oracle: L2OutputOracle(WinddownConstants.L2_ORACLE), + _guardian: WinddownConstants.GUARDIAN, + _paused: true, + _config: SystemConfig(WinddownConstants.SYSTEM_CONFIG), + _balanceClaimer: address(balanceClaimerProxy) + }); + + // Upgrade OptimismPortal + optimismPortalProxy.upgradeTo( + address(opPortalImpl) + ); + + // OptimismPortal assertions + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).L2_ORACLE()) == WinddownConstants.L2_ORACLE); + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).GUARDIAN()) == WinddownConstants.GUARDIAN); + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).SYSTEM_CONFIG()) == WinddownConstants.SYSTEM_CONFIG); + assert(address(OptimismPortal(payable(address(optimismPortalProxy))).BALANCE_CLAIMER()) == address(balanceClaimerProxy)); + // No assertion for pause since it's set in the initializer and setting true or false in the new implementation constructor parameter is idempotent + + // Deploy L1StandardBridge implementation + L1StandardBridge l1StandardBridgeImpl = new L1StandardBridge({ + _messenger: payable(WinddownConstants.MESSENGER), + _balanceClaimer: address(balanceClaimerProxy) + }); + + // Upgrade L1StandardBridge + l1StandardBridgeProxy.setCode(address(l1StandardBridgeImpl).code); + + // L1StandardBridge assertions + assert(address(L1StandardBridge(payable(address(l1StandardBridgeProxy))).BALANCE_CLAIMER()) == address(balanceClaimerProxy)); + assert(address(L1StandardBridge(payable(address(l1StandardBridgeProxy))).MESSENGER()) == WinddownConstants.MESSENGER); + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index cc9d66aa0cc5..8c31a3d61a57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11524,14 +11524,14 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -"forge-std@https://github.com/foundry-rs/forge-std.git#46264e9788017fc74f9f58b7efa0bc6e1df6d410": - version "1.5.2" - resolved "https://github.com/foundry-rs/forge-std.git#46264e9788017fc74f9f58b7efa0bc6e1df6d410" - "forge-std@https://github.com/foundry-rs/forge-std.git#53331f4cb2e313466f72440f3e73af048c454d02": version "1.2.0" resolved "https://github.com/foundry-rs/forge-std.git#53331f4cb2e313466f72440f3e73af048c454d02" +"forge-std@https://github.com/foundry-rs/forge-std.git#v1.9.4": + version "1.9.4" + resolved "https://github.com/foundry-rs/forge-std.git#1eea5bae12ae557d589f9f0f0edae2faa47cb262" + form-data@^2.2.0: version "2.5.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"