├── .env.sample ├── .gitignore ├── audit ├── 2023 - Obol Splits V1 Audit - Zach Obront.pdf ├── 2025 - Obol Splits V2 Audit - Nethermind.pdf └── 2025 - Obol Splits V3 Audit - Nethermind.pdf ├── src ├── interfaces │ ├── ILiquidWaterfall.sol │ ├── IwstETH.sol │ ├── IweETH.sol │ ├── IENSReverseRegistrar.sol │ ├── IWaterfallModule.sol │ ├── ISplitFactory.sol │ ├── IWaterfallFactoryModule.sol │ ├── ISplitWalletV2.sol │ ├── ISplitFactoryV2.sol │ ├── IDepositContract.sol │ ├── ISplitMain.sol │ └── ISplitMainV2.sol ├── test │ ├── utils │ │ └── mocks │ │ │ ├── MockERC20.sol │ │ │ ├── MockNFT.sol │ │ │ └── MockERC1155.sol │ ├── owr │ │ ├── OWRReentrancy.sol │ │ ├── OWRTestHelper.t.sol │ │ ├── token │ │ │ ├── integration │ │ │ │ └── GNOOTWRIntegration.t.sol │ │ │ └── OptimisticTokenWithdrawalRecipientFactory.t.sol │ │ └── OptimisticWithdrawalRecipientFactory.t.sol │ ├── lido │ │ ├── ObolLidoSplitTestHelper.sol │ │ ├── ObolLIdoSplitFactory.t.sol │ │ ├── integration │ │ │ └── LidoSplitIntegrationTest.sol │ │ └── ObolLidoSplit.t.sol │ ├── etherfi │ │ ├── ObolEtherfiSplitTestHelper.sol │ │ ├── ObolEtherfiSplitFactory.t.sol │ │ └── integration │ │ │ └── ObolEtherfiSplitIntegrationTest.sol │ ├── ovm │ │ ├── ObolValidatorManagerReentrancy.sol │ │ ├── mocks │ │ │ ├── DepositContractMock.sol │ │ │ └── SystemContractMock.sol │ │ └── ObolValidatorManagerFactory.t.sol │ ├── collector │ │ ├── ObolCollectorFactory.t.sol │ │ └── ObolCollector.t.sol │ └── controllers │ │ ├── IMSCFactory.t.sol │ │ └── IMSC.t.sol ├── base │ ├── BaseSplitFactory.sol │ └── BaseSplit.sol ├── collector │ ├── ObolCollector.sol │ └── ObolCollectorFactory.sol ├── lido │ ├── ObolLidoSplitFactory.sol │ └── ObolLidoSplit.sol ├── etherfi │ ├── ObolEtherfiSplitFactory.sol │ └── ObolEtherfiSplit.sol ├── controllers │ └── ImmutableSplitController.sol ├── ovm │ └── ObolValidatorManagerFactory.sol └── owr │ ├── OptimisticWithdrawalRecipientFactory.sol │ └── token │ └── OptimisticTokenWithdrawalRecipientFactory.sol ├── .vscode └── settings.json ├── LICENSE ├── .env.deployment ├── .github ├── workflows │ ├── slither.yml │ ├── label-issues.yml │ ├── ci.yml │ └── add_issue_to_project.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── script ├── IMSCFactory.s.sol ├── data │ ├── single-split-config-sample.json │ ├── deploy-otwr-sample.json │ ├── lido-data-sample.json │ └── nested-split-config-sample.json ├── OWRFactoryScript.s.sol ├── OWRFactoryWithTokenScript.s.sol ├── ObolLidoSplitFactoryScript.s.sol ├── ovm │ ├── Utils.s.sol │ ├── SystemContractFeesScript.s.sol │ ├── GrantRolesScript.s.sol │ ├── SetAmountOfPrincipalStakeScript.s.sol │ ├── SetBeneficiaryScript.s.sol │ ├── SetRewardRecipientScript.s.sol │ ├── DistributeFundsScript.s.sol │ ├── CreateOVMScript.s.sol │ ├── WithdrawScript.s.sol │ ├── DeployFactoryScript.s.sol │ ├── ConsolidateScript.s.sol │ ├── SweepScript.s.sol │ └── DepositScript.s.sol ├── splits │ ├── GetBalancesScript.s.sol │ ├── DistributeScript.s.sol │ ├── BaseScript.s.sol │ └── DeployScript.s.sol ├── DeployOTWRAndSplit.s.sol ├── ObolLidoSetupScript.sol └── SplitterConfiguration.sol ├── .prettierrc ├── .gitmodules ├── foundry.toml ├── full-deploy.sh ├── README.md └── CLAUDE.md /.env.sample: -------------------------------------------------------------------------------- 1 | SEPOLIA_RPC_URL= 2 | HOLESKY_RPC_URL= 3 | MAINNET_RPC_URL= 4 | ETHERSCAN_API_KEY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache/ 2 | out/ 3 | .gas-snapshot 4 | .env 5 | broadcast 6 | remappings.txt 7 | env/ 8 | result.json 9 | deployments/ 10 | .DS_Store -------------------------------------------------------------------------------- /audit/2023 - Obol Splits V1 Audit - Zach Obront.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ObolNetwork/obol-splits/HEAD/audit/2023 - Obol Splits V1 Audit - Zach Obront.pdf -------------------------------------------------------------------------------- /audit/2025 - Obol Splits V2 Audit - Nethermind.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ObolNetwork/obol-splits/HEAD/audit/2025 - Obol Splits V2 Audit - Nethermind.pdf -------------------------------------------------------------------------------- /audit/2025 - Obol Splits V3 Audit - Nethermind.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ObolNetwork/obol-splits/HEAD/audit/2025 - Obol Splits V3 Audit - Nethermind.pdf -------------------------------------------------------------------------------- /src/interfaces/ILiquidWaterfall.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface ILiquidWaterfall { 5 | function balanceOf(address owner, uint256 id) external returns (uint256); 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "solidity.packageDefaultDependenciesContractsDirectory": "src", 3 | "solidity.packageDefaultDependenciesDirectory": "lib", 4 | "solidity.compileUsingRemoteVersion": "v0.8.19", 5 | "solidity.defaultCompiler": "remote" 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | License 2 | 3 | Copyright 2025 Obol Labs, Inc. All rights reserved. 4 | 5 | This software is licensed under proprietary terms. Certain contracts in this repository are licensed more permissively, refer to the SPDX-License-Identifier annotation on these contracts. -------------------------------------------------------------------------------- /src/interfaces/IwstETH.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IwstETH { 5 | function wrap(uint256 amount) external returns (uint256); 6 | function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256); 7 | } 8 | -------------------------------------------------------------------------------- /.env.deployment: -------------------------------------------------------------------------------- 1 | PRIVATE_KEY= 2 | RPC_URL= 3 | 4 | # Obol Lido Split Configuration 5 | FEE_RECEIPIENT= 6 | FEE_SHARE= 7 | STETH_ADDRESS= 8 | WSTETH_ADDRESS= 9 | 10 | 11 | # IMSC Deployment Configuration 12 | SPLITMAIN= 13 | 14 | # OWR Factory Deployment Script Configuration 15 | OWR_ENS_NAME= 16 | ENS_REVERSE_REGISTRAR= 17 | OWR_ENS_OWNER= 18 | 19 | OBOL_LIDO_SPLIT_FACTORY= -------------------------------------------------------------------------------- /src/interfaces/IweETH.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IweETH { 5 | function wrap(uint256 _eETHAmount) external returns (uint256); 6 | function getEETHByWeETH(uint256 _weETHAmount) external view returns (uint256); 7 | function getWeETHByeETH(uint256 _eETHAmount) external view returns (uint256); 8 | function eETH() external view returns (address); 9 | } 10 | -------------------------------------------------------------------------------- /src/test/utils/mocks/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | constructor(string memory name, string memory symbol, uint8 decimals) ERC20(name, symbol, decimals) {} 8 | 9 | function mint(uint256 amount) external { 10 | _mint(msg.sender, amount); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/owr/OWRReentrancy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {OptimisticWithdrawalRecipient} from "src/owr/OptimisticWithdrawalRecipient.sol"; 6 | 7 | contract OWRReentrancy is Test { 8 | receive() external payable { 9 | if (address(this).balance <= 1 ether) OptimisticWithdrawalRecipient(msg.sender).distributeFunds(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/test/lido/ObolLidoSplitTestHelper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | contract ObolLidoSplitTestHelper { 5 | address internal STETH_MAINNET_ADDRESS = address(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); 6 | address internal WSTETH_MAINNET_ADDRESS = address(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); 7 | address internal RANDOM_stETH_ACCOUNT_ADDRESS = address(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); 8 | } 9 | -------------------------------------------------------------------------------- /src/test/etherfi/ObolEtherfiSplitTestHelper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | contract ObolEtherfiSplitTestHelper { 5 | address internal EETH_MAINNET_ADDRESS = address(0x35fA164735182de50811E8e2E824cFb9B6118ac2); 6 | address internal WEETH_MAINNET_ADDRESS = address(0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee); 7 | address internal RANDOM_EETH_ACCOUNT_ADDRESS = address(0x30653c83162ff00918842D8bFe016935Fdd6Ab84); 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/IENSReverseRegistrar.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IENSReverseRegistrar { 5 | function claim(address owner) external returns (bytes32); 6 | function defaultResolver() external view returns (address); 7 | function ens() external view returns (address); 8 | function node(address addr) external pure returns (bytes32); 9 | function setName(string memory name) external returns (bytes32); 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/slither.yml: -------------------------------------------------------------------------------- 1 | name: Slither Analysis 2 | on: [push] 3 | jobs: 4 | analyze: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | 9 | - name: Run Slither 10 | uses: crytic/slither-action@v0.4.0 11 | id: slither 12 | with: 13 | sarif: results.sarif 14 | fail-on: none 15 | 16 | - name: Upload SARIF file 17 | uses: github/codeql-action/upload-sarif@v3 18 | with: 19 | sarif_file: ${{ steps.slither.outputs.sarif }} 20 | -------------------------------------------------------------------------------- /script/IMSCFactory.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import {ImmutableSplitControllerFactory} from "src/controllers/ImmutableSplitControllerFactory.sol"; 6 | 7 | contract IMSCFactoryScript is Script { 8 | function run(address splitMain) external { 9 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 10 | vm.startBroadcast(privKey); 11 | 12 | new ImmutableSplitControllerFactory{salt: keccak256("obol.imsc.v1")}(splitMain); 13 | 14 | vm.stopBroadcast(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/utils/mocks/MockNFT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "solmate/tokens/ERC721.sol"; 5 | 6 | error DoesNotExist(); 7 | 8 | contract MockNFT is ERC721("NFT", "NFT") { 9 | function tokenURI(uint256 id) public view override returns (string memory) { 10 | if (_ownerOf[id] == address(0)) revert DoesNotExist(); 11 | 12 | return string(abi.encodePacked("NFT", id)); 13 | } 14 | 15 | function mint(address to, uint256 tokenID) external { 16 | _mint(to, tokenID); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "endOfLine": "auto", 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "plugins": ["prettier-plugin-solidity"], 9 | "overrides": [ 10 | { 11 | "files": "*.sol", 12 | "options": { 13 | "parser": "solidity-parse", 14 | "printWidth": 120, 15 | "tabWidth": 2, 16 | "useTabs": false, 17 | "singleQuote": false, 18 | "bracketSpacing": false 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/test/ovm/ObolValidatorManagerReentrancy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Proprietary 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 7 | 8 | contract ObolValidatorManagerReentrancy is Test { 9 | receive() external payable { 10 | console.log("receive() with value", msg.value, "and balance", address(this).balance); 11 | 12 | if (address(this).balance <= 1 ether) ObolValidatorManager(payable(msg.sender)).distributeFunds(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/label-issues.yml: -------------------------------------------------------------------------------- 1 | name: Label issues 2 | on: 3 | issues: 4 | types: 5 | - reopened 6 | - opened 7 | jobs: 8 | label_issues: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | steps: 13 | - uses: actions/github-script@v6 14 | with: 15 | script: | 16 | github.rest.issues.addLabels({ 17 | issue_number: context.issue.number, 18 | owner: context.repo.owner, 19 | repo: context.repo.repo, 20 | labels: ["contracts"] 21 | }) 22 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/ds-test"] 2 | path = lib/ds-test 3 | url = https://github.com/dapphub/ds-test 4 | [submodule "lib/forge-std"] 5 | path = lib/forge-std 6 | url = https://github.com/foundry-rs/forge-std 7 | tag = v1.9.6 8 | [submodule "lib/solady"] 9 | path = lib/solady 10 | url = https://github.com/vectorized/solady 11 | branch = v0.0.123 12 | [submodule "lib/splits-utils"] 13 | path = lib/splits-utils 14 | url = https://github.com/0xSplits/splits-utils 15 | [submodule "lib/solmate"] 16 | path = lib/solmate 17 | url = https://github.com/transmissions11/solmate 18 | 19 | 20 | -------------------------------------------------------------------------------- /script/data/single-split-config-sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "simple", 4 | "owner": "0x46aB8712c7A5423b717F648529B1c7A17099750A", 5 | "totalAllocation": 1000, 6 | "distributionIncentive": 0, 7 | "splitType": "pull", 8 | "allocations": [ 9 | { 10 | "recipient": "0xE84E904936C595C55b9Ad08532d9aD0A5d76df72", 11 | "allocation": 500 12 | }, 13 | { 14 | "recipient": "0xCaA54030A875F765aCaC38f8DB64b9227912be63", 15 | "allocation": 500 16 | } 17 | ] 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | A clear and concise description of what the bug is. 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior: 11 | 1. Go to '...' 12 | 2. Click on '....' 13 | 3. Scroll down to '....' 14 | 4. See error 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /src/test/utils/mocks/MockERC1155.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC1155} from "solmate/tokens/ERC1155.sol"; 5 | 6 | contract MockERC1155 is ERC1155 { 7 | function uri(uint256) public pure override returns (string memory) { 8 | return "uri"; 9 | } 10 | 11 | function safeMint(address to, uint256 id, uint256 amount, bytes memory data) external { 12 | _mint(to, id, amount, data); 13 | } 14 | 15 | function safeBatchMint(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) external { 16 | _batchMint(to, ids, amounts, data); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: run tests 8 | 9 | jobs: 10 | check: 11 | name: Obol Manager Contracts 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | submodules: recursive 17 | 18 | - name: Install Foundry 19 | uses: onbjerg/foundry-toolchain@v1 20 | with: 21 | version: nightly 22 | 23 | - name: Run tests 24 | env: 25 | SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} 26 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 27 | run: forge test -vvv 28 | -------------------------------------------------------------------------------- /script/data/deploy-otwr-sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "principalRecipient": "0x0000000000000000000000000000000000000001", 4 | "split": { 5 | "accounts": [ 6 | "0x0000000000000000000000000000000000000001", 7 | "0x0000000000000000000000000000000000000002", 8 | "0x0000000000000000000000000000000000000003" 9 | ], 10 | "controller": "0x0000000000000000000000000000000000000004", 11 | "distributorFee": 1, 12 | "percentAllocations": [ 13 | 840000, 14 | 60000, 15 | 100000 16 | ] 17 | } 18 | } 19 | ] -------------------------------------------------------------------------------- /script/OWRFactoryScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import {OptimisticWithdrawalRecipientFactory} from "src/owr/OptimisticWithdrawalRecipientFactory.sol"; 6 | 7 | contract OWRFactoryScript is Script { 8 | function run(string memory _name, address _ensReverseRegistrar, address _ensOwner) external { 9 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 10 | 11 | vm.startBroadcast(privKey); 12 | 13 | new OptimisticWithdrawalRecipientFactory{salt: keccak256("obol.owrFactory.v1")}( 14 | _name, _ensReverseRegistrar, _ensOwner 15 | ); 16 | 17 | vm.stopBroadcast(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /script/OWRFactoryWithTokenScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import {OptimisticTokenWithdrawalRecipientFactory} from "src/owr/token/OptimisticTokenWithdrawalRecipientFactory.sol"; 6 | 7 | contract OWRWFactoryWithTokenScript is Script { 8 | uint256 constant ETH_STAKE_THRESHOLD = 16 ether; 9 | uint256 constant GNO_STAKE_THRESHOLD = 0.8 ether; 10 | 11 | function run() external { 12 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 13 | 14 | vm.startBroadcast(privKey); 15 | 16 | new OptimisticTokenWithdrawalRecipientFactory{salt: keccak256("obol.owrFactoryWithToken.v0.0")}(GNO_STAKE_THRESHOLD); 17 | 18 | vm.stopBroadcast(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/interfaces/IWaterfallModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IWaterfallModule { 5 | /// Waterfalls target token inside the contract to next-in-line recipients 6 | /// @dev pushes funds to recipients 7 | function waterfallFunds() external; 8 | 9 | /// Address of ERC20 to waterfall (0x0 used for ETH) 10 | /// @dev equivalent to address public immutable token; 11 | function token() external pure returns (address); 12 | 13 | /// Return unpacked tranches 14 | /// @return recipients Addresses to waterfall payments to 15 | /// @return thresholds Absolute payment thresholds for waterfall recipients 16 | function getTranches() external pure returns (address[] memory recipients, uint256[] memory thresholds); 17 | } 18 | -------------------------------------------------------------------------------- /script/ObolLidoSplitFactoryScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import {ObolLidoSplitFactory} from "src/lido/ObolLidoSplitFactory.sol"; 6 | import {ERC20} from "solmate/tokens/ERC20.sol"; 7 | 8 | contract ObolLidoSplitFactoryScript is Script { 9 | function run(address _feeRecipient, uint256 _feeShare, address _stETH, address _wstETH) external { 10 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 11 | vm.startBroadcast(privKey); 12 | 13 | ERC20 stETH = ERC20(_stETH); 14 | ERC20 wstETH = ERC20(_wstETH); 15 | 16 | new ObolLidoSplitFactory{salt: keccak256("obol.lidoSplitFactory.v1")}(_feeRecipient, _feeShare, stETH, wstETH); 17 | 18 | vm.stopBroadcast(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/base/BaseSplitFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | abstract contract BaseSplitFactory { 5 | /// ----------------------------------------------------------------------- 6 | /// errors 7 | /// ----------------------------------------------------------------------- 8 | /// @dev Invalid address 9 | error Invalid_Address(); 10 | 11 | /// ----------------------------------------------------------------------- 12 | /// events 13 | /// ----------------------------------------------------------------------- 14 | /// Emitted on createCollector 15 | event CreateSplit(address token, address withdrawalAddress); 16 | 17 | function createCollector(address token, address withdrawalAddress) external virtual returns (address collector); 18 | } 19 | -------------------------------------------------------------------------------- /src/interfaces/ISplitFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | import {ISplitMainV2} from "./ISplitMainV2.sol"; 6 | 7 | interface ISplitFactory { 8 | function splitMain() external view returns (ISplitMainV2); 9 | 10 | function createSplit( 11 | bytes32 splitWalletId, 12 | address[] calldata accounts, 13 | uint32[] calldata percentAllocations, 14 | uint32 distributorFee, 15 | address distributor, 16 | address controller 17 | ) external returns (address); 18 | 19 | function predictImmutableSplitAddress( 20 | bytes32 splitWalletId, 21 | address[] calldata accounts, 22 | uint32[] calldata percentAllocations, 23 | uint32 distributorFee 24 | ) external returns (address); 25 | } 26 | -------------------------------------------------------------------------------- /src/interfaces/IWaterfallFactoryModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | interface IWaterfallFactoryModule { 5 | /// Create a new WaterfallModule clone 6 | /// @param token Address of ERC20 to waterfall (0x0 used for ETH) 7 | /// @param nonWaterfallRecipient Address to recover non-waterfall tokens to 8 | /// @param recipients Addresses to waterfall payments to 9 | /// @param thresholds Absolute payment thresholds for waterfall recipients 10 | /// (last recipient has no threshold & receives all residual flows) 11 | /// @return wm Address of new WaterfallModule clone 12 | function createWaterfallModule( 13 | address token, 14 | address nonWaterfallRecipient, 15 | address[] calldata recipients, 16 | uint256[] calldata thresholds 17 | ) external returns (address); 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/add_issue_to_project.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow adding every issue in this repo to the new Project Management Board 2 | name: Add Issue To Project 3 | 4 | # Controls when the workflow will run - new issues 5 | on: 6 | issues: 7 | types: 8 | - opened 9 | 10 | # This workflow contains a single job called "build" 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | # Steps 15 | steps: 16 | - name: Add Issue To Project 17 | uses: actions/add-to-project@v0.3.0 18 | with: 19 | # URL of the project to add issues to 20 | project-url: https://github.com/orgs/ObolNetwork/projects/7 21 | # A GitHub personal access token with write access to the project, need org admin's token with repo and project permissions, need to store the token outside the script if public 22 | github-token: ${{ secrets.GH_ORG_ADMIN_SECRET }} -------------------------------------------------------------------------------- /src/test/ovm/mocks/DepositContractMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Proprietary 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console.sol"; 5 | import {IDepositContract} from "../../../interfaces/IDepositContract.sol"; 6 | 7 | /// @title DepositContractMock 8 | /// @notice This contract mocks the standard Deposit Contract. 9 | contract DepositContractMock is IDepositContract { 10 | function deposit(bytes calldata, bytes calldata, bytes calldata, bytes32) external payable override { 11 | console.log("DepositContractMock.deposit called with value", msg.value); 12 | } 13 | 14 | function get_deposit_root() external pure override returns (bytes32) { 15 | revert("DepositContractMock.get_deposit_root not implemented"); 16 | } 17 | 18 | function get_deposit_count() external pure override returns (bytes memory) { 19 | revert("DepositContractMock.get_deposit_count not implemented"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 12 | ### Summary 13 | 14 | 15 | ### Details 16 | 17 | 18 | ### How to test it 19 | 20 | 21 | ticket: #000 22 | -------------------------------------------------------------------------------- /src/collector/ObolCollector.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; 6 | import {Clone} from "solady/utils/Clone.sol"; 7 | import {BaseSplit} from "../base/BaseSplit.sol"; 8 | 9 | /// @title ObolCollector 10 | /// @author Obol 11 | /// @notice An contract used to receive and distribute rewards minus fees 12 | contract ObolCollector is BaseSplit { 13 | constructor(address _feeRecipient, uint256 _feeShare) BaseSplit(_feeRecipient, _feeShare) {} 14 | 15 | function _beforeRescueFunds(address tokenAddress) internal pure override { 16 | // prevent bypass 17 | if (tokenAddress == token()) revert Invalid_Address(); 18 | } 19 | 20 | function _beforeDistribute() internal view override returns (address tokenAddress, uint256 amount) { 21 | tokenAddress = token(); 22 | 23 | if (tokenAddress == ETH_ADDRESS) amount = address(this).balance; 24 | else amount = ERC20(tokenAddress).balanceOf(address(this)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /script/ovm/Utils.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | 7 | library Utils { 8 | // This function prints the explorer URL for a given address based on the current chain ID. 9 | function printExplorerUrl(address addr) internal view { 10 | string memory baseUrl; 11 | if (block.chainid == 1) baseUrl = "https://etherscan.io/address/"; 12 | else if (block.chainid == 11_155_111) baseUrl = "https://sepolia.etherscan.io/address/"; 13 | else if (block.chainid == 560_048) baseUrl = "https://hoodi.etherscan.io/address/"; 14 | else baseUrl = "https://etherscan.io/address/"; // Default fallback 15 | 16 | console.log("Explorer URL for address %s%s", baseUrl, addr); 17 | } 18 | 19 | // This function checks if an address is a contract by checking its code length. 20 | function isContract(address addr) internal view returns (bool) { 21 | uint256 codeLength; 22 | assembly { 23 | codeLength := extcodesize(addr) 24 | } 25 | return codeLength > 0; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/collector/ObolCollectorFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolCollectorFactory, ObolCollector} from "src/collector/ObolCollectorFactory.sol"; 6 | import {MockERC20} from "src/test/utils/mocks/MockERC20.sol"; 7 | 8 | contract ObolCollectorFactoryTest is Test { 9 | error Invalid_Address(); 10 | 11 | address feeRecipient; 12 | uint256 feeShare; 13 | address splitWallet; 14 | 15 | ObolCollectorFactory collectorFactory; 16 | 17 | function setUp() public { 18 | feeRecipient = makeAddr("feeRecipient"); 19 | splitWallet = makeAddr("splitWallet"); 20 | feeShare = 1e4; // 10% 21 | collectorFactory = new ObolCollectorFactory(feeRecipient, feeShare); 22 | } 23 | 24 | function testCannot_CreateCollectorInvalidWithdrawalAddress() public { 25 | vm.expectRevert(Invalid_Address.selector); 26 | collectorFactory.createCollector(address(0), address(0)); 27 | } 28 | 29 | function test_CreateCollector() public { 30 | collectorFactory.createCollector(address(0), splitWallet); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | test = 'src/test' 4 | out = 'out' 5 | libs = ['lib'] 6 | remappings = [ 7 | 'ds-test/=lib/ds-test/src/', 8 | 'solmate/=lib/solmate/src/', 9 | 'splits-tests/=lib/splits-utils/test/', 10 | 'solady/=lib/solady/src/', 11 | ] 12 | solc_version = '0.8.19' 13 | gas_reports = ["*"] 14 | fs_permissions = [{ access = "read-write", path = "./"}] 15 | evm_version = "shanghai" 16 | # Suppressed compiler warnings: 17 | # 2018: Function state mutability can be restricted to view/pure 18 | # 5574: Contract code size exceeds spurious dragon limit 19 | ignored_error_codes = [2018, 5574] 20 | 21 | [rpc_endpoints] 22 | mainnet = "${MAINNET_RPC_URL}" 23 | sepolia = "${SEPOLIA_RPC_URL}" 24 | 25 | [fmt] 26 | bracket_spacing = false 27 | int_types = "long" 28 | line_length = 120 29 | multiline_func_header = "attributes_first" 30 | number_underscore = "thousands" 31 | quote_style = "double" 32 | single_line_statement_blocks = "single" 33 | tab_width = 2 34 | wrap_comments = true 35 | 36 | [fuzz] 37 | runs = 100 38 | 39 | [etherscan] 40 | chiado = { key = "${ETHERSCAN_API_KEY}", url = "https://blockscout.com/gnosis/chiado/api" } -------------------------------------------------------------------------------- /script/ovm/SystemContractFeesScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | 7 | // The script simply prints the immediate fees for the system contracts. 8 | contract SystemContractFeesScript is Script { 9 | // From https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7251.md 10 | address constant consolidationSysContract = 0x0000BBdDc7CE488642fb579F8B00f3a590007251; 11 | // From https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7002.md 12 | address constant withdrawalSysContract = 0x00000961Ef480Eb55e80D19ad83579A64c007002; 13 | 14 | function run() external view { 15 | (bool ok1, bytes memory consolidationFeeData) = consolidationSysContract.staticcall(""); 16 | require(ok1, "Failed to get consolidation fee"); 17 | 18 | (bool ok2, bytes memory withdrawalFeeData) = withdrawalSysContract.staticcall(""); 19 | require(ok2, "Failed to get withdrawal fee"); 20 | 21 | console.log("Consolidation Fee", uint256(bytes32(consolidationFeeData)), "WEI"); 22 | console.log("Withdrawal Fee", uint256(bytes32(withdrawalFeeData)), "WEI"); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /script/ovm/GrantRolesScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls grantRoles() for an ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract GrantRolesScript is Script { 15 | function run(address ovmAddress, address account, uint256 roles) external { 16 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 17 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 18 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 19 | if (account == address(0)) revert("Account address cannot be zero"); 20 | if (roles == 0) revert("Roles cannot be zero"); 21 | 22 | vm.startBroadcast(privKey); 23 | 24 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 25 | ovm.grantRoles(account, roles); 26 | 27 | console.log("Account %s now has roles: %d", account, ovm.rolesOf(account)); 28 | 29 | vm.stopBroadcast(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /full-deploy.sh: -------------------------------------------------------------------------------- 1 | # This is designed to be used to do a full deployment of the contracts on a network 2 | # Copy .env.deployment to .env and fill variables 3 | # This script will skip a deployment if the variables are not set properly 4 | 5 | set -e 6 | 7 | if [[ $FEE_RECEIPIENT == "" || $FEE_SHARE == "" || $STETH_ADDRESS == "" || $WSTETH_ADDRESS == ""]] then 8 | echo "Skipping script/ObolLidoSplitFactoryScript deployment" 9 | else 10 | forge script script/ObolLidoSplitFactoryScript.s.sol:ObolLidoSplitFactoryScript --rpc-url $RPC_URL -vv --sig "run(address,uint256,address,address)" $FEE_RECEIPIENT $FEE_SHARE $STETH_ADDRESS $WSTETH_ADDRESS --broadcast --verify 11 | fi 12 | 13 | if [[ $OWR_ENS_NAME == "" || $ENS_REVERSE_REGISTRAR == "" || $OWR_ENS_OWNER == "" ]] then 14 | echo "Skipping script/OWRFactoryScript.s.sol deployment" 15 | else 16 | forge script script/OWRFactoryScript.s.sol:OWRFactoryScript --rpc-url $RPC_URL -vv --sig "run(string,address,address)" $OWR_ENS_NAME $ENS_REVERSE_REGISTRAR $OWR_ENS_OWNER --broadcast --verify 17 | fi 18 | 19 | if [[ $SPLITMAIN == "" ]] then 20 | echo "Skipping script/IMSCFactory.s.sol:IMSCFactoryScript deployment" 21 | else 22 | forge script script/IMSCFactory.s.sol:IMSCFactoryScript --rpc-url $RPC_URL -vv --sig "run(address)" $SPLITMAIN --verify --broadcast 23 | fi 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature or Improvement Ticket" 3 | about: Create a new feature or suggest an improvement 4 | --- 5 | 6 | # 🎯 Problem to be solved 7 | 8 | 9 | 10 | # 🛠️ Proposed solution 11 | 12 | - [ ] Approved design doc: *link* 13 | 14 | # 🧪 Tests 15 | 16 | 17 | 18 | # 👐 Additional acceptance criteria 19 | 20 | 21 | 22 | # ❌ Out of Scope 23 | 24 | 25 | 26 | 33 | -------------------------------------------------------------------------------- /script/ovm/SetAmountOfPrincipalStakeScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls setAmountOfPrincipalStake() for an ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract SetAmountOfPrincipalStakeScript is Script { 15 | function run(address ovmAddress, uint256 newAmount) external { 16 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 17 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 18 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 19 | 20 | vm.startBroadcast(privKey); 21 | 22 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 23 | 24 | console.log("OVM address:", ovmAddress); 25 | console.log("Current amount of principal stake: %d gwei", ovm.amountOfPrincipalStake() / 1 gwei); 26 | 27 | ovm.setAmountOfPrincipalStake(newAmount); 28 | 29 | console.log("New amount of principal stake: %d gwei", ovm.amountOfPrincipalStake() / 1 gwei); 30 | 31 | vm.stopBroadcast(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /script/ovm/SetBeneficiaryScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls setBeneficiary() for an ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract SetBeneficiaryScript is Script { 15 | function run(address ovmAddress, address newBeneficiary) external { 16 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 17 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 18 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 19 | if (newBeneficiary == address(0)) revert("New beneficiary recipient address cannot be zero"); 20 | 21 | vm.startBroadcast(privKey); 22 | 23 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 24 | 25 | console.log("OVM address:", ovmAddress); 26 | console.log("Current beneficiary: %s", ovm.getBeneficiary()); 27 | 28 | ovm.setBeneficiary(newBeneficiary); 29 | 30 | console.log("New beneficiary: %s", ovm.getBeneficiary()); 31 | 32 | vm.stopBroadcast(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /script/ovm/SetRewardRecipientScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls setRewardRecipient() for an ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract SetRewardRecipientScript is Script { 15 | function run(address ovmAddress, address newRewardRecipient) external { 16 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 17 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 18 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 19 | if (newRewardRecipient == address(0)) revert("New reward recipient address cannot be zero"); 20 | 21 | vm.startBroadcast(privKey); 22 | 23 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 24 | 25 | console.log("OVM address:", ovmAddress); 26 | console.log("Current reward recipient: %s", ovm.rewardRecipient()); 27 | 28 | ovm.setRewardRecipient(newRewardRecipient); 29 | 30 | console.log("New reward recipient: %s", ovm.rewardRecipient()); 31 | 32 | vm.stopBroadcast(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /script/data/lido-data-sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "accounts": [ 4 | "0x0000000000000000000000000000000000000001", 5 | "0x0000000000000000000000000000000000000002", 6 | "0x0000000000000000000000000000000000000003" 7 | ], 8 | "controller": "0x0000000000000000000000000000000000000004", 9 | "distributorFee": 1, 10 | "percentAllocations": [ 11 | 840000, 12 | 60000, 13 | 100000 14 | ] 15 | }, 16 | { 17 | "accounts": [ 18 | "0x0000000000000000000000000000000000000001", 19 | "0x0000000000000000000000000000000000000002", 20 | "0x0000000000000000000000000000000000000003" 21 | ], 22 | "controller": "0x0000000000000000000000000000000000000004", 23 | "distributorFee": 1, 24 | "percentAllocations": [ 25 | 840000, 26 | 60000, 27 | 100000 28 | ] 29 | }, 30 | { 31 | "accounts": [ 32 | "0x0000000000000000000000000000000000000001", 33 | "0x0000000000000000000000000000000000000002", 34 | "0x0000000000000000000000000000000000000003" 35 | ], 36 | "controller": "0x0000000000000000000000000000000000000004", 37 | "distributorFee": 1, 38 | "percentAllocations": [ 39 | 840000, 40 | 60000, 41 | 100000 42 | ] 43 | } 44 | ] -------------------------------------------------------------------------------- /script/data/nested-split-config-sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "obol", 4 | "owner": "0x46aB8712c7A5423b717F648529B1c7A17099750A", 5 | "totalAllocation": 1000, 6 | "distributionIncentive": 0, 7 | "splitType": "push", 8 | "allocations": [ 9 | { 10 | "recipient": "dv", 11 | "allocation": 900 12 | }, 13 | { 14 | "recipient": "0x46aB8712c7A5423b717F648529B1c7A17099750A", 15 | "allocation": 100 16 | } 17 | ] 18 | }, 19 | { 20 | "name": "dv", 21 | "owner": "0x46aB8712c7A5423b717F648529B1c7A17099750A", 22 | "totalAllocation": 1000, 23 | "distributionIncentive": 0, 24 | "splitType": "push", 25 | "allocations": [ 26 | { 27 | "recipient": "0xE84E904936C595C55b9Ad08532d9aD0A5d76df72", 28 | "allocation": 250 29 | }, 30 | { 31 | "recipient": "0xCaA54030A875F765aCaC38f8DB64b9227912be63", 32 | "allocation": 250 33 | }, 34 | { 35 | "recipient": "0x2f064dde4BE8854105e551F7407ffE31858559AF", 36 | "allocation": 250 37 | }, 38 | { 39 | "recipient": "0xd2924E80327b74Dc67582909aB738e0697FA2c45", 40 | "allocation": 250 41 | } 42 | ] 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /src/interfaces/ISplitWalletV2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import {ISplitFactoryV2} from "./ISplitFactoryV2.sol"; 5 | 6 | // Simplified interface for SplitWalletV2 7 | // https://github.com/0xSplits/splits-contracts-monorepo/blob/main/packages/splits-v2/src/splitters/SplitWalletV2.sol 8 | interface ISplitWalletV2 { 9 | /** 10 | * @notice Gets the native token address. 11 | * @return The native token address. 12 | */ 13 | function NATIVE_TOKEN() external pure returns (address); 14 | 15 | /** 16 | * @notice Gets the total token balance of the split wallet and the warehouse. 17 | * @param _token The token to get the balance of. 18 | * @return splitBalance The token balance in the split wallet. 19 | * @return warehouseBalance The token balance in the warehouse of the split wallet. 20 | */ 21 | function getSplitBalance(address _token) external view returns (uint256 splitBalance, uint256 warehouseBalance); 22 | 23 | /** 24 | * @notice Distributes the tokens in the split & Warehouse to the recipients. 25 | * @dev The split must be initialized and the hash of _split must match splitHash. 26 | * @param _split The split struct containing the split data that gets distributed. 27 | * @param _token The token to distribute. 28 | * @param _distributor The distributor of the split. 29 | */ 30 | function distribute(ISplitFactoryV2.Split calldata _split, address _token, address _distributor) external; 31 | } 32 | -------------------------------------------------------------------------------- /src/test/owr/OWRTestHelper.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.17; 3 | 4 | contract OWRTestHelper { 5 | address internal constant ETH_ADDRESS = address(0); 6 | 7 | uint256 internal constant MAX_TRANCHE_SIZE = 2; 8 | 9 | uint256 internal constant ETH_STAKE = 32 ether; 10 | 11 | uint256 internal constant BALANCE_CLASSIFICATION_THRESHOLD = 16 ether; 12 | 13 | /// ----------------------------------------------------------------------- 14 | /// helper fns 15 | /// ----------------------------------------------------------------------- 16 | 17 | function generateTranches(uint256 rSeed, uint256 tSeed) 18 | internal 19 | pure 20 | returns (address principal, address reward, uint256 threshold) 21 | { 22 | (principal, reward) = generateTrancheRecipients(rSeed); 23 | threshold = generateTrancheThreshold(tSeed); 24 | } 25 | 26 | function generateTrancheRecipients(uint256 _seed) internal pure returns (address principal, address reward) { 27 | bytes32 seed = bytes32(_seed); 28 | 29 | seed = keccak256(abi.encodePacked(seed)); 30 | principal = address(bytes20(seed)); 31 | 32 | seed = keccak256(abi.encodePacked(seed)); 33 | reward = address(bytes20(seed)); 34 | } 35 | 36 | function generateTrancheThreshold(uint256 _seed) internal pure returns (uint256 threshold) { 37 | uint256 seed = _seed; 38 | seed = uint256(keccak256(abi.encodePacked(seed))); 39 | threshold = uint96(seed); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/interfaces/ISplitFactoryV2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | // The interface is the simplified version of the original contract (splits.org). 5 | // The original contract uses greater solc version. 6 | interface ISplitFactoryV2 { 7 | /** 8 | * @notice Split struct 9 | * @dev This struct is used to store the split information. 10 | * @dev There are no hard caps on the number of recipients/totalAllocation/allocation unit. Thus the chain and its 11 | * gas limits will dictate these hard caps. Please double check if the split you are creating can be distributed on 12 | * the chain. 13 | * @param recipients The recipients of the split. 14 | * @param allocations The allocations of the split. 15 | * @param totalAllocation The total allocation of the split. 16 | * @param distributionIncentive The incentive for distribution. Limits max incentive to 6.5%. 17 | */ 18 | struct Split { 19 | address[] recipients; 20 | uint256[] allocations; 21 | uint256 totalAllocation; 22 | uint16 distributionIncentive; 23 | } 24 | 25 | /** 26 | * @notice Create a new split with params and owner. 27 | * @dev Uses a hash-based incrementing nonce over params and owner. 28 | * @dev designed to be used with integrating contracts to avoid salt management and needing to handle the potential 29 | * for griefing via front-running. See docs for more information. 30 | * @param _splitParams Params to create split with. 31 | * @param _owner Owner of created split. 32 | * @param _creator Creator of created split. 33 | */ 34 | function createSplit(Split calldata _splitParams, address _owner, address _creator) external returns (address split); 35 | } 36 | -------------------------------------------------------------------------------- /src/collector/ObolCollectorFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {LibClone} from "solady/utils/LibClone.sol"; 5 | import {ObolCollector} from "./ObolCollector.sol"; 6 | import {BaseSplitFactory} from "../base/BaseSplitFactory.sol"; 7 | 8 | /// @title ObolCollector 9 | /// @author Obol 10 | /// @notice A factory contract for cheaply deploying ObolCollector. 11 | /// @dev The address returned should be used to as reward address collecting rewards 12 | contract ObolCollectorFactory is BaseSplitFactory { 13 | /// ----------------------------------------------------------------------- 14 | /// libraries 15 | /// ----------------------------------------------------------------------- 16 | using LibClone for address; 17 | 18 | /// ----------------------------------------------------------------------- 19 | /// storage 20 | /// ----------------------------------------------------------------------- 21 | 22 | /// @dev collector implementation 23 | ObolCollector public immutable collectorImpl; 24 | 25 | constructor(address _feeRecipient, uint256 _feeShare) { 26 | collectorImpl = new ObolCollector(_feeRecipient, _feeShare); 27 | } 28 | 29 | /// @dev Create a new collector 30 | /// @dev address(0) is used to represent ETH 31 | /// @param token collector token address 32 | /// @param withdrawalAddress withdrawalAddress to receive tokens 33 | function createCollector(address token, address withdrawalAddress) external override returns (address collector) { 34 | if (withdrawalAddress == address(0)) revert Invalid_Address(); 35 | 36 | collector = address(collectorImpl).clone(abi.encodePacked(withdrawalAddress, token)); 37 | 38 | emit CreateSplit(token, withdrawalAddress); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /script/ovm/DistributeFundsScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls distributeFunds() for an ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract DistributeFundsScript is Script { 15 | function run(address ovmAddress) external { 16 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 17 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 18 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 19 | 20 | vm.startBroadcast(privKey); 21 | 22 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 23 | 24 | console.log("OVM address:", ovmAddress); 25 | console.log("--- State Before Distribution ---"); 26 | console.log("OVM balance: %d gwei", address(ovm).balance / 1 gwei); 27 | console.log("Amount of principal stake: %d gwei", ovm.amountOfPrincipalStake() / 1 gwei); 28 | console.log("Funds pending withdrawal: %d gwei", ovm.fundsPendingWithdrawal() / 1 gwei); 29 | console.log("Principal threshold: %d gwei", ovm.principalThreshold()); 30 | console.log("Beneficiary (principal recipient): %s", ovm.getBeneficiary()); 31 | console.log("Reward recipient: %s", ovm.rewardRecipient()); 32 | 33 | console.log("--- Distributing Funds ---"); 34 | ovm.distributeFunds(); 35 | 36 | console.log("--- State After Distribution ---"); 37 | console.log("Amount of principal stake: %d gwei", ovm.amountOfPrincipalStake() / 1 gwei); 38 | console.log("Distribution completed successfully"); 39 | 40 | vm.stopBroadcast(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lido/ObolLidoSplitFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {LibClone} from "solady/utils/LibClone.sol"; 5 | import {ERC20} from "solmate/tokens/ERC20.sol"; 6 | import {BaseSplitFactory} from "../base/BaseSplitFactory.sol"; 7 | import "./ObolLidoSplit.sol"; 8 | 9 | /// @title ObolLidoSplitFactory 10 | /// @author Obol 11 | /// @notice A factory contract for cheaply deploying ObolLidoSplit. 12 | /// @dev The address returned should be used to as reward address for Lido 13 | contract ObolLidoSplitFactory is BaseSplitFactory { 14 | 15 | /// ----------------------------------------------------------------------- 16 | /// libraries 17 | /// ----------------------------------------------------------------------- 18 | using LibClone for address; 19 | 20 | /// ----------------------------------------------------------------------- 21 | /// storage 22 | /// ----------------------------------------------------------------------- 23 | 24 | /// @dev lido split implementation 25 | ObolLidoSplit public immutable lidoSplitImpl; 26 | 27 | constructor(address _feeRecipient, uint256 _feeShare, ERC20 _stETH, ERC20 _wstETH) { 28 | lidoSplitImpl = new ObolLidoSplit(_feeRecipient, _feeShare, _stETH, _wstETH); 29 | } 30 | 31 | // Creates a wrapper for splitWallet that transforms stETH token into 32 | /// wstETH 33 | /// @dev Create a new collector 34 | /// @dev address(0) is used to represent ETH 35 | /// @param withdrawalAddress Address of the splitWallet to transfer wstETH to 36 | /// @return collector Address of the wrappper split 37 | function createCollector(address, address withdrawalAddress) external override returns (address collector) { 38 | if (withdrawalAddress == address(0)) revert Invalid_Address(); 39 | 40 | collector = address(lidoSplitImpl).clone(abi.encodePacked(withdrawalAddress)); 41 | 42 | emit CreateSplit(address(0), collector); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/etherfi/ObolEtherfiSplitFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {LibClone} from "solady/utils/LibClone.sol"; 5 | import {ERC20} from "solmate/tokens/ERC20.sol"; 6 | import "./ObolEtherfiSplit.sol"; 7 | import {BaseSplitFactory} from "../base/BaseSplitFactory.sol"; 8 | 9 | /// @title ObolEtherfiSplitFactory 10 | /// @author Obol 11 | /// @notice A factory contract for cheaply deploying ObolEtherfiSplit. 12 | /// @dev The address returned should be used to as reward address for EtherFi 13 | contract ObolEtherfiSplitFactory is BaseSplitFactory { 14 | /// ----------------------------------------------------------------------- 15 | /// libraries 16 | /// ----------------------------------------------------------------------- 17 | using LibClone for address; 18 | 19 | /// ----------------------------------------------------------------------- 20 | /// storage 21 | /// ----------------------------------------------------------------------- 22 | 23 | /// @dev Ethersfi split implementation 24 | ObolEtherfiSplit public immutable etherfiSplitImpl; 25 | 26 | constructor(address _feeRecipient, uint256 _feeShare, ERC20 _eETH, ERC20 _weETH) { 27 | etherfiSplitImpl = new ObolEtherfiSplit(_feeRecipient, _feeShare, _eETH, _weETH); 28 | } 29 | 30 | /// Creates a wrapper for splitWallet that transforms eETH token into 31 | /// weETH 32 | /// @dev Create a new collector 33 | /// @dev address(0) is used to represent ETH 34 | /// @param withdrawalAddress Address of the splitWallet to transfer weETH to 35 | /// @return collector Address of the wrappper split 36 | function createCollector(address, address withdrawalAddress) external override returns (address collector) { 37 | if (withdrawalAddress == address(0)) revert Invalid_Address(); 38 | 39 | collector = address(etherfiSplitImpl).clone(abi.encodePacked(withdrawalAddress)); 40 | 41 | emit CreateSplit(address(0), collector); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/lido/ObolLIdoSplitFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolLidoSplitFactory} from "src/lido/ObolLidoSplitFactory.sol"; 6 | import {ERC20} from "solmate/tokens/ERC20.sol"; 7 | import {BaseSplitFactory} from "src/base/BaseSplitFactory.sol"; 8 | import {ObolLidoSplitTestHelper} from "./ObolLidoSplitTestHelper.sol"; 9 | 10 | contract ObolLidoSplitFactoryTest is ObolLidoSplitTestHelper, Test { 11 | ObolLidoSplitFactory internal lidoSplitFactory; 12 | ObolLidoSplitFactory internal lidoSplitFactoryWithFee; 13 | 14 | address demoSplit; 15 | 16 | event CreateSplit(address token, address split); 17 | 18 | function setUp() public { 19 | uint256 mainnetBlock = 17_421_005; 20 | vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); 21 | 22 | lidoSplitFactory = 23 | new ObolLidoSplitFactory(address(0), 0, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 24 | 25 | lidoSplitFactoryWithFee = 26 | new ObolLidoSplitFactory(address(this), 1e3, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 27 | 28 | demoSplit = makeAddr("demoSplit"); 29 | } 30 | 31 | function testCan_CreateSplit() public { 32 | vm.expectEmit(true, true, true, false, address(lidoSplitFactory)); 33 | emit CreateSplit(address(0), address(0x1)); 34 | 35 | lidoSplitFactory.createCollector(address(0), demoSplit); 36 | 37 | vm.expectEmit(true, true, true, false, address(lidoSplitFactoryWithFee)); 38 | emit CreateSplit(address(0), address(0x1)); 39 | 40 | lidoSplitFactoryWithFee.createCollector(address(0), demoSplit); 41 | } 42 | 43 | function testCannot_CreateSplitInvalidAddress() public { 44 | vm.expectRevert(BaseSplitFactory.Invalid_Address.selector); 45 | lidoSplitFactory.createCollector(address(0), address(0)); 46 | 47 | vm.expectRevert(BaseSplitFactory.Invalid_Address.selector); 48 | lidoSplitFactoryWithFee.createCollector(address(0), address(0)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /script/splits/GetBalancesScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {Script, console} from "forge-std/Script.sol"; 5 | import {LibString} from "solady/utils/LibString.sol"; 6 | import {BaseScript} from "./BaseScript.s.sol"; 7 | import {ISplitWalletV2} from "../../src/interfaces/ISplitWalletV2.sol"; 8 | import {stdJson} from "forge-std/StdJson.sol"; 9 | 10 | // 11 | // This script reads Split balances previously deployed with DeployScript. 12 | // To run this script, the following environment variables must be set: 13 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 14 | // Example usage: 15 | // forge script script/splits/GetBalancesScript.s.sol --sig "run(string)" -vvv \ 16 | // --rpc-url https://your-rpc-provider "" 17 | // 18 | contract GetBalancesScript is BaseScript { 19 | using stdJson for string; 20 | 21 | function run(string memory splitsDeploymentFilePath) external view { 22 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 23 | if (privKey == 0) { 24 | console.log("PRIVATE_KEY is not set"); 25 | return; 26 | } 27 | 28 | console.log("Reading splits deployment from file: %s", splitsDeploymentFilePath); 29 | string memory deploymentsFile = vm.readFile(splitsDeploymentFilePath); 30 | 31 | string[] memory keys = vm.parseJsonKeys(deploymentsFile, "."); 32 | for (uint256 i = 0; i < keys.length; i++) { 33 | string memory key = string.concat(".", keys[i]); 34 | address splitAddress = vm.parseJsonAddress(deploymentsFile, key); 35 | ISplitWalletV2 splitWallet = ISplitWalletV2(splitAddress); 36 | address nativeToken = splitWallet.NATIVE_TOKEN(); 37 | (uint256 splitBalance, uint256 warehouseBalance) = splitWallet.getSplitBalance(nativeToken); 38 | console.log("Split %s at %s:", keys[i], splitAddress); 39 | console.log(" split balance: %d gwei", splitBalance / 1e9); 40 | console.log(" warehouse balance: %d gwei", warehouseBalance / 1e9); 41 | } 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/test/etherfi/ObolEtherfiSplitFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolEtherfiSplitFactory} from "src/etherfi/ObolEtherfiSplitFactory.sol"; 6 | import {BaseSplitFactory} from "src/base/BaseSplitFactory.sol"; 7 | import {ERC20} from "solmate/tokens/ERC20.sol"; 8 | import {ObolEtherfiSplitTestHelper} from "./ObolEtherfiSplitTestHelper.sol"; 9 | 10 | contract ObolEtherfiSplitFactoryTest is ObolEtherfiSplitTestHelper, Test { 11 | ObolEtherfiSplitFactory internal etherfiSplitFactory; 12 | ObolEtherfiSplitFactory internal etherfiSplitFactoryWithFee; 13 | 14 | address demoSplit; 15 | 16 | event CreateSplit(address token, address split); 17 | 18 | function setUp() public { 19 | uint256 mainnetBlock = 19_228_949; 20 | vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); 21 | 22 | etherfiSplitFactory = 23 | new ObolEtherfiSplitFactory(address(0), 0, ERC20(EETH_MAINNET_ADDRESS), ERC20(WEETH_MAINNET_ADDRESS)); 24 | 25 | etherfiSplitFactoryWithFee = 26 | new ObolEtherfiSplitFactory(address(this), 1e3, ERC20(EETH_MAINNET_ADDRESS), ERC20(WEETH_MAINNET_ADDRESS)); 27 | 28 | demoSplit = makeAddr("demoSplit"); 29 | } 30 | 31 | function testCan_CreateSplit() public { 32 | vm.expectEmit(true, true, true, false, address(etherfiSplitFactory)); 33 | emit CreateSplit(address(0), address(0x1)); 34 | 35 | etherfiSplitFactory.createCollector(address(0), demoSplit); 36 | 37 | vm.expectEmit(true, true, true, false, address(etherfiSplitFactoryWithFee)); 38 | emit CreateSplit(address(0), address(0x1)); 39 | 40 | etherfiSplitFactoryWithFee.createCollector(address(0), demoSplit); 41 | } 42 | 43 | function testCannot_CreateSplitInvalidAddress() public { 44 | vm.expectRevert(BaseSplitFactory.Invalid_Address.selector); 45 | etherfiSplitFactory.createCollector(address(0), address(0)); 46 | 47 | vm.expectRevert(BaseSplitFactory.Invalid_Address.selector); 48 | etherfiSplitFactoryWithFee.createCollector(address(0), address(0)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /script/ovm/CreateOVMScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "script/ovm/Utils.s.sol"; 7 | import {ObolValidatorManagerFactory} from "src/ovm/ObolValidatorManagerFactory.sol"; 8 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 9 | 10 | // 11 | // This script creates a new instance of the ObolValidatorManager contract. 12 | // To run this script, the following environment variables must be set: 13 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 14 | // The first script parameter is the deployed ObolValidatorManagerFactory contract. 15 | // You need to either deploy one using DeployFactoryScript, or the predeployed one by Obol: 16 | // https://docs.obol.org/next/learn/readme/obol-splits#obol-validator-manager-factory-deployment 17 | // 18 | contract CreateOVMScript is Script { 19 | function run( 20 | address ovmFactory, 21 | address owner, 22 | address beneficiary, 23 | address rewardRecipient, 24 | uint64 principalThreshold 25 | ) external { 26 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 27 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 28 | if (!Utils.isContract(ovmFactory)) revert("OVM Factory address is not set or invalid"); 29 | if (owner == address(0)) revert("Owner address cannot be zero"); 30 | if (beneficiary == address(0)) revert("Beneficiary recipient address cannot be zero"); 31 | if (rewardRecipient == address(0)) revert("Reward recipient address cannot be zero"); 32 | if (principalThreshold == 0) revert("Principal threshold cannot be zero"); 33 | 34 | vm.startBroadcast(privKey); 35 | 36 | ObolValidatorManagerFactory factory = ObolValidatorManagerFactory(ovmFactory); 37 | ObolValidatorManager ovm = 38 | factory.createObolValidatorManager(owner, beneficiary, rewardRecipient, principalThreshold); 39 | 40 | console.log("ObolValidatorManager created at address", address(ovm)); 41 | Utils.printExplorerUrl(address(ovm)); 42 | 43 | vm.stopBroadcast(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /script/ovm/WithdrawScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls withdraw() for an ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract WithdrawScript is Script { 15 | function run( 16 | address ovmAddress, 17 | bytes calldata pubkey, 18 | uint64 amount, 19 | uint256 maxFeePerWithdrawal, 20 | address excessFeeRecipient 21 | ) external { 22 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 23 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 24 | if (!Utils.isContract(ovmAddress)) revert("Invalid OVM address"); 25 | if (amount == 0) revert("Invalid withdrawal amount"); 26 | if (pubkey.length != 48) revert("Invalid pubkey length, must be 48 bytes"); 27 | if (maxFeePerWithdrawal == 0) revert("Invalid max fee per withdrawal"); 28 | if (excessFeeRecipient == address(0)) revert("Invalid excess fee recipient address"); 29 | 30 | vm.startBroadcast(privKey); 31 | 32 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 33 | 34 | console.log("OVM address:", ovmAddress); 35 | console.log("Withdrawing for pubkey (first 20 bytes):"); 36 | console.logBytes(pubkey[:20]); 37 | console.log("Amount to withdraw: %d gwei", amount); 38 | console.log("Max fee per withdrawal: %d wei", maxFeePerWithdrawal); 39 | console.log("Excess fee recipient: %s", excessFeeRecipient); 40 | 41 | bytes[] memory pubKeys = new bytes[](1); 42 | pubKeys[0] = pubkey; 43 | 44 | uint64[] memory amounts = new uint64[](1); 45 | amounts[0] = amount; 46 | 47 | ovm.withdraw{value: maxFeePerWithdrawal}(pubKeys, amounts, maxFeePerWithdrawal, excessFeeRecipient); 48 | 49 | console.log("Withdrawal request submitted successfully"); 50 | 51 | vm.stopBroadcast(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/etherfi/ObolEtherfiSplit.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; 6 | import {Clone} from "solady/utils/Clone.sol"; 7 | import {IweETH} from "src/interfaces/IweETH.sol"; 8 | 9 | import {BaseSplit} from "../base/BaseSplit.sol"; 10 | 11 | /// @title ObolEtherfiSplit 12 | /// @author Obol 13 | /// @notice A wrapper for 0xsplits/split-contracts SplitWallet that transforms 14 | /// eEth token to weETH token because eEth is a rebasing token 15 | /// @dev Wraps eETH to weETH and 16 | contract ObolEtherfiSplit is BaseSplit { 17 | /// @notice eETH token 18 | ERC20 public immutable eETH; 19 | 20 | /// @notice weETH token 21 | ERC20 public immutable weETH; 22 | 23 | /// @notice Constructor 24 | /// @param _feeRecipient address to receive fee 25 | /// @param _feeShare fee share scaled by PERCENTAGE_SCALE 26 | /// @param _eETH eETH address 27 | /// @param _weETH weETH address 28 | constructor(address _feeRecipient, uint256 _feeShare, ERC20 _eETH, ERC20 _weETH) BaseSplit(_feeRecipient, _feeShare) { 29 | eETH = _eETH; 30 | weETH = _weETH; 31 | } 32 | 33 | function _beforeRescueFunds(address tokenAddress) internal view override { 34 | // we check weETH here so rescueFunds can't be used 35 | // to bypass fee 36 | if (tokenAddress == address(eETH) || tokenAddress == address(weETH)) revert Invalid_Address(); 37 | } 38 | 39 | /// Wraps the current eETH token balance to weETH 40 | /// transfers the weETH balance to withdrawalAddress for distribution 41 | function _beforeDistribute() internal override returns (address tokenAddress, uint256 amount) { 42 | tokenAddress = address(weETH); 43 | 44 | // get current balance 45 | uint256 balance = eETH.balanceOf(address(this)); 46 | // approve the weETH 47 | eETH.approve(address(weETH), balance); 48 | // wrap into wseth 49 | // we ignore the return value 50 | IweETH(address(weETH)).wrap(balance); 51 | // we use balanceOf here in case some weETH is stuck in the 52 | // contract we would be able to rescue it 53 | amount = ERC20(weETH).balanceOf(address(this)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lido/ObolLidoSplit.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; 6 | import {Clone} from "solady/utils/Clone.sol"; 7 | import {IwstETH} from "src/interfaces/IwstETH.sol"; 8 | import {BaseSplit} from "../base/BaseSplit.sol"; 9 | 10 | /// @title ObolLidoSplit 11 | /// @author Obol 12 | /// @notice A wrapper for 0xsplits/split-contracts SplitWallet that transforms 13 | /// stETH token to wstETH token because stETH is a rebasing token 14 | /// @dev Wraps stETH to wstETH and transfers to defined SplitWallet address 15 | contract ObolLidoSplit is BaseSplit { 16 | /// @notice stETH token 17 | ERC20 public immutable stETH; 18 | 19 | /// @notice wstETH token 20 | ERC20 public immutable wstETH; 21 | 22 | /// @notice Constructor 23 | /// @param _feeRecipient address to receive fee 24 | /// @param _feeShare fee share scaled by PERCENTAGE_SCALE 25 | /// @param _stETH stETH address 26 | /// @param _wstETH wstETH address 27 | constructor(address _feeRecipient, uint256 _feeShare, ERC20 _stETH, ERC20 _wstETH) BaseSplit(_feeRecipient, _feeShare) { 28 | stETH = _stETH; 29 | wstETH = _wstETH; 30 | } 31 | 32 | function _beforeRescueFunds(address tokenAddress) internal view override { 33 | // we check weETH here so rescueFunds can't be used 34 | // to bypass fee 35 | if (tokenAddress == address(stETH) || tokenAddress == address(wstETH)) revert Invalid_Address(); 36 | } 37 | 38 | /// Wraps the current stETH token balance to wstETH 39 | /// transfers the wstETH balance to withdrawalAddress for distribution 40 | function _beforeDistribute() internal override returns (address tokenAddress, uint256 amount) { 41 | tokenAddress = address(wstETH); 42 | 43 | // get current balance 44 | uint256 balance = stETH.balanceOf(address(this)); 45 | // approve the wstETH 46 | stETH.approve(address(wstETH), balance); 47 | // wrap into wstETH 48 | // we ignore the return value 49 | IwstETH(address(wstETH)).wrap(balance); 50 | // we use balanceOf here in case some wstETH is stuck in the 51 | // contract we would be able to rescue it 52 | amount = ERC20(wstETH).balanceOf(address(this)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /script/ovm/DeployFactoryScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "script/ovm/Utils.s.sol"; 7 | import {ObolValidatorManagerFactory} from "src/ovm/ObolValidatorManagerFactory.sol"; 8 | 9 | // 10 | // This script deploys the ObolValidatorManagerFactory contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // Please verify the addresses below before running the script! 14 | // 15 | contract DeployFactoryScript is Script { 16 | // From https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7251.md 17 | address constant consolidationSysContract = 0x0000BBdDc7CE488642fb579F8B00f3a590007251; 18 | // From https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7002.md 19 | address constant withdrawalSysContract = 0x00000961Ef480Eb55e80D19ad83579A64c007002; 20 | // By default the script is aiming mainnet or major testnets, but not devnets. 21 | // For devnets use 0x4242424242424242424242424242424242424242 22 | address constant depositSysContract = 0x00000000219ab540356cBB839Cbe05303d7705Fa; 23 | // ENS deployments: https://docs.ens.domains/learn/deployments/ 24 | // Mainnet: 0xa58E81fe9b61B5c3fE2AFD33CF304c454AbFc7Cb 25 | // Sepolia: 0xA0a1AbcDAe1a2a4A2EF8e9113Ff0e02DD81DC0C6 26 | // Hoodi: no deployment yet, patch the code manually to remove ENS params 27 | address ensReverseRegistrar = 0xA0a1AbcDAe1a2a4A2EF8e9113Ff0e02DD81DC0C6; 28 | 29 | function run(string calldata name) external { 30 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 31 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 32 | if (!Utils.isContract(ensReverseRegistrar)) revert("ENS Reverse Registrar address is not set or invalid"); 33 | 34 | address ensOwner = vm.addr(privKey); 35 | 36 | vm.startBroadcast(privKey); 37 | 38 | ObolValidatorManagerFactory factory = new ObolValidatorManagerFactory{salt: keccak256(bytes(name))}( 39 | consolidationSysContract, withdrawalSysContract, depositSysContract, name, ensReverseRegistrar, ensOwner 40 | ); 41 | 42 | console.log("ObolValidatorManagerFactory deployed at", address(factory)); 43 | Utils.printExplorerUrl(address(factory)); 44 | 45 | vm.stopBroadcast(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/interfaces/IDepositContract.sol: -------------------------------------------------------------------------------- 1 | // ┏━━━┓━┏┓━┏┓━━┏━━━┓━━┏━━━┓━━━━┏━━━┓━━━━━━━━━━━━━━━━━━━┏┓━━━━━┏━━━┓━━━━━━━━━┏┓━━━━━━━━━━━━━━┏┓━ 2 | // ┃┏━━┛┏┛┗┓┃┃━━┃┏━┓┃━━┃┏━┓┃━━━━┗┓┏┓┃━━━━━━━━━━━━━━━━━━┏┛┗┓━━━━┃┏━┓┃━━━━━━━━┏┛┗┓━━━━━━━━━━━━┏┛┗┓ 3 | // ┃┗━━┓┗┓┏┛┃┗━┓┗┛┏┛┃━━┃┃━┃┃━━━━━┃┃┃┃┏━━┓┏━━┓┏━━┓┏━━┓┏┓┗┓┏┛━━━━┃┃━┗┛┏━━┓┏━┓━┗┓┏┛┏━┓┏━━┓━┏━━┓┗┓┏┛ 4 | // ┃┏━━┛━┃┃━┃┏┓┃┏━┛┏┛━━┃┃━┃┃━━━━━┃┃┃┃┃┏┓┃┃┏┓┃┃┏┓┃┃━━┫┣┫━┃┃━━━━━┃┃━┏┓┃┏┓┃┃┏┓┓━┃┃━┃┏┛┗━┓┃━┃┏━┛━┃┃━ 5 | // ┃┗━━┓━┃┗┓┃┃┃┃┃┃┗━┓┏┓┃┗━┛┃━━━━┏┛┗┛┃┃┃━┫┃┗┛┃┃┗┛┃┣━━┃┃┃━┃┗┓━━━━┃┗━┛┃┃┗┛┃┃┃┃┃━┃┗┓┃┃━┃┗┛┗┓┃┗━┓━┃┗┓ 6 | // ┗━━━┛━┗━┛┗┛┗┛┗━━━┛┗┛┗━━━┛━━━━┗━━━┛┗━━┛┃┏━┛┗━━┛┗━━┛┗┛━┗━┛━━━━┗━━━┛┗━━┛┗┛┗┛━┗━┛┗┛━┗━━━┛┗━━┛━┗━┛ 7 | // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃┃━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 8 | // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┗┛━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9 | 10 | // SPDX-License-Identifier: CC0-1.0 11 | 12 | // pragma solidity 0.6.11 was the original version 13 | pragma solidity 0.8.19; 14 | 15 | // This interface is designed to be compatible with the Vyper version. 16 | /// @notice This is the Ethereum 2.0 deposit contract interface. 17 | /// For more information see the Phase 0 specification under https://github.com/ethereum/eth2.0-specs 18 | interface IDepositContract { 19 | /// @notice A processed deposit event. 20 | event DepositEvent( 21 | bytes pubkey, 22 | bytes withdrawal_credentials, 23 | bytes amount, 24 | bytes signature, 25 | bytes index 26 | ); 27 | 28 | /// @notice Submit a Phase 0 DepositData object. 29 | /// @param pubkey A BLS12-381 public key. 30 | /// @param withdrawal_credentials Commitment to a public key for withdrawals. 31 | /// @param signature A BLS12-381 signature. 32 | /// @param deposit_data_root The SHA-256 hash of the SSZ-encoded DepositData object. 33 | /// Used as a protection against malformed input. 34 | function deposit( 35 | bytes calldata pubkey, 36 | bytes calldata withdrawal_credentials, 37 | bytes calldata signature, 38 | bytes32 deposit_data_root 39 | ) external payable; 40 | 41 | /// @notice Query the current deposit root hash. 42 | /// @return The deposit root hash. 43 | function get_deposit_root() external view returns (bytes32); 44 | 45 | /// @notice Query the current deposit count. 46 | /// @return The deposit count encoded as a little endian 64-bit number. 47 | function get_deposit_count() external view returns (bytes memory); 48 | } -------------------------------------------------------------------------------- /script/ovm/ConsolidateScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | import {IObolValidatorManager} from "src/interfaces/IObolValidatorManager.sol"; 9 | 10 | // 11 | // This script calls consolidate() for an ObolValidatorManager contract. 12 | // To run this script, the following environment variables must be set: 13 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 14 | // 15 | contract ConsolidateScript is Script { 16 | function run( 17 | address ovmAddress, 18 | bytes calldata src, 19 | bytes calldata dst, 20 | uint256 maxFeePerConsolidation, 21 | address excessFeeRecipient 22 | ) external { 23 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 24 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 25 | if (!Utils.isContract(ovmAddress)) revert("Invalid OVM address"); 26 | if (src.length != 48) revert("Invalid source pubkey length, must be 48 bytes"); 27 | if (dst.length != 48) revert("Invalid destination pubkey length, must be 48 bytes"); 28 | if (maxFeePerConsolidation == 0) revert("Invalid max fee per consolidation"); 29 | if (excessFeeRecipient == address(0)) revert("Invalid excess fee recipient address"); 30 | 31 | vm.startBroadcast(privKey); 32 | 33 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 34 | 35 | console.log("OVM address:", ovmAddress); 36 | console.log("Source pubkey (first 20 bytes):"); 37 | console.logBytes(src[:20]); 38 | console.log("Destination pubkey (first 20 bytes):"); 39 | console.logBytes(dst[:20]); 40 | console.log("Max fee per consolidation: %d wei", maxFeePerConsolidation); 41 | console.log("Excess fee recipient: %s", excessFeeRecipient); 42 | 43 | bytes[] memory sourcePubKeys = new bytes[](1); 44 | sourcePubKeys[0] = src; 45 | 46 | IObolValidatorManager.ConsolidationRequest[] memory requests = new IObolValidatorManager.ConsolidationRequest[](1); 47 | requests[0] = IObolValidatorManager.ConsolidationRequest({srcPubKeys: sourcePubKeys, targetPubKey: dst}); 48 | 49 | ovm.consolidate{value: maxFeePerConsolidation}(requests, maxFeePerConsolidation, excessFeeRecipient); 50 | 51 | console.log("Consolidation request submitted successfully"); 52 | 53 | vm.stopBroadcast(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/lido/integration/LidoSplitIntegrationTest.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolLidoSplitFactory, ObolLidoSplit} from "src/lido/ObolLidoSplitFactory.sol"; 6 | import {ERC20} from "solmate/tokens/ERC20.sol"; 7 | import {ObolLidoSplitTestHelper} from "../ObolLidoSplitTestHelper.sol"; 8 | import {ISplitMain} from "src/interfaces/ISplitMain.sol"; 9 | 10 | contract ObolLidoSplitIntegrationTest is ObolLidoSplitTestHelper, Test { 11 | ObolLidoSplitFactory internal lidoSplitFactory; 12 | ObolLidoSplit internal lidoSplit; 13 | 14 | address splitter; 15 | 16 | address[] accounts; 17 | uint32[] percentAllocations; 18 | 19 | address internal SPLIT_MAIN_MAINNET = 0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE; 20 | 21 | function setUp() public { 22 | uint256 mainnetBlock = 17_421_005; 23 | vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); 24 | 25 | lidoSplitFactory = 26 | new ObolLidoSplitFactory(address(0), 0, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 27 | 28 | accounts = new address[](2); 29 | accounts[0] = makeAddr("accounts0"); 30 | accounts[1] = makeAddr("accounts1"); 31 | 32 | percentAllocations = new uint32[](2); 33 | percentAllocations[0] = 400_000; 34 | percentAllocations[1] = 600_000; 35 | 36 | splitter = ISplitMain(SPLIT_MAIN_MAINNET).createSplit(accounts, percentAllocations, 0, address(0)); 37 | 38 | lidoSplit = ObolLidoSplit(lidoSplitFactory.createCollector(address(0), splitter)); 39 | } 40 | 41 | function test_CanDistribute() public { 42 | vm.prank(RANDOM_stETH_ACCOUNT_ADDRESS); 43 | ERC20(STETH_MAINNET_ADDRESS).transfer(address(lidoSplit), 100 ether); 44 | 45 | lidoSplit.distribute(); 46 | 47 | ISplitMain(SPLIT_MAIN_MAINNET).distributeERC20( 48 | splitter, ERC20(WSTETH_MAINNET_ADDRESS), accounts, percentAllocations, 0, address(0) 49 | ); 50 | 51 | ERC20[] memory tokens = new ERC20[](1); 52 | tokens[0] = ERC20(WSTETH_MAINNET_ADDRESS); 53 | 54 | ISplitMain(SPLIT_MAIN_MAINNET).withdraw(accounts[0], 0, tokens); 55 | ISplitMain(SPLIT_MAIN_MAINNET).withdraw(accounts[1], 0, tokens); 56 | 57 | assertEq( 58 | ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(accounts[0]), 35_483_996_363_190_140_092, "invalid account 0 balance" 59 | ); 60 | assertEq( 61 | ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(accounts[1]), 53_225_994_544_785_210_138, "invalid account 1 balance" 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/etherfi/integration/ObolEtherfiSplitIntegrationTest.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolEtherfiSplitFactory, ObolEtherfiSplit} from "src/etherfi/ObolEtherfiSplitFactory.sol"; 6 | import {ERC20} from "solmate/tokens/ERC20.sol"; 7 | import {ObolEtherfiSplitTestHelper} from "../ObolEtherfiSplitTestHelper.sol"; 8 | import {ISplitMain} from "src/interfaces/ISplitMain.sol"; 9 | 10 | contract ObolEtherfiSplitIntegrationTest is ObolEtherfiSplitTestHelper, Test { 11 | ObolEtherfiSplitFactory internal etherfiSplitFactory; 12 | ObolEtherfiSplit internal etherfiSplit; 13 | 14 | address splitter; 15 | 16 | address[] accounts; 17 | uint32[] percentAllocations; 18 | 19 | address internal SPLIT_MAIN_MAINNET = 0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE; 20 | 21 | function setUp() public { 22 | uint256 mainnetBlock = 19_228_949; 23 | vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); 24 | 25 | etherfiSplitFactory = 26 | new ObolEtherfiSplitFactory(address(0), 0, ERC20(EETH_MAINNET_ADDRESS), ERC20(WEETH_MAINNET_ADDRESS)); 27 | 28 | accounts = new address[](2); 29 | accounts[0] = makeAddr("accounts0"); 30 | accounts[1] = makeAddr("accounts1"); 31 | 32 | percentAllocations = new uint32[](2); 33 | percentAllocations[0] = 400_000; 34 | percentAllocations[1] = 600_000; 35 | 36 | splitter = ISplitMain(SPLIT_MAIN_MAINNET).createSplit(accounts, percentAllocations, 0, address(0)); 37 | 38 | etherfiSplit = ObolEtherfiSplit(etherfiSplitFactory.createCollector(address(0), splitter)); 39 | } 40 | 41 | function test_etherfi_integration_CanDistribute() public { 42 | vm.prank(RANDOM_EETH_ACCOUNT_ADDRESS); 43 | ERC20(EETH_MAINNET_ADDRESS).transfer(address(etherfiSplit), 100 ether); 44 | 45 | etherfiSplit.distribute(); 46 | 47 | ISplitMain(SPLIT_MAIN_MAINNET).distributeERC20( 48 | splitter, ERC20(WEETH_MAINNET_ADDRESS), accounts, percentAllocations, 0, address(0) 49 | ); 50 | 51 | ERC20[] memory tokens = new ERC20[](1); 52 | tokens[0] = ERC20(WEETH_MAINNET_ADDRESS); 53 | 54 | ISplitMain(SPLIT_MAIN_MAINNET).withdraw(accounts[0], 0, tokens); 55 | ISplitMain(SPLIT_MAIN_MAINNET).withdraw(accounts[1], 0, tokens); 56 | 57 | assertEq( 58 | ERC20(WEETH_MAINNET_ADDRESS).balanceOf(accounts[0]), 38_787_430_925_418_583_374, "invalid account 0 balance" 59 | ); 60 | assertEq( 61 | ERC20(WEETH_MAINNET_ADDRESS).balanceOf(accounts[1]), 58_181_146_388_127_875_061, "invalid account 1 balance" 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /script/ovm/SweepScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls sweep() for an ObolValidatorManager contract. 11 | // The sweep function allows sweeping funds from the pull balance to a recipient. 12 | // - If beneficiary is address(0), funds are swept to the principal recipient (no owner check required) 13 | // - If beneficiary is specified, only owner can call and funds are swept to that address 14 | // - If amount is 0, all available pull balance for principal recipient is swept 15 | // To run this script, the following environment variables must be set: 16 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 17 | // 18 | contract SweepScript is Script { 19 | function run(address ovmAddress, address beneficiary, uint256 amount) external { 20 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 21 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 22 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 23 | 24 | vm.startBroadcast(privKey); 25 | 26 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 27 | 28 | console.log("OVM address:", ovmAddress); 29 | console.log("--- State Before Sweep ---"); 30 | address principalRecipient = ovm.getBeneficiary(); 31 | console.log("Principal recipient: %s", principalRecipient); 32 | console.log("Pull balance for principal recipient: %d gwei", ovm.getPullBalance(principalRecipient) / 1 gwei); 33 | console.log("Funds pending withdrawal: %d gwei", ovm.fundsPendingWithdrawal() / 1 gwei); 34 | 35 | if (beneficiary == address(0)) { 36 | console.log("Sweeping to principal recipient (no beneficiary override)"); 37 | if (amount == 0) console.log("Amount: ALL available pull balance"); 38 | else console.log("Amount to sweep: %d gwei", amount / 1 gwei); 39 | } else { 40 | console.log("Sweeping to custom beneficiary: %s", beneficiary); 41 | if (amount == 0) console.log("Amount: ALL available pull balance"); 42 | else console.log("Amount to sweep: %d gwei", amount / 1 gwei); 43 | } 44 | 45 | console.log("--- Executing Sweep ---"); 46 | ovm.sweep(beneficiary, amount); 47 | 48 | console.log("--- State After Sweep ---"); 49 | console.log("Pull balance for principal recipient: %d gwei", ovm.getPullBalance(principalRecipient) / 1 gwei); 50 | console.log("Funds pending withdrawal: %d gwei", ovm.fundsPendingWithdrawal() / 1 gwei); 51 | console.log("Sweep completed successfully"); 52 | 53 | vm.stopBroadcast(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /script/DeployOTWRAndSplit.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import {OptimisticTokenWithdrawalRecipientFactory} from "src/owr/token/OptimisticTokenWithdrawalRecipientFactory.sol"; 6 | import {ISplitMain, SplitConfiguration} from "src/interfaces/ISplitMain.sol"; 7 | import {SplitterConfiguration} from "./SplitterConfiguration.sol"; 8 | 9 | contract DeployOTWRAndSplit is Script, SplitterConfiguration { 10 | error Invalid_PrincipalRecipient(); 11 | 12 | struct ConfigurationData { 13 | address principalRecipient; 14 | JsonSplitData split; 15 | } 16 | 17 | /// @param jsonFilePath the data format can be seen in ./data/deploy-otwr-sample.json 18 | /// @param token address of the OTWR token 19 | /// @param splitMain address for 0xsplits splitMain 20 | /// @param OTWRFactory address for factory 21 | /// @param stakeSize in normal numbers e.g. 32 for 32 ether 22 | function run(string memory jsonFilePath, address token, address splitMain, address OTWRFactory, uint256 stakeSize) 23 | external 24 | { 25 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 26 | bytes memory parsedJson = vm.parseJson(vm.readFile(jsonFilePath)); 27 | 28 | ConfigurationData[] memory data = abi.decode(parsedJson, (ConfigurationData[])); 29 | _validateInputJson(data); 30 | 31 | // deploy the split and obol script 32 | string memory jsonKey = "otwrDeploy"; 33 | string memory finalJSON; 34 | 35 | uint256 stakeAmount = stakeSize * 1 ether; 36 | 37 | for (uint256 i = 0; i < data.length; i++) { 38 | // deploy split 39 | ConfigurationData memory currentConfiguration = data[i]; 40 | 41 | vm.startBroadcast(privKey); 42 | 43 | address split = ISplitMain(splitMain).createSplit( 44 | currentConfiguration.split.accounts, 45 | currentConfiguration.split.percentAllocations, 46 | currentConfiguration.split.distributorFee, 47 | currentConfiguration.split.controller 48 | ); 49 | 50 | // create obol split 51 | address otwrAddress = address( 52 | OptimisticTokenWithdrawalRecipientFactory(OTWRFactory).createOWRecipient( 53 | token, address(0), currentConfiguration.principalRecipient, split, stakeAmount 54 | ) 55 | ); 56 | 57 | vm.stopBroadcast(); 58 | 59 | string memory objKey = vm.toString(i); 60 | 61 | vm.serializeAddress(objKey, "splitAddress", split); 62 | string memory repsonse = vm.serializeAddress(objKey, "OTWRAddress", otwrAddress); 63 | 64 | finalJSON = vm.serializeString(jsonKey, objKey, repsonse); 65 | } 66 | 67 | vm.writeJson(finalJSON, "./otwr-split.json"); 68 | } 69 | 70 | function _validateInputJson(ConfigurationData[] memory configuration) internal pure { 71 | for (uint256 i = 0; i < configuration.length; i++) { 72 | if (configuration[i].principalRecipient == address(0)) revert Invalid_PrincipalRecipient(); 73 | _validateSplitInputJson(configuration[i].split); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/test/ovm/mocks/SystemContractMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Proprietary 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console.sol"; 5 | 6 | /// @title SystemContractMock 7 | /// @notice This contract simulates SystemContracts defined by EIP-7251 & EIP-7002. 8 | /// @dev This contract is used for testing purposes only. 9 | /// The receive() function omitted intentionally to catch all requests with fallback(). 10 | /// Ignore the warning: 11 | /// Warning (3628): This contract has a payable fallback function, but no receive ether function. 12 | /// Consider adding a receive ether function. 13 | contract SystemContractMock { 14 | uint256 internal immutable requestSize; 15 | bytes[] internal requests; 16 | bool internal failNextFeeRequest; 17 | bool internal failNextAddRequest; 18 | 19 | /// @notice Constructor 20 | /// @param _requestSize The expected request size: 96 for consolidation, 56 for withdrawal. 21 | constructor(uint256 _requestSize) { 22 | requestSize = _requestSize; 23 | } 24 | 25 | /// @notice Returns the requests made. 26 | function getRequests() external view returns (bytes[] memory) { 27 | return requests; 28 | } 29 | 30 | function setFailNextFeeRequest(bool _failNextFeeRequest) external { 31 | failNextFeeRequest = _failNextFeeRequest; 32 | } 33 | 34 | function setFailNextAddRequest(bool _failNextAddRequest) external { 35 | failNextAddRequest = _failNextAddRequest; 36 | } 37 | 38 | function fakeExponential(uint256 numerator) public pure returns (uint256) { 39 | uint256 DENOMINATOR = 17; 40 | uint256 i = 1; 41 | uint256 output = 0; 42 | uint256 numeratorAccum = DENOMINATOR; 43 | 44 | while (numeratorAccum > 0) { 45 | output += numeratorAccum; 46 | numeratorAccum = (numeratorAccum * numerator) / (DENOMINATOR * i); 47 | i += 1; 48 | } 49 | 50 | return output / DENOMINATOR; 51 | } 52 | 53 | fallback(bytes calldata) external payable returns (bytes memory) { 54 | uint256 feeWei = fakeExponential(requests.length); 55 | 56 | // If calldata is empty, return the fee 57 | if (msg.data.length == 0) { 58 | if (failNextFeeRequest) { 59 | failNextFeeRequest = false; 60 | revert("fee request failed"); 61 | } 62 | 63 | return abi.encodePacked(bytes32(feeWei)); 64 | } 65 | 66 | if (msg.value < feeWei) { 67 | console.log("insufficient fee, expected: ", feeWei, " received: ", msg.value); 68 | revert("insufficient fee"); 69 | } 70 | 71 | if (failNextAddRequest) { 72 | failNextAddRequest = false; 73 | revert("add request failed"); 74 | } 75 | 76 | // If calldata is not empty, consider it as a valid request 77 | if (msg.data.length != requestSize) { 78 | console.log("invalid calldata length, expected: ", requestSize, " received: ", msg.data.length); 79 | revert("invalid calldata length"); 80 | } 81 | 82 | requests.push(msg.data); 83 | 84 | // For any add request it returns nothing 85 | return new bytes(0); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /script/ObolLidoSetupScript.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import {ISplitMain, SplitConfiguration} from "src/interfaces/ISplitMain.sol"; 6 | import {ObolLidoSplitFactory} from "src/lido/ObolLidoSplitFactory.sol"; 7 | import {SplitterConfiguration} from "./SplitterConfiguration.sol"; 8 | 9 | /// @title ObolLidoScript 10 | /// @author Obol 11 | /// @notice Creates Split and ObolLidoSplit Adddresses 12 | /// 13 | /// @dev Takes a json file following the format defined at ./data/lido-data-sample.json 14 | /// and deploys split and ObolLido split contracts. 15 | /// 16 | /// It outputs the result of the script to "./result.json" 17 | /// 18 | /// NOTE: It's COMPULSORY the json file supplied follows the arrangement format defined 19 | /// in the sample file else the json parse will fail. 20 | /// 21 | /// 22 | /// To Run 23 | /// 24 | /// Step 1 fill in the appropriate details for env vars 25 | /// > cp .env.deployment .env 26 | /// 27 | /// Step 2 add to environment 28 | /// > source .env 29 | /// 30 | /// Step 3 Run forge script to simulate the execution of the transaction 31 | /// 32 | /// > forge script script/ObolLidoSetupScript.sol:ObolLidoSetupScript --fork-url $RPC_URL -vvvv --sig 33 | /// "run(string,address,address)" "" $SPLITMAIN 34 | /// $OBOL_LIDO_SPLIT_FACTORY 35 | /// 36 | /// add --broadcast flag to broadcast to the public blockchain 37 | 38 | contract ObolLidoSetupScript is Script, SplitterConfiguration { 39 | function run(string memory jsonFilePath, address splitMain, address obolLidoSplitFactory) external { 40 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 41 | 42 | string memory file = vm.readFile(jsonFilePath); 43 | bytes memory parsedJson = vm.parseJson(file); 44 | JsonSplitData[] memory configuration = abi.decode(parsedJson, (JsonSplitData[])); 45 | _validateSplitInputJson(configuration); 46 | 47 | // deploy the split and obol script 48 | string memory jsonKey = "lidoObolDeploy"; 49 | string memory finalJSON; 50 | 51 | for (uint256 j = 0; j < configuration.length; j++) { 52 | string memory objKey = vm.toString(j); 53 | // deploy split 54 | JsonSplitData memory currentConfiguration = configuration[j]; 55 | 56 | vm.startBroadcast(privKey); 57 | 58 | address split = ISplitMain(splitMain).createSplit( 59 | currentConfiguration.accounts, 60 | currentConfiguration.percentAllocations, 61 | currentConfiguration.distributorFee, 62 | currentConfiguration.controller 63 | ); 64 | 65 | // create obol split 66 | address obolLidoSplitAdress = ObolLidoSplitFactory(obolLidoSplitFactory).createCollector(address(0), split); 67 | 68 | vm.stopBroadcast(); 69 | 70 | vm.serializeAddress(objKey, "splitAddress", split); 71 | string memory repsonse = vm.serializeAddress(objKey, "obolLidoSplitAddress", obolLidoSplitAdress); 72 | 73 | finalJSON = vm.serializeString(jsonKey, objKey, repsonse); 74 | } 75 | 76 | vm.writeJson(finalJSON, "./result.json"); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Obol Logo](https://obol.tech/obolnetwork.png) 2 | 3 |

