-
Notifications
You must be signed in to change notification settings - Fork 29
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
base: master
Are you sure you want to change the base?
Changes from 14 commits
d356cc9
82fab2e
0f13953
0b68310
c9fd604
c377e8e
a3aeac9
57bd0fc
b6100b6
e8734d6
71ffa8d
9e0fb99
98f483a
bcc1184
1f85fb6
bdee463
d40a117
f4f2ffd
5069c14
2e13202
b18bd18
1e12673
c14c348
35fba66
e95cfc0
bbf2d83
2cbbc04
1482cb0
8ced81e
86ac4a0
1e8cdea
6fffba6
89db9d5
c5a2dd1
c85e920
cca361e
268efe5
a13919d
58fe3fb
898896a
25ac914
fd52a52
6eefec0
9963471
9dcb732
0e35c6b
77e61d8
1435cc8
8565d21
805bf07
2de521a
fc4a179
8acd291
a7e974b
83bcd4f
34f67b9
da5af14
577587b
980af7d
599d287
5bd6630
2dd2115
fbcd9f1
3730663
72172e1
9dd6db1
b61899b
80c21be
4862685
f36c180
3356771
ddd1b59
43a5a5e
aa53280
8ae4844
26b5a0b
a785f25
8785224
edc30cb
b666808
23fd48b
7a909ac
0e6662b
f2cbc82
d193b00
17aabaa
c782ab3
b877f7e
3128cf3
fdbf21a
1cb9971
fd0f75f
ce827c2
3230fa7
07bbcfa
3b4be39
c2e6a53
95cfaaa
824bcf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
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/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(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,280 @@ | ||
// 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 { | ||
__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. | ||
*/ | ||
function redeemEEth(uint256 eEthAmount, address receiver, address owner) public whenNotPaused nonReentrant { | ||
require(eEthAmount <= eEth.balanceOf(owner), "EtherFiRedemptionManager: Insufficient balance"); | ||
require(canRedeem(eEthAmount), "EtherFiRedemptionManager: Exceeded total redeemable amount"); | ||
|
||
uint256 beforeEEthAmount = eEth.balanceOf(address(this)); | ||
IERC20(address(eEth)).safeTransferFrom(owner, address(this), eEthAmount); | ||
uint256 afterEEthAmount = eEth.balanceOf(address(this)); | ||
|
||
uint256 transferredEEthAmount = afterEEthAmount - beforeEEthAmount; | ||
_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. | ||
*/ | ||
function redeemWeEth(uint256 weEthAmount, address receiver, address owner) public whenNotPaused nonReentrant { | ||
uint256 eEthShares = weEthAmount; | ||
uint256 eEthAmount = liquidityPool.amountForShare(eEthShares); | ||
require(weEthAmount <= weEth.balanceOf(owner), "EtherFiRedemptionManager: Insufficient balance"); | ||
require(canRedeem(eEthAmount), "EtherFiRedemptionManager: 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; | ||
_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. | ||
*/ | ||
function _redeem(uint256 ethAmount, address receiver) internal { | ||
_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 sharesToBurn = liquidityPool.sharesForWithdrawalAmount(eEthAmountToReceiver); | ||
|
||
uint256 ethShareFee = ethShares - sharesToBurn; | ||
uint256 feeShareToTreasury = ethShareFee.mulDiv(exitFeeSplitToTreasuryInBps, BASIS_POINT_SCALE); | ||
uint256 eEthFeeAmountToTreasury = liquidityPool.amountForShare(feeShareToTreasury); | ||
uint256 feeShareToStakers = ethShareFee - feeShareToTreasury; | ||
|
||
// Withdraw ETH from the liquidity pool | ||
uint256 prevBalance = address(this).balance; | ||
assert (liquidityPool.withdraw(address(this), eEthAmountToReceiver) == sharesToBurn); | ||
uint256 ethReceived = address(this).balance - prevBalance; | ||
|
||
// To Stakers by burning shares | ||
eEth.burnShares(address(this), feeShareToStakers); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to whitelist redemption manager as well for being able to call burn shares in liquidity pool with latest fix There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess its better to just add a burnShares function on Liquidity pool which can be called by this contract instead, since LP is aware of this contract already while EETH would need to add another var to track this contract addr |
||
|
||
// 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}(""); | ||
seongyun-ko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
require(success, "EtherFiRedemptionManager: Transfer failed"); | ||
require(address(liquidityPool).balance == prevLpBalance - ethReceived, "EtherFiRedemptionManager: Invalid liquidity pool balance"); | ||
|
||
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) { | ||
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), "EtherFiRedemptionManager: Unauthorized"); | ||
} | ||
|
||
modifier hasRole(bytes32 role) { | ||
_hasRole(role, msg.sender); | ||
_; | ||
} | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better to do a require statement for clear error messages
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sg