Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

weETH withdrawal: Instant withdrawal with Fee + Implicit withdrawal fee handling #207

Open
wants to merge 42 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d356cc9
init. Instant Withdrawal via Buffer
seongyun-ko Dec 6, 2024
82fab2e
implemented instant fee mechanism, handling of the implicit fee
seongyun-ko Dec 9, 2024
0f13953
fix scripts
seongyun-ko Dec 12, 2024
0b68310
add role registry, consider eth amount locked for withdrawal in liqui…
seongyun-ko Dec 17, 2024
c9fd604
use setter for 'shareRemainderSplitToTreasuryInBps', add more fuzz te…
seongyun-ko Dec 19, 2024
c377e8e
handle issues in calculating the dust shares
seongyun-ko Dec 20, 2024
a3aeac9
improve comments
seongyun-ko Dec 20, 2024
57bd0fc
add sorted & unique constraints + type change to reduce gas
seongyun-ko Dec 24, 2024
b6100b6
update 'handleAccumulatedShareRemainder' to be callable by admin
seongyun-ko Dec 24, 2024
e8734d6
Certora audit: (1) add {aggregateSumEEthShareAmount}, (2) fix {_claim…
seongyun-ko Dec 26, 2024
71ffa8d
wip: to be amended
seongyun-ko Dec 30, 2024
9e0fb99
add simplified {invalidate, validate} request, fix unit tests
seongyun-ko Dec 30, 2024
98f483a
rename EtherFiWithdrawBuffer -> EtherFiRedemptionManager
seongyun-ko Dec 30, 2024
bcc1184
fix the logic to check the aggr calls
seongyun-ko Dec 30, 2024
1f85fb6
Update test/WithdrawRequestNFT.t.sol
jtfirek Dec 30, 2024
bdee463
reduce gas spending for 'call', update the upgrade init function, rem…
seongyun-ko Dec 30, 2024
d40a117
apply gas opt for BucketLimiter
seongyun-ko Dec 30, 2024
f4f2ffd
improve assetion tsets, apply design pattern, function rename
seongyun-ko Dec 30, 2024
5069c14
apply CEI pattern to 'handleRemainder'
seongyun-ko Dec 30, 2024
2e13202
apply CEI pattern to 'redeem'
seongyun-ko Dec 31, 2024
b18bd18
use 'totalRemainderEEthShares' instead of locked share
seongyun-ko Dec 31, 2024
1e12673
initializeOnUpgrade cant be called twice
seongyun-ko Dec 31, 2024
c14c348
initializeOnUpgrade onlyOnce
seongyun-ko Dec 31, 2024
35fba66
use uint256 instead of uint32
seongyun-ko Dec 31, 2024
e95cfc0
revert
seongyun-ko Dec 31, 2024
bbf2d83
improve the fuzz test
seongyun-ko Dec 31, 2024
2cbbc04
(1) pause the contract on upgrade, (2) prevent from calling 'aggregat…
seongyun-ko Jan 1, 2025
1482cb0
only owner of the funds can call {redeemEEth, redeemWeEth}
seongyun-ko Jan 2, 2025
8ced81e
disable unpause until the scan is completed
seongyun-ko Jan 2, 2025
86ac4a0
check the basis points params are below 1e4
seongyun-ko Jan 2, 2025
1e8cdea
disallow calling 'initializeOnUpgradeWithRedemptionManager' with inva…
seongyun-ko Jan 2, 2025
6fffba6
(1) withdrawRequestNFT cannot call LiquidityPool.addEthAmountLockedFo…
seongyun-ko Jan 2, 2025
89db9d5
prevent finalizing future requests
seongyun-ko Jan 2, 2025
c5a2dd1
add {redeemEEthWithPermit, redeemWeEthWithPermit}, use try-catch for
seongyun-ko Jan 2, 2025
c85e920
remove a redundant check
seongyun-ko Jan 2, 2025
cca361e
Prevent 'initializeOnUpgrade' of LiquidityPool from being called twic…
seongyun-ko Jan 2, 2025
268efe5
use 'isScanOfShareRemainderCompleted'
seongyun-ko Jan 2, 2025
a13919d
add the max value constraint on rate limit
seongyun-ko Jan 2, 2025
58fe3fb
remove equality condition
seongyun-ko Jan 2, 2025
898896a
fix the overflow issue in '_refill'
seongyun-ko Jan 2, 2025
25ac914
Update EtherFiRedemptionManager.sol
seongyun-ko Jan 2, 2025
fd52a52
prevent the total shares from going below 1 gwei after redemption
seongyun-ko Jan 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/BucketLimiter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions script/deploys/DeployEtherFiWithdrawalBuffer.s.sol
Original file line number Diff line number Diff line change
@@ -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/EtherFiWithdrawalBuffer.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);

EtherFiWithdrawalBuffer impl = new EtherFiWithdrawalBuffer(
addressProvider.getContractAddress("LiquidityPool"),
addressProvider.getContractAddress("EETH"),
addressProvider.getContractAddress("WeETH"),
0x0c83EAe1FE72c390A02E426572854931EefF93BA, // protocol safe
0x1d3Af47C1607A2EF33033693A9989D1d1013BB50 // role registry
);
UUPSProxy proxy = new UUPSProxy(payable(impl), "");

EtherFiWithdrawalBuffer instance = EtherFiWithdrawalBuffer(payable(proxy));
instance.initialize(10_00, 1_00, 1_00, 5 ether, 0.001 ether);

