diff --git a/docs/audit/LidoSplit.md b/docs/audit/ObolLidoSplit.md similarity index 92% rename from docs/audit/LidoSplit.md rename to docs/audit/ObolLidoSplit.md index bf0ebe4..5eedf98 100644 --- a/docs/audit/LidoSplit.md +++ b/docs/audit/ObolLidoSplit.md @@ -48,8 +48,8 @@ Source Units in Scope: **`1`** (**100%**) | Type | File | Logic Contracts | Interfaces | Lines | nLines | nSLOC | Comment Lines | Complex. Score | Capabilities | | ---- | ------ | --------------- | ---------- | ----- | ------ | ----- | ------------- | -------------- | ------------ | -| ๐Ÿ“๐Ÿ” | src/lido/LidoSplit.sol | 1 | 1 | 72 | 64 | 29 | 31 | 34 | **** | -| ๐Ÿ“๐Ÿ” | **Totals** | **1** | **1** | **72** | **64** | **29** | **31** | **34** | **** | +| ๐Ÿ“ | src/lido/ObolLidoSplit.sol | 1 | **** | 117 | 117 | 53 | 46 | 59 | **** | +| ๐Ÿ“ | **Totals** | **1** | **** | **117** | **117** | **53** | **46** | **59** | **** | Legend: [โž•] @@ -131,14 +131,14 @@ The analysis finished with **`0`** errors and **`0`** duplicate files. #### Inline Documentation -- **Comment-to-Source Ratio:** On average there are`1` code lines per comment (lower=better). +- **Comment-to-Source Ratio:** On average there are`1.15` code lines per comment (lower=better). - **ToDo's:** `0` #### Components | ๐Ÿ“Contracts | ๐Ÿ“šLibraries | ๐Ÿ”Interfaces | ๐ŸŽจAbstract | | ------------- | ----------- | ------------ | ---------- | -| 1 | 0 | 1 | 0 | +| 1 | 0 | 0 | 0 | #### Exposed Functions @@ -146,23 +146,23 @@ This section lists functions that are explicitly declared public or payable. Ple | ๐ŸŒPublic | ๐Ÿ’ฐPayable | | ---------- | --------- | -| 5 | 0 | +| 3 | 0 | | External | Internal | Private | Pure | View | | ---------- | -------- | ------- | ---- | ---- | -| 2 | 3 | 0 | 3 | 0 | +| 2 | 3 | 0 | 1 | 0 | #### StateVariables | Total | ๐ŸŒPublic | | ---------- | --------- | -| 3 | 0 | +| 7 | 4 | #### Capabilities | Solidity Versions observed | ๐Ÿงช Experimental Features | ๐Ÿ’ฐ Can Receive Funds | ๐Ÿ–ฅ Uses Assembly | ๐Ÿ’ฃ Has Destroyable Contracts | | -------------------------- | ------------------------ | -------------------- | ---------------- | ---------------------------- | -| `=0.8.17` | | **** | **** | **** | +| `0.8.19` | | **** | **** | **** | | ๐Ÿ“ค Transfers ETH | โšก Low-Level Calls | ๐Ÿ‘ฅ DelegateCall | ๐Ÿงฎ Uses Hash Functions | ๐Ÿ”– ECRecover | ๐ŸŒ€ New/Create/Create2 | | ---------------- | ----------------- | --------------- | ---------------------- | ------------ | --------------------- | @@ -179,6 +179,7 @@ This section lists functions that are explicitly declared public or payable. Ple | solady/utils/Clone.sol | 1 | | solmate/tokens/ERC20.sol | 1 | | solmate/utils/SafeTransferLib.sol | 1 | +| src/interfaces/IwstETH.sol | 1 | #### Totals @@ -237,7 +238,7 @@ This section lists functions that are explicitly declared public or payable. Ple | File Name | SHA-1 Hash | |-------------|--------------| -| src/lido/LidoSplit.sol | a6d06d355c3e9abd9b6674b54a0b9b9960d3da33 | +| src/lido/ObolLidoSplit.sol | e60ac5c37593dd7b11dc04af62baa7b122e98ed5 | Contracts Description Table @@ -247,15 +248,11 @@ This section lists functions that are explicitly declared public or payable. Ple |:----------:|:-------------------:|:----------------:|:----------------:|:---------------:| | โ”” | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | |||||| -| **IwSTETH** | Interface | ||| -| โ”” | wrap | External โ—๏ธ | ๐Ÿ›‘ |NOโ—๏ธ | -|||||| -| **LidoSplit** | Implementation | Clone ||| +| **ObolLidoSplit** | Implementation | Clone ||| | โ”” | | Public โ—๏ธ | ๐Ÿ›‘ |NOโ—๏ธ | | โ”” | splitWallet | Public โ—๏ธ | |NOโ—๏ธ | -| โ”” | stETHAddress | Public โ—๏ธ | |NOโ—๏ธ | -| โ”” | wstETHAddress | Public โ—๏ธ | |NOโ—๏ธ | | โ”” | distribute | External โ—๏ธ | ๐Ÿ›‘ |NOโ—๏ธ | +| โ”” | rescueFunds | External โ—๏ธ | ๐Ÿ›‘ |NOโ—๏ธ | Legend diff --git a/docs/audit/LidoSplitFactory.md b/docs/audit/ObolLidoSplitFactory.md similarity index 93% rename from docs/audit/LidoSplitFactory.md rename to docs/audit/ObolLidoSplitFactory.md index 4749e3a..f904fbf 100644 --- a/docs/audit/LidoSplitFactory.md +++ b/docs/audit/ObolLidoSplitFactory.md @@ -48,8 +48,8 @@ Source Units in Scope: **`1`** (**100%**) | Type | File | Logic Contracts | Interfaces | Lines | nLines | nSLOC | Comment Lines | Complex. Score | Capabilities | | ---- | ------ | --------------- | ---------- | ----- | ------ | ----- | ------------- | -------------- | ------------ | -| ๐Ÿ“ | src/lido/LidoSplitFactory.sol | 1 | **** | 73 | 73 | 31 | 25 | 24 | **๐ŸŒ€** | -| ๐Ÿ“ | **Totals** | **1** | **** | **73** | **73** | **31** | **25** | **24** | **๐ŸŒ€** | +| ๐Ÿ“ | src/lido/ObolLidoSplitFactory.sol | 1 | **** | 54 | 54 | 18 | 24 | 22 | **๐ŸŒ€** | +| ๐Ÿ“ | **Totals** | **1** | **** | **54** | **54** | **18** | **24** | **22** | **๐ŸŒ€** | Legend: [โž•] @@ -131,7 +131,7 @@ The analysis finished with **`0`** errors and **`0`** duplicate files. #### Inline Documentation -- **Comment-to-Source Ratio:** On average there are`1.24` code lines per comment (lower=better). +- **Comment-to-Source Ratio:** On average there are`0.75` code lines per comment (lower=better). - **ToDo's:** `0` #### Components @@ -156,17 +156,17 @@ This section lists functions that are explicitly declared public or payable. Ple | Total | ๐ŸŒPublic | | ---------- | --------- | -| 3 | 3 | +| 1 | 1 | #### Capabilities | Solidity Versions observed | ๐Ÿงช Experimental Features | ๐Ÿ’ฐ Can Receive Funds | ๐Ÿ–ฅ Uses Assembly | ๐Ÿ’ฃ Has Destroyable Contracts | | -------------------------- | ------------------------ | -------------------- | ---------------- | ---------------------------- | -| `=0.8.17` | | **** | **** | **** | +| `0.8.19` | | **** | **** | **** | | ๐Ÿ“ค Transfers ETH | โšก Low-Level Calls | ๐Ÿ‘ฅ DelegateCall | ๐Ÿงฎ Uses Hash Functions | ๐Ÿ”– ECRecover | ๐ŸŒ€ New/Create/Create2 | | ---------------- | ----------------- | --------------- | ---------------------- | ------------ | --------------------- | -| **** | **** | **** | **** | **** | `yes`
โ†’ `NewContract:LidoSplit` | +| **** | **** | **** | **** | **** | `yes`
โ†’ `NewContract:ObolLidoSplit` | | โ™ป๏ธ TryCatch | ฮฃ Unchecked | | ---------- | ----------- | @@ -236,7 +236,7 @@ This section lists functions that are explicitly declared public or payable. Ple | File Name | SHA-1 Hash | |-------------|--------------| -| src/lido/LidoSplitFactory.sol | fbe7fc44155c90479b3d1c3f46886b2e67f0d5c0 | +| src/lido/ObolLidoSplitFactory.sol | 39e631fd6416d7ab96b78b1b26855fda259dff64 | Contracts Description Table @@ -246,7 +246,7 @@ This section lists functions that are explicitly declared public or payable. Ple |:----------:|:-------------------:|:----------------:|:----------------:|:---------------:| | โ”” | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | |||||| -| **LidoSplitFactory** | Implementation | ||| +| **ObolLidoSplitFactory** | Implementation | ||| | โ”” | | Public โ—๏ธ | ๐Ÿ›‘ |NOโ—๏ธ | | โ”” | createSplit | External โ—๏ธ | ๐Ÿ›‘ |NOโ—๏ธ | diff --git a/foundry.toml b/foundry.toml index 48cd2d6..03b326f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -9,6 +9,7 @@ remappings = [ 'solady/=lib/solady/src/', ] solc_version = '0.8.19' +gas_reports = ["*"] [rpc_endpoints] @@ -27,4 +28,4 @@ tab_width = 2 wrap_comments = true [fuzz] -runs = 1000 +runs = 100 diff --git a/src/controllers/ImmutableSplitController.sol b/src/controllers/ImmutableSplitController.sol index 543ce37..0c7348a 100644 --- a/src/controllers/ImmutableSplitController.sol +++ b/src/controllers/ImmutableSplitController.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.19; import {ISplitMain} from "../interfaces/ISplitMain.sol"; diff --git a/src/controllers/ImmutableSplitControllerFactory.sol b/src/controllers/ImmutableSplitControllerFactory.sol index 98237af..4999872 100644 --- a/src/controllers/ImmutableSplitControllerFactory.sol +++ b/src/controllers/ImmutableSplitControllerFactory.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.19; import {ISplitMain} from "../interfaces/ISplitMain.sol"; diff --git a/src/interfaces/IwstETH.sol b/src/interfaces/IwstETH.sol new file mode 100644 index 0000000..7918250 --- /dev/null +++ b/src/interfaces/IwstETH.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IwstETH { + function wrap(uint256 amount) external returns (uint256); + function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256); +} \ No newline at end of file diff --git a/src/lido/LidoSplit.sol b/src/lido/ObolLidoSplit.sol similarity index 60% rename from src/lido/LidoSplit.sol rename to src/lido/ObolLidoSplit.sol index 6457e34..c27450b 100644 --- a/src/lido/LidoSplit.sol +++ b/src/lido/ObolLidoSplit.sol @@ -4,20 +4,18 @@ pragma solidity 0.8.19; import {ERC20} from "solmate/tokens/ERC20.sol"; import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; import {Clone} from "solady/utils/Clone.sol"; +import {IwstETH} from "src/interfaces/IwstETH.sol"; -interface IwSTETH { - function wrap(uint256 amount) external returns (uint256); -} - -/// @title LidoSplit +/// @title ObolLidoSplit /// @author Obol /// @notice A wrapper for 0xsplits/split-contracts SplitWallet that transforms /// stETH token to wstETH token because stETH is a rebasing token /// @dev Wraps stETH to wstETH and transfers to defined SplitWallet address -contract LidoSplit is Clone { - +contract ObolLidoSplit is Clone { error Invalid_Address(); - + error Invalid_FeeShare(uint256 fee); + error Invalid_FeeRecipient(); + /// ----------------------------------------------------------------------- /// libraries /// ----------------------------------------------------------------------- @@ -25,6 +23,7 @@ contract LidoSplit is Clone { using SafeTransferLib for address; address internal constant ETH_ADDRESS = address(0); + uint256 internal constant PERCENTAGE_SCALE = 1e5; /// ----------------------------------------------------------------------- /// storage - cwia offsets @@ -34,20 +33,35 @@ contract LidoSplit is Clone { // 0; first item uint256 internal constant SPLIT_WALLET_ADDRESS_OFFSET = 0; - /// ----------------------------------------------------------------------- /// storage /// ----------------------------------------------------------------------- - + /// @notice stETH token ERC20 public immutable stETH; /// @notice wstETH token ERC20 public immutable wstETH; - constructor(ERC20 _stETH, ERC20 _wstETH) { + /// @notice fee address + address public immutable feeRecipient; + + /// @notice fee share + uint256 public immutable feeShare; + + /// @notice Constructor + /// @param _feeRecipient address to receive fee + /// @param _feeShare fee share scaled by PERCENTAGE_SCALE + /// @param _stETH stETH address + /// @param _wstETH wstETH address + constructor(address _feeRecipient, uint256 _feeShare, ERC20 _stETH, ERC20 _wstETH) { + if (_feeShare >= PERCENTAGE_SCALE) revert Invalid_FeeShare(_feeShare); + if (_feeShare > 0 && _feeRecipient == address(0)) revert Invalid_FeeRecipient(); + + feeRecipient = _feeRecipient; stETH = _stETH; wstETH = _wstETH; + feeShare = _feeShare; } /// Address of split wallet to send funds to to @@ -65,17 +79,33 @@ contract LidoSplit is Clone { // approve the wstETH stETH.approve(address(wstETH), balance); // wrap into wseth - amount = IwSTETH(address(wstETH)).wrap(balance); - // transfer to split wallet - ERC20(wstETH).safeTransfer(splitWallet(), amount); + // we ignore the return value + IwstETH(address(wstETH)).wrap(balance); + // we use balanceOf here in case some wstETH is stuck in the + // contract we would be able to rescue it + amount = ERC20(wstETH).balanceOf(address(this)); + + if (feeShare > 0) { + uint256 fee = (amount * feeShare) / PERCENTAGE_SCALE; + // transfer to split wallet + // update amount to reflect fee charged + ERC20(wstETH).safeTransfer(splitWallet(), amount -= fee); + // transfer to fee address + ERC20(wstETH).safeTransfer(feeRecipient, fee); + } else { + // transfer to split wallet + ERC20(wstETH).safeTransfer(splitWallet(), amount); + } } /// @notice Rescue stuck ETH and tokens /// Uses token == address(0) to represent ETH /// @return balance Amount of ETH or tokens rescued function rescueFunds(address token) external returns (uint256 balance) { - if (token == address(stETH)) revert Invalid_Address(); - + // we check wstETH here so rescueFunds can't be used + // to bypass fee + if (token == address(stETH) || token == address(wstETH)) revert Invalid_Address(); + if (token == ETH_ADDRESS) { balance = address(this).balance; if (balance > 0) splitWallet().safeTransferETH(balance); diff --git a/src/lido/LidoSplitFactory.sol b/src/lido/ObolLidoSplitFactory.sol similarity index 76% rename from src/lido/LidoSplitFactory.sol rename to src/lido/ObolLidoSplitFactory.sol index 922827c..4d0514f 100644 --- a/src/lido/LidoSplitFactory.sol +++ b/src/lido/ObolLidoSplitFactory.sol @@ -3,13 +3,13 @@ pragma solidity 0.8.19; import {LibClone} from "solady/utils/LibClone.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; -import "./LidoSplit.sol"; +import "./ObolLidoSplit.sol"; -/// @title LidoSplitFactory +/// @title ObolLidoSplitFactory /// @author Obol -/// @notice A factory contract for cheaply deploying LidoSplit. +/// @notice A factory contract for cheaply deploying ObolLidoSplit. /// @dev The address returned should be used to as reward address for Lido -contract LidoSplitFactory { +contract ObolLidoSplitFactory { /// ----------------------------------------------------------------------- /// errors /// ----------------------------------------------------------------------- @@ -27,17 +27,17 @@ contract LidoSplitFactory { /// ----------------------------------------------------------------------- /// Emitted after lido split - event CreateLidoSplit(address split); + event CreateObolLidoSplit(address split); /// ----------------------------------------------------------------------- /// storage /// ----------------------------------------------------------------------- /// @dev lido split implementation - LidoSplit public immutable lidoSplitImpl; + ObolLidoSplit public immutable lidoSplitImpl; - constructor(ERC20 _stETH, ERC20 _wstETH) { - lidoSplitImpl = new LidoSplit(_stETH, _wstETH); + constructor(address _feeRecipient, uint256 _feeShare, ERC20 _stETH, ERC20 _wstETH) { + lidoSplitImpl = new ObolLidoSplit(_feeRecipient, _feeShare, _stETH, _wstETH); } /// Creates a wrapper for splitWallet that transforms stETH token into @@ -49,6 +49,6 @@ contract LidoSplitFactory { lidoSplit = address(lidoSplitImpl).clone(abi.encodePacked(splitWallet)); - emit CreateLidoSplit(lidoSplit); + emit CreateObolLidoSplit(lidoSplit); } } diff --git a/src/test/controllers/IMSC.t.sol b/src/test/controllers/IMSC.t.sol index 7d3cc43..b1c9d95 100644 --- a/src/test/controllers/IMSC.t.sol +++ b/src/test/controllers/IMSC.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; diff --git a/src/test/controllers/IMSCFactory.t.sol b/src/test/controllers/IMSCFactory.t.sol index 6a25d45..2079b24 100644 --- a/src/test/controllers/IMSCFactory.t.sol +++ b/src/test/controllers/IMSCFactory.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; diff --git a/src/test/lido/LIdoSplitFactory.t.sol b/src/test/lido/LIdoSplitFactory.t.sol deleted file mode 100644 index 9ffb7cb..0000000 --- a/src/test/lido/LIdoSplitFactory.t.sol +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.19; - -import "forge-std/Test.sol"; -import {LidoSplitFactory} from "src/lido/LidoSplitFactory.sol"; -import {ERC20} from "solmate/tokens/ERC20.sol"; -import {LidoSplitTestHelper} from "./LidoSplitTestHelper.sol"; - -contract LidoSplitFactoryTest is LidoSplitTestHelper, Test { - LidoSplitFactory internal lidoSplitFactory; - - address demoSplit; - - event CreateLidoSplit(address split); - - function setUp() public { - uint256 mainnetBlock = 17_421_005; - vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); - - lidoSplitFactory = new LidoSplitFactory( - ERC20(STETH_MAINNET_ADDRESS), - ERC20(WSTETH_MAINNET_ADDRESS) - ); - - demoSplit = makeAddr("demoSplit"); - } - - function testCan_CreateSplit() public { - vm.expectEmit(true, true, true, false, address(lidoSplitFactory)); - emit CreateLidoSplit(address(0x1)); - - lidoSplitFactory.createSplit(demoSplit); - } - - function testCannot_CreateSplitInvalidAddress() public { - vm.expectRevert(LidoSplitFactory.Invalid_Wallet.selector); - lidoSplitFactory.createSplit(address(0)); - } -} diff --git a/src/test/lido/LidoSplit.t.sol b/src/test/lido/LidoSplit.t.sol deleted file mode 100644 index 37a5d33..0000000 --- a/src/test/lido/LidoSplit.t.sol +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.19; - -import "forge-std/Test.sol"; -import {LidoSplitFactory, LidoSplit} from "src/lido/LidoSplitFactory.sol"; -import {ERC20} from "solmate/tokens/ERC20.sol"; -import {LidoSplitTestHelper} from "./LidoSplitTestHelper.sol"; -import { MockERC20 } from "src/test/utils/mocks/MockERC20.sol"; - - -contract LidoSplitTest is LidoSplitTestHelper, Test { - LidoSplitFactory internal lidoSplitFactory; - LidoSplit internal lidoSplit; - - address demoSplit; - - MockERC20 mERC20; - - function setUp() public { - uint256 mainnetBlock = 17_421_005; - vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); - - lidoSplitFactory = new LidoSplitFactory( - ERC20(STETH_MAINNET_ADDRESS), - ERC20(WSTETH_MAINNET_ADDRESS) - ); - - demoSplit = makeAddr("demoSplit"); - - lidoSplit = LidoSplit(lidoSplitFactory.createSplit(demoSplit)); - - mERC20 = new MockERC20("Test Token", "TOK", 18); - mERC20.mint(type(uint256).max); - } - - function test_CloneArgsIsCorrect() public { - assertEq(lidoSplit.splitWallet(), demoSplit, "invalid address"); - assertEq(address(lidoSplit.stETH()), STETH_MAINNET_ADDRESS, "invalid stETH address"); - assertEq(address(lidoSplit.wstETH()), WSTETH_MAINNET_ADDRESS, "invalid wstETH address"); - } - - function test_CanRescueFunds() public { - // rescue ETH - uint256 amountOfEther = 1 ether; - deal(address(lidoSplit), amountOfEther); - - uint256 balance = lidoSplit.rescueFunds(address(0)); - assertEq(balance, amountOfEther, "balance not rescued"); - assertEq(address(lidoSplit).balance, 0, "balance is not zero"); - assertEq(address(lidoSplit.splitWallet()).balance, amountOfEther, "rescue not successful"); - - // rescue tokens - mERC20.transfer(address(lidoSplit), amountOfEther); - uint256 tokenBalance = lidoSplit.rescueFunds(address(mERC20)); - assertEq(tokenBalance, amountOfEther, "token - balance not rescued"); - assertEq(mERC20.balanceOf(address(lidoSplit)), 0, "token - balance is not zero"); - assertEq(mERC20.balanceOf(lidoSplit.splitWallet()), amountOfEther, "token - rescue not successful"); - } - - function testCannot_RescueLidoTokens() public { - vm.expectRevert( - LidoSplit.Invalid_Address.selector - ); - lidoSplit.rescueFunds(address(STETH_MAINNET_ADDRESS)); - } - - function test_CanDistribute() public { - // we use a random account on Etherscan to credit the lidoSplit address - // with 10 ether worth of stETH on mainnet - vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); - ERC20(STETH_MAINNET_ADDRESS).transfer(address(lidoSplit), 100 ether); - - uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); - - uint256 amount = lidoSplit.distribute(); - - assertTrue(amount > 0, "invalid amount"); - - uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); - - assertGe(afterBalance, prevBalance, "after balance greater"); - } -} diff --git a/src/test/lido/ObolLIdoSplitFactory.t.sol b/src/test/lido/ObolLIdoSplitFactory.t.sol new file mode 100644 index 0000000..fb5d5cc --- /dev/null +++ b/src/test/lido/ObolLIdoSplitFactory.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {ObolLidoSplitFactory} from "src/lido/ObolLidoSplitFactory.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ObolLidoSplitTestHelper} from "./ObolLidoSplitTestHelper.sol"; + +contract ObolLidoSplitFactoryTest is ObolLidoSplitTestHelper, Test { + ObolLidoSplitFactory internal lidoSplitFactory; + ObolLidoSplitFactory internal lidoSplitFactoryWithFee; + + address demoSplit; + + event CreateObolLidoSplit(address split); + + function setUp() public { + uint256 mainnetBlock = 17_421_005; + vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); + + lidoSplitFactory = new ObolLidoSplitFactory( + address(0), + 0, + ERC20(STETH_MAINNET_ADDRESS), + ERC20(WSTETH_MAINNET_ADDRESS) + ); + + lidoSplitFactoryWithFee = new ObolLidoSplitFactory( + address(this), + 1e3, + ERC20(STETH_MAINNET_ADDRESS), + ERC20(WSTETH_MAINNET_ADDRESS) + ); + + demoSplit = makeAddr("demoSplit"); + } + + function testCan_CreateSplit() public { + vm.expectEmit(true, true, true, false, address(lidoSplitFactory)); + emit CreateObolLidoSplit(address(0x1)); + + lidoSplitFactory.createSplit(demoSplit); + + vm.expectEmit(true, true, true, false, address(lidoSplitFactoryWithFee)); + emit CreateObolLidoSplit(address(0x1)); + + lidoSplitFactoryWithFee.createSplit(demoSplit); + } + + function testCannot_CreateSplitInvalidAddress() public { + vm.expectRevert(ObolLidoSplitFactory.Invalid_Wallet.selector); + lidoSplitFactory.createSplit(address(0)); + + vm.expectRevert(ObolLidoSplitFactory.Invalid_Wallet.selector); + lidoSplitFactoryWithFee.createSplit(address(0)); + } +} diff --git a/src/test/lido/ObolLidoSplit.t.sol b/src/test/lido/ObolLidoSplit.t.sol new file mode 100644 index 0000000..c9b98a0 --- /dev/null +++ b/src/test/lido/ObolLidoSplit.t.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {ObolLidoSplitFactory, ObolLidoSplit, IwstETH} from "src/lido/ObolLidoSplitFactory.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ObolLidoSplitTestHelper} from "./ObolLidoSplitTestHelper.sol"; +import {MockERC20} from "src/test/utils/mocks/MockERC20.sol"; + +contract ObolLidoSplitTest is ObolLidoSplitTestHelper, Test { + uint256 internal constant PERCENTAGE_SCALE = 1e5; + + ObolLidoSplitFactory internal lidoSplitFactory; + ObolLidoSplitFactory internal lidoSplitFactoryWithFee; + + ObolLidoSplit internal lidoSplit; + ObolLidoSplit internal lidoSplitWithFee; + + address demoSplit; + address feeRecipient; + uint256 feeShare; + + MockERC20 mERC20; + + function setUp() public { + uint256 mainnetBlock = 17_421_005; + vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); + + feeRecipient = makeAddr("feeRecipient"); + feeShare = 1e4; + + lidoSplitFactory = new ObolLidoSplitFactory( + address(0), + 0, + ERC20(STETH_MAINNET_ADDRESS), + ERC20(WSTETH_MAINNET_ADDRESS) + ); + + lidoSplitFactoryWithFee = new ObolLidoSplitFactory( + feeRecipient, + feeShare, + ERC20(STETH_MAINNET_ADDRESS), + ERC20(WSTETH_MAINNET_ADDRESS) + ); + + demoSplit = makeAddr("demoSplit"); + + lidoSplit = ObolLidoSplit(lidoSplitFactory.createSplit(demoSplit)); + lidoSplitWithFee = ObolLidoSplit(lidoSplitFactoryWithFee.createSplit(demoSplit)); + + mERC20 = new MockERC20("Test Token", "TOK", 18); + mERC20.mint(type(uint256).max); + } + + function test_CannotCreateInvalidFeeRecipient() public { + vm.expectRevert( + ObolLidoSplit.Invalid_FeeRecipient.selector + ); + new ObolLidoSplit(address(0), 10, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); + } + + function test_CannotCreateInvalidFeeShare() public { + vm.expectRevert( + abi.encodeWithSelector(ObolLidoSplit.Invalid_FeeShare.selector, PERCENTAGE_SCALE + 1) + ); + new ObolLidoSplit(address(1), PERCENTAGE_SCALE + 1, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); + + vm.expectRevert( + abi.encodeWithSelector(ObolLidoSplit.Invalid_FeeShare.selector, PERCENTAGE_SCALE) + ); + new ObolLidoSplit(address(1), PERCENTAGE_SCALE, ERC20(STETH_MAINNET_ADDRESS), ERC20(WSTETH_MAINNET_ADDRESS)); + } + + function test_CloneArgsIsCorrect() public { + assertEq(lidoSplit.splitWallet(), demoSplit, "invalid address"); + assertEq(address(lidoSplit.stETH()), STETH_MAINNET_ADDRESS, "invalid stETH address"); + assertEq(address(lidoSplit.wstETH()), WSTETH_MAINNET_ADDRESS, "invalid wstETH address"); + assertEq(lidoSplit.feeRecipient(), address(0), "invalid fee recipient"); + assertEq(lidoSplit.feeShare(), 0, "invalid fee amount"); + + assertEq(lidoSplitWithFee.splitWallet(), demoSplit, "invalid address"); + assertEq(address(lidoSplitWithFee.stETH()), STETH_MAINNET_ADDRESS, "invalid stETH address"); + assertEq(address(lidoSplitWithFee.wstETH()), WSTETH_MAINNET_ADDRESS, "invalid wstETH address"); + assertEq(lidoSplitWithFee.feeRecipient(), feeRecipient, "invalid fee recipient /2"); + assertEq(lidoSplitWithFee.feeShare(), feeShare, "invalid fee share /2"); + } + + function test_CanRescueFunds() public { + // rescue ETH + uint256 amountOfEther = 1 ether; + deal(address(lidoSplit), amountOfEther); + + uint256 balance = lidoSplit.rescueFunds(address(0)); + assertEq(balance, amountOfEther, "balance not rescued"); + assertEq(address(lidoSplit).balance, 0, "balance is not zero"); + assertEq(address(lidoSplit.splitWallet()).balance, amountOfEther, "rescue not successful"); + + // rescue tokens + mERC20.transfer(address(lidoSplit), amountOfEther); + uint256 tokenBalance = lidoSplit.rescueFunds(address(mERC20)); + assertEq(tokenBalance, amountOfEther, "token - balance not rescued"); + assertEq(mERC20.balanceOf(address(lidoSplit)), 0, "token - balance is not zero"); + assertEq(mERC20.balanceOf(lidoSplit.splitWallet()), amountOfEther, "token - rescue not successful"); + } + + function testCannot_RescueLidoTokens() public { + vm.expectRevert(ObolLidoSplit.Invalid_Address.selector); + lidoSplit.rescueFunds(address(STETH_MAINNET_ADDRESS)); + + vm.expectRevert(ObolLidoSplit.Invalid_Address.selector); + lidoSplit.rescueFunds(address(WSTETH_MAINNET_ADDRESS)); + } + + function test_CanDistributeWithoutFee() public { + // we use a random account on Etherscan to credit the lidoSplit address + // with 10 ether worth of stETH on mainnet + vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); + ERC20(STETH_MAINNET_ADDRESS).transfer(address(lidoSplit), 100 ether); + + uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); + + uint256 amount = lidoSplit.distribute(); + + assertTrue(amount > 0, "invalid amount"); + + uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); + + assertGe(afterBalance, prevBalance, "after balance greater"); + } + + function test_CanDistributeWithFee() public { + // we use a random account on Etherscan to credit the lidoSplit address + // with 10 ether worth of stETH on mainnet + vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); + uint256 amountToDistribute = 100 ether; + ERC20(STETH_MAINNET_ADDRESS).transfer(address(lidoSplitWithFee), amountToDistribute); + + uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); + + uint256 balance = ERC20(STETH_MAINNET_ADDRESS).balanceOf(address(lidoSplitWithFee)); + + uint256 wstETHDistributed = IwstETH(WSTETH_MAINNET_ADDRESS).getWstETHByStETH(balance); + + uint256 amount = lidoSplitWithFee.distribute(); + + assertTrue(amount > 0, "invalid amount"); + + uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit); + + assertGe(afterBalance, prevBalance, "after balance greater"); + + uint256 expectedFee = (wstETHDistributed * feeShare) / PERCENTAGE_SCALE; + + assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(feeRecipient), expectedFee, "invalid fee transferred"); + + assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(demoSplit), wstETHDistributed - expectedFee, "invalid amount"); + } + + function testFuzz_CanDistributeWithFee( + address anotherSplit, + uint256 amountToDistribute, + address fuzzFeeRecipient, + uint256 fuzzFeeShare + ) public { + vm.assume(anotherSplit != address(0)); + vm.assume(fuzzFeeRecipient != anotherSplit); + vm.assume(fuzzFeeShare > 0 && fuzzFeeShare < PERCENTAGE_SCALE); + vm.assume(fuzzFeeRecipient != address(0)); + vm.assume(amountToDistribute > 1 ether); + vm.assume(amountToDistribute < 10 ether); + + ObolLidoSplitFactory fuzzFactorySplitWithFee = new ObolLidoSplitFactory( + fuzzFeeRecipient, + fuzzFeeShare, + ERC20(STETH_MAINNET_ADDRESS), + ERC20(WSTETH_MAINNET_ADDRESS) + ); + + ObolLidoSplit fuzzSplitWithFee = ObolLidoSplit(fuzzFactorySplitWithFee.createSplit(anotherSplit)); + + vm.prank(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); + + ERC20(STETH_MAINNET_ADDRESS).transfer(address(fuzzSplitWithFee), amountToDistribute); + + uint256 prevBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(anotherSplit); + + uint256 balance = ERC20(STETH_MAINNET_ADDRESS).balanceOf(address(fuzzSplitWithFee)); + + uint256 wstETHDistributed = IwstETH(WSTETH_MAINNET_ADDRESS).getWstETHByStETH(balance); + + uint256 amount = fuzzSplitWithFee.distribute(); + + assertTrue(amount > 0, "invalid amount"); + + uint256 afterBalance = ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(anotherSplit); + + assertGe(afterBalance, prevBalance, "after balance greater"); + + uint256 expectedFee = (wstETHDistributed * fuzzFeeShare) / PERCENTAGE_SCALE; + + assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(fuzzFeeRecipient), expectedFee, "invalid fee transferred"); + + assertEq(ERC20(WSTETH_MAINNET_ADDRESS).balanceOf(anotherSplit), wstETHDistributed - expectedFee, "invalid amount"); + } +} diff --git a/src/test/lido/LidoSplitTestHelper.sol b/src/test/lido/ObolLidoSplitTestHelper.sol similarity index 80% rename from src/test/lido/LidoSplitTestHelper.sol rename to src/test/lido/ObolLidoSplitTestHelper.sol index c705688..b2c8506 100644 --- a/src/test/lido/LidoSplitTestHelper.sol +++ b/src/test/lido/ObolLidoSplitTestHelper.sol @@ -1,7 +1,7 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; -contract LidoSplitTestHelper { +contract ObolLidoSplitTestHelper { address internal STETH_MAINNET_ADDRESS = address(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); address internal WSTETH_MAINNET_ADDRESS = address(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); address internal RANDOM_stETH_ACCOUNT_ADDRESS = address(0x2bf3937b8BcccE4B65650F122Bb3f1976B937B2f); diff --git a/src/test/lido/integration/LidoSplitIntegrationTest.sol b/src/test/lido/integration/LidoSplitIntegrationTest.sol index e901135..24049a8 100644 --- a/src/test/lido/integration/LidoSplitIntegrationTest.sol +++ b/src/test/lido/integration/LidoSplitIntegrationTest.sol @@ -1,15 +1,15 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import {LidoSplitFactory, LidoSplit} from "src/lido/LidoSplitFactory.sol"; +import {ObolLidoSplitFactory, ObolLidoSplit} from "src/lido/ObolLidoSplitFactory.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; -import {LidoSplitTestHelper} from "../LidoSplitTestHelper.sol"; +import {ObolLidoSplitTestHelper} from "../ObolLidoSplitTestHelper.sol"; import {ISplitMain} from "src/interfaces/ISplitMain.sol"; -contract LidoSplitIntegrationTest is LidoSplitTestHelper, Test { - LidoSplitFactory internal lidoSplitFactory; - LidoSplit internal lidoSplit; +contract ObolLidoSplitIntegrationTest is ObolLidoSplitTestHelper, Test { + ObolLidoSplitFactory internal lidoSplitFactory; + ObolLidoSplit internal lidoSplit; address splitter; @@ -22,10 +22,12 @@ contract LidoSplitIntegrationTest is LidoSplitTestHelper, Test { uint256 mainnetBlock = 17_421_005; vm.createSelectFork(getChain("mainnet").rpcUrl, mainnetBlock); - lidoSplitFactory = new LidoSplitFactory( - ERC20(STETH_MAINNET_ADDRESS), - ERC20(WSTETH_MAINNET_ADDRESS) - ); + lidoSplitFactory = new ObolLidoSplitFactory( + address(0), + 0, + ERC20(STETH_MAINNET_ADDRESS), + ERC20(WSTETH_MAINNET_ADDRESS) + ); accounts = new address[](2); accounts[0] = makeAddr("accounts0"); @@ -37,7 +39,7 @@ contract LidoSplitIntegrationTest is LidoSplitTestHelper, Test { splitter = ISplitMain(SPLIT_MAIN_MAINNET).createSplit(accounts, percentAllocations, 0, address(0)); - lidoSplit = LidoSplit(lidoSplitFactory.createSplit(splitter)); + lidoSplit = ObolLidoSplit(lidoSplitFactory.createSplit(splitter)); } function test_CanDistribute() public { diff --git a/src/test/owr/OWRTestHelper.t.sol b/src/test/owr/OWRTestHelper.t.sol index 1451b2a..925b942 100644 --- a/src/test/owr/OWRTestHelper.t.sol +++ b/src/test/owr/OWRTestHelper.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.17; contract OWRTestHelper { diff --git a/src/test/owr/OptimisticWithdrawalRecipient.t.sol b/src/test/owr/OptimisticWithdrawalRecipient.t.sol index 8422559..a2ab4e0 100644 --- a/src/test/owr/OptimisticWithdrawalRecipient.t.sol +++ b/src/test/owr/OptimisticWithdrawalRecipient.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; diff --git a/src/test/owr/OptimisticWithdrawalRecipientFactory.t.sol b/src/test/owr/OptimisticWithdrawalRecipientFactory.t.sol index 41f390c..b67b8dd 100644 --- a/src/test/owr/OptimisticWithdrawalRecipientFactory.t.sol +++ b/src/test/owr/OptimisticWithdrawalRecipientFactory.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; diff --git a/src/test/utils/mocks/MockERC1155.sol b/src/test/utils/mocks/MockERC1155.sol index 7b9b55c..7176977 100644 --- a/src/test/utils/mocks/MockERC1155.sol +++ b/src/test/utils/mocks/MockERC1155.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.17; import {ERC1155} from "solmate/tokens/ERC1155.sol"; diff --git a/src/test/utils/mocks/MockNFT.sol b/src/test/utils/mocks/MockNFT.sol index 4518ac2..53d3c6e 100644 --- a/src/test/utils/mocks/MockNFT.sol +++ b/src/test/utils/mocks/MockNFT.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "solmate/tokens/ERC721.sol";