diff --git a/lib/BucketLimiter.sol b/lib/BucketLimiter.sol index eb410f78e..87a70a2f1 100644 --- a/lib/BucketLimiter.sol +++ b/lib/BucketLimiter.sol @@ -71,6 +71,11 @@ library BucketLimiter { return limit.remaining >= amount; } + function consumable(Limit memory limit) external view returns (uint64) { + _refill(limit); + return limit.remaining; + } + /* * Consumes the given amount from the bucket, if there is sufficient capacity, and returns * whether the bucket had enough remaining capacity to consume the amount. @@ -104,18 +109,22 @@ library BucketLimiter { } function _refill(Limit memory limit) internal view { - // We allow for overflow here, as the delta is resilient against it. uint64 now_ = uint64(block.timestamp); - uint64 delta; + + if (now_ == limit.lastRefill) { + return; + } + + uint256 delta; unchecked { delta = now_ - limit.lastRefill; } - uint64 tokens = delta * limit.refillRate; - uint64 newRemaining = limit.remaining + tokens; + uint256 tokens = delta * uint256(limit.refillRate); + uint256 newRemaining = uint256(limit.remaining) + tokens; if (newRemaining > limit.capacity) { limit.remaining = limit.capacity; } else { - limit.remaining = newRemaining; + limit.remaining = uint64(newRemaining); } limit.lastRefill = now_; } @@ -157,4 +166,4 @@ library BucketLimiter { refill(limit); limit.remaining = remaining; } -} \ No newline at end of file +} diff --git a/script/deploys/DeployEtherFiRestaker.s.sol b/script/deploys/DeployEtherFiRestaker.s.sol new file mode 100644 index 000000000..0f4ec2225 --- /dev/null +++ b/script/deploys/DeployEtherFiRestaker.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; + +import "../../src/Liquifier.sol"; +import "../../src/EtherFiRestaker.sol"; +import "../../src/helpers/AddressProvider.sol"; +import "../../src/UUPSProxy.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + +contract Deploy is Script { + using Strings for string; + + UUPSProxy public liquifierProxy; + + Liquifier public liquifierInstance; + + AddressProvider public addressProvider; + + address admin; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address addressProviderAddress = vm.envAddress("CONTRACT_REGISTRY"); + addressProvider = AddressProvider(addressProviderAddress); + + vm.startBroadcast(deployerPrivateKey); + + EtherFiRestaker restaker = EtherFiRestaker(payable(new UUPSProxy(payable(new EtherFiRestaker()), ""))); + restaker.initialize( + addressProvider.getContractAddress("LiquidityPool"), + addressProvider.getContractAddress("Liquifier") + ); + + new Liquifier(); + + // addressProvider.addContract(address(liquifierInstance), "Liquifier"); + + vm.stopBroadcast(); + } +} diff --git a/script/deploys/DeployEtherFiWithdrawalBuffer.s.sol b/script/deploys/DeployEtherFiWithdrawalBuffer.s.sol new file mode 100644 index 000000000..ebc4050cf --- /dev/null +++ b/script/deploys/DeployEtherFiWithdrawalBuffer.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; + +import "@openzeppelin/contracts/utils/Strings.sol"; + +import "../../src/Liquifier.sol"; +import "../../src/EtherFiRestaker.sol"; +import "../../src/helpers/AddressProvider.sol"; +import "../../src/UUPSProxy.sol"; +import "../../src/EtherFiRedemptionManager.sol"; + + +contract Deploy is Script { + using Strings for string; + AddressProvider public addressProvider; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address addressProviderAddress = vm.envAddress("CONTRACT_REGISTRY"); + addressProvider = AddressProvider(addressProviderAddress); + + vm.startBroadcast(deployerPrivateKey); + + EtherFiRedemptionManager impl = new EtherFiRedemptionManager( + addressProvider.getContractAddress("LiquidityPool"), + addressProvider.getContractAddress("EETH"), + addressProvider.getContractAddress("WeETH"), + 0x0c83EAe1FE72c390A02E426572854931EefF93BA, // protocol safe + 0x1d3Af47C1607A2EF33033693A9989D1d1013BB50 // role registry + ); + UUPSProxy proxy = new UUPSProxy(payable(impl), ""); + + EtherFiRedemptionManager instance = EtherFiRedemptionManager(payable(proxy)); + instance.initialize(10_00, 1_00, 1_00, 5 ether, 0.001 ether); + + vm.stopBroadcast(); + } +} diff --git a/script/deploys/DeployPhaseTwo.s.sol b/script/deploys/DeployPhaseTwo.s.sol index a144e0a35..c61d13feb 100644 --- a/script/deploys/DeployPhaseTwo.s.sol +++ b/script/deploys/DeployPhaseTwo.s.sol @@ -81,7 +81,7 @@ contract DeployPhaseTwoScript is Script { } retrieve_contract_addresses(); - withdrawRequestNftImplementation = new WithdrawRequestNFT(); + withdrawRequestNftImplementation = new WithdrawRequestNFT(address(0)); withdrawRequestNftProxy = new UUPSProxy(address(withdrawRequestNftImplementation), ""); withdrawRequestNftInstance = WithdrawRequestNFT(payable(withdrawRequestNftProxy)); diff --git a/script/specialized/weEth_withdrawal_v2.s.sol b/script/specialized/weEth_withdrawal_v2.s.sol new file mode 100644 index 000000000..680028cde --- /dev/null +++ b/script/specialized/weEth_withdrawal_v2.s.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import "test/TestSetup.sol"; + +import "src/helpers/AddressProvider.sol"; + +contract Upgrade is Script { + + AddressProvider public addressProvider; + address public addressProviderAddress = 0x8487c5F8550E3C3e7734Fe7DCF77DB2B72E4A848; + address public roleRegistry = 0x1d3Af47C1607A2EF33033693A9989D1d1013BB50; + address public treasury = 0x0c83EAe1FE72c390A02E426572854931EefF93BA; + address public pauser = 0x9AF1298993DC1f397973C62A5D47a284CF76844D; + + WithdrawRequestNFT withdrawRequestNFTInstance; + LiquidityPool liquidityPoolInstance; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + AddressProvider addressProvider = AddressProvider(addressProviderAddress); + + withdrawRequestNFTInstance = WithdrawRequestNFT(payable(addressProvider.getContractAddress("WithdrawRequestNFT"))); + liquidityPoolInstance = LiquidityPool(payable(addressProvider.getContractAddress("LiquidityPool"))); + + vm.startBroadcast(deployerPrivateKey); + + // deploy_upgrade(); + // agg(); + // handle_remainder(); + + vm.stopBroadcast(); + } + + function deploy_upgrade() internal { + UUPSProxy etherFiRedemptionManagerProxy = new UUPSProxy(address(new EtherFiRedemptionManager( + addressProvider.getContractAddress("LiquidityPool"), + addressProvider.getContractAddress("EETH"), + addressProvider.getContractAddress("WeETH"), + treasury, + roleRegistry)), ""); + EtherFiRedemptionManager etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(etherFiRedemptionManagerProxy)); + etherFiRedemptionManagerInstance.initialize(10_00, 1_00, 1_00, 5 ether, 0.001 ether); // 10% fee split to treasury, 1% exit fee, 1% low watermark + + withdrawRequestNFTInstance.upgradeTo(address(new WithdrawRequestNFT(treasury))); + withdrawRequestNFTInstance.initializeOnUpgrade(pauser, 50_00); // 50% fee split to treasury + + liquidityPoolInstance.upgradeTo(address(new LiquidityPool())); + liquidityPoolInstance.initializeOnUpgradeWithRedemptionManager(address(etherFiRedemptionManagerInstance)); + } + + function agg() internal { + uint256 numToScanPerTx = 1024; + uint256 cnt = (withdrawRequestNFTInstance.nextRequestId() / numToScanPerTx) + 1; + console.log(cnt); + for (uint256 i = 0; i < cnt; i++) { + withdrawRequestNFTInstance.aggregateSumEEthShareAmount(numToScanPerTx); + } + } + + function handle_remainder() internal { + withdrawRequestNFTInstance.updateAdmin(msg.sender, true); + withdrawRequestNFTInstance.unPauseContract(); + uint256 remainder = withdrawRequestNFTInstance.getEEthRemainderAmount(); + console.log(remainder); + withdrawRequestNFTInstance.handleRemainder(remainder); + } +} \ No newline at end of file diff --git a/script/upgrades/WithdrawRequestNFTUpgradeScript.s.sol b/script/upgrades/WithdrawRequestNFTUpgradeScript.s.sol index 823cf90a5..115251475 100644 --- a/script/upgrades/WithdrawRequestNFTUpgradeScript.s.sol +++ b/script/upgrades/WithdrawRequestNFTUpgradeScript.s.sol @@ -20,7 +20,7 @@ contract WithdrawRequestNFTUpgrade is Script { vm.startBroadcast(deployerPrivateKey); WithdrawRequestNFT oracleInstance = WithdrawRequestNFT(proxyAddress); - WithdrawRequestNFT v2Implementation = new WithdrawRequestNFT(); + WithdrawRequestNFT v2Implementation = new WithdrawRequestNFT(address(0)); oracleInstance.upgradeTo(address(v2Implementation)); diff --git a/src/EtherFiRedemptionManager.sol b/src/EtherFiRedemptionManager.sol new file mode 100644 index 000000000..5ff6b5cf7 --- /dev/null +++ b/src/EtherFiRedemptionManager.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/draft-IERC20Permit.sol"; +import "@openzeppelin-upgradeable/contracts/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import "@openzeppelin-upgradeable/contracts/security/PausableUpgradeable.sol"; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + +import "./interfaces/ILiquidityPool.sol"; +import "./interfaces/IeETH.sol"; +import "./interfaces/IWeETH.sol"; + +import "lib/BucketLimiter.sol"; + +import "./RoleRegistry.sol"; + +/* + The contract allows instant redemption of eETH and weETH tokens to ETH with an exit fee. + - It has the exit fee as a percentage of the total amount redeemed. + - It has a rate limiter to limit the total amount that can be redeemed in a given time period. +*/ +contract EtherFiRedemptionManager is Initializable, OwnableUpgradeable, PausableUpgradeable, ReentrancyGuardUpgradeable, UUPSUpgradeable { + using SafeERC20 for IERC20; + using Math for uint256; + + uint256 private constant BUCKET_UNIT_SCALE = 1e12; + uint256 private constant BASIS_POINT_SCALE = 1e4; + + bytes32 public constant PROTOCOL_PAUSER = keccak256("PROTOCOL_PAUSER"); + bytes32 public constant PROTOCOL_UNPAUSER = keccak256("PROTOCOL_UNPAUSER"); + bytes32 public constant PROTOCOL_ADMIN = keccak256("PROTOCOL_ADMIN"); + + RoleRegistry public immutable roleRegistry; + address public immutable treasury; + IeETH public immutable eEth; + IWeETH public immutable weEth; + ILiquidityPool public immutable liquidityPool; + + BucketLimiter.Limit public limit; + uint16 public exitFeeSplitToTreasuryInBps; + uint16 public exitFeeInBps; + uint16 public lowWatermarkInBpsOfTvl; // bps of TVL + + event Redeemed(address indexed receiver, uint256 redemptionAmount, uint256 feeAmountToTreasury, uint256 feeAmountToStakers); + + receive() external payable {} + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _liquidityPool, address _eEth, address _weEth, address _treasury, address _roleRegistry) { + roleRegistry = RoleRegistry(_roleRegistry); + treasury = _treasury; + liquidityPool = ILiquidityPool(payable(_liquidityPool)); + eEth = IeETH(_eEth); + weEth = IWeETH(_weEth); + + _disableInitializers(); + } + + function initialize(uint16 _exitFeeSplitToTreasuryInBps, uint16 _exitFeeInBps, uint16 _lowWatermarkInBpsOfTvl, uint256 _bucketCapacity, uint256 _bucketRefillRate) external initializer { + require(_exitFeeInBps <= BASIS_POINT_SCALE, "INVALID"); + require(_exitFeeSplitToTreasuryInBps <= BASIS_POINT_SCALE, "INVALID"); + require(_lowWatermarkInBpsOfTvl <= BASIS_POINT_SCALE, "INVALID"); + + __Ownable_init(); + __UUPSUpgradeable_init(); + __Pausable_init(); + __ReentrancyGuard_init(); + + limit = BucketLimiter.create(_convertToBucketUnit(_bucketCapacity, Math.Rounding.Down), _convertToBucketUnit(_bucketRefillRate, Math.Rounding.Down)); + exitFeeSplitToTreasuryInBps = _exitFeeSplitToTreasuryInBps; + exitFeeInBps = _exitFeeInBps; + lowWatermarkInBpsOfTvl = _lowWatermarkInBpsOfTvl; + } + + /** + * @notice Redeems eETH for ETH. + * @param eEthAmount The amount of eETH to redeem after the exit fee. + * @param receiver The address to receive the redeemed ETH. + */ + function redeemEEth(uint256 eEthAmount, address receiver) public whenNotPaused nonReentrant { + require(eEthAmount <= eEth.balanceOf(msg.sender), "EtherFiRedemptionManager: Insufficient balance"); + require(canRedeem(eEthAmount), "EtherFiRedemptionManager: Exceeded total redeemable amount"); + + (uint256 eEthShares, uint256 eEthAmountToReceiver, uint256 eEthFeeAmountToTreasury, uint256 sharesToBurn, uint256 feeShareToTreasury) = _calcRedemption(eEthAmount); + + IERC20(address(eEth)).safeTransferFrom(msg.sender, address(this), eEthAmount); + + _redeem(eEthAmount, eEthShares, receiver, eEthAmountToReceiver, eEthFeeAmountToTreasury, sharesToBurn, feeShareToTreasury); + } + + /** + * @notice Redeems weETH for ETH. + * @param weEthAmount The amount of weETH to redeem after the exit fee. + * @param receiver The address to receive the redeemed ETH. + */ + function redeemWeEth(uint256 weEthAmount, address receiver) public whenNotPaused nonReentrant { + uint256 eEthAmount = weEth.getEETHByWeETH(weEthAmount); + require(weEthAmount <= weEth.balanceOf(msg.sender), "EtherFiRedemptionManager: Insufficient balance"); + require(canRedeem(eEthAmount), "EtherFiRedemptionManager: Exceeded total redeemable amount"); + + (uint256 eEthShares, uint256 eEthAmountToReceiver, uint256 eEthFeeAmountToTreasury, uint256 sharesToBurn, uint256 feeShareToTreasury) = _calcRedemption(eEthAmount); + + IERC20(address(weEth)).safeTransferFrom(msg.sender, address(this), weEthAmount); + weEth.unwrap(weEthAmount); + + _redeem(eEthAmount, eEthShares, receiver, eEthAmountToReceiver, eEthFeeAmountToTreasury, sharesToBurn, feeShareToTreasury); + } + + function redeemEEthWithPermit(uint256 eEthAmount, address receiver, IeETH.PermitInput calldata permit) external { + try eEth.permit(msg.sender, address(this), permit.value, permit.deadline, permit.v, permit.r, permit.s) {} catch {} + redeemEEth(eEthAmount, receiver); + } + + function redeemWeEthWithPermit(uint256 weEthAmount, address receiver, IWeETH.PermitInput calldata permit) external { + try weEth.permit(msg.sender, address(this), permit.value, permit.deadline, permit.v, permit.r, permit.s) {} catch {} + redeemWeEth(weEthAmount, receiver); + } + + /** + * @notice Redeems ETH. + * @param ethAmount The amount of ETH to redeem after the exit fee. + * @param receiver The address to receive the redeemed ETH. + */ + function _redeem(uint256 ethAmount, uint256 eEthShares, address receiver, uint256 eEthAmountToReceiver, uint256 eEthFeeAmountToTreasury, uint256 sharesToBurn, uint256 feeShareToTreasury) internal { + _updateRateLimit(ethAmount); + + // Derive additionals + uint256 eEthShareFee = eEthShares - sharesToBurn; + uint256 feeShareToStakers = eEthShareFee - feeShareToTreasury; + + // Snapshot balances & shares for sanity check at the end + uint256 prevBalance = address(this).balance; + uint256 prevLpBalance = address(liquidityPool).balance; + uint256 totalEEthShare = eEth.totalShares(); + + // Withdraw ETH from the liquidity pool + assert (liquidityPool.withdraw(address(this), eEthAmountToReceiver) == sharesToBurn); + uint256 ethReceived = address(this).balance - prevBalance; + + // To Stakers by burning shares + eEth.burnShares(address(this), feeShareToStakers); + + // To Treasury by transferring eETH + IERC20(address(eEth)).safeTransfer(treasury, eEthFeeAmountToTreasury); + + require(eEth.totalShares() >= 1 gwei && eEth.totalShares() == totalEEthShare - (sharesToBurn + feeShareToStakers), "EtherFiRedemptionManager: Invalid total shares"); + + // To Receiver by transferring ETH + (bool success, ) = receiver.call{value: ethReceived, gas: 10_000}(""); + require(success, "EtherFiRedemptionManager: Transfer failed"); + + // Make sure the liquidity pool balance is correct && total shares are correct + require(address(liquidityPool).balance == prevLpBalance - ethReceived, "EtherFiRedemptionManager: Invalid liquidity pool balance"); + require(eEth.totalShares() >= 1 gwei && eEth.totalShares() == totalEEthShare - (sharesToBurn + feeShareToStakers), "EtherFiRedemptionManager: Invalid total shares"); + + emit Redeemed(receiver, ethAmount, eEthFeeAmountToTreasury, eEthAmountToReceiver); + } + + /** + * @dev if the contract has less than the low watermark, it will not allow any instant redemption. + */ + function lowWatermarkInETH() public view returns (uint256) { + return liquidityPool.getTotalPooledEther().mulDiv(lowWatermarkInBpsOfTvl, BASIS_POINT_SCALE); + } + + /** + * @dev Returns the total amount that can be redeemed. + */ + function totalRedeemableAmount() external view returns (uint256) { + uint256 liquidEthAmount = address(liquidityPool).balance - liquidityPool.ethAmountLockedForWithdrawal(); + if (liquidEthAmount < lowWatermarkInETH()) { + return 0; + } + uint64 consumableBucketUnits = BucketLimiter.consumable(limit); + uint256 consumableAmount = _convertFromBucketUnit(consumableBucketUnits); + return Math.min(consumableAmount, liquidEthAmount); + } + + /** + * @dev Returns whether the given amount can be redeemed. + * @param amount The ETH or eETH amount to check. + */ + function canRedeem(uint256 amount) public view returns (bool) { + uint256 liquidEthAmount = address(liquidityPool).balance - liquidityPool.ethAmountLockedForWithdrawal(); + if (liquidEthAmount < lowWatermarkInETH()) { + return false; + } + uint64 bucketUnit = _convertToBucketUnit(amount, Math.Rounding.Up); + bool consumable = BucketLimiter.canConsume(limit, bucketUnit); + return consumable && amount <= liquidEthAmount; + } + + /** + * @dev Sets the maximum size of the bucket that can be consumed in a given time period. + * @param capacity The capacity of the bucket. + */ + function setCapacity(uint256 capacity) external hasRole(PROTOCOL_ADMIN) { + // max capacity = max(uint64) * 1e12 ~= 16 * 1e18 * 1e12 = 16 * 1e12 ether, which is practically enough + uint64 bucketUnit = _convertToBucketUnit(capacity, Math.Rounding.Down); + BucketLimiter.setCapacity(limit, bucketUnit); + } + + /** + * @dev Sets the rate at which the bucket is refilled per second. + * @param refillRate The rate at which the bucket is refilled per second. + */ + function setRefillRatePerSecond(uint256 refillRate) external hasRole(PROTOCOL_ADMIN) { + // max refillRate = max(uint64) * 1e12 ~= 16 * 1e18 * 1e12 = 16 * 1e12 ether per second, which is practically enough + uint64 bucketUnit = _convertToBucketUnit(refillRate, Math.Rounding.Down); + BucketLimiter.setRefillRate(limit, bucketUnit); + } + + /** + * @dev Sets the exit fee. + * @param _exitFeeInBps The exit fee. + */ + function setExitFeeBasisPoints(uint16 _exitFeeInBps) external hasRole(PROTOCOL_ADMIN) { + require(_exitFeeInBps <= BASIS_POINT_SCALE, "INVALID"); + exitFeeInBps = _exitFeeInBps; + } + + function setLowWatermarkInBpsOfTvl(uint16 _lowWatermarkInBpsOfTvl) external hasRole(PROTOCOL_ADMIN) { + require(_lowWatermarkInBpsOfTvl <= BASIS_POINT_SCALE, "INVALID"); + lowWatermarkInBpsOfTvl = _lowWatermarkInBpsOfTvl; + } + + function setExitFeeSplitToTreasuryInBps(uint16 _exitFeeSplitToTreasuryInBps) external hasRole(PROTOCOL_ADMIN) { + require(_exitFeeSplitToTreasuryInBps <= BASIS_POINT_SCALE, "INVALID"); + exitFeeSplitToTreasuryInBps = _exitFeeSplitToTreasuryInBps; + } + + function pauseContract() external hasRole(PROTOCOL_PAUSER) { + _pause(); + } + + function unPauseContract() external hasRole(PROTOCOL_UNPAUSER) { + _unpause(); + } + + function _updateRateLimit(uint256 amount) internal { + uint64 bucketUnit = _convertToBucketUnit(amount, Math.Rounding.Up); + require(BucketLimiter.consume(limit, bucketUnit), "BucketRateLimiter: rate limit exceeded"); + } + + function _convertToBucketUnit(uint256 amount, Math.Rounding rounding) internal pure returns (uint64) { + require(amount < type(uint64).max * BUCKET_UNIT_SCALE, "EtherFiRedemptionManager: Amount too large"); + return (rounding == Math.Rounding.Up) ? SafeCast.toUint64((amount + BUCKET_UNIT_SCALE - 1) / BUCKET_UNIT_SCALE) : SafeCast.toUint64(amount / BUCKET_UNIT_SCALE); + } + + function _convertFromBucketUnit(uint64 bucketUnit) internal pure returns (uint256) { + return bucketUnit * BUCKET_UNIT_SCALE; + } + + + function _calcRedemption(uint256 ethAmount) internal view returns (uint256 eEthShares, uint256 eEthAmountToReceiver, uint256 eEthFeeAmountToTreasury, uint256 sharesToBurn, uint256 feeShareToTreasury) { + eEthShares = liquidityPool.sharesForAmount(ethAmount); + eEthAmountToReceiver = liquidityPool.amountForShare(eEthShares.mulDiv(BASIS_POINT_SCALE - exitFeeInBps, BASIS_POINT_SCALE)); // ethShareToReceiver + + sharesToBurn = liquidityPool.sharesForWithdrawalAmount(eEthAmountToReceiver); + uint256 eEthShareFee = eEthShares - sharesToBurn; + feeShareToTreasury = eEthShareFee.mulDiv(exitFeeSplitToTreasuryInBps, BASIS_POINT_SCALE); + eEthFeeAmountToTreasury = liquidityPool.amountForShare(feeShareToTreasury); + } + + /** + * @dev Preview taking an exit fee on redeem. See {IERC4626-previewRedeem}. + */ + // redeemable amount after exit fee + function previewRedeem(uint256 shares) public view returns (uint256) { + uint256 amountInEth = liquidityPool.amountForShare(shares); + return amountInEth - _fee(amountInEth, exitFeeInBps); + } + + function _fee(uint256 assets, uint256 feeBasisPoints) internal pure virtual returns (uint256) { + return assets.mulDiv(feeBasisPoints, BASIS_POINT_SCALE, Math.Rounding.Up); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + function getImplementation() external view returns (address) { + return _getImplementation(); + } + + function _hasRole(bytes32 role, address account) internal view returns (bool) { + require(roleRegistry.hasRole(role, account), "EtherFiRedemptionManager: Unauthorized"); + } + + modifier hasRole(bytes32 role) { + _hasRole(role, msg.sender); + _; + } + +} diff --git a/src/LiquidityPool.sol b/src/LiquidityPool.sol index 93e032abf..13f2d6e0f 100644 --- a/src/LiquidityPool.sol +++ b/src/LiquidityPool.sol @@ -23,6 +23,9 @@ import "./interfaces/IEtherFiAdmin.sol"; import "./interfaces/IAuctionManager.sol"; import "./interfaces/ILiquifier.sol"; +import "./EtherFiRedemptionManager.sol"; + + contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, ILiquidityPool { //-------------------------------------------------------------------------------------- //--------------------------------- STATE-VARIABLES ---------------------------------- @@ -69,6 +72,8 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL bool private isLpBnftHolder; + EtherFiRedemptionManager public etherFiRedemptionManager; + //-------------------------------------------------------------------------------------- //------------------------------------- EVENTS --------------------------------------- //-------------------------------------------------------------------------------------- @@ -133,12 +138,17 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL } function initializeOnUpgrade(address _auctionManager, address _liquifier) external onlyOwner { - require(_auctionManager != address(0) && _liquifier != address(0), "Invalid params"); + require(_auctionManager != address(0) && _liquifier != address(0) && address(auctionManager) == address(0) && address(liquifier) == address(0), "Invalid"); auctionManager = IAuctionManager(_auctionManager); liquifier = ILiquifier(_liquifier); } + function initializeOnUpgradeWithRedemptionManager(address _etherFiRedemptionManager) external onlyOwner { + require(address(etherFiRedemptionManager) == address(0) && _etherFiRedemptionManager != address(0), "Invalid"); + etherFiRedemptionManager = EtherFiRedemptionManager(payable(_etherFiRedemptionManager)); + } + // Used by eETH staking flow function deposit() external payable returns (uint256) { return deposit(address(0)); @@ -179,7 +189,7 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL /// it returns the amount of shares burned function withdraw(address _recipient, uint256 _amount) external whenNotPaused returns (uint256) { uint256 share = sharesForWithdrawalAmount(_amount); - require(msg.sender == address(withdrawRequestNFT) || msg.sender == address(membershipManager), "Incorrect Caller"); + require(msg.sender == address(withdrawRequestNFT) || msg.sender == address(membershipManager) || msg.sender == address(etherFiRedemptionManager), "Incorrect Caller"); if (totalValueInLp < _amount || (msg.sender == address(withdrawRequestNFT) && ethAmountLockedForWithdrawal < _amount) || eETH.balanceOf(msg.sender) < _amount) revert InsufficientLiquidity(); if (_amount > type(uint128).max || _amount == 0 || share == 0) revert InvalidAmount(); @@ -225,7 +235,7 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL whenNotPaused returns (uint256) { - eETH.permit(msg.sender, address(this), _permit.value, _permit.deadline, _permit.v, _permit.r, _permit.s); + try eETH.permit(msg.sender, address(this), _permit.value, _permit.deadline, _permit.v, _permit.r, _permit.s) {} catch {} return requestWithdraw(_owner, _amount); } @@ -522,17 +532,11 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL } function addEthAmountLockedForWithdrawal(uint128 _amount) external { - if (!(msg.sender == address(etherFiAdminContract) || msg.sender == address(withdrawRequestNFT))) revert IncorrectCaller(); + if (!(msg.sender == address(etherFiAdminContract))) revert IncorrectCaller(); ethAmountLockedForWithdrawal += _amount; } - function reduceEthAmountLockedForWithdrawal(uint128 _amount) external { - if (msg.sender != address(withdrawRequestNFT)) revert IncorrectCaller(); - - ethAmountLockedForWithdrawal -= _amount; - } - //-------------------------------------------------------------------------------------- //------------------------------ INTERNAL FUNCTIONS ---------------------------------- //-------------------------------------------------------------------------------------- diff --git a/src/WithdrawRequestNFT.sol b/src/WithdrawRequestNFT.sol index 64d30ae4f..a5fbeb413 100644 --- a/src/WithdrawRequestNFT.sol +++ b/src/WithdrawRequestNFT.sol @@ -10,9 +10,15 @@ import "./interfaces/ILiquidityPool.sol"; import "./interfaces/IWithdrawRequestNFT.sol"; import "./interfaces/IMembershipManager.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgradeable, IWithdrawRequestNFT { + using Math for uint256; + uint256 private constant BASIS_POINT_SCALE = 1e4; + address public immutable treasury; + ILiquidityPool public liquidityPool; IeETH public eETH; IMembershipManager public membershipManager; @@ -22,16 +28,34 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad uint32 public nextRequestId; uint32 public lastFinalizedRequestId; - uint96 public accumulatedDustEEthShares; // to be burned or used to cover the validator churn cost + uint16 public shareRemainderSplitToTreasuryInBps; + uint16 public _unused_gap; + + // inclusive + uint32 public currentRequestIdToScanFromForShareRemainder; + uint32 public lastRequestIdToScanUntilForShareRemainder; + uint256 public aggregateSumOfEEthShare; + + uint256 public totalRemainderEEthShares; + + bool public paused; + address public pauser; event WithdrawRequestCreated(uint32 indexed requestId, uint256 amountOfEEth, uint256 shareOfEEth, address owner, uint256 fee); event WithdrawRequestClaimed(uint32 indexed requestId, uint256 amountOfEEth, uint256 burntShareOfEEth, address owner, uint256 fee); event WithdrawRequestInvalidated(uint32 indexed requestId); event WithdrawRequestValidated(uint32 indexed requestId); event WithdrawRequestSeized(uint32 indexed requestId); + event HandledRemainderOfClaimedWithdrawRequests(uint256 eEthAmountToTreasury, uint256 eEthAmountBurnt); + + event Paused(address account); + event Unpaused(address account); + /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { + constructor(address _treasury) { + treasury = _treasury; + _disableInitializers(); } @@ -48,6 +72,24 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad nextRequestId = 1; } + function initializeOnUpgrade(address _pauser, uint16 _shareRemainderSplitToTreasuryInBps) external onlyOwner { + require(pauser == address(0) && _pauser != address(0), "Already initialized"); + require(_shareRemainderSplitToTreasuryInBps <= BASIS_POINT_SCALE, "INVALID"); + + paused = true; // make sure the contract is paused after the upgrade + pauser = _pauser; + + _unused_gap = 0; + + shareRemainderSplitToTreasuryInBps = _shareRemainderSplitToTreasuryInBps; + + currentRequestIdToScanFromForShareRemainder = 1; + lastRequestIdToScanUntilForShareRemainder = nextRequestId - 1; + + aggregateSumOfEEthShare = 0; + totalRemainderEEthShares = 0; + } + /// @notice creates a withdraw request and issues an associated NFT to the recipient /// @dev liquidity pool contract will call this function when a user requests withdraw /// @param amountOfEEth amount of eETH requested for withdrawal @@ -55,11 +97,12 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad /// @param recipient address to recieve with WithdrawRequestNFT /// @param fee fee to be subtracted from amount when recipient calls claimWithdraw /// @return uint256 id of the withdraw request - function requestWithdraw(uint96 amountOfEEth, uint96 shareOfEEth, address recipient, uint256 fee) external payable onlyLiquidtyPool returns (uint256) { + function requestWithdraw(uint96 amountOfEEth, uint96 shareOfEEth, address recipient, uint256 fee) external payable onlyLiquidtyPool whenNotPaused returns (uint256) { uint256 requestId = nextRequestId++; uint32 feeGwei = uint32(fee / 1 gwei); _requests[requestId] = IWithdrawRequestNFT.WithdrawRequest(amountOfEEth, shareOfEEth, true, feeGwei); + _safeMint(recipient, requestId); emit WithdrawRequestCreated(uint32(requestId), amountOfEEth, shareOfEEth, recipient, fee); @@ -67,7 +110,6 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad } function getClaimableAmount(uint256 tokenId) public view returns (uint256) { - require(tokenId < nextRequestId, "Request does not exist"); require(tokenId <= lastFinalizedRequestId, "Request is not finalized"); require(ownerOf(tokenId) != address(0), "Already Claimed"); @@ -84,7 +126,7 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad /// @notice called by the NFT owner to claim their ETH /// @dev burns the NFT and transfers ETH from the liquidity pool to the owner minus any fee, withdraw request must be valid and finalized /// @param tokenId the id of the withdraw request and associated NFT - function claimWithdraw(uint256 tokenId) external { + function claimWithdraw(uint256 tokenId) external whenNotPaused { return _claimWithdraw(tokenId, ownerOf(tokenId)); } @@ -93,63 +135,57 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad IWithdrawRequestNFT.WithdrawRequest memory request = _requests[tokenId]; require(request.isValid, "Request is not valid"); - uint256 fee = uint256(request.feeGwei) * 1 gwei; uint256 amountToWithdraw = getClaimableAmount(tokenId); + uint256 shareAmountToBurnForWithdrawal = liquidityPool.sharesForWithdrawalAmount(amountToWithdraw); // transfer eth to recipient _burn(tokenId); delete _requests[tokenId]; - if (fee > 0) { - // send fee to membership manager - liquidityPool.withdraw(address(membershipManager), fee); - } + // update accounting + totalRemainderEEthShares += request.shareOfEEth - shareAmountToBurnForWithdrawal; uint256 amountBurnedShare = liquidityPool.withdraw(recipient, amountToWithdraw); - uint256 amountUnBurnedShare = request.shareOfEEth - amountBurnedShare; - if (amountUnBurnedShare > 0) { - accumulatedDustEEthShares += uint96(amountUnBurnedShare); - } + assert (amountBurnedShare == shareAmountToBurnForWithdrawal); - emit WithdrawRequestClaimed(uint32(tokenId), amountToWithdraw + fee, amountBurnedShare, recipient, fee); + emit WithdrawRequestClaimed(uint32(tokenId), amountToWithdraw, amountBurnedShare, recipient, 0); } - function batchClaimWithdraw(uint256[] calldata tokenIds) external { + function batchClaimWithdraw(uint256[] calldata tokenIds) external whenNotPaused { for (uint256 i = 0; i < tokenIds.length; i++) { _claimWithdraw(tokenIds[i], ownerOf(tokenIds[i])); } } - // a function to transfer accumulated shares to admin - function burnAccumulatedDustEEthShares() external onlyAdmin { - require(eETH.totalShares() > accumulatedDustEEthShares, "Inappropriate burn"); - uint256 amount = accumulatedDustEEthShares; - accumulatedDustEEthShares = 0; + // This function is used to aggregate the sum of the eEth shares of the requests that have not been claimed yet. + // To be triggered during the upgrade to the new version of the contract. + function aggregateSumEEthShareAmount(uint256 _numReqsToScan) external { + require(!isScanOfShareRemainderCompleted(), "scan is completed"); - eETH.burnShares(address(this), amount); + // [scanFrom, scanUntil] + uint256 scanFrom = currentRequestIdToScanFromForShareRemainder; + uint256 scanUntil = Math.min(lastRequestIdToScanUntilForShareRemainder, scanFrom + _numReqsToScan - 1); + + for (uint256 i = scanFrom; i <= scanUntil; i++) { + if (!_exists(i)) continue; + aggregateSumOfEEthShare += _requests[i].shareOfEEth; + } + + currentRequestIdToScanFromForShareRemainder = uint32(scanUntil + 1); + + // When the scan is completed, update the `totalRemainderEEthShares` and reset the `aggregateSumOfEEthShare` + if (isScanOfShareRemainderCompleted()) { + totalRemainderEEthShares = eETH.shares(address(this)) - aggregateSumOfEEthShare; + aggregateSumOfEEthShare = 0; // gone + } } - // Given an invalidated withdrawal request NFT of ID `requestId`:, - // - burn the NFT - // - withdraw its ETH to the `recipient` + // Seize the request simply by transferring it to another recipient function seizeInvalidRequest(uint256 requestId, address recipient) external onlyOwner { require(!_requests[requestId].isValid, "Request is valid"); - require(ownerOf(requestId) != address(0), "Already Claimed"); - - // Bring the NFT to the `msg.sender` == contract owner - _transfer(ownerOf(requestId), owner(), requestId); - - // Undo its invalidation to claim - _requests[requestId].isValid = true; - - // its ETH amount is not locked - // - if it was finalized when being invalidated, we revoked it via `reduceEthAmountLockedForWithdrawal` - // - if it was not finalized when being invalidated, it was not locked - uint256 ethAmount = getClaimableAmount(requestId); - liquidityPool.addEthAmountLockedForWithdrawal(uint128(ethAmount)); + require(_exists(requestId), "Request does not exist"); - // withdraw the ETH to the recipient - _claimWithdraw(requestId, recipient); + _transfer(ownerOf(requestId), recipient, requestId); emit WithdrawRequestSeized(uint32(requestId)); } @@ -168,23 +204,20 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad } function finalizeRequests(uint256 requestId) external onlyAdmin { + require(requestId > lastFinalizedRequestId, "Cannot undo finalization"); + require(requestId < nextRequestId, "Cannot finalize future requests"); lastFinalizedRequestId = uint32(requestId); } function invalidateRequest(uint256 requestId) external onlyAdmin { require(isValid(requestId), "Request is not valid"); - - if (isFinalized(requestId)) { - uint256 ethAmount = getClaimableAmount(requestId); - liquidityPool.reduceEthAmountLockedForWithdrawal(uint128(ethAmount)); - } - _requests[requestId].isValid = false; emit WithdrawRequestInvalidated(uint32(requestId)); } function validateRequest(uint256 requestId) external onlyAdmin { + require(_exists(requestId), "Request does not exist"); require(!_requests[requestId].isValid, "Request is valid"); _requests[requestId].isValid = true; @@ -196,7 +229,61 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad admins[_address] = _isAdmin; } - // invalid NFTs is non-transferable except for the case they are being burnt by the owner via `seizeInvalidRequest` + function updateShareRemainderSplitToTreasuryInBps(uint16 _shareRemainderSplitToTreasuryInBps) external onlyOwner { + require(_shareRemainderSplitToTreasuryInBps <= BASIS_POINT_SCALE, "INVALID"); + shareRemainderSplitToTreasuryInBps = _shareRemainderSplitToTreasuryInBps; + } + + function pauseContract() external onlyPauser { + paused = true; + emit Paused(msg.sender); + } + + function unPauseContract() external onlyAdmin { + require(isScanOfShareRemainderCompleted(), "scan is not completed"); + + paused = false; + emit Unpaused(msg.sender); + } + + /// @dev Handles the remainder of the eEth shares after the claim of the withdraw request + /// the remainder eETH share for a request = request.shareOfEEth - request.amountOfEEth / (eETH amount to eETH shares rate) + /// - Splits the remainder into two parts: + /// - Treasury: treasury gets a split of the remainder + /// - Burn: the rest of the remainder is burned + /// @param _eEthAmount: the remainder of the eEth amount + function handleRemainder(uint256 _eEthAmount) external onlyAdmin { + require(isScanOfShareRemainderCompleted(), "Not all prev requests have been scanned"); + require(getEEthRemainderAmount() >= _eEthAmount, "Not enough eETH remainder"); + + uint256 beforeEEthShares = eETH.shares(address(this)); + + uint256 eEthAmountToTreasury = _eEthAmount.mulDiv(shareRemainderSplitToTreasuryInBps, BASIS_POINT_SCALE); + uint256 eEthAmountToBurn = _eEthAmount - eEthAmountToTreasury; + uint256 eEthSharesToBurn = liquidityPool.sharesForAmount(eEthAmountToBurn); + uint256 eEthSharesToMoved = eEthSharesToBurn + liquidityPool.sharesForAmount(eEthAmountToTreasury); + + totalRemainderEEthShares -= eEthSharesToMoved; + + eETH.transfer(treasury, eEthAmountToTreasury); + eETH.burnShares(address(this), eEthSharesToBurn); + + require (beforeEEthShares - eEthSharesToMoved == eETH.shares(address(this)), "Invalid eETH shares after remainder handling"); + + emit HandledRemainderOfClaimedWithdrawRequests(eEthAmountToTreasury, liquidityPool.amountForShare(eEthSharesToBurn)); + } + + function getEEthRemainderAmount() public view returns (uint256) { + return liquidityPool.amountForShare(totalRemainderEEthShares); + } + + function isScanOfShareRemainderCompleted() public view returns (bool) { + return currentRequestIdToScanFromForShareRemainder == (lastRequestIdToScanUntilForShareRemainder + 1); + } + + // the withdraw request NFT is transferrable + // - if the request is valid, it can be transferred by the owner of the NFT + // - if the request is invalid, it can be transferred only by the owner of the WithdarwRequestNFT contract function _beforeTokenTransfer(address from, address to, uint256 firstTokenId, uint256 batchSize) internal override { for (uint256 i = 0; i < batchSize; i++) { uint256 tokenId = firstTokenId + i; @@ -210,13 +297,27 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad return _getImplementation(); } + function _requireNotPaused() internal view virtual { + require(!paused, "Pausable: paused"); + } + modifier onlyAdmin() { require(admins[msg.sender], "Caller is not the admin"); _; } + modifier onlyPauser() { + require(msg.sender == pauser || admins[msg.sender] || msg.sender == owner(), "Caller is not the pauser"); + _; + } + modifier onlyLiquidtyPool() { require(msg.sender == address(liquidityPool), "Caller is not the liquidity pool"); _; } + + modifier whenNotPaused() { + _requireNotPaused(); + _; + } } diff --git a/src/interfaces/ILiquidityPool.sol b/src/interfaces/ILiquidityPool.sol index 2f12fd76d..9400955db 100644 --- a/src/interfaces/ILiquidityPool.sol +++ b/src/interfaces/ILiquidityPool.sol @@ -50,6 +50,7 @@ interface ILiquidityPool { function sharesForAmount(uint256 _amount) external view returns (uint256); function sharesForWithdrawalAmount(uint256 _amount) external view returns (uint256); function amountForShare(uint256 _share) external view returns (uint256); + function ethAmountLockedForWithdrawal() external view returns (uint128); function deposit() external payable returns (uint256); function deposit(address _referral) external payable returns (uint256); @@ -70,7 +71,6 @@ interface ILiquidityPool { function rebase(int128 _accruedRewards) external; function payProtocolFees(uint128 _protocolFees) external; function addEthAmountLockedForWithdrawal(uint128 _amount) external; - function reduceEthAmountLockedForWithdrawal(uint128 _amount) external; function setStakingTargetWeights(uint32 _eEthWeight, uint32 _etherFanWeight) external; function updateAdmin(address _newAdmin, bool _isAdmin) external; diff --git a/src/interfaces/IWeETH.sol b/src/interfaces/IWeETH.sol index b64d76ca3..4741da079 100644 --- a/src/interfaces/IWeETH.sol +++ b/src/interfaces/IWeETH.sol @@ -6,6 +6,15 @@ import "./ILiquidityPool.sol"; import "./IeETH.sol"; interface IWeETH is IERC20Upgradeable { + + struct PermitInput { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + // STATE VARIABLES function eETH() external view returns (IeETH); function liquidityPool() external view returns (ILiquidityPool); diff --git a/src/interfaces/IeETH.sol b/src/interfaces/IeETH.sol index 74fc6affa..f8ee974b9 100644 --- a/src/interfaces/IeETH.sol +++ b/src/interfaces/IeETH.sol @@ -2,6 +2,15 @@ pragma solidity ^0.8.13; interface IeETH { + + struct PermitInput { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + function name() external pure returns (string memory); function symbol() external pure returns (string memory); function decimals() external pure returns (uint8); diff --git a/test/EtherFiRedemptionManager.t.sol b/test/EtherFiRedemptionManager.t.sol new file mode 100644 index 000000000..7aa5374d4 --- /dev/null +++ b/test/EtherFiRedemptionManager.t.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/console2.sol"; +import "./TestSetup.sol"; + +contract EtherFiRedemptionManagerTest is TestSetup { + + address user = vm.addr(999); + address op_admin = vm.addr(1000); + + function setUp() public { + setUpTests(); + } + + function setUp_Fork() public { + initializeRealisticFork(MAINNET_FORK); + + vm.startPrank(roleRegistry.owner()); + roleRegistry.grantRole(keccak256("PROTOCOL_ADMIN"), op_admin); + vm.stopPrank(); + + etherFiRedemptionManagerProxy = new UUPSProxy(address(new EtherFiRedemptionManager(address(liquidityPoolInstance), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistry))), ""); + etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(etherFiRedemptionManagerProxy)); + etherFiRedemptionManagerInstance.initialize(10_00, 1_00, 1_00, 5 ether, 0.001 ether); // 10% fee split to treasury, 1% exit fee, 1% low watermark + + _upgrade_liquidity_pool_contract(); + + vm.prank(liquidityPoolInstance.owner()); + liquidityPoolInstance.initializeOnUpgradeWithRedemptionManager(address(etherFiRedemptionManagerInstance)); + } + + function test_upgrade_only_by_owner() public { + setUp_Fork(); + + address impl = etherFiRedemptionManagerInstance.getImplementation(); + vm.prank(admin); + vm.expectRevert(); + etherFiRedemptionManagerInstance.upgradeTo(impl); + + vm.prank(etherFiRedemptionManagerInstance.owner()); + etherFiRedemptionManagerInstance.upgradeTo(impl); + } + + function test_rate_limit() public { + vm.deal(user, 1000 ether); + vm.prank(user); + liquidityPoolInstance.deposit{value: 1000 ether}(); + + assertEq(etherFiRedemptionManagerInstance.canRedeem(1 ether), true); + assertEq(etherFiRedemptionManagerInstance.canRedeem(5 ether - 1), true); + assertEq(etherFiRedemptionManagerInstance.canRedeem(5 ether + 1), false); + assertEq(etherFiRedemptionManagerInstance.canRedeem(10 ether), false); + assertEq(etherFiRedemptionManagerInstance.totalRedeemableAmount(), 5 ether); + } + + function test_lowwatermark_guardrail() public { + vm.deal(user, 100 ether); + + assertEq(etherFiRedemptionManagerInstance.lowWatermarkInETH(), 0 ether); + + vm.prank(user); + liquidityPoolInstance.deposit{value: 100 ether}(); + + vm.startPrank(etherFiRedemptionManagerInstance.owner()); + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(1_00); // 1% + assertEq(etherFiRedemptionManagerInstance.lowWatermarkInETH(), 1 ether); + + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(50_00); // 50% + assertEq(etherFiRedemptionManagerInstance.lowWatermarkInETH(), 50 ether); + + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(100_00); // 100% + assertEq(etherFiRedemptionManagerInstance.lowWatermarkInETH(), 100 ether); + + vm.expectRevert("INVALID"); + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(100_01); // 100.01% + } + + function testFuzz_redeemEEth( + uint256 depositAmount, + uint256 redeemAmount, + uint256 exitFeeSplitBps, + uint16 exitFeeBps, + uint16 lowWatermarkBps + ) public { + depositAmount = bound(depositAmount, 1 ether, 1000 ether); + redeemAmount = bound(redeemAmount, 0.1 ether, depositAmount); + exitFeeSplitBps = bound(exitFeeSplitBps, 0, 10000); + exitFeeBps = uint16(bound(uint256(exitFeeBps), 0, 10000)); + lowWatermarkBps = uint16(bound(uint256(lowWatermarkBps), 0, 10000)); + + vm.deal(user, depositAmount); + vm.startPrank(user); + liquidityPoolInstance.deposit{value: depositAmount}(); + vm.stopPrank(); + + // Set exitFeeSplitToTreasuryInBps + vm.prank(owner); + etherFiRedemptionManagerInstance.setExitFeeSplitToTreasuryInBps(uint16(exitFeeSplitBps)); + + // Set exitFeeBasisPoints and lowWatermarkInBpsOfTvl + vm.prank(owner); + etherFiRedemptionManagerInstance.setExitFeeBasisPoints(exitFeeBps); + + vm.prank(owner); + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(lowWatermarkBps); + + vm.startPrank(user); + if (etherFiRedemptionManagerInstance.canRedeem(redeemAmount)) { + uint256 userBalanceBefore = address(user).balance; + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(address(treasuryInstance)); + + IeETH.PermitInput memory permit = eEth_createPermitInput(999, address(etherFiRedemptionManagerInstance), redeemAmount, eETHInstance.nonces(user), 2**256 - 1, eETHInstance.DOMAIN_SEPARATOR()); + etherFiRedemptionManagerInstance.redeemEEthWithPermit(redeemAmount, user, permit); + + uint256 totalFee = (redeemAmount * exitFeeBps) / 10000; + uint256 treasuryFee = (totalFee * exitFeeSplitBps) / 10000; + uint256 userReceives = redeemAmount - totalFee; + + assertApproxEqAbs( + eETHInstance.balanceOf(address(treasuryInstance)), + treasuryBalanceBefore + treasuryFee, + 1e2 + ); + assertApproxEqAbs( + address(user).balance, + userBalanceBefore + userReceives, + 1e2 + ); + + } else { + vm.expectRevert(); + etherFiRedemptionManagerInstance.redeemEEth(redeemAmount, user); + } + vm.stopPrank(); + } + + function testFuzz_redeemWeEth( + uint256 depositAmount, + uint256 redeemAmount, + uint16 exitFeeSplitBps, + int256 rebase, + uint16 exitFeeBps, + uint16 lowWatermarkBps + ) public { + // Bound the parameters + depositAmount = bound(depositAmount, 1 ether, 1000 ether); + redeemAmount = bound(redeemAmount, 0.1 ether, depositAmount); + exitFeeSplitBps = uint16(bound(exitFeeSplitBps, 0, 10000)); + exitFeeBps = uint16(bound(exitFeeBps, 0, 10000)); + lowWatermarkBps = uint16(bound(lowWatermarkBps, 0, 10000)); + rebase = bound(rebase, 0, int128(uint128(depositAmount) / 10)); + + // Deal Ether to user and perform deposit + vm.deal(user, depositAmount); + vm.startPrank(user); + liquidityPoolInstance.deposit{value: depositAmount}(); + vm.stopPrank(); + + // Apply rebase + vm.prank(address(membershipManagerInstance)); + liquidityPoolInstance.rebase(int128(rebase)); + + // Set fee and watermark configurations + vm.prank(owner); + etherFiRedemptionManagerInstance.setExitFeeSplitToTreasuryInBps(uint16(exitFeeSplitBps)); + + vm.prank(owner); + etherFiRedemptionManagerInstance.setExitFeeBasisPoints(exitFeeBps); + + vm.prank(owner); + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(lowWatermarkBps); + + // Convert redeemAmount from ETH to weETH + vm.startPrank(user); + eETHInstance.approve(address(weEthInstance), redeemAmount); + weEthInstance.wrap(redeemAmount); + uint256 weEthAmount = weEthInstance.balanceOf(user); + + if (etherFiRedemptionManagerInstance.canRedeem(redeemAmount)) { + uint256 userBalanceBefore = address(user).balance; + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(address(treasuryInstance)); + + uint256 eEthAmount = liquidityPoolInstance.amountForShare(weEthAmount); + + IWeETH.PermitInput memory permit = weEth_createPermitInput(999, address(etherFiRedemptionManagerInstance), weEthAmount, weEthInstance.nonces(user), 2**256 - 1, weEthInstance.DOMAIN_SEPARATOR()); + etherFiRedemptionManagerInstance.redeemWeEthWithPermit(weEthAmount, user, permit); + + uint256 totalFee = (eEthAmount * exitFeeBps) / 10000; + uint256 treasuryFee = (totalFee * exitFeeSplitBps) / 10000; + uint256 userReceives = eEthAmount - totalFee; + + assertApproxEqAbs( + eETHInstance.balanceOf(address(treasuryInstance)), + treasuryBalanceBefore + treasuryFee, + 1e2 + ); + assertApproxEqAbs( + address(user).balance, + userBalanceBefore + userReceives, + 1e2 + ); + + } else { + vm.expectRevert(); + etherFiRedemptionManagerInstance.redeemWeEth(weEthAmount, user); + } + vm.stopPrank(); + } + + function testFuzz_role_management(address admin, address pauser, address unpauser, address user) public { + address owner = roleRegistry.owner(); + bytes32 PROTOCOL_ADMIN = keccak256("PROTOCOL_ADMIN"); + bytes32 PROTOCOL_PAUSER = keccak256("PROTOCOL_PAUSER"); + bytes32 PROTOCOL_UNPAUSER = keccak256("PROTOCOL_UNPAUSER"); + + vm.assume(admin != address(0) && admin != owner); + vm.assume(pauser != address(0) && pauser != owner && pauser != admin); + vm.assume(unpauser != address(0) && unpauser != owner && unpauser != admin && unpauser != pauser); + vm.assume(user != address(0) && user != owner && user != admin && user != pauser && user != unpauser); + + // Grant roles to respective addresses + vm.prank(owner); + roleRegistry.grantRole(PROTOCOL_ADMIN, admin); + vm.prank(owner); + roleRegistry.grantRole(PROTOCOL_PAUSER, pauser); + vm.prank(owner); + roleRegistry.grantRole(PROTOCOL_UNPAUSER, unpauser); + + // Admin performs admin-only actions + vm.startPrank(admin); + etherFiRedemptionManagerInstance.setCapacity(10 ether); + etherFiRedemptionManagerInstance.setRefillRatePerSecond(0.001 ether); + etherFiRedemptionManagerInstance.setExitFeeSplitToTreasuryInBps(1e4); + etherFiRedemptionManagerInstance.setLowWatermarkInBpsOfTvl(1e2); + etherFiRedemptionManagerInstance.setExitFeeBasisPoints(1e2); + vm.stopPrank(); + + // Pauser pauses the contract + vm.startPrank(pauser); + etherFiRedemptionManagerInstance.pauseContract(); + assertTrue(etherFiRedemptionManagerInstance.paused()); + vm.stopPrank(); + + // Unpauser unpauses the contract + vm.startPrank(unpauser); + etherFiRedemptionManagerInstance.unPauseContract(); + assertFalse(etherFiRedemptionManagerInstance.paused()); + vm.stopPrank(); + + // Revoke PROTOCOL_ADMIN role from admin + vm.prank(owner); + roleRegistry.revokeRole(PROTOCOL_ADMIN, admin); + + // Admin attempts admin-only actions after role revocation + vm.startPrank(admin); + vm.expectRevert("EtherFiRedemptionManager: Unauthorized"); + etherFiRedemptionManagerInstance.setCapacity(10 ether); + vm.stopPrank(); + + // Pauser attempts to unpause (should fail) + vm.startPrank(pauser); + vm.expectRevert("EtherFiRedemptionManager: Unauthorized"); + etherFiRedemptionManagerInstance.unPauseContract(); + vm.stopPrank(); + + // Unpauser attempts to pause (should fail) + vm.startPrank(unpauser); + vm.expectRevert("EtherFiRedemptionManager: Unauthorized"); + etherFiRedemptionManagerInstance.pauseContract(); + vm.stopPrank(); + + // User without role attempts admin-only actions + vm.startPrank(user); + vm.expectRevert("EtherFiRedemptionManager: Unauthorized"); + etherFiRedemptionManagerInstance.pauseContract(); + vm.expectRevert("EtherFiRedemptionManager: Unauthorized"); + etherFiRedemptionManagerInstance.unPauseContract(); + vm.stopPrank(); + } + + function test_mainnet_redeem_eEth() public { + setUp_Fork(); + + vm.deal(alice, 50000 ether); + vm.prank(alice); + liquidityPoolInstance.deposit{value: 50000 ether}(); + + + vm.deal(user, 100 ether); + vm.startPrank(user); + + liquidityPoolInstance.deposit{value: 10 ether}(); + + uint256 redeemableAmount = etherFiRedemptionManagerInstance.totalRedeemableAmount(); + uint256 userBalance = address(user).balance; + uint256 treasuryBalance = eETHInstance.balanceOf(address(etherFiRedemptionManagerInstance.treasury())); + + eETHInstance.approve(address(etherFiRedemptionManagerInstance), 1 ether); + etherFiRedemptionManagerInstance.redeemEEth(1 ether, user); + + uint256 totalFee = (1 ether * 1e2) / 1e4; + uint256 treasuryFee = (totalFee * 1e3) / 1e4; + uint256 userReceives = 1 ether - totalFee; + + assertApproxEqAbs(eETHInstance.balanceOf(address(etherFiRedemptionManagerInstance.treasury())), treasuryBalance + treasuryFee, 1e1); + assertApproxEqAbs(address(user).balance, userBalance + userReceives, 1e1); + + eETHInstance.approve(address(etherFiRedemptionManagerInstance), 5 ether); + vm.expectRevert("EtherFiRedemptionManager: Exceeded total redeemable amount"); + etherFiRedemptionManagerInstance.redeemEEth(5 ether, user); + + vm.stopPrank(); + } + + function test_mainnet_redeem_weEth_with_rebase() public { + setUp_Fork(); + + vm.deal(alice, 50000 ether); + vm.prank(alice); + liquidityPoolInstance.deposit{value: 50000 ether}(); + + vm.deal(user, 100 ether); + + vm.startPrank(user); + liquidityPoolInstance.deposit{value: 10 ether}(); + eETHInstance.approve(address(weEthInstance), 10 ether); + weEthInstance.wrap(1 ether); + vm.stopPrank(); + + uint256 one_percent_of_tvl = liquidityPoolInstance.getTotalPooledEther() / 100; + + vm.prank(address(membershipManagerV1Instance)); + liquidityPoolInstance.rebase(int128(uint128(one_percent_of_tvl))); // 10 eETH earned 1 ETH + + vm.startPrank(user); + uint256 weEthAmount = weEthInstance.balanceOf(user); + uint256 eEthAmount = liquidityPoolInstance.amountForShare(weEthAmount); + uint256 userBalance = address(user).balance; + uint256 treasuryBalance = eETHInstance.balanceOf(address(treasuryInstance)); + weEthInstance.approve(address(etherFiRedemptionManagerInstance), 1 ether); + etherFiRedemptionManagerInstance.redeemWeEth(weEthAmount, user); + + uint256 totalFee = (eEthAmount * 1e2) / 1e4; + uint256 treasuryFee = (totalFee * 1e3) / 1e4; + uint256 userReceives = eEthAmount - totalFee; + + assertApproxEqAbs(eETHInstance.balanceOf(address(treasuryInstance)), treasuryBalance + treasuryFee, 1e1); + assertApproxEqAbs(address(user).balance, userBalance + userReceives, 1e1); + + vm.stopPrank(); + } + + function test_mainnet_redeem_beyond_liquidity_fails() public { + setUp_Fork(); + + uint256 redeemAmount = liquidityPoolInstance.getTotalPooledEther() / 2; + vm.prank(address(liquidityPoolInstance)); + eETHInstance.mintShares(user, 2 * redeemAmount); + + vm.startPrank(op_admin); + etherFiRedemptionManagerInstance.setCapacity(2 * redeemAmount); + etherFiRedemptionManagerInstance.setRefillRatePerSecond(2 * redeemAmount); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + + vm.startPrank(user); + + uint256 userBalance = address(user).balance; + uint256 treasuryBalance = eETHInstance.balanceOf(address(etherFiRedemptionManagerInstance.treasury())); + + eETHInstance.approve(address(etherFiRedemptionManagerInstance), redeemAmount); + vm.expectRevert("EtherFiRedemptionManager: Exceeded total redeemable amount"); + etherFiRedemptionManagerInstance.redeemEEth(redeemAmount, user); + + vm.stopPrank(); + } +} diff --git a/test/TestSetup.sol b/test/TestSetup.sol index 0f13eb4b9..4eca2441f 100644 --- a/test/TestSetup.sol +++ b/test/TestSetup.sol @@ -49,6 +49,7 @@ import "../src/EtherFiAdmin.sol"; import "../src/EtherFiTimelock.sol"; import "../src/BucketRateLimiter.sol"; +import "../src/EtherFiRedemptionManager.sol"; contract TestSetup is Test { @@ -102,8 +103,10 @@ contract TestSetup is Test { UUPSProxy public membershipNftProxy; UUPSProxy public nftExchangeProxy; UUPSProxy public withdrawRequestNFTProxy; + UUPSProxy public etherFiRedemptionManagerProxy; UUPSProxy public etherFiOracleProxy; UUPSProxy public etherFiAdminProxy; + UUPSProxy public roleRegistryProxy; DepositDataGeneration public depGen; IDepositContract public depositContractEth2; @@ -161,6 +164,8 @@ contract TestSetup is Test { WithdrawRequestNFT public withdrawRequestNFTImplementation; WithdrawRequestNFT public withdrawRequestNFTInstance; + EtherFiRedemptionManager public etherFiRedemptionManagerInstance; + NFTExchange public nftExchangeImplementation; NFTExchange public nftExchangeInstance; @@ -186,6 +191,8 @@ contract TestSetup is Test { EtherFiTimelock public etherFiTimelockInstance; BucketRateLimiter public bucketRateLimiter; + RoleRegistry public roleRegistry; + bytes32 root; bytes32 rootMigration; bytes32 rootMigration2; @@ -388,6 +395,7 @@ contract TestSetup is Test { etherFiTimelockInstance = EtherFiTimelock(payable(addressProviderInstance.getContractAddress("EtherFiTimelock"))); etherFiAdminInstance = EtherFiAdmin(payable(addressProviderInstance.getContractAddress("EtherFiAdmin"))); etherFiOracleInstance = EtherFiOracle(payable(addressProviderInstance.getContractAddress("EtherFiOracle"))); + roleRegistry = RoleRegistry(0x1d3Af47C1607A2EF33033693A9989D1d1013BB50); } function setUpLiquifier(uint8 forkEnum) internal { @@ -551,7 +559,7 @@ contract TestSetup is Test { membershipNftProxy = new UUPSProxy(address(membershipNftImplementation), ""); membershipNftInstance = MembershipNFT(payable(membershipNftProxy)); - withdrawRequestNFTImplementation = new WithdrawRequestNFT(); + withdrawRequestNFTImplementation = new WithdrawRequestNFT(address(treasuryInstance)); withdrawRequestNFTProxy = new UUPSProxy(address(withdrawRequestNFTImplementation), ""); withdrawRequestNFTInstance = WithdrawRequestNFT(payable(withdrawRequestNFTProxy)); @@ -572,7 +580,19 @@ contract TestSetup is Test { etherFiRestakerProxy = new UUPSProxy(address(etherFiRestakerImplementation), ""); etherFiRestakerInstance = EtherFiRestaker(payable(etherFiRestakerProxy)); + roleRegistryProxy = new UUPSProxy(address(new RoleRegistry()), ""); + roleRegistry = RoleRegistry(address(roleRegistryProxy)); + roleRegistry.initialize(owner); + + etherFiRedemptionManagerProxy = new UUPSProxy(address(new EtherFiRedemptionManager(address(liquidityPoolInstance), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistry))), ""); + etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(etherFiRedemptionManagerProxy)); + etherFiRedemptionManagerInstance.initialize(10_00, 1_00, 1_00, 5 ether, 0.001 ether); + + roleRegistry.grantRole(keccak256("PROTOCOL_ADMIN"), owner); + liquidityPoolInstance.initialize(address(eETHInstance), address(stakingManagerInstance), address(etherFiNodeManagerProxy), address(membershipManagerInstance), address(TNFTInstance), address(etherFiAdminProxy), address(withdrawRequestNFTInstance)); + liquidityPoolInstance.initializeOnUpgradeWithRedemptionManager(address(etherFiRedemptionManagerInstance)); + membershipNftInstance.initialize("https://etherfi-cdn/{id}.json", address(membershipManagerInstance)); withdrawRequestNFTInstance.initialize(payable(address(liquidityPoolInstance)), payable(address(eETHInstance)), payable(address(membershipManagerInstance))); membershipManagerInstance.initialize( @@ -1013,6 +1033,34 @@ contract TestSetup is Test { return permitInput; } + function eEth_createPermitInput(uint256 privKey, address spender, uint256 value, uint256 nonce, uint256 deadline, bytes32 domianSeparator) public returns (IeETH.PermitInput memory) { + address _owner = vm.addr(privKey); + bytes32 digest = calculatePermitDigest(_owner, spender, value, nonce, deadline, domianSeparator); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); + IeETH.PermitInput memory permitInput = IeETH.PermitInput({ + value: value, + deadline: deadline, + v: v, + r: r, + s: s + }); + return permitInput; + } + + function weEth_createPermitInput(uint256 privKey, address spender, uint256 value, uint256 nonce, uint256 deadline, bytes32 domianSeparator) public returns (IWeETH.PermitInput memory) { + address _owner = vm.addr(privKey); + bytes32 digest = calculatePermitDigest(_owner, spender, value, nonce, deadline, domianSeparator); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); + IWeETH.PermitInput memory permitInput = IWeETH.PermitInput({ + value: value, + deadline: deadline, + v: v, + r: r, + s: s + }); + return permitInput; + } + function registerAsBnftHolder(address _user) internal { (bool registered, uint32 index) = liquidityPoolInstance.bnftHoldersIndexes(_user); if (!registered) liquidityPoolInstance.registerAsBnftHolder(_user); diff --git a/test/WithdrawRequestNFT.t.sol b/test/WithdrawRequestNFT.t.sol index 98accc85b..aeebdcb35 100644 --- a/test/WithdrawRequestNFT.t.sol +++ b/test/WithdrawRequestNFT.t.sol @@ -6,64 +6,33 @@ pragma solidity ^0.8.13; import "forge-std/console2.sol"; import "./TestSetup.sol"; -contract WithdrawRequestNFTTest is TestSetup { - function setUp() public { - setUpTests(); - } +contract WithdrawRequestNFTIntrusive is WithdrawRequestNFT { - function test_WithdrawRequestNftInitializedCorrectly() public { - assertEq(address(withdrawRequestNFTInstance.liquidityPool()), address(liquidityPoolInstance)); - assertEq(address(withdrawRequestNFTInstance.eETH()), address(eETHInstance)); - } - - function test_RequestWithdraw() public { - startHoax(bob); - liquidityPoolInstance.deposit{value: 10 ether}(); - vm.stopPrank(); - - assertEq(liquidityPoolInstance.getTotalPooledEther(), 10 ether); - assertEq(eETHInstance.balanceOf(address(bob)), 10 ether); - - uint96 amountOfEEth = 1 ether; + constructor() WithdrawRequestNFT(address(0)) {} - vm.prank(bob); - eETHInstance.approve(address(liquidityPoolInstance), amountOfEEth); - - vm.prank(bob); - uint256 requestId = liquidityPoolInstance.requestWithdraw(bob, amountOfEEth); - - WithdrawRequestNFT.WithdrawRequest memory request = withdrawRequestNFTInstance.getRequest(requestId); - - assertEq(request.amountOfEEth, 1 ether, "Amount of eEth should match"); - assertEq(request.shareOfEEth, 1 ether, "Share of eEth should match"); - assertTrue(request.isValid, "Request should be valid"); + function updateParam(uint32 _currentRequestIdToScanFromForShareRemainder, uint32 _lastRequestIdToScanUntilForShareRemainder) external { + currentRequestIdToScanFromForShareRemainder = _currentRequestIdToScanFromForShareRemainder; + lastRequestIdToScanUntilForShareRemainder = _lastRequestIdToScanUntilForShareRemainder; } + +} - function test_RequestIdIncrements() public { - startHoax(bob); - liquidityPoolInstance.deposit{value: 10 ether}(); - vm.stopPrank(); - - assertEq(liquidityPoolInstance.getTotalPooledEther(), 10 ether); - - uint96 amountOfEEth = 1 ether; - - vm.prank(bob); - eETHInstance.approve(address(liquidityPoolInstance), amountOfEEth); - - vm.prank(bob); - uint256 requestId1 = liquidityPoolInstance.requestWithdraw(bob, amountOfEEth); - - assertEq(requestId1, 1, "Request id should be 1"); +contract WithdrawRequestNFTTest is TestSetup { - vm.prank(bob); - eETHInstance.approve(address(liquidityPoolInstance), amountOfEEth); + uint32[] public reqIds =[ 20, 388, 478, 714, 726, 729, 735, 815, 861, 916, 941, 1014, 1067, 1154, 1194, 1253]; + address etherfi_admin_wallet = 0x2aCA71020De61bb532008049e1Bd41E451aE8AdC; - vm.prank(bob); - uint256 requestId2 = liquidityPoolInstance.requestWithdraw(bob, amountOfEEth); + function setUp() public { + setUpTests(); + } - assertEq(requestId2, 2, "Request id should be 2"); + function updateParam(uint32 _currentRequestIdToScanFromForShareRemainder, uint32 _lastRequestIdToScanUntilForShareRemainder) internal { + address cur_impl = withdrawRequestNFTInstance.getImplementation(); + address new_impl = address(new WithdrawRequestNFTIntrusive()); + withdrawRequestNFTInstance.upgradeTo(new_impl); + WithdrawRequestNFTIntrusive(address(withdrawRequestNFTInstance)).updateParam(_currentRequestIdToScanFromForShareRemainder, _lastRequestIdToScanUntilForShareRemainder); + withdrawRequestNFTInstance.upgradeTo(cur_impl); } function test_finalizeRequests() public { @@ -117,7 +86,7 @@ contract WithdrawRequestNFTTest is TestSetup { assertTrue(request.isValid, "Request should be valid"); } - function testInvalidClaimWithdraw() public { + function test_InvalidClaimWithdraw() public { startHoax(bob); liquidityPoolInstance.deposit{value: 10 ether}(); vm.stopPrank(); @@ -169,7 +138,7 @@ contract WithdrawRequestNFTTest is TestSetup { assertEq(liquidityPoolInstance.getTotalPooledEther(), 10 ether); assertEq(eETHInstance.balanceOf(bob), 10 ether); - assertEq(eETHInstance.balanceOf(address(withdrawRequestNFTInstance)), 0 ether, "eETH balance should be 0 ether"); + assertEq(eETHInstance.balanceOf(address(withdrawRequestNFTInstance)), 0 ether, "eETH balance should start from 0 ether"); // Case 1. // Even after the rebase, the withdrawal amount should remain the same; 1 eth @@ -179,9 +148,9 @@ contract WithdrawRequestNFTTest is TestSetup { vm.prank(bob); uint256 requestId = liquidityPoolInstance.requestWithdraw(bob, 1 ether); - assertEq(withdrawRequestNFTInstance.accumulatedDustEEthShares(), 0, "Accumulated dust should be 0"); assertEq(eETHInstance.balanceOf(bob), 9 ether); assertEq(eETHInstance.balanceOf(address(withdrawRequestNFTInstance)), 1 ether, "eETH balance should be 1 ether"); + assertEq(eETHInstance.balanceOf(address(treasuryInstance)), 0 ether, "Treasury balance should be 0 ether"); // Rebase with accrued_rewards = 10 ether for the deposited 10 ether // -> 1 ether eETH shares = 2 ether ETH @@ -202,12 +171,6 @@ contract WithdrawRequestNFTTest is TestSetup { assertEq(bobsEndingBalance, bobsStartingBalance + 1 ether, "Bobs balance should be 1 ether higher"); assertEq(eETHInstance.balanceOf(address(withdrawRequestNFTInstance)), 1 ether, "eETH balance should be 1 ether"); - assertEq(liquidityPoolInstance.amountForShare(withdrawRequestNFTInstance.accumulatedDustEEthShares()), 1 ether); - - vm.prank(alice); - withdrawRequestNFTInstance.burnAccumulatedDustEEthShares(); - assertEq(eETHInstance.balanceOf(address(withdrawRequestNFTInstance)), 0 ether, "eETH balance should be 0 ether"); - assertEq(eETHInstance.balanceOf(bob), 18 ether + 1 ether); // 1 ether eETH in `withdrawRequestNFT` contract is re-distributed to the eETH holders } function test_ValidClaimWithdrawWithNegativeRebase() public { @@ -377,45 +340,259 @@ contract WithdrawRequestNFTTest is TestSetup { _finalizeWithdrawalRequest(requestId); } - function test_InvalidatedRequestNft_NonTransferrable() public { - uint256 requestId = test_InvalidatedRequestNft_after_finalization(); + function test_aggregateSumEEthShareAmount() public { + initializeRealisticFork(MAINNET_FORK); - vm.prank(alice); - vm.expectRevert("INVALID_REQUEST"); - withdrawRequestNFTInstance.transferFrom(alice, bob, requestId); + address pauser = 0x9AF1298993DC1f397973C62A5D47a284CF76844D; + + vm.startPrank(withdrawRequestNFTInstance.owner()); + // 1. Upgrade + withdrawRequestNFTInstance.upgradeTo(address(new WithdrawRequestNFT(address(owner)))); + withdrawRequestNFTInstance.initializeOnUpgrade(pauser, 50_00); + withdrawRequestNFTInstance.updateAdmin(etherfi_admin_wallet, true); + + // 2. (For test) update the scan range + updateParam(1, 200); + + // 2. Confirm Paused & Can't Unpause + assertTrue(withdrawRequestNFTInstance.paused(), "Contract should be paused"); + vm.expectRevert("scan is not completed"); + withdrawRequestNFTInstance.unPauseContract(); + vm.stopPrank(); + + // 3. AggSum + // - Can't Unpause untill the scan is not completed + // - Can't aggregateSumEEthShareAmount after the scan is completed + withdrawRequestNFTInstance.aggregateSumEEthShareAmount(128); + assertFalse(withdrawRequestNFTInstance.isScanOfShareRemainderCompleted(), "Scan should be completed"); + + vm.prank(withdrawRequestNFTInstance.owner()); + vm.expectRevert("scan is not completed"); + withdrawRequestNFTInstance.unPauseContract(); + + withdrawRequestNFTInstance.aggregateSumEEthShareAmount(128); + assertTrue(withdrawRequestNFTInstance.isScanOfShareRemainderCompleted(), "Scan should be completed"); + + vm.expectRevert("scan is completed"); + withdrawRequestNFTInstance.aggregateSumEEthShareAmount(128); + + // 4. Can Unpause + vm.startPrank(withdrawRequestNFTInstance.owner()); + withdrawRequestNFTInstance.unPauseContract(); + vm.stopPrank(); + + // we will run the test on the forked mainnet to perform the full scan and confirm we can unpause } - function test_seizeInvalidAndMintNew_revert_if_not_owner() public { - uint256 requestId = test_InvalidatedRequestNft_after_finalization(); - uint256 claimableAmount = withdrawRequestNFTInstance.getRequest(requestId).amountOfEEth; + function test_handleRemainder() public { + test_aggregateSumEEthShareAmount(); - // REVERT if not owner - vm.prank(alice); - vm.expectRevert("Ownable: caller is not the owner"); - withdrawRequestNFTInstance.seizeInvalidRequest(requestId, chad); + vm.prank(etherfi_admin_wallet); + withdrawRequestNFTInstance.handleRemainder(0.01 ether); + + vm.stopPrank(); + } + + function testFuzz_RequestWithdraw(uint96 depositAmount, uint96 withdrawAmount, address recipient) public { + // Assume valid conditions + vm.assume(depositAmount >= 1 ether && depositAmount <= 1000 ether); + vm.assume(withdrawAmount > 0 && withdrawAmount <= depositAmount); + vm.assume(recipient != address(0) && recipient != address(liquidityPoolInstance)); + + // Setup initial balance for bob + vm.deal(bob, depositAmount); + + // Deposit ETH and get eETH + vm.startPrank(bob); + liquidityPoolInstance.deposit{value: depositAmount}(); + + // Approve and request withdraw + eETHInstance.approve(address(liquidityPoolInstance), withdrawAmount); + uint256 requestId = liquidityPoolInstance.requestWithdraw(recipient, withdrawAmount); + vm.stopPrank(); + + // Verify the request was created correctly + WithdrawRequestNFT.WithdrawRequest memory request = withdrawRequestNFTInstance.getRequest(requestId); + + assertEq(request.amountOfEEth, withdrawAmount, "Incorrect withdrawal amount"); + assertEq(request.shareOfEEth, liquidityPoolInstance.sharesForAmount(withdrawAmount), "Incorrect share amount"); + assertTrue(request.isValid, "Request should be valid"); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), recipient, "Incorrect NFT owner"); + + // Verify eETH balances + assertEq(eETHInstance.balanceOf(bob), depositAmount - withdrawAmount, "Incorrect remaining eETH balance"); + assertEq(eETHInstance.balanceOf(address(withdrawRequestNFTInstance)), withdrawAmount, "Incorrect contract eETH balance"); + assertEq(withdrawRequestNFTInstance.nextRequestId(), requestId + 1, "Incorrect next request ID"); + + if (eETHInstance.balanceOf(bob) > 0) { + uint256 reqAmount = eETHInstance.balanceOf(bob); + vm.startPrank(bob); + eETHInstance.approve(address(liquidityPoolInstance), reqAmount); + uint256 requestId2 = liquidityPoolInstance.requestWithdraw(recipient, reqAmount); + vm.stopPrank(); + assertEq(requestId2, requestId + 1, "Incorrect next request ID"); + } } - function test_InvalidatedRequestNft_seizeInvalidAndMintNew_1() public { - uint256 requestId = test_InvalidatedRequestNft_after_finalization(); - uint256 claimableAmount = withdrawRequestNFTInstance.getRequest(requestId).amountOfEEth; - uint256 chadBalance = address(chad).balance; + function testFuzz_ClaimWithdraw( + uint96 depositAmount, + uint96 withdrawAmount, + uint96 rebaseAmount, + uint16 remainderSplitBps, + address recipient + ) public { + // Assume valid conditions + vm.assume(depositAmount >= 1 ether && depositAmount <= 1e6 ether); + vm.assume(withdrawAmount > 0 && withdrawAmount <= depositAmount); + vm.assume(rebaseAmount >= 0 && rebaseAmount <= depositAmount); + vm.assume(remainderSplitBps <= 10000); + vm.assume(recipient != address(0) && recipient != address(liquidityPoolInstance)); + + withdrawRequestNFTInstance.aggregateSumEEthShareAmount(10); + + vm.expectRevert("scan is completed"); + withdrawRequestNFTInstance.aggregateSumEEthShareAmount(10); + + // Setup initial balance for recipient + vm.deal(recipient, depositAmount); + + // Configure remainder split + vm.prank(withdrawRequestNFTInstance.owner()); + withdrawRequestNFTInstance.updateShareRemainderSplitToTreasuryInBps(remainderSplitBps); + + // First deposit ETH to get eETH + vm.startPrank(recipient); + liquidityPoolInstance.deposit{value: depositAmount}(); + + // Record initial balances + uint256 treasuryEEthBefore = eETHInstance.balanceOf(address(treasuryInstance)); + uint256 recipientBalanceBefore = address(recipient).balance; + + // Request withdraw + eETHInstance.approve(address(liquidityPoolInstance), withdrawAmount); + uint256 requestId = liquidityPoolInstance.requestWithdraw(recipient, withdrawAmount); + vm.stopPrank(); + + // Get initial request state + WithdrawRequestNFT.WithdrawRequest memory request = withdrawRequestNFTInstance.getRequest(requestId); - vm.prank(owner); - withdrawRequestNFTInstance.seizeInvalidRequest(requestId, chad); + // Simulate rebase after request but before claim + vm.prank(address(membershipManagerInstance)); + liquidityPoolInstance.rebase(int128(uint128(rebaseAmount))); - assertEq(liquidityPoolInstance.ethAmountLockedForWithdrawal(), 0, "Must be withdrawn"); - assertEq(address(chad).balance, chadBalance + claimableAmount, "Chad should receive the claimable amount"); + // Calculate expected withdrawal amounts after rebase + uint256 sharesValue = liquidityPoolInstance.amountForShare(request.shareOfEEth); + uint256 expectedWithdrawAmount = withdrawAmount < sharesValue ? withdrawAmount : sharesValue; + uint256 expectedBurnedShares = liquidityPoolInstance.sharesForAmount(expectedWithdrawAmount); + uint256 expectedDustShares = request.shareOfEEth - expectedBurnedShares; + + // Track initial shares and total supply + uint256 initialTotalShares = eETHInstance.totalShares(); + + _finalizeWithdrawalRequest(requestId); + + vm.prank(recipient); + withdrawRequestNFTInstance.claimWithdraw(requestId); + + // Calculate expected burnt shares + uint256 burnedShares = initialTotalShares - eETHInstance.totalShares(); + + // Verify share burning + assertLe(burnedShares, request.shareOfEEth, "Burned shares should be less than or equal to requested shares"); + assertApproxEqAbs( + burnedShares, + expectedBurnedShares, + 1e3, + "Incorrect amount of shares burnt" + ); + + + // Verify total supply reduction + assertApproxEqAbs( + eETHInstance.totalShares(), + initialTotalShares - burnedShares, + 1, + "Total shares not reduced correctly" + ); + assertGe( + eETHInstance.totalShares(), + initialTotalShares - burnedShares, + "Total shares should be greater than or equal to initial shares minus burned shares" + ); + + // Verify the withdrawal results + WithdrawRequestNFT.WithdrawRequest memory requestAfter = withdrawRequestNFTInstance.getRequest(requestId); + + // Request should be cleared + assertEq(requestAfter.amountOfEEth, 0, "Request should be cleared after claim"); + + // NFT should be burned + vm.expectRevert("ERC721: invalid token ID"); + withdrawRequestNFTInstance.ownerOf(requestId); + + // Verify recipient received correct ETH amount + assertEq( + address(recipient).balance, + recipientBalanceBefore + expectedWithdrawAmount, + "Recipient should receive correct ETH amount" + ); + + assertApproxEqAbs( + withdrawRequestNFTInstance.totalRemainderEEthShares(), + expectedDustShares, + 1, + "Incorrect remainder shares" + ); + + uint256 dustEEthAmount = withdrawRequestNFTInstance.getEEthRemainderAmount(); + vm.startPrank(admin); + withdrawRequestNFTInstance.handleRemainder(dustEEthAmount / 2); + withdrawRequestNFTInstance.handleRemainder(dustEEthAmount / 2); } - function test_InvalidatedRequestNft_seizeInvalidAndMintNew_2() public { - uint256 requestId = test_InvalidatedRequestNft_before_finalization(); - uint256 claimableAmount = withdrawRequestNFTInstance.getRequest(requestId).amountOfEEth; - uint256 chadBalance = address(chad).balance; + function testFuzz_InvalidateRequest(uint96 depositAmount, uint96 withdrawAmount, address recipient) public { + // Assume valid conditions + vm.assume(depositAmount >= 1 ether && depositAmount <= 1000 ether); + vm.assume(withdrawAmount > 0 && withdrawAmount <= depositAmount); + vm.assume(recipient != address(0) && recipient != address(liquidityPoolInstance) && !withdrawRequestNFTInstance.admins(recipient)); + + // Setup initial balance and deposit + vm.deal(recipient, depositAmount); + + vm.startPrank(recipient); + liquidityPoolInstance.deposit{value: depositAmount}(); + + // Request withdraw + eETHInstance.approve(address(liquidityPoolInstance), withdrawAmount); + uint256 requestId = liquidityPoolInstance.requestWithdraw(recipient, withdrawAmount); + vm.stopPrank(); + + // Verify request is initially valid + assertTrue(withdrawRequestNFTInstance.isValid(requestId), "Request should start valid"); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), recipient, "Recipient should own NFT"); - vm.prank(owner); - withdrawRequestNFTInstance.seizeInvalidRequest(requestId, chad); + // Non-admin cannot invalidate + vm.prank(recipient); + vm.expectRevert("Caller is not the admin"); + withdrawRequestNFTInstance.invalidateRequest(requestId); + + // Admin invalidates request + vm.prank(withdrawRequestNFTInstance.owner()); + withdrawRequestNFTInstance.updateAdmin(admin, true); + vm.prank(admin); + withdrawRequestNFTInstance.invalidateRequest(requestId); + + // Verify request state after invalidation + assertFalse(withdrawRequestNFTInstance.isValid(requestId), "Request should be invalid"); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), recipient, "NFT ownership should remain unchanged"); + + // Verify cannot transfer invalid request + vm.prank(recipient); + vm.expectRevert("INVALID_REQUEST"); + withdrawRequestNFTInstance.transferFrom(recipient, address(0xdead), requestId); - assertEq(liquidityPoolInstance.ethAmountLockedForWithdrawal(), 0, "Must be withdrawn"); - assertEq(address(chad).balance, chadBalance + claimableAmount, "Chad should receive the claimable amount"); + // Owner can seize the invalidated request NFT + vm.prank(withdrawRequestNFTInstance.owner()); + withdrawRequestNFTInstance.seizeInvalidRequest(requestId, admin); } }