Obol Splits

4 | 5 | This repo contains Obol Splits smart contracts. This suite of smart contracts and associated tests are intended to serve as a public good to enable the safe and secure creation of Distributed Validators for Ethereum Consensus-based networks. 6 | 7 | ### Disclaimer 8 | 9 | The following smart contracts are provided as is, without warranty. Details of their audit can be consulted [here](https://docs.obol.tech/docs/sec/smart_contract_audit). 10 | 11 | ## Quickstart 12 | 13 | This repo is built with [foundry](https://github.com/foundry-rs/foundry), a rust-based solidity development environment, and relies on [solmate](https://github.com/Rari-Capital/solmate), an efficient solidity smart contract library. Read the docs on our [docs site](https://docs.obol.org/learn/intro/obol-splits) for more information on what Distributed Validators are, and their smart contract lifecycle. 14 | 15 | ### Installation 16 | 17 | Follow the instructions here to install [foundry](https://github.com/foundry-rs/foundry#installation). 18 | 19 | Then install the contract dependencies: 20 | 21 | ```sh 22 | forge install 23 | ``` 24 | 25 | ### Local Development 26 | 27 | To test your changes to the codebase run the unit tests with: 28 | 29 | ``` 30 | cp .env.sample .env 31 | ``` 32 | 33 | ```sh 34 | forge test 35 | ``` 36 | 37 | This command runs all tests. 38 | 39 | > NOTE: To run a specific test: 40 | ```sh 41 | forge test --match-contract ContractTest --match-test testFunction -vv 42 | ``` 43 | 44 | ### Build 45 | 46 | To compile your smart contracts and generate their ABIs run: 47 | 48 | ```sh 49 | forge build 50 | ``` 51 | 52 | This command generates compilation output into the `out` directory. 53 | 54 | ### Deployment 55 | 56 | This repo can be deployed with `forge create` or running the deployment scripts. 57 | 58 | #### Hoodi 59 | 60 | ObolValidatorManagerFactory: https://hoodi.etherscan.io/address/0x5754C8665B7e7BF15E83fCdF6d9636684B782b12 61 | 62 | #### Sepolia 63 | 64 | ObolValidatorManagerFactory: https://sepolia.etherscan.io/address/0xF32F8B563d8369d40C45D5d667C2B26937F2A3d3 65 | 66 | OptimisticWithdrawalRecipientFactory: https://sepolia.etherscan.io/address/0xca78f8fda7ec13ae246e4d4cd38b9ce25a12e64a 67 | 68 | OptimisticWithdrawalRecipient: https://sepolia.etherscan.io/address/0x99585e71ab1118682d51efefca0a170c70eef0d6 69 | 70 | #### Mainnet 71 | 72 | ObolValidatorManagerFactory: https://etherscan.io/address/0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584 73 | 74 | OptimisticWithdrawalRecipientFactory: https://etherscan.io/address/0x119acd7844cbdd5fc09b1c6a4408f490c8f7f522 75 | 76 | OptimisticWithdrawalRecipient: https://etherscan.io/address/0xe11eabf19a49c389d3e8735c35f8f34f28bdcb22 77 | 78 | ObolLidoSplitFactory: https://etherscan.io/address/0xA9d94139A310150Ca1163b5E23f3E1dbb7D9E2A6 79 | 80 | ObolLidoSplit: https://etherscan.io/address/0x2fB59065F049e0D0E3180C6312FA0FeB5Bbf0FE3 81 | 82 | ImmutableSplitControllerFactory: https://etherscan.io/address/0x49e7cA187F1E94d9A0d1DFBd6CCCd69Ca17F56a4 83 | 84 | ImmutableSplitController: https://etherscan.io/address/0xaF129979b773374dD3025d3F97353e73B0A6Cc8d 85 | 86 | ### Versioning 87 | 88 | Releases of this repo constitute post-fix review, audited commits. Consult the release notes and audits as not all contracts are in scope each audit. 89 | -------------------------------------------------------------------------------- /script/ovm/DepositScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: NONE 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Script.sol"; 5 | import "forge-std/console.sol"; 6 | import "./Utils.s.sol"; 7 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 8 | 9 | // 10 | // This script calls deposit() on a ObolValidatorManager contract. 11 | // To run this script, the following environment variables must be set: 12 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 13 | // 14 | contract DepositScript is Script { 15 | struct DepositData { 16 | // fields must be sorted alphabetically 17 | uint256 amount; 18 | string deposit_cli_version; 19 | string deposit_data_root; 20 | string deposit_message_root; 21 | string fork_version; 22 | string network_name; 23 | string pubkey; 24 | string signature; 25 | string withdrawal_credentials; 26 | } 27 | 28 | function run(address ovmAddress, string memory depositFilePath) external { 29 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 30 | if (privKey == 0) revert("set PRIVATE_KEY env var before using this script"); 31 | if (!Utils.isContract(ovmAddress)) revert("OVM address is not set or invalid"); 32 | 33 | console.log("Reading deposit data from file: %s", depositFilePath); 34 | 35 | string memory file = vm.readFile(depositFilePath); 36 | bytes memory parsedJson = vm.parseJson(file); 37 | DepositData[] memory depositDatas = abi.decode(parsedJson, (DepositData[])); 38 | 39 | console.log("Number of deposit records: %d", depositDatas.length); 40 | 41 | vm.startBroadcast(privKey); 42 | 43 | uint256 totalAmount; 44 | 45 | for (uint256 j = 0; j < depositDatas.length; j++) { 46 | DepositData memory depositData = depositDatas[j]; 47 | 48 | console.log("Deposit at index %d for amount of %d gwei:", j, depositData.amount); 49 | console.log(" PK: %s", depositData.pubkey); 50 | console.log(" WC: %s", depositData.withdrawal_credentials); 51 | 52 | totalAmount += depositData.amount; 53 | } 54 | 55 | console.log("Total amount will be deposited: %d gwei", totalAmount); 56 | require(address(this).balance >= totalAmount * 1 gwei, "You don't have enough balance to deposit"); 57 | 58 | ObolValidatorManager ovm = ObolValidatorManager(payable(ovmAddress)); 59 | console.log("Currently staked amount: %d gwei", ovm.amountOfPrincipalStake() / 1 gwei); 60 | 61 | // Executing deposits... 62 | for (uint256 j = 0; j < depositDatas.length; j++) { 63 | DepositData memory depositData = depositDatas[j]; 64 | 65 | console.log("Depositing %s for amount of %d gwei", depositData.pubkey, depositData.amount); 66 | 67 | bytes memory pubkey = vm.parseBytes(depositData.pubkey); 68 | bytes memory withdrawal_credentials = vm.parseBytes(depositData.withdrawal_credentials); 69 | bytes memory signature = vm.parseBytes(depositData.signature); 70 | bytes32 deposit_data_root = vm.parseBytes32(depositData.deposit_data_root); 71 | uint256 deposit_amount = depositData.amount * 1 gwei; 72 | ovm.deposit{value: deposit_amount}(pubkey, withdrawal_credentials, signature, deposit_data_root); 73 | 74 | console.log("Deposit successful for amount: %d gwei", depositData.amount); 75 | } 76 | 77 | console.log("All deposits executed successfully."); 78 | 79 | vm.stopBroadcast(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /script/splits/DistributeScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {Script, console} from "forge-std/Script.sol"; 5 | import {LibString} from "solady/utils/LibString.sol"; 6 | import {BaseScript} from "./BaseScript.s.sol"; 7 | import {ISplitWalletV2} from "../../src/interfaces/ISplitWalletV2.sol"; 8 | import {ISplitFactoryV2} from "../../src/interfaces/ISplitFactoryV2.sol"; 9 | import {stdJson} from "forge-std/StdJson.sol"; 10 | 11 | // 12 | // This script calls distribute() for deployed Splits. 13 | // To run this script, the following environment variables must be set: 14 | // - PRIVATE_KEY: the private key of the account that will distribute the rewards in the splitter. 15 | // Example usage: 16 | // forge script script/splits/DistributeScript.s.sol --sig "run(string,string)" -vvv --broadcast \ 17 | // --rpc-url https://your-rpc-provider "" "" 18 | // 19 | contract DistributeScript is BaseScript { 20 | using stdJson for string; 21 | 22 | mapping(string => uint256) private indices; 23 | address private distributorAddress; 24 | 25 | function run(string memory splitsDeploymentFilePath, string memory splitsConfigFilePath) external { 26 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 27 | if (privKey == 0) { 28 | console.log("PRIVATE_KEY is not set"); 29 | return; 30 | } 31 | 32 | SplitConfig[] memory splits = readSplitsConfig(splitsConfigFilePath); 33 | for (uint256 i = 0; i < splits.length; i++) { 34 | indices[splits[i].name] = i; 35 | } 36 | distributorAddress = vm.addr(privKey); 37 | 38 | console.log("Reading splits deployment from file: %s", splitsDeploymentFilePath); 39 | string memory deploymentsFile = vm.readFile(splitsDeploymentFilePath); 40 | 41 | vm.startBroadcast(privKey); 42 | 43 | string[] memory keys = vm.parseJsonKeys(deploymentsFile, "."); 44 | for (uint256 i = 0; i < keys.length; i++) { 45 | uint256 splitIndex = indices[keys[i]]; 46 | SplitConfig memory splitConfig = splits[splitIndex]; 47 | 48 | string memory key = string.concat(".", keys[i]); 49 | address splitAddress = vm.parseJsonAddress(deploymentsFile, key); 50 | ISplitWalletV2 splitWallet = ISplitWalletV2(splitAddress); 51 | address nativeToken = splitWallet.NATIVE_TOKEN(); 52 | console.log("Calling distribute() for split %s at %s:", keys[i], splitAddress); 53 | 54 | address[] memory recipients = new address[](splitConfig.allocations.length); 55 | uint256[] memory allocations = new uint256[](splitConfig.allocations.length); 56 | 57 | for (uint256 j = 0; j < splitConfig.allocations.length; j++) { 58 | allocations[j] = splitConfig.allocations[j].allocation; 59 | 60 | if (LibString.startsWith(splitConfig.allocations[j].recipient, "0x")) { 61 | recipients[j] = vm.parseAddress(splitConfig.allocations[j].recipient); 62 | } else if (bytes(splitConfig.allocations[j].recipient).length > 0) { 63 | string memory jsonKey = string.concat(".", splitConfig.allocations[j].recipient); 64 | recipients[j] = vm.parseJsonAddress(deploymentsFile, jsonKey); 65 | } 66 | } 67 | 68 | sortRecipientsAndAllocations(recipients, allocations, 0, int(recipients.length - 1)); 69 | 70 | splitWallet.distribute( 71 | ISplitFactoryV2.Split({ 72 | recipients: recipients, 73 | allocations: allocations, 74 | totalAllocation: splitConfig.totalAllocation, 75 | distributionIncentive: uint16(splitConfig.distributionIncentive) 76 | }), 77 | nativeToken, 78 | distributorAddress 79 | ); 80 | } 81 | 82 | vm.stopBroadcast(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/base/BaseSplit.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; 6 | import {Clone} from "solady/utils/Clone.sol"; 7 | 8 | abstract contract BaseSplit is Clone { 9 | error Invalid_Address(); 10 | error Invalid_FeeShare(uint256 val); 11 | error Invalid_FeeRecipient(); 12 | 13 | /// ----------------------------------------------------------------------- 14 | /// libraries 15 | /// ----------------------------------------------------------------------- 16 | using SafeTransferLib for ERC20; 17 | using SafeTransferLib for address; 18 | 19 | address internal constant ETH_ADDRESS = address(0); 20 | uint256 internal constant PERCENTAGE_SCALE = 1e5; 21 | 22 | /// @notice fee share 23 | uint256 public immutable feeShare; 24 | 25 | /// @notice fee address 26 | address public immutable feeRecipient; 27 | 28 | // withdrawal (adress, 20 bytes) 29 | // 0; first item 30 | uint256 internal constant WITHDRAWAL_ADDRESS_OFFSET = 0; 31 | // 20 = withdrawalAddress_offset (0) + withdrawalAddress_size (address, 20 bytes) 32 | uint256 internal constant TOKEN_ADDRESS_OFFSET = 20; 33 | 34 | constructor(address _feeRecipient, uint256 _feeShare) { 35 | if (_feeShare >= PERCENTAGE_SCALE) revert Invalid_FeeShare(_feeShare); 36 | if (_feeShare > 0 && _feeRecipient == address(0)) revert Invalid_FeeRecipient(); 37 | 38 | feeShare = _feeShare; 39 | feeRecipient = _feeRecipient; 40 | } 41 | 42 | /// ----------------------------------------------------------------------- 43 | /// View 44 | /// ----------------------------------------------------------------------- 45 | 46 | /// Address to send funds to to 47 | /// @dev equivalent to address public immutable withdrawalAddress 48 | function withdrawalAddress() public pure returns (address) { 49 | return _getArgAddress(WITHDRAWAL_ADDRESS_OFFSET); 50 | } 51 | 52 | /// Token addresss 53 | /// @dev equivalent to address public immutable token 54 | function token() public pure virtual returns (address) { 55 | return _getArgAddress(TOKEN_ADDRESS_OFFSET); 56 | } 57 | 58 | /// ----------------------------------------------------------------------- 59 | /// Public 60 | /// ----------------------------------------------------------------------- 61 | 62 | /// @notice Rescue stuck ETH and tokens 63 | /// Uses token == address(0) to represent ETH 64 | /// @return balance Amount of ETH or tokens rescued 65 | function rescueFunds(address tokenAddress) external virtual returns (uint256 balance) { 66 | _beforeRescueFunds(tokenAddress); 67 | 68 | if (tokenAddress == ETH_ADDRESS) { 69 | balance = address(this).balance; 70 | if (balance > 0) withdrawalAddress().safeTransferETH(balance); 71 | } else { 72 | balance = ERC20(tokenAddress).balanceOf(address(this)); 73 | if (balance > 0) ERC20(tokenAddress).safeTransfer(withdrawalAddress(), balance); 74 | } 75 | } 76 | 77 | /// @notice distribute funds to withdrawal address 78 | function distribute() external virtual returns (uint256) { 79 | (address tokenAddress, uint256 amount) = _beforeDistribute(); 80 | 81 | if (feeShare > 0) { 82 | uint256 fee = (amount * feeShare) / PERCENTAGE_SCALE; 83 | _transfer(tokenAddress, feeRecipient, fee); 84 | _transfer(tokenAddress, withdrawalAddress(), amount -= fee); 85 | } else { 86 | _transfer(tokenAddress, withdrawalAddress(), amount); 87 | } 88 | 89 | return amount; 90 | } 91 | 92 | /// ----------------------------------------------------------------------- 93 | /// Internal 94 | /// ----------------------------------------------------------------------- 95 | 96 | function _beforeRescueFunds(address tokenAddress) internal virtual; 97 | 98 | function _beforeDistribute() internal virtual returns (address tokenAddress, uint256 amount); 99 | 100 | function _transfer(address tokenAddress, address receiver, uint256 amount) internal { 101 | if (tokenAddress == ETH_ADDRESS) receiver.safeTransferETH(amount); 102 | else ERC20(tokenAddress).safeTransfer(receiver, amount); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /script/SplitterConfiguration.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | abstract contract SplitterConfiguration { 5 | /// @dev invalid split accounts configuration 6 | error InvalidSplit__TooFewAccounts(uint256 accountsLength); 7 | /// @notice Array lengths of accounts & percentAllocations don't match 8 | /// (`accountsLength` != `allocationsLength`) 9 | /// @param accountsLength Length of accounts array 10 | /// @param allocationsLength Length of percentAllocations array 11 | error InvalidSplit__AccountsAndAllocationsMismatch(uint256 accountsLength, uint256 allocationsLength); 12 | /// @notice Invalid percentAllocations sum `allocationsSum` must equal 13 | /// `PERCENTAGE_SCALE` 14 | /// @param allocationsSum Sum of percentAllocations array 15 | error InvalidSplit__InvalidAllocationsSum(uint32 allocationsSum); 16 | /// @notice Invalid accounts ordering at `index` 17 | /// @param index Index of out-of-order account 18 | error InvalidSplit__AccountsOutOfOrder(uint256 index); 19 | /// @notice Invalid percentAllocation of zero at `index` 20 | /// @param index Index of zero percentAllocation 21 | error InvalidSplit__AllocationMustBePositive(uint256 index); 22 | /// @notice Invalid distributorFee `distributorFee` cannot be greater than 23 | /// 10% (1e5) 24 | /// @param distributorFee Invalid distributorFee amount 25 | error InvalidSplit__InvalidDistributorFee(uint32 distributorFee); 26 | /// @notice Array of accounts size 27 | /// @param size acounts size 28 | error InvalidSplit__TooManyAccounts(uint256 size); 29 | 30 | uint256 internal constant PERCENTAGE_SCALE = 1e6; 31 | uint256 internal constant MAX_DISTRIBUTOR_FEE = 1e5; 32 | 33 | struct JsonSplitData { 34 | address[] accounts; 35 | address controller; 36 | uint32 distributorFee; 37 | uint32[] percentAllocations; 38 | } 39 | 40 | function _validateSplitInputJson(JsonSplitData[] memory configuration) internal pure { 41 | for (uint256 i = 0; i < configuration.length; i++) { 42 | address[] memory splitAddresses = configuration[i].accounts; 43 | uint32[] memory percents = configuration[i].percentAllocations; 44 | uint32 distributorFee = configuration[i].distributorFee; 45 | _validSplit(splitAddresses, percents, distributorFee); 46 | } 47 | } 48 | 49 | function _validateSplitInputJson(JsonSplitData memory configuration) internal pure { 50 | address[] memory splitAddresses = configuration.accounts; 51 | uint32[] memory percents = configuration.percentAllocations; 52 | uint32 distributorFee = configuration.distributorFee; 53 | _validSplit(splitAddresses, percents, distributorFee); 54 | } 55 | 56 | function _validSplit(address[] memory accounts, uint32[] memory percentAllocations, uint32 distributorFee) 57 | internal 58 | pure 59 | { 60 | if (accounts.length < 2) revert InvalidSplit__TooFewAccounts(accounts.length); 61 | if (accounts.length != percentAllocations.length) { 62 | revert InvalidSplit__AccountsAndAllocationsMismatch(accounts.length, percentAllocations.length); 63 | } 64 | // _getSum should overflow if any percentAllocation[i] < 0 65 | if (_getSum(percentAllocations) != PERCENTAGE_SCALE) { 66 | revert InvalidSplit__InvalidAllocationsSum(_getSum(percentAllocations)); 67 | } 68 | unchecked { 69 | // overflow should be impossible in for-loop index 70 | // cache accounts length to save gas 71 | uint256 loopLength = accounts.length - 1; 72 | for (uint256 i = 0; i < loopLength; ++i) { 73 | // overflow should be impossible in array access math 74 | if (accounts[i] >= accounts[i + 1]) revert InvalidSplit__AccountsOutOfOrder(i); 75 | if (percentAllocations[i] == uint32(0)) revert InvalidSplit__AllocationMustBePositive(i); 76 | } 77 | // overflow should be impossible in array access math with validated 78 | // equal array lengths 79 | if (percentAllocations[loopLength] == uint32(0)) revert InvalidSplit__AllocationMustBePositive(loopLength); 80 | } 81 | if (distributorFee > MAX_DISTRIBUTOR_FEE) revert InvalidSplit__InvalidDistributorFee(distributorFee); 82 | } 83 | 84 | function _getSum(uint32[] memory percents) internal pure returns (uint32 sum) { 85 | for (uint32 i = 0; i < percents.length; i++) { 86 | sum += percents[i]; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /script/splits/BaseScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {Script, console} from "forge-std/Script.sol"; 5 | import {stdJson} from "forge-std/StdJson.sol"; 6 | import {LibString} from "solady/utils/LibString.sol"; 7 | 8 | // The base script contains shared functionality for Splits. 9 | contract BaseScript is Script { 10 | using stdJson for string; 11 | 12 | // Maximum number of splits in the configuration file 13 | uint256 internal constant MAX_SPLITS = 32; 14 | 15 | // Maximum number of split allocations in the configuration file 16 | uint256 internal constant MAX_ALLOCATIONS = 32; 17 | 18 | struct SplitConfig { 19 | SplitAllocation[] allocations; 20 | uint256 distributionIncentive; 21 | string name; 22 | address owner; 23 | string splitType; 24 | uint256 totalAllocation; 25 | } 26 | 27 | struct SplitAllocation { 28 | uint256 allocation; 29 | string recipient; 30 | } 31 | 32 | // Reads the splits configuration from a JSON file. 33 | function readSplitsConfig(string memory splitsConfigFilePath) public view returns (SplitConfig[] memory) { 34 | string memory file = vm.readFile(splitsConfigFilePath); 35 | 36 | uint256 totalSplits = countJsonArray(file, "", MAX_SPLITS); 37 | require(totalSplits > 0, "No splits found in the configuration file."); 38 | 39 | SplitConfig[] memory splits = new SplitConfig[](totalSplits); 40 | 41 | for (uint256 i = 0; i < totalSplits; i++) { 42 | string memory key = string.concat(".[", vm.toString(i), "]"); 43 | 44 | SplitConfig memory split; 45 | split.name = file.readString(string.concat(key, ".name")); 46 | split.owner = file.readAddress(string.concat(key, ".owner")); 47 | split.splitType = file.readString(string.concat(key, ".splitType")); 48 | split.totalAllocation = file.readUint(string.concat(key, ".totalAllocation")); 49 | split.distributionIncentive = file.readUint(string.concat(key, ".distributionIncentive")); 50 | 51 | uint256 totalAllocations = countJsonArray(file, string.concat(key, ".allocations"), MAX_ALLOCATIONS); 52 | require(totalAllocations > 0, "No allocations found for the current split."); 53 | split.allocations = new SplitAllocation[](totalAllocations); 54 | 55 | for (uint256 j = 0; j < totalAllocations; j++) { 56 | string memory allocationKey = string.concat(key, ".allocations.[", vm.toString(j), "]"); 57 | if (!file.keyExists(allocationKey)) { 58 | break; 59 | } 60 | 61 | SplitAllocation memory splitAllocation; 62 | splitAllocation.recipient = file.readString(string.concat(allocationKey, ".recipient")); 63 | splitAllocation.allocation = file.readUint(string.concat(allocationKey, ".allocation")); 64 | split.allocations[j] = splitAllocation; 65 | } 66 | 67 | splits[i] = split; 68 | } 69 | 70 | return splits; 71 | } 72 | 73 | // Counts the number of elements in a JSON array. 74 | function countJsonArray(string memory json, string memory keyPrefix, uint256 max) public view returns (uint256) { 75 | for (uint256 i = 0; i < max; i++) { 76 | string memory key = string.concat(keyPrefix, ".[", vm.toString(i), "]"); 77 | if (!json.keyExists(key)) { 78 | return i; 79 | } 80 | } 81 | 82 | revert("Exceeded maximum number of elements in JSON array"); 83 | } 84 | 85 | function sortRecipientsAndAllocations( 86 | address[] memory recipients, 87 | uint256[] memory allocations, 88 | int left, 89 | int right 90 | ) internal pure { 91 | int i = left; 92 | int j = right; 93 | if (i == j) return; 94 | address pivot = recipients[uint(left + (right - left) / 2)]; 95 | while (i <= j) { 96 | while (recipients[uint(i)] < pivot) i++; 97 | while (pivot < recipients[uint(j)]) j--; 98 | if (i <= j) { 99 | (recipients[uint(i)], recipients[uint(j)]) = (recipients[uint(j)], recipients[uint(i)]); 100 | (allocations[uint(i)], allocations[uint(j)]) = (allocations[uint(j)], allocations[uint(i)]); 101 | i++; 102 | j--; 103 | } 104 | } 105 | if (left < j) sortRecipientsAndAllocations(recipients, allocations, left, j); 106 | if (i < right) sortRecipientsAndAllocations(recipients, allocations, i, right); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/controllers/ImmutableSplitController.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {ISplitMain} from "../interfaces/ISplitMain.sol"; 5 | import {Clone} from "solady/utils/Clone.sol"; 6 | 7 | /// @author Obol 8 | /// @dev Deploys a contract that can update a split should be called once as the 9 | /// configuration is defined at deployment and cannot change 10 | contract ImmutableSplitController is Clone { 11 | /// @notice IMSC already initialized 12 | error Initialized(); 13 | 14 | /// @notice 15 | error Unauthorized(); 16 | 17 | /// @notice Revert if split balance is > 1 18 | /// @dev Prevent distribution of current balance 19 | error Invalid_SplitBalance(); 20 | 21 | /// ----------------------------------------------------------------------- 22 | /// storage 23 | /// ----------------------------------------------------------------------- 24 | 25 | /// ----------------------------------------------------------------------- 26 | /// storage - constants 27 | /// ----------------------------------------------------------------------- 28 | uint256 internal constant ADDRESS_BITS = 160; 29 | uint256 internal constant ONE_WORD = 32; 30 | 31 | /// ----------------------------------------------------------------------- 32 | /// storage - cwia offsets 33 | /// ----------------------------------------------------------------------- 34 | 35 | // splitMain (address, 20 bytes) 36 | // 0; first item 37 | uint256 internal constant SPLIT_MAIN_OFFSET = 0; 38 | // distributorFee (uint32, 4 bytes) 39 | // 1; second item 40 | uint256 internal constant DISTRIBUTOR_FEE_OFFSET = 20; 41 | // onwer (address, 20 bytes) 42 | // 2; third item 43 | uint256 internal constant OWNER_OFFSET = 24; 44 | // recipeints size (uint8, 1 byte ) 45 | // 3; third item 46 | uint256 internal constant RECIPIENTS_SIZE_OFFSET = 44; 47 | // recipients data () 48 | // 4; fourth item 49 | uint256 internal constant RECIPIENTS_OFFSET = 45; 50 | 51 | /// ----------------------------------------------------------------------- 52 | /// storage - mutable 53 | /// ----------------------------------------------------------------------- 54 | /// @dev Address of split to update 55 | address public split; 56 | 57 | constructor() {} 58 | 59 | function init(address splitAddress) external { 60 | if (split != address(0)) revert Initialized(); 61 | 62 | split = splitAddress; 63 | } 64 | 65 | /// Updates split with the hardcoded configuration 66 | /// @dev Updates split with stored split configuration 67 | function updateSplit() external payable { 68 | if (msg.sender != owner()) revert Unauthorized(); 69 | 70 | (address[] memory accounts, uint32[] memory percentAllocations) = getNewSplitConfiguration(); 71 | 72 | // prevent distribution of existing money 73 | if (address(split).balance > 1) revert Invalid_SplitBalance(); 74 | 75 | ISplitMain(splitMain()).updateSplit(split, accounts, percentAllocations, uint32(distributorFee())); 76 | } 77 | 78 | /// Address of SplitMain 79 | /// @dev equivalent to address public immutable splitMain; 80 | function splitMain() public pure returns (address) { 81 | return _getArgAddress(SPLIT_MAIN_OFFSET); 82 | } 83 | 84 | /// Fee charged by distributor 85 | /// @dev equivalent to address public immutable distributorFee; 86 | function distributorFee() public pure returns (uint256) { 87 | return _getArgUint32(DISTRIBUTOR_FEE_OFFSET); 88 | } 89 | 90 | /// Adress of owner 91 | /// @dev equivalent to address public immutable owner; 92 | function owner() public pure returns (address) { 93 | return _getArgAddress(OWNER_OFFSET); 94 | } 95 | 96 | // Returns unpacked recipients 97 | /// @return accounts Addresses to receive payments 98 | /// @return percentAllocations Percentage share for split accounts 99 | function getNewSplitConfiguration() 100 | public 101 | pure 102 | returns (address[] memory accounts, uint32[] memory percentAllocations) 103 | { 104 | // fetch the size first 105 | // then parse the data gradually 106 | uint256 size = _recipientsSize(); 107 | accounts = new address[](size); 108 | percentAllocations = new uint32[](size); 109 | 110 | uint256 i = 0; 111 | for (; i < size;) { 112 | uint256 recipient = _getRecipient(i); 113 | accounts[i] = address(uint160(recipient)); 114 | percentAllocations[i] = uint32(recipient >> ADDRESS_BITS); 115 | unchecked { 116 | i++; 117 | } 118 | } 119 | } 120 | 121 | /// Number of recipeints 122 | /// @dev equivalent to address internal immutable _recipientsSize; 123 | function _recipientsSize() internal pure returns (uint256) { 124 | return _getArgUint8(RECIPIENTS_SIZE_OFFSET); 125 | } 126 | 127 | /// Gets recipient i 128 | /// @dev emulates to uint256[] internal immutable recipient; 129 | function _getRecipient(uint256 i) internal pure returns (uint256) { 130 | unchecked { 131 | // shouldn't overflow 132 | return _getArgUint256(RECIPIENTS_OFFSET + (i * ONE_WORD)); 133 | } 134 | } 135 | } -------------------------------------------------------------------------------- /src/test/owr/token/integration/GNOOTWRIntegration.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {OptimisticTokenWithdrawalRecipient} from "src/owr/token/OptimisticTokenWithdrawalRecipient.sol"; 6 | import {OptimisticTokenWithdrawalRecipientFactory} from "src/owr/token/OptimisticTokenWithdrawalRecipientFactory.sol"; 7 | import {MockERC20} from "../../../utils/mocks/MockERC20.sol"; 8 | import {OWRTestHelper} from "../../OWRTestHelper.t.sol"; 9 | 10 | contract GNOOWTRIntegration is OWRTestHelper, Test { 11 | OptimisticTokenWithdrawalRecipientFactory owrFactoryModule; 12 | MockERC20 mERC20; 13 | address public recoveryAddress; 14 | address public principalRecipient; 15 | address public rewardRecipient; 16 | uint256 public threshold; 17 | 18 | uint256 internal constant GNO_BALANCE_CLASSIFICATION_THRESHOLD = 0.8 ether; 19 | 20 | function setUp() public { 21 | mERC20 = new MockERC20("demo", "DMT", 18); 22 | mERC20.mint(type(uint256).max); 23 | 24 | owrFactoryModule = new OptimisticTokenWithdrawalRecipientFactory(GNO_BALANCE_CLASSIFICATION_THRESHOLD); 25 | 26 | recoveryAddress = makeAddr("recoveryAddress"); 27 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(10); 28 | threshold = 10 ether; 29 | } 30 | 31 | function test_Distribute() public { 32 | OptimisticTokenWithdrawalRecipient gnoRecipient = 33 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 34 | 35 | uint256 amountToStake = 0.001 ether; 36 | for (uint256 i = 0; i < 5; i++) { 37 | mERC20.transfer(address(gnoRecipient), amountToStake); 38 | } 39 | 40 | gnoRecipient.distributeFunds(); 41 | 42 | // ensure it goes to the rewardRecipient 43 | assertEq(mERC20.balanceOf(rewardRecipient), amountToStake * 5, "failed to stake"); 44 | 45 | // ensure it goes to principal recipient 46 | uint256 amountPrincipal = 2 ether; 47 | 48 | mERC20.transfer(address(gnoRecipient), amountPrincipal); 49 | gnoRecipient.distributeFunds(); 50 | 51 | // ensure it goes to the principal recipient 52 | assertEq(mERC20.balanceOf(principalRecipient), amountPrincipal, "failed to stake"); 53 | 54 | assertEq(gnoRecipient.claimedPrincipalFunds(), amountPrincipal, "invalid claimed principal funds"); 55 | 56 | uint256 prevRewardBalance = mERC20.balanceOf(rewardRecipient); 57 | 58 | for (uint256 i = 0; i < 5; i++) { 59 | mERC20.transfer(address(gnoRecipient), amountPrincipal); 60 | } 61 | 62 | gnoRecipient.distributeFunds(); 63 | 64 | // ensure it goes to the principal recipient 65 | assertEq(mERC20.balanceOf(principalRecipient), threshold, "principal recipient balance valid"); 66 | 67 | assertEq(gnoRecipient.claimedPrincipalFunds(), threshold, "claimed funds not equal threshold"); 68 | 69 | assertEq( 70 | mERC20.balanceOf(rewardRecipient), 71 | prevRewardBalance + amountPrincipal, 72 | "reward recipient should recieve remaining funds" 73 | ); 74 | } 75 | 76 | function testFuzz_Distribute( 77 | uint256 amountToDistribute, 78 | address fuzzPrincipalRecipient, 79 | address fuzzRewardRecipient, 80 | uint256 fuzzThreshold 81 | ) public { 82 | vm.assume(fuzzRewardRecipient != address(0)); 83 | vm.assume(fuzzPrincipalRecipient != address(0)); 84 | vm.assume(fuzzRewardRecipient != fuzzPrincipalRecipient); 85 | vm.assume(amountToDistribute > 0); 86 | fuzzThreshold = bound(fuzzThreshold, 1, type(uint96).max); 87 | 88 | OptimisticTokenWithdrawalRecipient gnoRecipient = 89 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, fuzzPrincipalRecipient, fuzzRewardRecipient, fuzzThreshold); 90 | 91 | uint256 amountToShare = bound(amountToDistribute, 1e18, type(uint96).max); 92 | 93 | mERC20.transfer(address(gnoRecipient), amountToShare); 94 | 95 | gnoRecipient.distributeFunds(); 96 | 97 | if (amountToShare >= GNO_BALANCE_CLASSIFICATION_THRESHOLD) { 98 | if (amountToShare > fuzzThreshold) { 99 | assertEq(mERC20.balanceOf(fuzzPrincipalRecipient), fuzzThreshold, "invalid principal balance 1"); 100 | assertEq(gnoRecipient.claimedPrincipalFunds(), fuzzThreshold, "invalid claimed principal funds 2"); 101 | assertEq(mERC20.balanceOf(fuzzRewardRecipient), amountToShare - fuzzThreshold, "invalid reward balance 3"); 102 | } else { 103 | assertEq(mERC20.balanceOf(fuzzPrincipalRecipient), amountToShare, "invalid principal balance 4"); 104 | assertEq(gnoRecipient.claimedPrincipalFunds(), amountToShare, "invalid claimed principal funds 5"); 105 | assertEq(mERC20.balanceOf(fuzzRewardRecipient), 0, "invalid reward balance 6"); 106 | } 107 | } else { 108 | assertEq(mERC20.balanceOf(fuzzPrincipalRecipient), 0, "invalid principal balance 7"); 109 | assertEq(gnoRecipient.claimedPrincipalFunds(), 0, "invalid claimed principal funds 8"); 110 | assertEq(mERC20.balanceOf(fuzzRewardRecipient), amountToShare, "invalid reward balance 9"); 111 | } 112 | 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ovm/ObolValidatorManagerFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Proprietary 2 | pragma solidity 0.8.19; 3 | 4 | import {ObolValidatorManager} from "./ObolValidatorManager.sol"; 5 | import {IENSReverseRegistrar} from "../interfaces/IENSReverseRegistrar.sol"; 6 | 7 | /// @title ObolValidatorManagerFactory 8 | /// @author Obol 9 | /// @notice A factory contract for deploying ObolValidatorManager. 10 | contract ObolValidatorManagerFactory { 11 | /// ----------------------------------------------------------------------- 12 | /// errors 13 | /// ----------------------------------------------------------------------- 14 | /// Owner cannot be address(0) 15 | error Invalid_Owner(); 16 | 17 | /// Some recipients are address(0) 18 | error Invalid__Recipients(); 19 | 20 | /// Threshold must be positive 21 | error Invalid__ZeroThreshold(); 22 | 23 | /// Threshold must be below 2048 ether 24 | error Invalid__ThresholdTooLarge(); 25 | 26 | /// ----------------------------------------------------------------------- 27 | /// events 28 | /// ----------------------------------------------------------------------- 29 | 30 | /// Emitted after a new ObolValidatorManager instance is deployed 31 | /// @param ovm Address of newly created ObolValidatorManager instance 32 | /// @param owner Owner of newly created ObolValidatorManager instance 33 | /// @param beneficiary Address to distribute principal payment to 34 | /// @param rewardRecipient Address to distribute reward payment to 35 | /// @param principalThreshold Principal vs rewards classification threshold (gwei) 36 | event CreateObolValidatorManager( 37 | address indexed ovm, address indexed owner, address beneficiary, address rewardRecipient, uint64 principalThreshold 38 | ); 39 | 40 | /// ----------------------------------------------------------------------- 41 | /// storage - immutable 42 | /// ----------------------------------------------------------------------- 43 | 44 | address public immutable consolidationSystemContract; 45 | address public immutable withdrawalSystemContract; 46 | address public immutable depositSystemContract; 47 | 48 | /// ----------------------------------------------------------------------- 49 | /// constructor 50 | /// ----------------------------------------------------------------------- 51 | 52 | /// @param _consolidationSystemContract Consolidation system contract address 53 | /// @param _withdrawalSystemContract Withdrawal system contract address 54 | /// @param _depositSystemContract Deposit system contract address 55 | /// @param _ensName ENS name to register 56 | /// @param _ensReverseRegistrar ENS reverse registrar address 57 | /// @param _ensOwner ENS owner address 58 | constructor( 59 | address _consolidationSystemContract, 60 | address _withdrawalSystemContract, 61 | address _depositSystemContract, 62 | string memory _ensName, 63 | address _ensReverseRegistrar, 64 | address _ensOwner 65 | ) { 66 | consolidationSystemContract = _consolidationSystemContract; 67 | withdrawalSystemContract = _withdrawalSystemContract; 68 | depositSystemContract = _depositSystemContract; 69 | 70 | IENSReverseRegistrar(_ensReverseRegistrar).setName(_ensName); 71 | IENSReverseRegistrar(_ensReverseRegistrar).claim(_ensOwner); 72 | } 73 | 74 | /// ----------------------------------------------------------------------- 75 | /// functions 76 | /// ----------------------------------------------------------------------- 77 | 78 | /// ----------------------------------------------------------------------- 79 | /// functions - public & external 80 | /// ----------------------------------------------------------------------- 81 | 82 | /// Create a new ObolValidatorManager instance 83 | /// @param owner Owner of the new ObolValidatorManager instance 84 | /// @param beneficiary Address to distribute principal payments to 85 | /// @param rewardRecipient Address to distribute reward payments to 86 | /// @param principalThreshold Principal vs rewards classification threshold (gwei), 87 | /// the recommended value is 16000000000 (16 ether). 88 | /// @return ovm Address of the new ObolValidatorManager instance 89 | function createObolValidatorManager( 90 | address owner, 91 | address beneficiary, 92 | address rewardRecipient, 93 | uint64 principalThreshold 94 | ) external returns (ObolValidatorManager ovm) { 95 | if (owner == address(0)) revert Invalid_Owner(); 96 | if (beneficiary == address(0) || rewardRecipient == address(0)) revert Invalid__Recipients(); 97 | if (principalThreshold == 0) revert Invalid__ZeroThreshold(); 98 | if (principalThreshold > 2048 * 1e9) revert Invalid__ThresholdTooLarge(); 99 | 100 | ovm = new ObolValidatorManager( 101 | consolidationSystemContract, 102 | withdrawalSystemContract, 103 | depositSystemContract, 104 | owner, 105 | beneficiary, 106 | rewardRecipient, 107 | principalThreshold 108 | ); 109 | 110 | emit CreateObolValidatorManager(address(ovm), owner, beneficiary, rewardRecipient, principalThreshold); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/interfaces/ISplitMain.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | 6 | struct SplitConfiguration { 7 | address[] accounts; 8 | uint32[] percentAllocations; 9 | uint32 distributorFee; 10 | address controller; 11 | } 12 | 13 | interface ISplitMain { 14 | /// @notice Creates a new split with recipients `accounts` with ownerships 15 | /// `percentAllocations`, a 16 | /// keeper fee for splitting of `distributorFee` and the controlling address 17 | /// `controller` 18 | /// @param accounts Ordered, unique list of addresses with ownership in the 19 | /// split 20 | /// @param percentAllocations Percent allocations associated with each 21 | /// address 22 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 23 | /// distribution 24 | /// @param controller Controlling address (0x0 if immutable) 25 | /// @return split Address of newly created split 26 | function createSplit( 27 | address[] calldata accounts, 28 | uint32[] calldata percentAllocations, 29 | uint32 distributorFee, 30 | address controller 31 | ) external returns (address); 32 | 33 | /// @notice Predicts the address for an immutable split created with 34 | /// recipients `accounts` with 35 | /// ownerships `percentAllocations` and a keeper fee for splitting of 36 | /// `distributorFee` 37 | /// @param accounts Ordered, unique list of addresses with ownership in the 38 | /// split 39 | /// @param percentAllocations Percent allocations associated with each 40 | /// address 41 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 42 | /// distribution 43 | /// @return split Predicted address of such an immutable split 44 | function predictImmutableSplitAddress( 45 | address[] calldata accounts, 46 | uint32[] calldata percentAllocations, 47 | uint32 distributorFee 48 | ) external view returns (address split); 49 | 50 | /// @notice Distributes the ETH balance for split `split` 51 | /// @dev `accounts`, `percentAllocations`, and `distributorFee` are verified 52 | /// by hashing 53 | /// & comparing to the hash in storage associated with split `split` 54 | /// @param split Address of split to distribute balance for 55 | /// @param accounts Ordered, unique list of addresses with ownership in the 56 | /// split 57 | /// @param percentAllocations Percent allocations associated with each 58 | /// address 59 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 60 | /// distribution 61 | /// @param distributorAddress Address to pay `distributorFee` to 62 | function distributeETH( 63 | address split, 64 | address[] calldata accounts, 65 | uint32[] calldata percentAllocations, 66 | uint32 distributorFee, 67 | address distributorAddress 68 | ) external; 69 | 70 | /// @notice Distributes the ERC20 `token` balance for split `split` 71 | /// @dev `accounts`, `percentAllocations`, and `distributorFee` are verified 72 | /// by hashing 73 | /// & comparing to the hash in storage associated with split `split` 74 | /// @dev pernicious ERC20s may cause overflow in this function inside 75 | /// _scaleAmountByPercentage, but results do not affect ETH & other ERC20 76 | /// balances 77 | /// @param split Address of split to distribute balance for 78 | /// @param token Address of ERC20 to distribute balance for 79 | /// @param accounts Ordered, unique list of addresses with ownership in the 80 | /// split 81 | /// @param percentAllocations Percent allocations associated with each 82 | /// address 83 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 84 | /// distribution 85 | /// @param distributorAddress Address to pay `distributorFee` to 86 | function distributeERC20( 87 | address split, 88 | ERC20 token, 89 | address[] calldata accounts, 90 | uint32[] calldata percentAllocations, 91 | uint32 distributorFee, 92 | address distributorAddress 93 | ) external; 94 | 95 | /// @notice Withdraw ETH &/ ERC20 balances for account `account` 96 | /// @param account Address to withdraw on behalf of 97 | /// @param withdrawETH Withdraw all ETH if nonzero 98 | /// @param tokens Addresses of ERC20s to withdraw 99 | function withdraw(address account, uint256 withdrawETH, ERC20[] calldata tokens) external; 100 | 101 | /// @notice Updates an existing split with recipients `accounts` with 102 | /// ownerships `percentAllocations` and a keeper fee 103 | /// for splitting of `distributorFee` 104 | /// @param split Address of mutable split to update 105 | /// @param accounts Ordered, unique list of addresses with ownership in the 106 | /// split 107 | /// @param percentAllocations Percent allocations associated with each 108 | /// address 109 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 110 | /// distribution 111 | function updateSplit( 112 | address split, 113 | address[] calldata accounts, 114 | uint32[] calldata percentAllocations, 115 | uint32 distributorFee 116 | ) external; 117 | 118 | function getHash(address split) external view returns (bytes32); 119 | } 120 | -------------------------------------------------------------------------------- /src/owr/OptimisticWithdrawalRecipientFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {OptimisticWithdrawalRecipient} from "./OptimisticWithdrawalRecipient.sol"; 5 | import {LibClone} from "solady/utils/LibClone.sol"; 6 | import {IENSReverseRegistrar} from "../interfaces/IENSReverseRegistrar.sol"; 7 | 8 | /// @title OptimisticWithdrawalRecipientFactory 9 | /// @author Obol 10 | /// @notice A factory contract for cheaply deploying 11 | /// OptimisticWithdrawalRecipient. 12 | /// @dev This contract uses token = address(0) to refer to ETH. 13 | contract OptimisticWithdrawalRecipientFactory { 14 | /// ----------------------------------------------------------------------- 15 | /// errors 16 | /// ----------------------------------------------------------------------- 17 | 18 | /// Invalid number of recipients, must be 2 19 | error Invalid__Recipients(); 20 | 21 | /// Thresholds must be positive 22 | error Invalid__ZeroThreshold(); 23 | 24 | /// Invalid threshold at `index`; must be < 2^96 25 | /// @param threshold threshold of too-large threshold 26 | error Invalid__ThresholdTooLarge(uint256 threshold); 27 | 28 | /// ----------------------------------------------------------------------- 29 | /// libraries 30 | /// ----------------------------------------------------------------------- 31 | 32 | using LibClone for address; 33 | 34 | /// ----------------------------------------------------------------------- 35 | /// events 36 | /// ----------------------------------------------------------------------- 37 | 38 | /// Emitted after a new OptimisticWithdrawalRecipient module is deployed 39 | /// @param owr Address of newly created OptimisticWithdrawalRecipient clone 40 | /// @param recoveryAddress Address to recover non-OWR tokens to 41 | /// @param principalRecipient Address to distribute principal payment to 42 | /// @param rewardRecipient Address to distribute reward payment to 43 | /// @param threshold Absolute payment threshold for OWR first recipient 44 | /// (reward recipient has no threshold & receives all residual flows) 45 | event CreateOWRecipient( 46 | address indexed owr, address recoveryAddress, address principalRecipient, address rewardRecipient, uint256 threshold 47 | ); 48 | 49 | /// ----------------------------------------------------------------------- 50 | /// storage 51 | /// ----------------------------------------------------------------------- 52 | 53 | uint256 internal constant ADDRESS_BITS = 160; 54 | 55 | /// OptimisticWithdrawalRecipient implementation address 56 | OptimisticWithdrawalRecipient public immutable owrImpl; 57 | 58 | /// ----------------------------------------------------------------------- 59 | /// constructor 60 | /// ----------------------------------------------------------------------- 61 | 62 | constructor(string memory _ensName, address _ensReverseRegistrar, address _ensOwner) { 63 | owrImpl = new OptimisticWithdrawalRecipient(); 64 | IENSReverseRegistrar(_ensReverseRegistrar).setName(_ensName); 65 | IENSReverseRegistrar(_ensReverseRegistrar).claim(_ensOwner); 66 | } 67 | 68 | /// ----------------------------------------------------------------------- 69 | /// functions 70 | /// ----------------------------------------------------------------------- 71 | 72 | /// ----------------------------------------------------------------------- 73 | /// functions - public & external 74 | /// ----------------------------------------------------------------------- 75 | 76 | /// Create a new OptimisticWithdrawalRecipient clone 77 | /// @param recoveryAddress Address to recover tokens to 78 | /// If this address is 0x0, recovery of unrelated tokens can be completed by 79 | /// either the principal or reward recipients. If this address is set, only 80 | /// this address can recover 81 | /// tokens (or ether) that isn't the token of the OWRecipient contract 82 | /// @param principalRecipient Address to distribute principal payments to 83 | /// @param rewardRecipient Address to distribute reward payments to 84 | /// @param amountOfPrincipalStake Absolute amount of stake to be paid to 85 | /// principal recipient (multiple of 32 ETH) 86 | /// (reward recipient has no threshold & receives all residual flows) 87 | /// it cannot be greater than uint96 88 | /// @return owr Address of new OptimisticWithdrawalRecipient clone 89 | function createOWRecipient( 90 | address recoveryAddress, 91 | address principalRecipient, 92 | address rewardRecipient, 93 | uint256 amountOfPrincipalStake 94 | ) external returns (OptimisticWithdrawalRecipient owr) { 95 | /// checks 96 | 97 | // ensure doesn't have address(0) 98 | if (principalRecipient == address(0) || rewardRecipient == address(0)) revert Invalid__Recipients(); 99 | // ensure threshold isn't zero 100 | if (amountOfPrincipalStake == 0) revert Invalid__ZeroThreshold(); 101 | // ensure threshold isn't too large 102 | if (amountOfPrincipalStake > type(uint96).max) revert Invalid__ThresholdTooLarge(amountOfPrincipalStake); 103 | 104 | /// effects 105 | uint256 principalData = (amountOfPrincipalStake << ADDRESS_BITS) | uint256(uint160(principalRecipient)); 106 | uint256 rewardData = uint256(uint160(rewardRecipient)); 107 | 108 | // would not exceed contract size limits 109 | // important to not reorder 110 | bytes memory data = abi.encodePacked(recoveryAddress, principalData, rewardData); 111 | owr = OptimisticWithdrawalRecipient(address(owrImpl).clone(data)); 112 | 113 | emit CreateOWRecipient(address(owr), recoveryAddress, principalRecipient, rewardRecipient, amountOfPrincipalStake); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/owr/token/OptimisticTokenWithdrawalRecipientFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity 0.8.19; 3 | 4 | import {OptimisticTokenWithdrawalRecipient} from "src/owr/token/OptimisticTokenWithdrawalRecipient.sol"; 5 | import {LibClone} from "solady/utils/LibClone.sol"; 6 | 7 | /// @title OptimisticTokenWithdrawalRecipientFactory 8 | /// @author Obol 9 | /// @notice A factory contract for cheaply deploying 10 | /// OptimisticTokenWithdrawalRecipient. 11 | /// @dev This contract uses token = address(0) to refer to ETH. 12 | contract OptimisticTokenWithdrawalRecipientFactory { 13 | /// ----------------------------------------------------------------------- 14 | /// errors 15 | /// ----------------------------------------------------------------------- 16 | 17 | /// Invalid token 18 | error Invalid_Token(); 19 | 20 | /// Invalid number of recipients, must be 2 21 | error Invalid__Recipients(); 22 | 23 | /// Thresholds must be positive 24 | error Invalid__ZeroThreshold(); 25 | 26 | /// Invalid threshold at `index`; must be < 2^96 27 | /// @param threshold threshold of too-large threshold 28 | error Invalid__ThresholdTooLarge(uint256 threshold); 29 | 30 | /// ----------------------------------------------------------------------- 31 | /// libraries 32 | /// ----------------------------------------------------------------------- 33 | 34 | using LibClone for address; 35 | 36 | /// ----------------------------------------------------------------------- 37 | /// events 38 | /// ----------------------------------------------------------------------- 39 | 40 | /// Emitted after a new OptimisticWithdrawalWithTokenRecipient module is deployed 41 | /// @param owr Address of newly created OptimisticWithdrawalWithTokenRecipient clone 42 | /// @param token Address of ERC20 to distribute (0x0 used for ETH) 43 | /// @param recoveryAddress Address to recover non-OWR tokens to 44 | /// @param principalRecipient Address to distribute principal payment to 45 | /// @param rewardRecipient Address to distribute reward payment to 46 | /// @param threshold Absolute payment threshold for OWR first recipient 47 | /// (reward recipient has no threshold & receives all residual flows) 48 | event CreateOWRecipient( 49 | address indexed owr, 50 | address token, 51 | address recoveryAddress, 52 | address principalRecipient, 53 | address rewardRecipient, 54 | uint256 threshold 55 | ); 56 | 57 | /// ----------------------------------------------------------------------- 58 | /// storage 59 | /// ----------------------------------------------------------------------- 60 | 61 | uint256 internal constant ADDRESS_BITS = 160; 62 | 63 | /// OptimisticTokenWithdrawalRecipient implementation address 64 | OptimisticTokenWithdrawalRecipient public immutable owrImpl; 65 | 66 | /// ----------------------------------------------------------------------- 67 | /// constructor 68 | /// ----------------------------------------------------------------------- 69 | 70 | constructor(uint256 threshold) { 71 | owrImpl = new OptimisticTokenWithdrawalRecipient(threshold); 72 | } 73 | 74 | /// ----------------------------------------------------------------------- 75 | /// functions 76 | /// ----------------------------------------------------------------------- 77 | 78 | /// ----------------------------------------------------------------------- 79 | /// functions - public & external 80 | /// ----------------------------------------------------------------------- 81 | 82 | /// Create a new OptimisticTokenWithdrawalRecipient clone 83 | /// @param token Address of ERC20 to distribute (0x0 used for ETH) 84 | /// @param recoveryAddress Address to recover tokens to 85 | /// If this address is 0x0, recovery of unrelated tokens can be completed by 86 | /// either the principal or reward recipients. If this address is set, only 87 | /// this address can recover 88 | /// tokens (or ether) that isn't the token of the OWRecipient contract 89 | /// @param principalRecipient Address to distribute principal payments to 90 | /// @param rewardRecipient Address to distribute reward payments to 91 | /// @param amountOfPrincipalStake Absolute amount of stake to be paid to 92 | /// principal recipient (multiple of 32 ETH) 93 | /// (reward recipient has no threshold & receives all residual flows) 94 | /// it cannot be greater than uint96 95 | /// @return owr Address of new OptimisticTokenWithdrawalRecipient clone 96 | function createOWRecipient( 97 | address token, 98 | address recoveryAddress, 99 | address principalRecipient, 100 | address rewardRecipient, 101 | uint256 amountOfPrincipalStake 102 | ) external returns (OptimisticTokenWithdrawalRecipient owr) { 103 | /// checks 104 | 105 | // ensure doesn't have address(0) 106 | if (principalRecipient == address(0) || rewardRecipient == address(0)) revert Invalid__Recipients(); 107 | // ensure threshold isn't zero 108 | if (amountOfPrincipalStake == 0) revert Invalid__ZeroThreshold(); 109 | // ensure threshold isn't too large 110 | if (amountOfPrincipalStake > type(uint96).max) revert Invalid__ThresholdTooLarge(amountOfPrincipalStake); 111 | 112 | /// effects 113 | uint256 principalData = (amountOfPrincipalStake << ADDRESS_BITS) | uint256(uint160(principalRecipient)); 114 | uint256 rewardData = uint256(uint160(rewardRecipient)); 115 | 116 | // would not exceed contract size limits 117 | // important to not reorder 118 | bytes memory data = abi.encodePacked(token, recoveryAddress, principalData, rewardData); 119 | owr = OptimisticTokenWithdrawalRecipient(address(owrImpl).clone(data)); 120 | 121 | emit CreateOWRecipient( 122 | address(owr), token, recoveryAddress, principalRecipient, rewardRecipient, amountOfPrincipalStake 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/test/owr/OptimisticWithdrawalRecipientFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {OptimisticWithdrawalRecipient} from "src/owr/OptimisticWithdrawalRecipient.sol"; 6 | import {OptimisticWithdrawalRecipientFactory} from "src/owr/OptimisticWithdrawalRecipientFactory.sol"; 7 | import {MockERC20} from "../utils/mocks/MockERC20.sol"; 8 | import {OWRTestHelper} from "./OWRTestHelper.t.sol"; 9 | import {IENSReverseRegistrar} from "../../interfaces/IENSReverseRegistrar.sol"; 10 | 11 | contract OptimisticWithdrawalRecipientFactoryTest is OWRTestHelper, Test { 12 | event CreateOWRecipient( 13 | address indexed owr, address recoveryAddress, address principalRecipient, address rewardRecipient, uint256 threshold 14 | ); 15 | 16 | address public ENS_REVERSE_REGISTRAR_GOERLI = 0x084b1c3C81545d370f3634392De611CaaBFf8148; 17 | 18 | OptimisticWithdrawalRecipientFactory owrFactoryModule; 19 | 20 | address public recoveryAddress; 21 | address public principalRecipient; 22 | address public rewardRecipient; 23 | uint256 public threshold; 24 | 25 | function setUp() public { 26 | vm.mockCall( 27 | ENS_REVERSE_REGISTRAR_GOERLI, 28 | abi.encodeWithSelector(IENSReverseRegistrar.setName.selector), 29 | bytes.concat(bytes32(0)) 30 | ); 31 | vm.mockCall( 32 | ENS_REVERSE_REGISTRAR_GOERLI, 33 | abi.encodeWithSelector(IENSReverseRegistrar.claim.selector), 34 | bytes.concat(bytes32(0)) 35 | ); 36 | 37 | owrFactoryModule = 38 | new OptimisticWithdrawalRecipientFactory("demo.obol.eth", ENS_REVERSE_REGISTRAR_GOERLI, address(this)); 39 | 40 | recoveryAddress = makeAddr("recoveryAddress"); 41 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(10); 42 | threshold = ETH_STAKE; 43 | } 44 | 45 | function testCan_createOWRecipient() public { 46 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 47 | 48 | recoveryAddress = address(0); 49 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 50 | } 51 | 52 | function testCan_emitOnCreate() public { 53 | // don't check deploy address 54 | vm.expectEmit(false, true, true, true); 55 | 56 | emit CreateOWRecipient(address(0xdead), recoveryAddress, principalRecipient, rewardRecipient, threshold); 57 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 58 | 59 | recoveryAddress = address(0); 60 | 61 | // don't check deploy address 62 | vm.expectEmit(false, true, true, true); 63 | emit CreateOWRecipient(address(0xdead), recoveryAddress, principalRecipient, rewardRecipient, threshold); 64 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 65 | } 66 | 67 | function testCannot_createWithInvalidRecipients() public { 68 | (principalRecipient, rewardRecipient, threshold) = generateTranches(1, 1); 69 | // eth 70 | vm.expectRevert(OptimisticWithdrawalRecipientFactory.Invalid__Recipients.selector); 71 | owrFactoryModule.createOWRecipient(recoveryAddress, address(0), rewardRecipient, threshold); 72 | 73 | vm.expectRevert(OptimisticWithdrawalRecipientFactory.Invalid__Recipients.selector); 74 | owrFactoryModule.createOWRecipient(recoveryAddress, address(0), address(0), threshold); 75 | 76 | vm.expectRevert(OptimisticWithdrawalRecipientFactory.Invalid__Recipients.selector); 77 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, address(0), threshold); 78 | } 79 | 80 | function testCannot_createWithInvalidThreshold() public { 81 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(2); 82 | threshold = 0; 83 | 84 | vm.expectRevert(OptimisticWithdrawalRecipientFactory.Invalid__ZeroThreshold.selector); 85 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 86 | 87 | vm.expectRevert( 88 | abi.encodeWithSelector( 89 | OptimisticWithdrawalRecipientFactory.Invalid__ThresholdTooLarge.selector, type(uint128).max 90 | ) 91 | ); 92 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, type(uint128).max); 93 | } 94 | 95 | /// ----------------------------------------------------------------------- 96 | /// Fuzzing Tests 97 | /// ---------------------------------------------------------------------- 98 | 99 | function testFuzzCan_createOWRecipient(address _recoveryAddress, uint256 recipientsSeed, uint256 thresholdSeed) 100 | public 101 | { 102 | recoveryAddress = _recoveryAddress; 103 | 104 | (principalRecipient, rewardRecipient, threshold) = generateTranches(recipientsSeed, thresholdSeed); 105 | 106 | vm.expectEmit(false, true, true, true); 107 | emit CreateOWRecipient(address(0xdead), recoveryAddress, principalRecipient, rewardRecipient, threshold); 108 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 109 | } 110 | 111 | function testFuzzCannot_CreateWithZeroThreshold(uint256 _receipientSeed) public { 112 | threshold = 0; 113 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(_receipientSeed); 114 | 115 | // eth 116 | vm.expectRevert(OptimisticWithdrawalRecipientFactory.Invalid__ZeroThreshold.selector); 117 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 118 | } 119 | 120 | function testFuzzCannot_CreateWithLargeThreshold(uint256 _receipientSeed, uint256 _threshold) public { 121 | vm.assume(_threshold > type(uint96).max); 122 | 123 | threshold = _threshold; 124 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(_receipientSeed); 125 | 126 | vm.expectRevert( 127 | abi.encodeWithSelector(OptimisticWithdrawalRecipientFactory.Invalid__ThresholdTooLarge.selector, _threshold) 128 | ); 129 | owrFactoryModule.createOWRecipient(recoveryAddress, principalRecipient, rewardRecipient, threshold); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /script/splits/DeployScript.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {Script, console} from "forge-std/Script.sol"; 5 | import {stdJson} from "forge-std/StdJson.sol"; 6 | import {ISplitFactoryV2} from "../../src/interfaces/ISplitFactoryV2.sol"; 7 | import {LibString} from "solady/utils/LibString.sol"; 8 | import {BaseScript} from "./BaseScript.s.sol"; 9 | 10 | // 11 | // This script deploys Split contracts using provided SplitFactories, 12 | // in accordance with the splits configuration file. 13 | // To run this script, the following environment variables must be set: 14 | // - PRIVATE_KEY: the private key of the account that will deploy the contract 15 | // Example usage: 16 | // forge script script/splits/DeployScript.s.sol --sig "run(address,address,string)" \ 17 | // --rpc-url https://your-rpc-provider --broadcast -vvv \ 18 | // "" "" "" 19 | // 20 | // SplitFactory addresses can be found here: 21 | // https://github.com/0xSplits/splits-contracts-monorepo/tree/main/packages/splits-v2/deployments 22 | // 23 | contract DeployScript is BaseScript { 24 | using stdJson for string; 25 | 26 | // To detect loops in splits configuration 27 | address private constant DEPLOYING_SPLIT = 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF; 28 | 29 | mapping(string => address) private deployments; 30 | mapping(string => uint256) private indices; 31 | 32 | address private deployerAddress; 33 | ISplitFactoryV2 private pullFactory; 34 | ISplitFactoryV2 private pushFactory; 35 | 36 | function run(address pullSplitFactory, address pushSplitFactory, string memory splitsConfigFilePath) external { 37 | uint256 privKey = vm.envUint("PRIVATE_KEY"); 38 | if (privKey == 0) { 39 | console.log("PRIVATE_KEY is not set"); 40 | return; 41 | } 42 | deployerAddress = vm.addr(privKey); 43 | 44 | require(pullSplitFactory != address(0), "PullSplitFactory address is not set"); 45 | require(pushSplitFactory != address(0), "PushSplitFactory address is not set"); 46 | pullFactory = ISplitFactoryV2(pullSplitFactory); 47 | pushFactory = ISplitFactoryV2(pushSplitFactory); 48 | 49 | console.log("Reading splits configuration from file: %s", splitsConfigFilePath); 50 | SplitConfig[] memory configSplits = readSplitsConfig(splitsConfigFilePath); 51 | require(configSplits.length > 0, "No splits found in the configuration file."); 52 | console.log("Found %d splits in the configuration file", configSplits.length); 53 | 54 | for (uint256 i = 0; i < configSplits.length; i++) { 55 | indices[configSplits[i].name] = i; 56 | } 57 | 58 | vm.startBroadcast(privKey); 59 | 60 | for (uint256 i = 0; i < configSplits.length; i++) { 61 | deploySplit(configSplits, configSplits[i].name); 62 | } 63 | 64 | writeDeploymentJson(getFileName(splitsConfigFilePath), configSplits); 65 | 66 | vm.stopBroadcast(); 67 | } 68 | 69 | function deploySplit(SplitConfig[] memory configSplits, string memory splitName) public returns (address) { 70 | if (deployments[splitName] != address(0) && deployments[splitName] != DEPLOYING_SPLIT) { 71 | return deployments[splitName]; 72 | } 73 | 74 | if (deployments[splitName] == DEPLOYING_SPLIT) { 75 | console.log("Split %s is already processing, it must be a loop", splitName); 76 | revert("Loop detected in splits configuration."); 77 | } 78 | deployments[splitName] = DEPLOYING_SPLIT; 79 | 80 | SplitConfig memory split = configSplits[indices[splitName]]; 81 | 82 | address[] memory recipients = new address[](split.allocations.length); 83 | uint256[] memory allocations = new uint256[](split.allocations.length); 84 | 85 | for (uint256 j = 0; j < split.allocations.length; j++) { 86 | if (LibString.startsWith(split.allocations[j].recipient, "0x")) { 87 | recipients[j] = vm.parseAddress(split.allocations[j].recipient); 88 | } else if (bytes(split.allocations[j].recipient).length > 0) { 89 | recipients[j] = deploySplit(configSplits, split.allocations[j].recipient); 90 | } else { 91 | console.log("Recipient address is not set for allocation %d in split %s", j, splitName); 92 | revert("Recipient address is not set for allocation."); 93 | } 94 | 95 | allocations[j] = split.allocations[j].allocation; 96 | } 97 | 98 | sortRecipientsAndAllocations(recipients, allocations, 0, int(recipients.length - 1)); 99 | 100 | ISplitFactoryV2.Split memory newSplit = ISplitFactoryV2.Split({ 101 | recipients: recipients, 102 | allocations: allocations, 103 | totalAllocation: split.totalAllocation, 104 | distributionIncentive: uint16(split.distributionIncentive) 105 | }); 106 | 107 | address splitAddress; 108 | if (compareStrings(split.splitType, "pull")) { 109 | splitAddress = pullFactory.createSplit(newSplit, split.owner, deployerAddress); 110 | } else if (compareStrings(split.splitType, "push")) { 111 | splitAddress = pushFactory.createSplit(newSplit, split.owner, deployerAddress); 112 | } else { 113 | console.log("Unknown split type: %s", split.splitType); 114 | revert("Unsupported split type provided. Allowed pull or push only."); 115 | } 116 | 117 | console.log("Split %s deployed at", split.name, splitAddress); 118 | deployments[split.name] = splitAddress; 119 | 120 | return splitAddress; 121 | } 122 | 123 | function compareStrings(string memory a, string memory b) public pure returns (bool) { 124 | return LibString.eq(a, b); 125 | } 126 | 127 | function getFileName(string memory filePath) public pure returns (string memory) { 128 | return LibString.slice(filePath, LibString.lastIndexOf(filePath, "/") + 1, bytes(filePath).length); 129 | } 130 | 131 | function writeDeploymentJson(string memory _splitsConfigFileName, SplitConfig[] memory _splits) internal { 132 | string memory deploymentsDir = string.concat(vm.projectRoot(), "/deployments"); 133 | if (!vm.exists(deploymentsDir)) { 134 | vm.createDir(deploymentsDir, true); 135 | } 136 | 137 | string memory file = string.concat(vm.projectRoot(), "/deployments/", _splitsConfigFileName); 138 | string memory root; 139 | string memory last; 140 | for (uint256 i = 0; i < _splits.length; i++) { 141 | SplitConfig memory split = _splits[i]; 142 | last = root.serialize(split.name, deployments[split.name]); 143 | } 144 | vm.writeFile(file, last); 145 | 146 | console.log("Deployments saved to file: %s", file); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/test/ovm/ObolValidatorManagerFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Proprietary 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolValidatorManager} from "src/ovm/ObolValidatorManager.sol"; 6 | import {ObolValidatorManagerFactory} from "src/ovm/ObolValidatorManagerFactory.sol"; 7 | import {MockERC20} from "../utils/mocks/MockERC20.sol"; 8 | import {SystemContractMock} from "./mocks/SystemContractMock.sol"; 9 | import {DepositContractMock} from "./mocks/DepositContractMock.sol"; 10 | import {IENSReverseRegistrar} from "../../interfaces/IENSReverseRegistrar.sol"; 11 | 12 | contract ObolValidatorManagerFactoryTest is Test { 13 | event CreateObolValidatorManager( 14 | address indexed ovm, 15 | address indexed owner, 16 | address beneficiary, 17 | address rewardRecipient, 18 | uint64 principalThreshold 19 | ); 20 | 21 | address public ENS_REVERSE_REGISTRAR = 0x084b1c3C81545d370f3634392De611CaaBFf8148; 22 | 23 | uint64 public constant BALANCE_CLASSIFICATION_THRESHOLD_GWEI = 16 ether / 1 gwei; 24 | 25 | SystemContractMock consolidationMock; 26 | SystemContractMock withdrawalMock; 27 | DepositContractMock depositMock; 28 | ObolValidatorManagerFactory ovmFactory; 29 | 30 | address public beneficiary; 31 | address public rewardsRecipient; 32 | uint64 public principalThreshold; 33 | 34 | function setUp() public { 35 | vm.mockCall( 36 | ENS_REVERSE_REGISTRAR, 37 | abi.encodeWithSelector(IENSReverseRegistrar.setName.selector), 38 | bytes.concat(bytes32(0)) 39 | ); 40 | vm.mockCall( 41 | ENS_REVERSE_REGISTRAR, 42 | abi.encodeWithSelector(IENSReverseRegistrar.claim.selector), 43 | bytes.concat(bytes32(0)) 44 | ); 45 | 46 | consolidationMock = new SystemContractMock(48 + 48); 47 | withdrawalMock = new SystemContractMock(48 + 8); 48 | depositMock = new DepositContractMock(); 49 | 50 | ovmFactory = new ObolValidatorManagerFactory( 51 | address(consolidationMock), 52 | address(withdrawalMock), 53 | address(depositMock), 54 | "demo.obol.eth", 55 | ENS_REVERSE_REGISTRAR, 56 | address(this) 57 | ); 58 | 59 | beneficiary = makeAddr("beneficiary"); 60 | rewardsRecipient = makeAddr("rewardsRecipient"); 61 | principalThreshold = BALANCE_CLASSIFICATION_THRESHOLD_GWEI; 62 | } 63 | 64 | function testCan_createOVM() public { 65 | ObolValidatorManager ovm = ovmFactory.createObolValidatorManager( 66 | address(this), 67 | beneficiary, 68 | rewardsRecipient, 69 | principalThreshold 70 | ); 71 | assertEq(ovm.owner(), address(this)); 72 | assertEq(address(ovm.consolidationSystemContract()), address(consolidationMock)); 73 | assertEq(address(ovm.withdrawalSystemContract()), address(withdrawalMock)); 74 | } 75 | 76 | function testCan_emitOnCreate() public { 77 | // don't check deploy address 78 | vm.expectEmit(false, true, true, true); 79 | 80 | emit CreateObolValidatorManager( 81 | address(0xdead), 82 | address(this), 83 | beneficiary, 84 | rewardsRecipient, 85 | principalThreshold 86 | ); 87 | ovmFactory.createObolValidatorManager( 88 | address(this), 89 | beneficiary, 90 | rewardsRecipient, 91 | principalThreshold 92 | ); 93 | 94 | // don't check deploy address 95 | vm.expectEmit(false, true, true, true); 96 | emit CreateObolValidatorManager( 97 | address(0xdead), 98 | address(this), 99 | beneficiary, 100 | rewardsRecipient, 101 | principalThreshold 102 | ); 103 | ovmFactory.createObolValidatorManager( 104 | address(this), 105 | beneficiary, 106 | rewardsRecipient, 107 | principalThreshold 108 | ); 109 | } 110 | 111 | function testCannot_createWithInvalidOwner() public { 112 | vm.expectRevert(ObolValidatorManagerFactory.Invalid_Owner.selector); 113 | ovmFactory.createObolValidatorManager( 114 | address(0), 115 | beneficiary, 116 | rewardsRecipient, 117 | principalThreshold 118 | ); 119 | } 120 | 121 | function testCannot_createWithInvalidRecipients() public { 122 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__Recipients.selector); 123 | ovmFactory.createObolValidatorManager( 124 | address(this), 125 | address(0), 126 | rewardsRecipient, 127 | principalThreshold 128 | ); 129 | 130 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__Recipients.selector); 131 | ovmFactory.createObolValidatorManager(address(this), address(0), address(0), principalThreshold); 132 | 133 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__Recipients.selector); 134 | ovmFactory.createObolValidatorManager( 135 | address(this), 136 | beneficiary, 137 | address(0), 138 | principalThreshold 139 | ); 140 | } 141 | 142 | function testCannot_createWithInvalidThreshold() public { 143 | principalThreshold = 0; 144 | 145 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__ZeroThreshold.selector); 146 | ovmFactory.createObolValidatorManager( 147 | address(this), 148 | beneficiary, 149 | rewardsRecipient, 150 | principalThreshold 151 | ); 152 | 153 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__ThresholdTooLarge.selector); 154 | ovmFactory.createObolValidatorManager( 155 | address(this), 156 | beneficiary, 157 | rewardsRecipient, 158 | type(uint64).max 159 | ); 160 | } 161 | 162 | /// ----------------------------------------------------------------------- 163 | /// Fuzzing Tests 164 | /// ---------------------------------------------------------------------- 165 | 166 | function testFuzzCan_createOVM(uint64 _threshold) public { 167 | vm.assume(_threshold > 0 && _threshold < 2048 * 1e9); 168 | 169 | vm.expectEmit(false, true, true, true); 170 | emit CreateObolValidatorManager( 171 | address(0xdead), 172 | address(this), 173 | beneficiary, 174 | rewardsRecipient, 175 | _threshold 176 | ); 177 | ovmFactory.createObolValidatorManager( 178 | address(this), 179 | beneficiary, 180 | rewardsRecipient, 181 | _threshold 182 | ); 183 | } 184 | 185 | function testFuzzCannot_CreateWithZeroThreshold(address _rewardsRecipient) public { 186 | vm.assume(_rewardsRecipient != address(0)); 187 | principalThreshold = 0; 188 | 189 | // eth 190 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__ZeroThreshold.selector); 191 | ovmFactory.createObolValidatorManager( 192 | address(this), 193 | beneficiary, 194 | _rewardsRecipient, 195 | principalThreshold 196 | ); 197 | } 198 | 199 | function testFuzzCannot_CreateWithLargeThreshold(address _rewardsRecipient, uint64 _threshold) public { 200 | vm.assume(_threshold > 2048 * 1e9); 201 | vm.assume(_rewardsRecipient != address(0)); 202 | 203 | vm.expectRevert(ObolValidatorManagerFactory.Invalid__ThresholdTooLarge.selector); 204 | ovmFactory.createObolValidatorManager( 205 | address(this), 206 | beneficiary, 207 | _rewardsRecipient, 208 | _threshold 209 | ); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/interfaces/ISplitMainV2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {ERC20} from "solmate/tokens/ERC20.sol"; 5 | 6 | struct SplitConfiguration { 7 | address[] accounts; 8 | uint32[] percentAllocations; 9 | uint32 distributorFee; 10 | address distributor; 11 | address controller; 12 | } 13 | 14 | interface ISplitMainV2 { 15 | /// @notice Creates a new split with recipients `accounts` with ownerships 16 | /// `percentAllocations`, a 17 | /// keeper fee for splitting of `distributorFee` and the controlling address 18 | /// `controller` 19 | /// @param accounts Ordered, unique list of addresses with ownership in the 20 | /// split 21 | /// @param percentAllocations Percent allocations associated with each 22 | /// address 23 | /// @param controller Controlling address (0x0 if immutable) 24 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 25 | /// distribution 26 | /// @return split Address of newly created split 27 | function createSplit( 28 | address splitWalletImplementation, 29 | address[] calldata accounts, 30 | uint32[] calldata percentAllocations, 31 | address controller, 32 | address distributor, 33 | uint32 distributorFee 34 | ) external returns (address); 35 | 36 | /// @notice Predicts the address for an immutable split created with 37 | /// recipients `accounts` with 38 | /// ownerships `percentAllocations` and a keeper fee for splitting of 39 | /// `distributorFee` 40 | /// @param accounts Ordered, unique list of addresses with ownership in the 41 | /// split 42 | /// @param percentAllocations Percent allocations associated with each 43 | /// address 44 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 45 | /// distribution 46 | /// @return split Predicted address of such an immutable split 47 | function predictImmutableSplitAddress( 48 | address splitWalletImplementation, 49 | address[] calldata accounts, 50 | uint32[] calldata percentAllocations, 51 | uint32 distributorFee 52 | ) external view returns (address split); 53 | 54 | function updateSplit( 55 | address split, 56 | address[] calldata accounts, 57 | uint32[] calldata percentAllocations, 58 | uint32 distributorFee 59 | ) external; 60 | 61 | function transferControl(address split, address newController) external; 62 | 63 | function cancelControlTransfer(address split) external; 64 | 65 | function acceptControl(address split) external; 66 | 67 | function makeSplitImmutable(address split) external; 68 | 69 | /// @notice Distributes the ETH balance for split `split` 70 | /// @dev `accounts`, `percentAllocations`, and `distributorFee` are verified 71 | /// by hashing 72 | /// & comparing to the hash in storage associated with split `split` 73 | /// @param split Address of split to distribute balance for 74 | /// @param accounts Ordered, unique list of addresses with ownership in the 75 | /// split 76 | /// @param percentAllocations Percent allocations associated with each 77 | /// address 78 | /// @param distributorFee Keeper fee paid by split to cover gas costs of 79 | /// distribution 80 | /// @param distributorAddress Address to pay `distributorFee` to 81 | function distributeETH( 82 | address split, 83 | address[] calldata accounts, 84 | uint32[] calldata percentAllocations, 85 | uint32 distributorFee, 86 | address distributorAddress 87 | ) external; 88 | 89 | function updateAndDistributeETH( 90 | address split, 91 | address[] calldata accounts, 92 | uint32[] calldata percentAllocations, 93 | uint32 distributorFee, 94 | address distributorAddress 95 | ) external; 96 | 97 | function distributeERC20( 98 | address split, 99 | ERC20 token, 100 | address[] calldata accounts, 101 | uint32[] calldata percentAllocations, 102 | uint32 distributorFee, 103 | address distributorAddress 104 | ) external; 105 | 106 | function updateAndDistributeERC20( 107 | address split, 108 | ERC20 token, 109 | address[] calldata accounts, 110 | uint32[] calldata percentAllocations, 111 | uint32 distributorFee, 112 | address distributorAddress 113 | ) external; 114 | 115 | /// @notice Withdraw ETH &/ ERC20 balances for account `account` 116 | /// @param account Address to withdraw on behalf of 117 | /// @param withdrawETH Withdraw all ETH if nonzero 118 | /// @param tokens Addresses of ERC20s to withdraw 119 | function withdraw(address account, uint256 withdrawETH, ERC20[] calldata tokens) external; 120 | 121 | /** 122 | * EVENTS 123 | */ 124 | 125 | /** 126 | * @notice emitted after each successful split creation 127 | * @param split Address of the created split 128 | */ 129 | event CreateSplit(address indexed split); 130 | 131 | /** 132 | * @notice emitted after each successful split update 133 | * @param split Address of the updated split 134 | */ 135 | event UpdateSplit(address indexed split); 136 | 137 | /** 138 | * @notice emitted after each initiated split control transfer 139 | * @param split Address of the split control transfer was initiated for 140 | * @param newPotentialController Address of the split's new potential 141 | * controller 142 | */ 143 | event InitiateControlTransfer(address indexed split, address indexed newPotentialController); 144 | 145 | /** 146 | * @notice emitted after each canceled split control transfer 147 | * @param split Address of the split control transfer was canceled for 148 | */ 149 | event CancelControlTransfer(address indexed split); 150 | 151 | /** 152 | * @notice emitted after each successful split control transfer 153 | * @param split Address of the split control was transferred for 154 | * @param previousController Address of the split's previous controller 155 | * @param newController Address of the split's new controller 156 | */ 157 | event ControlTransfer(address indexed split, address indexed previousController, address indexed newController); 158 | 159 | /** 160 | * @notice emitted after each successful ETH balance split 161 | * @param split Address of the split that distributed its balance 162 | * @param amount Amount of ETH distributed 163 | * @param distributorAddress Address to credit distributor fee to 164 | */ 165 | event DistributeETH(address indexed split, uint256 amount, address indexed distributorAddress); 166 | 167 | /** 168 | * @notice emitted after each successful ERC20 balance split 169 | * @param split Address of the split that distributed its balance 170 | * @param token Address of ERC20 distributed 171 | * @param amount Amount of ERC20 distributed 172 | * @param distributorAddress Address to credit distributor fee to 173 | */ 174 | event DistributeERC20(address indexed split, ERC20 indexed token, uint256 amount, address indexed distributorAddress); 175 | 176 | /** 177 | * @notice emitted after each successful withdrawal 178 | * @param account Address that funds were withdrawn to 179 | * @param ethAmount Amount of ETH withdrawn 180 | * @param tokens Addresses of ERC20s withdrawn 181 | * @param tokenAmounts Amounts of corresponding ERC20s withdrawn 182 | */ 183 | event Withdrawal(address indexed account, uint256 ethAmount, ERC20[] tokens, uint256[] tokenAmounts); 184 | } 185 | -------------------------------------------------------------------------------- /src/test/controllers/IMSCFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import { 6 | ImmutableSplitControllerFactory, 7 | ImmutableSplitController 8 | } from "src/controllers/ImmutableSplitControllerFactory.sol"; 9 | import {ISplitMain} from "src/interfaces/ISplitMain.sol"; 10 | 11 | contract IMSCFactory is Test { 12 | error Invalid_Address(); 13 | error Invalid_Owner(); 14 | error InvalidSplit_Address(); 15 | error InvalidSplit__TooFewAccounts(uint256 accountsLength); 16 | error InvalidSplit__AccountsAndAllocationsMismatch(uint256 accountsLength, uint256 allocationsLength); 17 | error InvalidSplit__InvalidAllocationsSum(uint32 allocationsSum); 18 | error InvalidSplit__AccountsOutOfOrder(uint256 index); 19 | error InvalidSplit__AllocationMustBePositive(uint256 index); 20 | error InvalidSplit__InvalidDistributorFee(uint32 distributorFee); 21 | 22 | address internal SPLIT_MAIN_SEPOLIA = 0x5924cD81dC672151527B1E4b5Ef57B69cBD07Eda; 23 | uint32 public constant SPLIT_MAIN_PERCENTAGE_SCALE = 1e6; 24 | uint256 public constant PERCENTAGE_SCALE = 1e6; 25 | 26 | ImmutableSplitControllerFactory public factory; 27 | ImmutableSplitController public cntrlImpl; 28 | 29 | address owner; 30 | 31 | address[] accounts; 32 | uint32[] percentAllocations; 33 | 34 | function setUp() public { 35 | vm.createSelectFork(getChain("sepolia").rpcUrl); 36 | 37 | factory = new ImmutableSplitControllerFactory(SPLIT_MAIN_SEPOLIA); 38 | cntrlImpl = factory.controller(); 39 | 40 | accounts = new address[](2); 41 | accounts[0] = makeAddr("accounts0"); 42 | accounts[1] = makeAddr("accounts1"); 43 | 44 | percentAllocations = new uint32[](2); 45 | percentAllocations[0] = 400_000; 46 | percentAllocations[1] = 600_000; 47 | 48 | owner = makeAddr("owner"); 49 | } 50 | 51 | function test_RevertIfSplitMainIsInvalid() public { 52 | vm.expectRevert(Invalid_Address.selector); 53 | new ImmutableSplitControllerFactory(address(0)); 54 | } 55 | 56 | function test_RevertIfAccountSizeIsOne() public { 57 | address[] memory newAccounts = new address[](1); 58 | newAccounts[0] = makeAddr("testRevertIfAccountSizeIsOne"); 59 | 60 | vm.expectRevert(abi.encodeWithSelector(InvalidSplit__TooFewAccounts.selector, newAccounts.length)); 61 | 62 | factory.createController( 63 | address(1), owner, newAccounts, percentAllocations, 0, keccak256(abi.encodePacked(uint256(12))) 64 | ); 65 | } 66 | 67 | function test_RevertIfAccountAndAllocationMismatch() public { 68 | uint32[] memory newPercentAllocations = new uint32[](3); 69 | newPercentAllocations[0] = 200_000; 70 | newPercentAllocations[1] = 200_000; 71 | newPercentAllocations[2] = 600_000; 72 | 73 | vm.expectRevert( 74 | abi.encodeWithSelector( 75 | InvalidSplit__AccountsAndAllocationsMismatch.selector, accounts.length, newPercentAllocations.length 76 | ) 77 | ); 78 | 79 | factory.createController( 80 | address(1), owner, accounts, newPercentAllocations, 0, keccak256(abi.encodePacked(uint256(12))) 81 | ); 82 | } 83 | 84 | function test_RevertIfAccountOutOfOrder() public { 85 | address[] memory newAccounts = new address[](2); 86 | newAccounts[0] = address(0x4); 87 | newAccounts[1] = address(0x1); 88 | 89 | vm.expectRevert(abi.encodeWithSelector(InvalidSplit__AccountsOutOfOrder.selector, 0)); 90 | 91 | factory.createController( 92 | address(1), owner, newAccounts, percentAllocations, 0, keccak256(abi.encodePacked(uint256(12))) 93 | ); 94 | } 95 | 96 | function test_RevertIfZeroPercentAllocation() public { 97 | uint32[] memory newPercentAllocations = new uint32[](2); 98 | newPercentAllocations[0] = SPLIT_MAIN_PERCENTAGE_SCALE; 99 | newPercentAllocations[1] = 0; 100 | 101 | vm.expectRevert(abi.encodeWithSelector(InvalidSplit__AllocationMustBePositive.selector, 1)); 102 | 103 | factory.createController( 104 | address(1), owner, accounts, newPercentAllocations, 0, keccak256(abi.encodePacked(uint256(12))) 105 | ); 106 | } 107 | 108 | function test_RevertIfInvalidDistributorFee() public { 109 | uint32 invalidDistributorFee = 1e6; 110 | 111 | vm.expectRevert(abi.encodeWithSelector(InvalidSplit__InvalidDistributorFee.selector, invalidDistributorFee)); 112 | 113 | factory.createController( 114 | address(1), owner, accounts, percentAllocations, invalidDistributorFee, keccak256(abi.encodePacked(uint256(12))) 115 | ); 116 | } 117 | 118 | function test_RevertIfInvalidAllocationSum() public { 119 | uint32[] memory newPercentAllocations = new uint32[](2); 120 | newPercentAllocations[0] = SPLIT_MAIN_PERCENTAGE_SCALE; 121 | newPercentAllocations[1] = 1; 122 | 123 | vm.expectRevert( 124 | abi.encodeWithSelector(InvalidSplit__InvalidAllocationsSum.selector, SPLIT_MAIN_PERCENTAGE_SCALE + 1) 125 | ); 126 | 127 | factory.createController( 128 | address(1), owner, accounts, newPercentAllocations, 0, keccak256(abi.encodePacked(uint256(12))) 129 | ); 130 | } 131 | 132 | function test_RevertIfInvalidOwner() public { 133 | vm.expectRevert(Invalid_Owner.selector); 134 | 135 | factory.createController( 136 | address(1), address(0), accounts, percentAllocations, 0, keccak256(abi.encodePacked(uint256(123))) 137 | ); 138 | } 139 | 140 | function test_RevertIfInvalidSplitAddress() public { 141 | vm.expectRevert(InvalidSplit_Address.selector); 142 | 143 | factory.createController( 144 | address(0), address(1), accounts, percentAllocations, 0, keccak256(abi.encodePacked(uint256(123))) 145 | ); 146 | } 147 | 148 | function test_RevertIfRecipeintSizeTooMany() public { 149 | bytes32 deploymentSalt = keccak256(abi.encodePacked(uint256(1102))); 150 | 151 | uint256 size = 400; 152 | address[] memory localAccounts = _generateAddresses(1, size); 153 | uint32[] memory localAllocations = _generatePercentAlloc(size); 154 | 155 | vm.expectRevert( 156 | abi.encodeWithSelector(ImmutableSplitControllerFactory.InvalidSplit__TooManyAccounts.selector, size) 157 | ); 158 | 159 | factory.createController(address(1), owner, localAccounts, localAllocations, 0, deploymentSalt); 160 | } 161 | 162 | function test_CanCreateController() public { 163 | bytes32 deploymentSalt = keccak256(abi.encodePacked(uint256(1102))); 164 | 165 | address predictedAddress = 166 | factory.predictSplitControllerAddress(owner, accounts, percentAllocations, 0, deploymentSalt); 167 | 168 | address split = ISplitMain(SPLIT_MAIN_SEPOLIA).createSplit(accounts, percentAllocations, 0, predictedAddress); 169 | 170 | ImmutableSplitController controller = 171 | factory.createController(split, owner, accounts, percentAllocations, 0, deploymentSalt); 172 | 173 | assertEq(address(controller), predictedAddress, "predicted_address_invalid"); 174 | } 175 | 176 | function _generateAddresses(uint256 _seed, uint256 size) internal pure returns (address[] memory accts) { 177 | accts = new address[](size); 178 | uint160 seed = uint160(uint256(keccak256(abi.encodePacked(_seed)))); 179 | for (uint160 i; i < size; i++) { 180 | accts[i] = address(seed); 181 | seed += 1; 182 | } 183 | } 184 | 185 | function _generatePercentAlloc(uint256 size) internal pure returns (uint32[] memory alloc) { 186 | alloc = new uint32[](size); 187 | for (uint256 i; i < size; i++) { 188 | alloc[i] = uint32(PERCENTAGE_SCALE / size); 189 | } 190 | 191 | if (PERCENTAGE_SCALE % size != 0) alloc[size - 1] += uint32(PERCENTAGE_SCALE % size); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Obol Splits is a suite of Solidity smart contracts enabling safe creation and management of Distributed Validators for Ethereum Consensus-based networks. Built with Foundry, targeting Solidity 0.8.19 with Shanghai EVM compatibility. 8 | 9 | ## Development Commands 10 | 11 | ```sh 12 | # Setup 13 | forge install && cp .env.sample .env 14 | 15 | # Testing 16 | forge test # All tests 17 | forge test --match-contract C --match-test t # Specific test 18 | forge test --gas-report # With gas reporting 19 | 20 | # Build & Deploy 21 | forge build 22 | forge script script/DeployFactoryScript.s.sol 23 | ``` 24 | 25 | ## Architecture Overview 26 | 27 | ### Core Contract Types 28 | 29 | **ObolValidatorManager (OVM)** - Validator management with ETH2 deposits, withdrawals (EIP-7002), consolidations (EIP-7251) 30 | - 6 role-based permissions: WITHDRAWAL (0x01), CONSOLIDATION (0x02), SET_BENEFICIARY (0x04), RECOVER_FUNDS (0x08), SET_REWARD (0x10), DEPOSIT (0x20) 31 | - PUSH/PULL distribution modes; principal threshold (gwei) routes funds 32 | - `sweep()` extracts from `pullBalances[principalRecipient]` - anyone can call with beneficiary=address(0), owner for custom address 33 | - Non-proxy (deployed via `new`, not Clone) 34 | 35 | **OptimisticWithdrawalRecipient (OWR)** - ETH distribution via 16 ETH threshold, Clone proxy, PUSH/PULL modes 36 | 37 | **OptimisticTokenWithdrawalRecipient** - OWR for ERC20 with configurable threshold 38 | 39 | **ObolLidoSplit** - Wraps stETH→wstETH for 0xSplits (Clone + BaseSplit) 40 | 41 | **ObolEtherfiSplit** - Wraps eETH→weETH for 0xSplits (Clone + BaseSplit) 42 | 43 | **ImmutableSplitController** - Immutable 0xSplits config (CWIA pattern) 44 | 45 | ### Factory Pattern 46 | 47 | Clone factories (Solady LibClone): OptimisticWithdrawalRecipientFactory, OptimisticTokenWithdrawalRecipientFactory, ObolLidoSplitFactory, ObolEtherfiSplitFactory, ObolCollectorFactory, ImmutableSplitControllerFactory 48 | 49 | **Exception**: ObolValidatorManagerFactory deploys full instances via `new` (not clones) 50 | 51 | ### Key Patterns 52 | 53 | - **Clone Proxy**: Minimal proxy with CWIA optimization (all except OVM) 54 | - **Two-Phase Distribution**: PUSH (0) = direct transfer; PULL (1) = deferred via `withdrawPullBalance()`. Prevents malicious recipient DOS. 55 | - **Sweep (OVM)**: Extract from `pullBalances[principalRecipient]`. Anyone if beneficiary=0, owner for custom address. Amount=0 sweeps all. 56 | - **BaseSplit**: Abstract base with distribute(), rescueFunds(), fee mechanism (PERCENTAGE_SCALE=1e5) 57 | - **Rebasing Wrapping**: stETH→wstETH, eETH→weETH for 0xSplits 58 | 59 | ## Constants & External Integrations 60 | 61 | **Constants:** 62 | - `BALANCE_CLASSIFICATION_THRESHOLD_GWEI = 16 ether / 1 gwei` (OVM tests), `BALANCE_CLASSIFICATION_THRESHOLD = 16 ether` (OWR) 63 | - `PERCENTAGE_SCALE = 1e5`, `PUBLIC_KEY_LENGTH = 48`, Distribution modes: `PUSH = 0, PULL = 1` 64 | - OVM Roles: WITHDRAWAL (0x01), CONSOLIDATION (0x02), SET_BENEFICIARY (0x04), RECOVER_FUNDS (0x08), SET_REWARD (0x10), DEPOSIT (0x20) 65 | 66 | **Integrations:** 0xSplits (SplitMain), Lido (stETH/wstETH), EtherFi (eETH/weETH), Deposit Contract (ETH2), EIP-7002 (0x09Fc...aAaA), EIP-7251 (0x0043...EFf6), ENS Reverse Registrar 67 | 68 | ## Project Structure 69 | 70 | ``` 71 | src/ 72 | ├── base/ BaseSplit and BaseSplitFactory abstracts 73 | ├── collector/ ObolCollector for reward collection 74 | ├── controllers/ ImmutableSplitController 75 | ├── etherfi/ EtherFi integration (eETH → weETH) 76 | ├── interfaces/ All interface definitions (IObolValidatorManager, etc.) 77 | ├── lido/ Lido integration (stETH → wstETH) 78 | ├── ovm/ ObolValidatorManager and ObolValidatorManagerFactory 79 | ├── owr/ OptimisticWithdrawalRecipient (ETH distribution) 80 | │ └── token/ Token-based withdrawal recipient 81 | └── test/ Test suite organized by feature 82 | ├── ovm/ OVM tests and mocks 83 | ├── owr/ OWR tests 84 | └── ... 85 | 86 | script/ Deployment and management scripts 87 | ├── ovm/ OVM-specific scripts (12 scripts) 88 | │ ├── DeployFactoryScript.s.sol 89 | │ ├── CreateOVMScript.s.sol 90 | │ ├── DepositScript.s.sol 91 | │ ├── DistributeFundsScript.s.sol 92 | │ ├── ConsolidateScript.s.sol 93 | │ ├── WithdrawScript.s.sol 94 | │ ├── GrantRolesScript.s.sol 95 | │ ├── SetBeneficiaryScript.s.sol 96 | │ ├── SetRewardRecipientScript.s.sol 97 | │ ├── SetAmountOfPrincipalStakeScript.s.sol 98 | │ ├── SystemContractFeesScript.s.sol 99 | │ └── Utils.s.sol 100 | ├── splits/ 0xSplits deployment scripts 101 | └── data/ Sample configuration JSON files 102 | ``` 103 | 104 | ## Testing & Security 105 | 106 | **Testing:** 107 | - Unit tests (role checks, fees, distribution), integration tests (lido/, etherfi/, owr/token/integration/) 108 | - Mocks: SystemContractMock (EIP-7002/7251), DepositContractMock, MockERC20/1155/NFT 109 | - 100 fuzz runs, .t.sol suffix, 43+ OVM tests (PUSH/PULL, sweep, roles, edge cases) 110 | - OVM test pattern: ≥16 ether → beneficiary, <16 ether → reward 111 | 112 | **Security:** 113 | - ReentrancyGuard on OVM distribute/sweep 114 | - PUSH/PULL prevents DOS, role-based access (6 OVM roles) 115 | - Fund recovery via `recoverFunds()`, 48-byte pubkey validation 116 | - Sweep allows emergency extraction, fee validation with refunds 117 | - `fundsPendingWithdrawal` prevents over-distribution 118 | 119 | ## Deployment Addresses 120 | 121 | **Mainnet:** OWRFactory: 0x119acd7844cbdd5fc09b1c6a4408f490c8f7f522, OWR: 0xe11eabf19a49c389d3e8735c35f8f34f28bdcb22, ObolLidoSplitFactory: 0xA9d94139A310150Ca1163b5E23f3E1dbb7D9E2A6, ObolLidoSplit: 0x2fB59065F049e0D0E3180C6312FA0FeB5Bbf0FE3, IMSCFactory: 0x49e7cA187F1E94d9A0d1DFBd6CCCd69Ca17F56a4, IMSC: 0xaF129979b773374dD3025d3F97353e73B0A6Cc8d 122 | 123 | **Sepolia:** OWRFactory: 0xca78f8fda7ec13ae246e4d4cd38b9ce25a12e64a, OWR: 0x99585e71ab1118682d51efefca0a170c70eef0d6 124 | 125 | ## Notes 126 | 127 | Solidity 0.8.19, Shanghai EVM, gas reports enabled, audited (https://docs.obol.tech/docs/sec/smart_contract_audit), formatting: 2-space tabs, 120 char lines, no bracket spacing 128 | 129 | ## OVM Workflows 130 | 131 | **Lifecycle:** 132 | 1. Deploy: `ObolValidatorManagerFactory.createObolValidatorManager(owner, beneficiary, rewardRecipient, principalThreshold)` 133 | 2. Grant roles: `grantRoles(user, DEPOSIT_ROLE | WITHDRAWAL_ROLE)` 134 | 3. Deposit: `deposit(pubkey, withdrawal_credentials, signature, deposit_data_root)` with 32 ETH 135 | 4. Distribute: `distributeFunds()` (PUSH) or `distributeFundsPull()` (PULL), then `withdrawPullBalance(account)` 136 | 5. Emergency: `sweep(address(0), 0)` extracts all principal pull balance to beneficiary 137 | 138 | **Distribution:** If `balance - fundsPendingWithdrawal >= principalThreshold * 1e9` AND `amountOfPrincipalStake > 0`: pay principal first (up to `amountOfPrincipalStake`), overflow to reward. Else: all to reward. `amountOfPrincipalStake` decrements on payout. 139 | 140 | **Sweep:** `sweep(address(0), amount)` anyone→principalRecipient; `sweep(customAddr, amount)` owner→custom; `sweep(address(0), 0)` sweeps all 141 | 142 | **EIP-7002 Withdrawals:** `withdraw(pubKeys, amounts, maxFeePerWithdrawal, excessFeeRecipient)` - requires WITHDRAWAL_ROLE, ETH for `fee * pubKeys.length` 143 | 144 | **EIP-7251 Consolidations:** `consolidate(requests, maxFeePerConsolidation, excessFeeRecipient)` - requires CONSOLIDATION_ROLE, max 63 source pubkeys per request 145 | -------------------------------------------------------------------------------- /src/test/controllers/IMSC.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import { 6 | ImmutableSplitControllerFactory, 7 | ImmutableSplitController 8 | } from "src/controllers/ImmutableSplitControllerFactory.sol"; 9 | import {ISplitMain} from "src/interfaces/ISplitMain.sol"; 10 | 11 | contract IMSC is Test { 12 | error Initialized(); 13 | error Unauthorized(); 14 | error Invalid_SplitBalance(); 15 | 16 | address internal SPLIT_MAIN_SEPOLIA = 0x5924cD81dC672151527B1E4b5Ef57B69cBD07Eda; 17 | uint256 public constant PERCENTAGE_SCALE = 1e6; 18 | 19 | ImmutableSplitControllerFactory public factory; 20 | ImmutableSplitController public cntrlImpl; 21 | 22 | ImmutableSplitController public controller; 23 | 24 | address[] accounts; 25 | uint32[] percentAllocations; 26 | 27 | address[] controllerAccounts; 28 | uint32[] controllerPercentAllocations; 29 | 30 | address split; 31 | address owner; 32 | 33 | function setUp() public { 34 | vm.createSelectFork(getChain("sepolia").rpcUrl); 35 | 36 | factory = new ImmutableSplitControllerFactory(SPLIT_MAIN_SEPOLIA); 37 | cntrlImpl = factory.controller(); 38 | 39 | accounts = new address[](2); 40 | accounts[0] = makeAddr("accounts0"); 41 | accounts[1] = makeAddr("accounts1"); 42 | 43 | owner = makeAddr("accounts3"); 44 | 45 | percentAllocations = new uint32[](2); 46 | percentAllocations[0] = 400_000; 47 | percentAllocations[1] = 600_000; 48 | 49 | controllerAccounts = new address[](3); 50 | controllerAccounts[0] = makeAddr("accounts0"); 51 | controllerAccounts[1] = makeAddr("accounts1"); 52 | controllerAccounts[2] = makeAddr("accounts3"); 53 | 54 | controllerPercentAllocations = new uint32[](3); 55 | controllerPercentAllocations[0] = 400_000; 56 | controllerPercentAllocations[1] = 300_000; 57 | controllerPercentAllocations[2] = 300_000; 58 | 59 | bytes32 deploymentSalt = keccak256(abi.encodePacked(uint256(64))); 60 | 61 | // predict controller address 62 | address predictedControllerAddress = 63 | factory.predictSplitControllerAddress(owner, controllerAccounts, controllerPercentAllocations, 0, deploymentSalt); 64 | 65 | split = ISplitMain(SPLIT_MAIN_SEPOLIA).createSplit(accounts, percentAllocations, 0, predictedControllerAddress); 66 | 67 | // deploy controller 68 | controller = 69 | factory.createController(split, owner, controllerAccounts, controllerPercentAllocations, 0, deploymentSalt); 70 | } 71 | 72 | function testCannot_DoubleInitialiseIMSC() public { 73 | vm.expectRevert(Initialized.selector); 74 | 75 | controller.init(address(0x3)); 76 | } 77 | 78 | function testCan_getSplitMain() public { 79 | assertEq(controller.splitMain(), SPLIT_MAIN_SEPOLIA, "valid splitMain address"); 80 | } 81 | 82 | function testCan_getOwner() public { 83 | assertEq(controller.owner(), owner, "valid controller owner"); 84 | } 85 | 86 | function testCan_getDistributorFee() public { 87 | assertEq(controller.distributorFee(), 0, "invalid distributor fee"); 88 | 89 | uint32 maxDistributorFee = 1e5; 90 | 91 | ImmutableSplitController customController = factory.createController( 92 | split, 93 | owner, 94 | controllerAccounts, 95 | controllerPercentAllocations, 96 | maxDistributorFee, 97 | keccak256(abi.encodePacked(uint256(640))) 98 | ); 99 | 100 | assertEq(customController.distributorFee(), maxDistributorFee, "invalid distributor fee"); 101 | } 102 | 103 | function testCan_getSplitConfiguration() public { 104 | (address[] memory localAccounts, uint32[] memory localPercentAllocations) = controller.getNewSplitConfiguration(); 105 | 106 | assertEq(localAccounts, controllerAccounts, "invalid accounts"); 107 | 108 | assertEq(localPercentAllocations.length, controllerPercentAllocations.length, "unequal length percent allocations"); 109 | 110 | for (uint256 i; i < localPercentAllocations.length; i++) { 111 | assertEq( 112 | uint256(localPercentAllocations[i]), uint256(controllerPercentAllocations[i]), "invalid percentAllocations" 113 | ); 114 | } 115 | } 116 | 117 | function testCan_getSplit() public { 118 | assertEq(controller.split(), split); 119 | } 120 | 121 | function testCannot_updateSplitIfNonOwner() public { 122 | vm.expectRevert(Unauthorized.selector); 123 | controller.updateSplit(); 124 | } 125 | 126 | function testCannot_updateSplitIfBalanceGreaterThanOne() public { 127 | deal(address(split), 1 ether); 128 | vm.expectRevert(Invalid_SplitBalance.selector); 129 | vm.prank(owner); 130 | controller.updateSplit(); 131 | } 132 | 133 | function testCan_updateSplit() public { 134 | vm.prank(owner); 135 | controller.updateSplit(); 136 | 137 | assertEq( 138 | ISplitMain(SPLIT_MAIN_SEPOLIA).getHash(split), 139 | _hashSplit(controllerAccounts, controllerPercentAllocations, 0), 140 | "invalid split hash" 141 | ); 142 | } 143 | 144 | function testFuzz_updateSplit( 145 | address ownerAddress, 146 | uint256 splitSeed, 147 | uint256 controllerSeed, 148 | uint8 splitSize, 149 | uint8 controllerSize 150 | ) public { 151 | vm.assume(ownerAddress != address(0)); 152 | vm.assume(splitSeed != controllerSeed); 153 | vm.assume(splitSize > 1); 154 | vm.assume(controllerSize > 1); 155 | 156 | address[] memory splitterAccts = _generateAddresses(splitSeed, splitSize); 157 | address[] memory ctrllerAccounts = _generateAddresses(controllerSeed, controllerSize); 158 | 159 | uint32[] memory splitterPercentAlloc = _generatePercentAlloc(splitSize); 160 | uint32[] memory ctrllerPercentAlloc = _generatePercentAlloc(controllerSize); 161 | 162 | bytes32 deploymentSalt = keccak256(abi.encodePacked(uint256(604))); 163 | 164 | // predict controller address 165 | address predictedControllerAddress = 166 | factory.predictSplitControllerAddress(ownerAddress, ctrllerAccounts, ctrllerPercentAlloc, 0, deploymentSalt); 167 | 168 | // create split 169 | address fuzzSplit = 170 | ISplitMain(SPLIT_MAIN_SEPOLIA).createSplit(splitterAccts, splitterPercentAlloc, 0, predictedControllerAddress); 171 | 172 | // create controller 173 | controller = 174 | factory.createController(fuzzSplit, ownerAddress, ctrllerAccounts, ctrllerPercentAlloc, 0, deploymentSalt); 175 | 176 | assertEq(controller.owner(), ownerAddress, "invalid owner address"); 177 | 178 | // get current split hash 179 | bytes32 currentSplitHash = ISplitMain(SPLIT_MAIN_SEPOLIA).getHash(fuzzSplit); 180 | // update split 181 | vm.prank(ownerAddress); 182 | controller.updateSplit(); 183 | 184 | bytes32 newSplitHash = ISplitMain(SPLIT_MAIN_SEPOLIA).getHash(fuzzSplit); 185 | 186 | bytes32 calculatedSplitHash = _hashSplit(ctrllerAccounts, ctrllerPercentAlloc, 0); 187 | 188 | assertTrue(currentSplitHash != newSplitHash, "update split hash"); 189 | assertEq(calculatedSplitHash, newSplitHash, "split hash equal"); 190 | } 191 | 192 | function _hashSplit(address[] memory accts, uint32[] memory percentAlloc, uint32 distributorFee) 193 | internal 194 | pure 195 | returns (bytes32) 196 | { 197 | return keccak256(abi.encodePacked(accts, percentAlloc, distributorFee)); 198 | } 199 | 200 | function _generateAddresses(uint256 _seed, uint256 size) internal pure returns (address[] memory accts) { 201 | accts = new address[](size); 202 | uint160 seed = uint160(uint256(keccak256(abi.encodePacked(_seed)))); 203 | for (uint160 i; i < size; i++) { 204 | accts[i] = address(seed); 205 | seed += 1; 206 | } 207 | } 208 | 209 | function _generatePercentAlloc(uint256 size) internal pure returns (uint32[] memory alloc) { 210 | alloc = new uint32[](size); 211 | for (uint256 i; i < size; i++) { 212 | alloc[i] = uint32(PERCENTAGE_SCALE / size); 213 | } 214 | 215 | if (PERCENTAGE_SCALE % size != 0) alloc[size - 1] += uint32(PERCENTAGE_SCALE % size); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/test/collector/ObolCollector.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolCollectorFactory, ObolCollector} from "src/collector/ObolCollectorFactory.sol"; 6 | import {MockERC20} from "src/test/utils/mocks/MockERC20.sol"; 7 | import {BaseSplit} from "src/base/BaseSplit.sol"; 8 | 9 | contract ObolCollectorTest is Test { 10 | 11 | uint256 internal constant PERCENTAGE_SCALE = 1e5; 12 | 13 | address feeRecipient; 14 | address withdrawalAddress; 15 | address ethWithdrawalAddress; 16 | 17 | uint256 feeShare; 18 | MockERC20 mERC20; 19 | MockERC20 rescueERC20; 20 | 21 | ObolCollectorFactory collectorFactoryWithFee; 22 | 23 | ObolCollector collectorWithFee; 24 | ObolCollector ethCollectorWithFee; 25 | 26 | function setUp() public { 27 | feeRecipient = makeAddr("feeRecipient"); 28 | withdrawalAddress = makeAddr("withdrawalAddress"); 29 | ethWithdrawalAddress = makeAddr("ethWithdrawalAddress"); 30 | mERC20 = new MockERC20("Test Token", "TOK", 18); 31 | rescueERC20 = new MockERC20("Rescue Test Token", "TOK", 18); 32 | 33 | feeShare = 1e4; // 10% 34 | collectorFactoryWithFee = new ObolCollectorFactory(feeRecipient, feeShare); 35 | 36 | collectorWithFee = ObolCollector(collectorFactoryWithFee.createCollector(address(mERC20), withdrawalAddress)); 37 | ethCollectorWithFee = ObolCollector(collectorFactoryWithFee.createCollector(address(0), ethWithdrawalAddress)); 38 | 39 | mERC20.mint(type(uint256).max); 40 | rescueERC20.mint(type(uint256).max); 41 | } 42 | 43 | function test_InvalidFeeShare() public { 44 | vm.expectRevert(abi.encodeWithSelector(BaseSplit.Invalid_FeeShare.selector, 1e10)); 45 | new ObolCollectorFactory(address(0), 1e10); 46 | 47 | vm.expectRevert(abi.encodeWithSelector(BaseSplit.Invalid_FeeShare.selector, 1e5)); 48 | new ObolCollectorFactory(address(0), 1e5); 49 | } 50 | 51 | function test_feeShare() public { 52 | assertEq(collectorWithFee.feeShare(), feeShare, "invalid collector fee"); 53 | 54 | assertEq(ethCollectorWithFee.feeShare(), feeShare, "invalid collector value fee"); 55 | } 56 | 57 | function test_feeRecipient() public { 58 | assertEq(collectorWithFee.feeRecipient(), feeRecipient, "invalid collector feeRecipient"); 59 | 60 | assertEq(ethCollectorWithFee.feeRecipient(), feeRecipient, "invalid collector feeRecipient 2"); 61 | } 62 | 63 | function test_withdrawalAddress() public { 64 | assertEq(collectorWithFee.withdrawalAddress(), withdrawalAddress, "invalid split wallet"); 65 | 66 | assertEq(ethCollectorWithFee.withdrawalAddress(), ethWithdrawalAddress, "invalid eth split wallet"); 67 | } 68 | 69 | function test_token() public { 70 | assertEq(collectorWithFee.token(), address(mERC20), "invalid token"); 71 | 72 | assertEq(ethCollectorWithFee.token(), address(0), "ivnalid token eth"); 73 | } 74 | 75 | function test_DistributeERC20WithFee() public { 76 | uint256 amountToDistribute = 10 ether; 77 | 78 | mERC20.transfer(address(collectorWithFee), amountToDistribute); 79 | 80 | collectorWithFee.distribute(); 81 | 82 | uint256 fee = amountToDistribute * feeShare / PERCENTAGE_SCALE; 83 | 84 | assertEq(mERC20.balanceOf(feeRecipient), fee, "invalid fee share"); 85 | 86 | assertEq(mERC20.balanceOf(withdrawalAddress), amountToDistribute - fee, "invalid amount to split"); 87 | } 88 | 89 | function testFuzz_DistributeERC20WithFee( 90 | uint256 amountToDistribute, 91 | uint256 fuzzFeeShare, 92 | address fuzzFeeRecipient, 93 | address fuzzWithdrawalAddress 94 | ) public { 95 | vm.assume(amountToDistribute > 0); 96 | vm.assume(fuzzWithdrawalAddress != address(0)); 97 | vm.assume(fuzzFeeRecipient != address(0)); 98 | vm.assume(fuzzFeeRecipient != fuzzWithdrawalAddress); 99 | 100 | amountToDistribute = bound(amountToDistribute, 1, type(uint128).max); 101 | fuzzFeeShare = bound(fuzzFeeShare, 1, 8 * 1e4); 102 | 103 | ObolCollectorFactory fuzzCollectorFactoryWithFee = new ObolCollectorFactory(fuzzFeeRecipient, fuzzFeeShare); 104 | ObolCollector fuzzCollectorWithFee = 105 | ObolCollector(fuzzCollectorFactoryWithFee.createCollector(address(mERC20), fuzzWithdrawalAddress)); 106 | 107 | uint256 feeRecipientBalancePrev = mERC20.balanceOf(fuzzFeeRecipient); 108 | uint256 fuzzWithdrawalAddressBalancePrev = mERC20.balanceOf(fuzzWithdrawalAddress); 109 | 110 | mERC20.transfer(address(fuzzCollectorWithFee), amountToDistribute); 111 | 112 | fuzzCollectorWithFee.distribute(); 113 | 114 | uint256 fee = amountToDistribute * fuzzFeeShare / PERCENTAGE_SCALE; 115 | 116 | assertEq(mERC20.balanceOf(fuzzFeeRecipient), feeRecipientBalancePrev + fee, "invalid fee share"); 117 | 118 | assertEq( 119 | mERC20.balanceOf(fuzzWithdrawalAddress), 120 | fuzzWithdrawalAddressBalancePrev + amountToDistribute - fee, 121 | "invalid amount to split" 122 | ); 123 | } 124 | 125 | function test_DistributeETHWithFee() public { 126 | uint256 amountToDistribute = 10 ether; 127 | 128 | vm.deal(address(ethCollectorWithFee), amountToDistribute); 129 | 130 | ethCollectorWithFee.distribute(); 131 | 132 | uint256 fee = amountToDistribute * feeShare / PERCENTAGE_SCALE; 133 | 134 | assertEq(address(feeRecipient).balance, fee, "invalid fee share"); 135 | 136 | assertEq(address(ethWithdrawalAddress).balance, amountToDistribute - fee, "invalid amount to split"); 137 | } 138 | 139 | function testFuzz_DistributeETHWithFee(uint256 amountToDistribute, uint256 fuzzFeeShare) public { 140 | vm.assume(amountToDistribute > 0); 141 | vm.assume(fuzzFeeShare > 0); 142 | 143 | address fuzzWithdrawalAddress = makeAddr("fuzzWithdrawalAddress"); 144 | address fuzzFeeRecipient = makeAddr("fuzzFeeRecipient"); 145 | 146 | amountToDistribute = bound(amountToDistribute, 1, type(uint96).max); 147 | fuzzFeeShare = bound(fuzzFeeShare, 1, 9 * 1e4); 148 | 149 | ObolCollectorFactory fuzzCollectorFactoryWithFee = new ObolCollectorFactory(fuzzFeeRecipient, fuzzFeeShare); 150 | ObolCollector fuzzETHCollectorWithFee = 151 | ObolCollector(fuzzCollectorFactoryWithFee.createCollector(address(0), fuzzWithdrawalAddress)); 152 | 153 | vm.deal(address(fuzzETHCollectorWithFee), amountToDistribute); 154 | 155 | uint256 fuzzFeeRecipientBalance = address(fuzzFeeRecipient).balance; 156 | uint256 fuzzWithdrawalAddressBalance = address(fuzzWithdrawalAddress).balance; 157 | 158 | fuzzETHCollectorWithFee.distribute(); 159 | 160 | uint256 fee = amountToDistribute * fuzzFeeShare / PERCENTAGE_SCALE; 161 | 162 | assertEq(address(fuzzFeeRecipient).balance, fuzzFeeRecipientBalance + fee, "invalid fee share"); 163 | 164 | assertEq( 165 | address(fuzzWithdrawalAddress).balance, 166 | fuzzWithdrawalAddressBalance + amountToDistribute - fee, 167 | "invalid amount to split" 168 | ); 169 | } 170 | 171 | function testCannot_RescueControllerToken() public { 172 | deal(address(ethCollectorWithFee), 1 ether); 173 | vm.expectRevert(BaseSplit.Invalid_Address.selector); 174 | ethCollectorWithFee.rescueFunds(address(0)); 175 | 176 | mERC20.transfer(address(collectorWithFee), 1 ether); 177 | vm.expectRevert(BaseSplit.Invalid_Address.selector); 178 | collectorWithFee.rescueFunds(address(mERC20)); 179 | } 180 | 181 | function test_RescueTokens() public { 182 | uint256 amountToRescue = 1 ether; 183 | deal(address(collectorWithFee), amountToRescue); 184 | collectorWithFee.rescueFunds(address(0)); 185 | 186 | assertEq(address(withdrawalAddress).balance, amountToRescue, "invalid amount"); 187 | 188 | rescueERC20.transfer(address(collectorWithFee), amountToRescue); 189 | collectorWithFee.rescueFunds(address(rescueERC20)); 190 | assertEq(rescueERC20.balanceOf(withdrawalAddress), amountToRescue, "invalid erc20 amount"); 191 | 192 | // ETH 193 | rescueERC20.transfer(address(ethCollectorWithFee), amountToRescue); 194 | ethCollectorWithFee.rescueFunds(address(rescueERC20)); 195 | 196 | assertEq(rescueERC20.balanceOf(ethWithdrawalAddress), amountToRescue, "invalid erc20 amount"); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/test/owr/token/OptimisticTokenWithdrawalRecipientFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {OptimisticWithdrawalRecipient} from "src/owr/OptimisticWithdrawalRecipient.sol"; 6 | import {OptimisticTokenWithdrawalRecipientFactory} from "src/owr/token/OptimisticTokenWithdrawalRecipientFactory.sol"; 7 | import {MockERC20} from "../../utils/mocks/MockERC20.sol"; 8 | import {OWRTestHelper} from "../OWRTestHelper.t.sol"; 9 | 10 | contract OptimisticTokenWithdrawalRecipientFactoryTest is OWRTestHelper, Test { 11 | event CreateOWRecipient( 12 | address indexed owr, 13 | address token, 14 | address recoveryAddress, 15 | address principalRecipient, 16 | address rewardRecipient, 17 | uint256 threshold 18 | ); 19 | 20 | OptimisticTokenWithdrawalRecipientFactory owrFactoryModule; 21 | MockERC20 mERC20; 22 | address public recoveryAddress; 23 | address public principalRecipient; 24 | address public rewardRecipient; 25 | uint256 public threshold; 26 | 27 | function setUp() public { 28 | mERC20 = new MockERC20("Test Token", "TOK", 18); 29 | mERC20.mint(type(uint256).max); 30 | 31 | owrFactoryModule = new OptimisticTokenWithdrawalRecipientFactory(BALANCE_CLASSIFICATION_THRESHOLD); 32 | 33 | recoveryAddress = makeAddr("recoveryAddress"); 34 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(10); 35 | threshold = ETH_STAKE; 36 | } 37 | 38 | function testCan_createOWRecipient() public { 39 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 40 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 41 | 42 | recoveryAddress = address(0); 43 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 44 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 45 | } 46 | 47 | function testCan_emitOnCreate() public { 48 | // don't check deploy address 49 | vm.expectEmit(false, true, true, true); 50 | emit CreateOWRecipient( 51 | address(0xdead), ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold 52 | ); 53 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 54 | 55 | // don't check deploy address 56 | vm.expectEmit(false, true, true, true); 57 | emit CreateOWRecipient( 58 | address(0xdead), address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold 59 | ); 60 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 61 | 62 | recoveryAddress = address(0); 63 | // don't check deploy address 64 | vm.expectEmit(false, true, true, true); 65 | emit CreateOWRecipient( 66 | address(0xdead), ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold 67 | ); 68 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 69 | 70 | // don't check deploy address 71 | vm.expectEmit(false, true, true, true); 72 | emit CreateOWRecipient( 73 | address(0xdead), address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold 74 | ); 75 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 76 | } 77 | 78 | function testCannot_createWithInvalidRecipients() public { 79 | (principalRecipient, rewardRecipient, threshold) = generateTranches(1, 1); 80 | // eth 81 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__Recipients.selector); 82 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, address(0), rewardRecipient, threshold); 83 | 84 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__Recipients.selector); 85 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, address(0), address(0), threshold); 86 | 87 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__Recipients.selector); 88 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, address(0), threshold); 89 | 90 | // erc20 91 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__Recipients.selector); 92 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, address(0), rewardRecipient, threshold); 93 | 94 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__Recipients.selector); 95 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, address(0), address(0), threshold); 96 | 97 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__Recipients.selector); 98 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, address(0), threshold); 99 | } 100 | 101 | function testCannot_createWithInvalidThreshold() public { 102 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(2); 103 | threshold = 0; 104 | 105 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__ZeroThreshold.selector); 106 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 107 | 108 | vm.expectRevert( 109 | abi.encodeWithSelector( 110 | OptimisticTokenWithdrawalRecipientFactory.Invalid__ThresholdTooLarge.selector, type(uint128).max 111 | ) 112 | ); 113 | owrFactoryModule.createOWRecipient( 114 | ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, type(uint128).max 115 | ); 116 | } 117 | 118 | /// ----------------------------------------------------------------------- 119 | /// Fuzzing Tests 120 | /// ---------------------------------------------------------------------- 121 | 122 | function testFuzzCan_createOWRecipient(address _recoveryAddress, uint256 recipientsSeed, uint256 thresholdSeed) 123 | public 124 | { 125 | recoveryAddress = _recoveryAddress; 126 | 127 | (principalRecipient, rewardRecipient, threshold) = generateTranches(recipientsSeed, thresholdSeed); 128 | 129 | vm.expectEmit(false, true, true, true); 130 | emit CreateOWRecipient( 131 | address(0xdead), ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold 132 | ); 133 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 134 | 135 | vm.expectEmit(false, true, true, true); 136 | emit CreateOWRecipient( 137 | address(0xdead), address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold 138 | ); 139 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 140 | } 141 | 142 | function testFuzzCannot_CreateWithZeroThreshold(uint256 _receipientSeed) public { 143 | threshold = 0; 144 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(_receipientSeed); 145 | 146 | // eth 147 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__ZeroThreshold.selector); 148 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 149 | 150 | // erc20 151 | vm.expectRevert(OptimisticTokenWithdrawalRecipientFactory.Invalid__ZeroThreshold.selector); 152 | 153 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 154 | } 155 | 156 | function testFuzzCannot_CreateWithLargeThreshold(uint256 _receipientSeed, uint256 _threshold) public { 157 | vm.assume(_threshold > type(uint96).max); 158 | 159 | threshold = _threshold; 160 | (principalRecipient, rewardRecipient) = generateTrancheRecipients(_receipientSeed); 161 | 162 | vm.expectRevert( 163 | abi.encodeWithSelector(OptimisticTokenWithdrawalRecipientFactory.Invalid__ThresholdTooLarge.selector, _threshold) 164 | ); 165 | 166 | owrFactoryModule.createOWRecipient(ETH_ADDRESS, recoveryAddress, principalRecipient, rewardRecipient, threshold); 167 | 168 | vm.expectRevert( 169 | abi.encodeWithSelector(OptimisticTokenWithdrawalRecipientFactory.Invalid__ThresholdTooLarge.selector, _threshold) 170 | ); 171 | 172 | owrFactoryModule.createOWRecipient(address(mERC20), recoveryAddress, principalRecipient, rewardRecipient, threshold); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/test/lido/ObolLidoSplit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | import {ObolLidoSplitFactory, ObolLidoSplit, IwstETH} from "src/lido/ObolLidoSplitFactory.sol"; 6 | import {BaseSplit} from "src/base/BaseSplit.sol"; 7 | import {ERC20} from "solmate/tokens/ERC20.sol"; 8 | import {ObolLidoSplitTestHelper} from "./ObolLidoSplitTestHelper.sol"; 9 | import {MockERC20} from "src/test/utils/mocks/MockERC20.sol"; 10 | 11 | contract ObolLidoSplitTest is ObolLidoSplitTestHelper, Test { 12 | uint256 internal constant PERCENTAGE_SCALE = 1e5; 13 | 14 | ObolLidoSplitFactory internal lidoSplitFactory; 15 | ObolLidoSplitFactory internal lidoSplitFactoryWithFee; 16 | 17 | ObolLidoSplit internal lidoSplit; 18 | ObolLidoSplit internal lidoSplitWithFee; 19 | 20 | address demoSplit; 21 | address feeRecipient; 22 | uint256 feeShare; 23 | 24 | MockERC20 mERC20; 25 | 26 | function setUp() public { 27 | uint256 mainnetBlock = 17_421_005; 28 | vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); 29 | 30 | feeRecipient = makeAddr("feeRecipient"); 31 | feeShare = 1e4; 32 | 33 | lidoSplitFactory = 34 | new ObolLidoSplitFactory(address(0), 0, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 35 | 36 | lidoSplitFactoryWithFee = 37 | new ObolLidoSplitFactory(feeRecipient, feeShare, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 38 | 39 | demoSplit = makeAddr("demoSplit"); 40 | 41 | lidoSplit = ObolLidoSplit(lidoSplitFactory.createCollector(address(0), demoSplit)); 42 | lidoSplitWithFee = ObolLidoSplit(lidoSplitFactoryWithFee.createCollector(address(0), demoSplit)); 43 | 44 | mERC20 = new MockERC20("Test Token", "TOK", 18); 45 | mERC20.mint(type(uint256).max); 46 | } 47 | 48 | function test_CannotCreateInvalidFeeRecipient() public { 49 | vm.expectRevert(BaseSplit.Invalid_FeeRecipient.selector); 50 | new ObolLidoSplit(address(0), 10, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 51 | } 52 | 53 | function test_CannotCreateInvalidFeeShare() public { 54 | vm.expectRevert(abi.encodeWithSelector(BaseSplit.Invalid_FeeShare.selector, PERCENTAGE_SCALE + 1)); 55 | new ObolLidoSplit(address(1), PERCENTAGE_SCALE + 1, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 56 | 57 | vm.expectRevert(abi.encodeWithSelector(BaseSplit.Invalid_FeeShare.selector, PERCENTAGE_SCALE)); 58 | new ObolLidoSplit(address(1), PERCENTAGE_SCALE, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); 59 | } 60 | 61 | function test_CloneArgsIsCorrect() public { 62 | assertEq(lidoSplit.withdrawalAddress(), demoSplit, "invalid address"); 63 | assertEq(address(lidoSplit.stETH()), STETH_MAINNET_ADDRESS, "invalid stETH address"); 64 | assertEq(address(lidoSplit.wstETH()), WSTETH_MAINNET_ADDRESS, "invalid wstETH address"); 65 | assertEq(lidoSplit.feeRecipient(), address(0), "invalid fee recipient"); 66 | assertEq(lidoSplit.feeShare(), 0, "invalid fee amount"); 67 | 68 | assertEq(lidoSplitWithFee.withdrawalAddress(), demoSplit, "invalid address"); 69 | assertEq(address(lidoSplitWithFee.stETH()), STETH_MAINNET_ADDRESS, "invalid stETH address"); 70 | assertEq(address(lidoSplitWithFee.wstETH()), WSTETH_MAINNET_ADDRESS, "invalid wstETH address"); 71 | assertEq(lidoSplitWithFee.feeRecipient(), feeRecipient, "invalid fee recipient /2"); 72 | assertEq(lidoSplitWithFee.feeShare(), feeShare, "invalid fee share /2"); 73 | } 74 | 75 | function test_CanRescueFunds() public { 76 | // rescue ETH 77 | uint256 amountOfEther = 1 ether; 78 | deal(address(lidoSplit), amountOfEther); 79 | 80 | uint256 balance = lidoSplit.rescueFunds(address(0)); 81 | assertEq(balance, amountOfEther, "balance not rescued"); 82 | assertEq(address(lidoSplit).balance, 0, "balance is not zero"); 83 | assertEq(address(lidoSplit.withdrawalAddress()).balance, amountOfEther, "rescue not successful"); 84 | 85 | // rescue tokens 86 | mERC20.transfer(address(lidoSplit), amountOfEther); 87 | uint256 tokenBalance = lidoSplit.rescueFunds(address(mERC20)); 88 | assertEq(tokenBalance, amountOfEther, "token - balance not rescued"); 89 | assertEq(mERC20.balanceOf(address(lidoSplit)), 0, "token - balance is not zero"); 90 | assertEq(mERC20.balanceOf(lidoSplit.withdrawalAddress()), amountOfEther, "token - rescue not successful"); 91 | } 92 | 93 | function testCannot_RescueLidoTokens() public { 94 | vm.expectRevert(BaseSplit.Invalid_Address.selector); 95 | lidoSplit.rescueFunds(address(STETH_MAINNET_ADDRESS)); 96 | 97 | vm.expectRevert(BaseSplit.Invalid_Address.selector); 98 | lidoSplit.rescueFunds(address(WSTETH_MAINNET_ADDRESS)); 99 | } 100 | 101 | function test_CanDistributeWithoutFee() public { 102 | // we use a random account on Etherscan to credit the lidoSplit address 103 | // with 10 ether worth of stETH on mainnet 104 | vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); 105 | ERC20(STETH_MAINNET_ADDRESS).transfer(address(lidoSplit), 100 ether); 106 | 107 | uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); 108 | 109 | uint256 amount = lidoSplit.distribute(); 110 | 111 | assertTrue(amount > 0, "invalid amount"); 112 | 113 | uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); 114 | 115 | assertGe(afterBalance, prevBalance, "after balance greater"); 116 | } 117 | 118 | function test_CanDistributeWithFee() public { 119 | // we use a random account on Etherscan to credit the lidoSplit address 120 | // with 10 ether worth of stETH on mainnet 121 | vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); 122 | uint256 amountToDistribute = 100 ether; 123 | ERC20(STETH_MAINNET_ADDRESS).transfer(address(lidoSplitWithFee), amountToDistribute); 124 | 125 | uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); 126 | 127 | uint256 balance = ERC20(STETH_MAINNET_ADDRESS).balanceOf(address(lidoSplitWithFee)); 128 | 129 | uint256 wstETHDistributed = IwstETH(WSTETH_MAINNET_ADDRESS).getWstETHByStETH(balance); 130 | 131 | uint256 amount = lidoSplitWithFee.distribute(); 132 | 133 | assertTrue(amount > 0, "invalid amount"); 134 | 135 | uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); 136 | 137 | assertGe(afterBalance, prevBalance, "after balance greater"); 138 | 139 | uint256 expectedFee = (wstETHDistributed * feeShare) / PERCENTAGE_SCALE; 140 | 141 | assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(feeRecipient), expectedFee, "invalid fee transferred"); 142 | 143 | assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit), wstETHDistributed - expectedFee, "invalid amount"); 144 | } 145 | 146 | function testFuzz_CanDistributeWithFee( 147 | address anotherSplit, 148 | uint8 amountToDistributeEth, 149 | address fuzzFeeRecipient, 150 | uint16 fuzzFeeShare 151 | ) public { 152 | vm.assume(anotherSplit != address(0)); 153 | vm.assume(fuzzFeeRecipient != anotherSplit); 154 | vm.assume(fuzzFeeShare > 0 && fuzzFeeShare < PERCENTAGE_SCALE); 155 | vm.assume(fuzzFeeRecipient != address(0)); 156 | vm.assume(amountToDistributeEth > 1 && amountToDistributeEth < 200); 157 | 158 | uint256 amountToDistribute = uint256(amountToDistributeEth) * 1 ether; 159 | 160 | ObolLidoSplitFactory fuzzFactorySplitWithFee = new ObolLidoSplitFactory( 161 | fuzzFeeRecipient, fuzzFeeShare, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS) 162 | ); 163 | 164 | ObolLidoSplit fuzzSplitWithFee = ObolLidoSplit(fuzzFactorySplitWithFee.createCollector(address(0), anotherSplit)); 165 | 166 | vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); 167 | 168 | ERC20(STETH_MAINNET_ADDRESS).transfer(address(fuzzSplitWithFee), amountToDistribute); 169 | 170 | uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(anotherSplit); 171 | 172 | uint256 balance = ERC20(STETH_MAINNET_ADDRESS).balanceOf(address(fuzzSplitWithFee)); 173 | 174 | uint256 wstETHDistributed = IwstETH(WSTETH_MAINNET_ADDRESS).getWstETHByStETH(balance); 175 | 176 | uint256 amount = fuzzSplitWithFee.distribute(); 177 | 178 | assertTrue(amount > 0, "invalid amount"); 179 | 180 | uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(anotherSplit); 181 | 182 | assertGe(afterBalance, prevBalance, "after balance greater"); 183 | 184 | uint256 expectedFee = (wstETHDistributed * fuzzFeeShare) / PERCENTAGE_SCALE; 185 | 186 | assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(fuzzFeeRecipient), expectedFee, "invalid fee transferred"); 187 | 188 | assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(anotherSplit), wstETHDistributed - expectedFee, "invalid amount"); 189 | } 190 | } 191 | --------------------------------------------------------------------------------