vm.stopBroadcast();
}
}
2 changes: 1 addition & 1 deletion script/deploys/DeployPhaseTwo.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
2 changes: 1 addition & 1 deletion script/upgrades/WithdrawRequestNFTUpgradeScript.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
278 changes: 278 additions & 0 deletions src/EtherFiWithdrawalBuffer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
// SPDX-License-Identifier: MIT
seongyun-ko marked this conversation as resolved.
Show resolved Hide resolved
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 EtherFiWithdrawalBuffer 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

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 {
__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.
* @param owner The address of the owner of the eETH.
* @return The amount of ETH sent to the receiver and the exit fee amount.
*/
function redeemEEth(uint256 eEthAmount, address receiver, address owner) public whenNotPaused nonReentrant returns (uint256, uint256) {
require(eEthAmount <= eEth.balanceOf(owner), "EtherFiWithdrawalBuffer: Insufficient balance");
require(canRedeem(eEthAmount), "EtherFiWithdrawalBuffer: Exceeded total redeemable amount");

uint256 beforeEEthAmount = eEth.balanceOf(address(this));
jtfirek marked this conversation as resolved.
Show resolved Hide resolved
IERC20(address(eEth)).safeTransferFrom(owner, address(this), eEthAmount);
uint256 afterEEthAmount = eEth.balanceOf(address(this));

uint256 transferredEEthAmount = afterEEthAmount - beforeEEthAmount;
return _redeem(transferredEEthAmount, receiver);
}

/**
* @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.
* @param owner The address of the owner of the weETH.
* @return The amount of ETH sent to the receiver and the exit fee amount.
*/
function redeemWeEth(uint256 weEthAmount, address receiver, address owner) public whenNotPaused nonReentrant returns (uint256, uint256) {
uint256 eEthShares = weEthAmount;
uint256 eEthAmount = liquidityPool.amountForShare(eEthShares);
require(weEthAmount <= weEth.balanceOf(owner), "EtherFiWithdrawalBuffer: Insufficient balance");
require(canRedeem(eEthAmount), "EtherFiWithdrawalBuffer: Exceeded total redeemable amount");

uint256 beforeEEthAmount = eEth.balanceOf(address(this));
IERC20(address(weEth)).safeTransferFrom(owner, address(this), weEthAmount);
weEth.unwrap(weEthAmount);
uint256 afterEEthAmount = eEth.balanceOf(address(this));

uint256 transferredEEthAmount = afterEEthAmount - beforeEEthAmount;
return _redeem(transferredEEthAmount, 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.
* @return The amount of ETH sent to the receiver and the exit fee amount.
*/
function _redeem(uint256 ethAmount, address receiver) internal returns (uint256, uint256) {
_updateRateLimit(ethAmount);

uint256 ethShares = liquidityPool.sharesForAmount(ethAmount);
uint256 ethShareToReceiver = ethShares.mulDiv(BASIS_POINT_SCALE - exitFeeInBps, BASIS_POINT_SCALE);
uint256 eEthAmountToReceiver = liquidityPool.amountForShare(ethShareToReceiver);

uint256 prevLpBalance = address(liquidityPool).balance;
uint256 prevBalance = address(this).balance;
uint256 burnedShares = (eEthAmountToReceiver > 0) ? liquidityPool.withdraw(address(this), eEthAmountToReceiver) : 0;
uint256 ethReceived = address(this).balance - prevBalance;

uint256 ethShareFee = ethShares - burnedShares;
uint256 eEthAmountFee = liquidityPool.amountForShare(ethShareFee);
uint256 feeShareToTreasury = ethShareFee.mulDiv(exitFeeSplitToTreasuryInBps, BASIS_POINT_SCALE);
uint256 eEthFeeAmountToTreasury = liquidityPool.amountForShare(feeShareToTreasury);
uint256 feeShareToStakers = ethShareFee - feeShareToTreasury;

// To Stakers by burning shares
eEth.burnShares(address(this), feeShareToStakers);

// To Treasury by transferring eETH
IERC20(address(eEth)).safeTransfer(treasury, eEthFeeAmountToTreasury);

// To Receiver by transferring ETH
(bool success, ) = receiver.call{value: ethReceived, gas: 100_000}("");
require(success && address(liquidityPool).balance == prevLpBalance - ethReceived, "EtherFiWithdrawalBuffer: Transfer failed");

return (ethReceived, eEthAmountFee);
}

/**
* @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) {
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;
}

/**
* @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), "EtherFiWithdrawalBuffer: Unauthorized");
}

modifier hasRole(bytes32 role) {
_hasRole(role, msg.sender);
_;
}

}
11 changes: 10 additions & 1 deletion src/LiquidityPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import "./interfaces/IEtherFiAdmin.sol";
import "./interfaces/IAuctionManager.sol";
import "./interfaces/ILiquifier.sol";

import "./EtherFiWithdrawalBuffer.sol";


contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, ILiquidityPool {
//--------------------------------------------------------------------------------------
//--------------------------------- STATE-VARIABLES ----------------------------------
Expand Down Expand Up @@ -69,6 +72,8 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL

bool private isLpBnftHolder;

EtherFiWithdrawalBuffer public etherFiWithdrawalBuffer;

//--------------------------------------------------------------------------------------
//------------------------------------- EVENTS ---------------------------------------
//--------------------------------------------------------------------------------------
Expand Down Expand Up @@ -139,6 +144,10 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL
liquifier = ILiquifier(_liquifier);
}

function initializeOnUpgradeWithWithdrawalBuffer(address _withdrawalBuffer) external onlyOwner {
etherFiWithdrawalBuffer = EtherFiWithdrawalBuffer(payable(_withdrawalBuffer));
}

// Used by eETH staking flow
function deposit() external payable returns (uint256) {
return deposit(address(0));
Expand Down Expand Up @@ -179,7 +188,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(etherFiWithdrawalBuffer), "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();

Expand Down
Loading
Loading