mirror of
https://github.com/Instadapp/fluid-contracts-public.git
synced 2024-07-29 21:57:37 +00:00
424 lines
19 KiB
Solidity
424 lines
19 KiB
Solidity
// SPDX-License-Identifier: BUSL-1.1
|
|
pragma solidity 0.8.21;
|
|
|
|
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
import { Address } from "@openzeppelin/contracts/utils/Address.sol";
|
|
import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
|
|
|
|
import { LiquiditySlotsLink } from "../../libraries/liquiditySlotsLink.sol";
|
|
import { ILidoWithdrawalQueue } from "./interfaces/external/iLidoWithdrawalQueue.sol";
|
|
import { IFluidLiquidity } from "../../liquidity/interfaces/iLiquidity.sol";
|
|
import { ErrorTypes } from "./errorTypes.sol";
|
|
import { Error } from "./error.sol";
|
|
import { Events } from "./events.sol";
|
|
import { Variables } from "./variables.sol";
|
|
import { LiquidityCalcs } from "../../libraries/liquidityCalcs.sol";
|
|
|
|
abstract contract StETHQueueCore is Variables, Events, Error {
|
|
/// @dev validates that an address is not the zero address
|
|
modifier validAddress(address value_) {
|
|
if (value_ == address(0)) {
|
|
revert StETHQueueError(ErrorTypes.StETH__AddressZero);
|
|
}
|
|
_;
|
|
}
|
|
|
|
/// @dev reads the current, updated borrow exchange price for Native ETH at Liquidity
|
|
function _getLiquidityExchangePrice() internal view returns (uint256 borrowExchangePrice_) {
|
|
(, borrowExchangePrice_) = LiquidityCalcs.calcExchangePrices(
|
|
LIQUIDITY.readFromStorage(LIQUIDITY_EXCHANGE_PRICES_SLOT)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// @dev ReentrancyGuard based on OpenZeppelin implementation.
|
|
/// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.8/contracts/security/ReentrancyGuard.sol
|
|
abstract contract ReentrancyGuard is StETHQueueCore {
|
|
uint8 internal constant REENTRANCY_NOT_ENTERED = 1;
|
|
uint8 internal constant REENTRANCY_ENTERED = 2;
|
|
|
|
constructor() {
|
|
_status = REENTRANCY_ENTERED; // set status to entered on logic contract so only delegateCalls are possible
|
|
}
|
|
|
|
/// @dev Prevents a contract from calling itself, directly or indirectly.
|
|
/// See OpenZeppelin implementation for more info
|
|
modifier nonReentrant() {
|
|
// On the first call to nonReentrant, _status will be NOT_ENTERED
|
|
if (_status != REENTRANCY_NOT_ENTERED) {
|
|
revert StETHQueueError(ErrorTypes.StETH__Reentrancy);
|
|
}
|
|
|
|
// Any calls to nonReentrant after this point will fail
|
|
_status = REENTRANCY_ENTERED;
|
|
|
|
_;
|
|
|
|
// By storing the original value once again, a refund is triggered (see
|
|
// https://eips.ethereum.org/EIPS/eip-2200)
|
|
_status = REENTRANCY_NOT_ENTERED;
|
|
}
|
|
}
|
|
|
|
/// @dev FluidStETHQueue admin related methods
|
|
abstract contract StETHQueueAdmin is Variables, ReentrancyGuard {
|
|
/// @dev only auths guard
|
|
modifier onlyAuths() {
|
|
if (!isAuth(msg.sender)) {
|
|
revert StETHQueueError(ErrorTypes.StETH__Unauthorized);
|
|
}
|
|
_;
|
|
}
|
|
|
|
/// @notice reads if a certain `auth_` address is an allowed auth or not
|
|
function isAuth(address auth_) public view returns (bool) {
|
|
return auth_ == owner() || _auths[auth_] == 1;
|
|
}
|
|
|
|
/// @notice reads if a certain `guardian_` address is an allowed guardian or not
|
|
function isGuardian(address guardian_) public view returns (bool) {
|
|
return guardian_ == owner() || _guardians[guardian_] == 1;
|
|
}
|
|
|
|
/// @notice reads if a certain `user_` address is an allowed user or not
|
|
function isUserAllowed(address user_) public view returns (bool) {
|
|
return _allowed[user_] == 1;
|
|
}
|
|
|
|
/// @notice reads if the protocol is paused or not
|
|
function isPaused() public view returns (bool){
|
|
return _status == REENTRANCY_ENTERED;
|
|
}
|
|
|
|
/// @notice Sets an address as allowed auth or not. Only callable by owner.
|
|
/// @param auth_ address to set auth value for
|
|
/// @param allowed_ bool flag for whether address is allowed as auth or not
|
|
function setAuth(address auth_, bool allowed_) external onlyOwner validAddress(auth_) {
|
|
_auths[auth_] = allowed_ ? 1 : 0;
|
|
|
|
emit LogSetAuth(auth_, allowed_);
|
|
}
|
|
|
|
/// @notice Sets an address as allowed guardian or not. Only callable by owner.
|
|
/// @param guardian_ address to set guardian value for
|
|
/// @param allowed_ bool flag for whether address is allowed as guardian or not
|
|
function setGuardian(address guardian_, bool allowed_) external onlyOwner validAddress(guardian_) {
|
|
_guardians[guardian_] = allowed_ ? 1 : 0;
|
|
|
|
emit LogSetGuardian(guardian_, allowed_);
|
|
}
|
|
|
|
/// @notice Sets an address as allowed user or not. Only callable by auths.
|
|
/// @param user_ address to set allowed value for
|
|
/// @param allowed_ bool flag for whether address is allowed as user or not
|
|
function setUserAllowed(address user_, bool allowed_) external onlyAuths validAddress(user_) {
|
|
_allowed[user_] = allowed_ ? 1 : 0;
|
|
|
|
emit LogSetAllowed(user_, allowed_);
|
|
}
|
|
|
|
/// @notice Sets `maxLTV` to `maxLTV_` (in 1e2: 1% = 100, 100% = 10000). Must be > 0 and < 100%.
|
|
function setMaxLTV(uint16 maxLTV_) external onlyAuths {
|
|
if (maxLTV_ == 0) {
|
|
revert StETHQueueError(ErrorTypes.StETH__MaxLTVZero);
|
|
}
|
|
if (maxLTV_ >= HUNDRED_PERCENT) {
|
|
revert StETHQueueError(ErrorTypes.StETH__MaxLTVAboveCap);
|
|
}
|
|
|
|
maxLTV = maxLTV_;
|
|
emit LogSetMaxLTV(maxLTV_);
|
|
}
|
|
|
|
/// @notice Pauses the protocol (blocks queue() and claim()). Only callable by guardians.
|
|
function pause() external {
|
|
if (!isGuardian(msg.sender)) {
|
|
revert StETHQueueError(ErrorTypes.StETH__Unauthorized);
|
|
}
|
|
|
|
_status = REENTRANCY_ENTERED;
|
|
|
|
emit LogPaused();
|
|
}
|
|
|
|
/// @notice Unpauses the protocol (enables queue() and claim()). Only callable by owner.
|
|
function unpause() external onlyOwner(){
|
|
_status = REENTRANCY_NOT_ENTERED;
|
|
|
|
emit LogUnpaused();
|
|
}
|
|
|
|
/// @notice Sets `allowListActive` flag to `status_`. Only callable by owner.
|
|
function setAllowListActive(bool status_) external onlyOwner {
|
|
allowListActive = status_;
|
|
|
|
emit LogSetAllowListActive(status_);
|
|
}
|
|
}
|
|
|
|
/// @title StETHQueue
|
|
/// @notice queues an amount of stETH at the Lido WithdrawalQueue, using it as collateral to borrow an amount
|
|
/// of ETH that is paid back when Lido Withdrawal is claimable. Useful e.g. to deleverage a stETH / ETH borrow position.
|
|
/// User target group are whales that want to deleverage stETH / ETH without having to swap (no slippage).
|
|
/// @dev claims are referenced to via the claimTo address and the Lido requestIdFrom, which must be tracked from the moment
|
|
/// of queuing, where it is emitted in the `LogQueue` event, to pass in that information later for `claim()`.
|
|
/// @dev For view methods / accessing data, use the "StETHResolver" periphery contract.
|
|
//
|
|
// @dev Note that as a precaution if any claim fails for unforeseen, unexpected reasons, this contract is upgradeable so
|
|
// that Governance could rescue the funds.
|
|
// Note that claiming at Lido Withdrawal Queue is gas-cost-wise cheaper than queueing. So any queue process that passes
|
|
// below block gas limit, also passes at claiming.
|
|
contract FluidStETHQueue is Variables, StETHQueueCore, StETHQueueAdmin, UUPSUpgradeable {
|
|
constructor(
|
|
IFluidLiquidity liquidity_,
|
|
ILidoWithdrawalQueue lidoWithdrawalQueue_,
|
|
IERC20 stETH_
|
|
)
|
|
validAddress(address(liquidity_))
|
|
validAddress(address(lidoWithdrawalQueue_))
|
|
validAddress(address(stETH_))
|
|
Variables(liquidity_, lidoWithdrawalQueue_, stETH_)
|
|
{
|
|
// ensure logic contract initializer is not abused by disabling initializing
|
|
// see https://forum.openzeppelin.com/t/security-advisory-initialize-uups-implementation-contracts/15301
|
|
// and https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract
|
|
_disableInitializers();
|
|
}
|
|
|
|
/// @notice initializes the contract with `owner_` as owner
|
|
function initialize(address owner_) public initializer validAddress(owner_) {
|
|
_transferOwnership(owner_);
|
|
|
|
// approve infinite stETH to Lido withdrawal queue for requesting withdrawals
|
|
SafeERC20.safeApprove(STETH, address(LIDO_WITHDRAWAL_QUEUE), type(uint256).max);
|
|
|
|
_status = REENTRANCY_NOT_ENTERED; // set reentrancy to not entered on proxy
|
|
|
|
allowListActive = true; // start protocol in a protected state with allow list being active
|
|
|
|
// Borrow a minor dust amount of ETH (`DUST_BORROW_AMOUNT`) from Liquidity to avoid any potential reverts
|
|
// because of rounding differences etc
|
|
LIQUIDITY.operate(NATIVE_TOKEN_ADDRESS, 0, int256(DUST_BORROW_AMOUNT), address(0), address(this), new bytes(0));
|
|
}
|
|
|
|
receive() external payable {}
|
|
|
|
function _authorizeUpgrade(address) internal override onlyOwner {}
|
|
|
|
/// @notice override renounce ownership as it could leave the contract in an unwanted state if called by mistake.
|
|
function renounceOwnership() public view override onlyOwner {
|
|
revert StETHQueueError(ErrorTypes.StETH__RenounceOwnershipUnsupported);
|
|
}
|
|
|
|
/// @notice queues an amount of stETH at the Lido WithdrawalQueue, using it as collateral to borrow an amount
|
|
/// of ETH from Liquidity that is paid back when Lido Withdrawal is claimable, triggered with `claim()`.
|
|
/// @dev if `allowListActive` == true, then only allowed users can call this method.
|
|
/// @param ethBorrowAmount_ amount of ETH to borrow and send to `borrowTo_`
|
|
/// @param stETHAmount_ amount of stETH to queue at Lido Withdrawal Queue and use as collateral
|
|
/// @param borrowTo_ receiver of the `ethBorrowAmount_`
|
|
/// @param claimTo_ receiver of the left over stETH funds at time of `claim()`
|
|
/// @return requestIdFrom_ first request id at Lido withdrawal queue. Used to identify claims
|
|
function queue(
|
|
uint256 ethBorrowAmount_,
|
|
uint256 stETHAmount_,
|
|
address borrowTo_,
|
|
address claimTo_
|
|
) public nonReentrant validAddress(borrowTo_) validAddress(claimTo_) returns (uint256 requestIdFrom_) {
|
|
if (allowListActive && !isUserAllowed(msg.sender)) {
|
|
revert StETHQueueError(ErrorTypes.StETH__Unauthorized);
|
|
}
|
|
|
|
// 1. sanity checks
|
|
if (ethBorrowAmount_ == 0 || stETHAmount_ == 0) {
|
|
revert StETHQueueError(ErrorTypes.StETH__InputAmountZero);
|
|
}
|
|
// validity check ltv of borrow amount / collateral is below configured maxLTV
|
|
if ((ethBorrowAmount_ * HUNDRED_PERCENT) / stETHAmount_ > maxLTV) {
|
|
revert StETHQueueError(ErrorTypes.StETH__MaxLTV);
|
|
}
|
|
|
|
// 2. get `stETHAmount_` from msg.sender. must be approved to this contract
|
|
SafeERC20.safeTransferFrom(STETH, msg.sender, address(this), stETHAmount_);
|
|
|
|
// 3. queue stETH withdrawal at Lido, receive withdrawal NFT
|
|
uint256[] memory amounts_;
|
|
{
|
|
// maximum amount of stETH that is possible to withdraw by a single request at Lido (should be 1_000 stETH).
|
|
uint256 maxStETHWithdrawalAmount_ = LIDO_WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT();
|
|
// minimum amount of stETH that is possible to withdraw by a single request at Lido (should be 100 wei).
|
|
uint256 minStETHWithdrawalAmount_ = LIDO_WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT();
|
|
|
|
if (stETHAmount_ > maxStETHWithdrawalAmount_) {
|
|
// if withdraw amount is > MAX_STETH_WITHDRAWAL_AMOUNT it must be split into multiple smaller amounts
|
|
// each of maximum MAX_STETH_WITHDRAWAL_AMOUNT
|
|
|
|
bool lastAmountExact_;
|
|
uint256 fullAmountsLength_ = stETHAmount_ / maxStETHWithdrawalAmount_;
|
|
unchecked {
|
|
// check if remainder for last amount in array is exactly matching MAX_STETH_WITHDRAWAL_AMOUNT
|
|
lastAmountExact_ = stETHAmount_ % maxStETHWithdrawalAmount_ == 0;
|
|
// total elements are count of full amounts + 1 (unless lastAmountExact_ is true)
|
|
amounts_ = new uint256[](fullAmountsLength_ + (lastAmountExact_ ? 0 : 1));
|
|
}
|
|
// fill amounts array with MAX_STETH_WITHDRAWAL_AMOUNT except for last element
|
|
for (uint256 i; i < fullAmountsLength_; ) {
|
|
amounts_[i] = maxStETHWithdrawalAmount_;
|
|
|
|
unchecked {
|
|
++i;
|
|
}
|
|
}
|
|
|
|
if (!lastAmountExact_) {
|
|
// last element is result of modulo operation so length of array is fullAmountsLength_ +1
|
|
amounts_[fullAmountsLength_] = stETHAmount_ % maxStETHWithdrawalAmount_;
|
|
|
|
if (amounts_[fullAmountsLength_] < minStETHWithdrawalAmount_) {
|
|
// make sure remainder amount for last element in array is at least MIN_STETH_WITHDRAWAL_AMOUNT.
|
|
// if smaller, deduct MIN_STETH_WITHDRAWAL_AMOUNT from the second last element and it to the last.
|
|
unchecked {
|
|
amounts_[fullAmountsLength_ - 1] -= minStETHWithdrawalAmount_;
|
|
amounts_[fullAmountsLength_] += minStETHWithdrawalAmount_;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
amounts_ = new uint256[](1);
|
|
amounts_[0] = stETHAmount_;
|
|
}
|
|
}
|
|
|
|
// request withdrawal at Lido, receiving the withdrawal NFT to this contract as owner
|
|
uint256[] memory requestIds_ = LIDO_WITHDRAWAL_QUEUE.requestWithdrawals(amounts_, address(this));
|
|
|
|
requestIdFrom_ = requestIds_[0];
|
|
|
|
// 4. borrow amount of ETH from Liquidity and send it to msg.sender.
|
|
// sender can use this to e.g. pay back a flashloan used to deleverage at a borrowing protocol.
|
|
(, uint256 borrowExchangePrice_) = LIQUIDITY.operate(
|
|
NATIVE_TOKEN_ADDRESS,
|
|
0,
|
|
int256(ethBorrowAmount_),
|
|
address(0),
|
|
borrowTo_,
|
|
new bytes(0)
|
|
);
|
|
|
|
uint256 borrowAmountRaw_ = (ethBorrowAmount_ * EXCHANGE_PRICES_PRECISION) / borrowExchangePrice_;
|
|
if (borrowAmountRaw_ == 0) {
|
|
revert StETHQueueError(ErrorTypes.StETH__BorrowAmountRawRoundingZero);
|
|
}
|
|
|
|
// 5. write linked claim data in storage
|
|
claims[claimTo_][requestIdFrom_] = Claim({
|
|
// storing borrow amount in raw to account for borrow interest that must be paid back at `claim()` time.
|
|
borrowAmountRaw: uint128(borrowAmountRaw_),
|
|
checkpoint: uint48(LIDO_WITHDRAWAL_QUEUE.getLastCheckpointIndex()),
|
|
requestIdTo: uint40(requestIds_[requestIds_.length - 1])
|
|
});
|
|
|
|
// 6. emit event
|
|
emit LogQueue(claimTo_, requestIdFrom_, ethBorrowAmount_, stETHAmount_, borrowTo_);
|
|
}
|
|
|
|
/// @notice claims all open requests at LidoWithdrawalQueue for `claimTo_`, repays the borrowed ETH amount at Liquidity
|
|
/// and sends the rest of funds to `claimTo_`.
|
|
/// @param claimTo_ claimTo receiver to process the claim for
|
|
/// @param requestIdFrom_ Lido requestId from (start), as emitted at time of queuing (`queue()`) via `LogQueue`
|
|
/// @return claimedAmount_ total amount of claimed stETH
|
|
/// @return repayAmount_ total repaid ETH amount at Liquidity
|
|
function claim(
|
|
address claimTo_,
|
|
uint256 requestIdFrom_
|
|
) public nonReentrant returns (uint256 claimedAmount_, uint256 repayAmount_) {
|
|
Claim memory claim_ = claims[claimTo_][requestIdFrom_];
|
|
|
|
if (claim_.checkpoint == 0) {
|
|
// this implicitly confirms input params claimTo_ and requestIdFrom_ are valid, as a claim was found.
|
|
revert StETHQueueError(ErrorTypes.StETH__NoClaimQueued);
|
|
}
|
|
|
|
// store snapshot of balance before claiming
|
|
claimedAmount_ = address(this).balance;
|
|
|
|
// 1. claim all requests at Lido. This will burn the NFTs.
|
|
uint256 requestsLength_ = claim_.requestIdTo - requestIdFrom_ + 1;
|
|
if (requestsLength_ == 1) {
|
|
// only one request id
|
|
LIDO_WITHDRAWAL_QUEUE.claimWithdrawal(claim_.requestIdTo);
|
|
} else {
|
|
uint256 curRequest_ = requestIdFrom_;
|
|
|
|
// build requestIds array from `requestIdFrom` to `requestIdTo`
|
|
uint256[] memory requestIds_ = new uint256[](requestsLength_);
|
|
for (uint256 i; i < requestsLength_; ) {
|
|
requestIds_[i] = curRequest_;
|
|
|
|
unchecked {
|
|
++i;
|
|
++curRequest_;
|
|
}
|
|
}
|
|
// claim withdrawals at Lido queue
|
|
LIDO_WITHDRAWAL_QUEUE.claimWithdrawals(
|
|
requestIds_,
|
|
LIDO_WITHDRAWAL_QUEUE.findCheckpointHints(
|
|
requestIds_,
|
|
claim_.checkpoint,
|
|
LIDO_WITHDRAWAL_QUEUE.getLastCheckpointIndex()
|
|
)
|
|
);
|
|
}
|
|
|
|
claimedAmount_ = address(this).balance - claimedAmount_;
|
|
|
|
// 2. calculate borrowed amount to repay after interest with updated exchange price from Liquidity
|
|
// round up for safe rounding
|
|
repayAmount_ = ((claim_.borrowAmountRaw * _getLiquidityExchangePrice()) / EXCHANGE_PRICES_PRECISION) + 1;
|
|
|
|
// 3. repay borrow amount at Liquidity. reverts if claimed amount does not cover borrowed amount.
|
|
LIQUIDITY.operate{ value: repayAmount_ }(
|
|
NATIVE_TOKEN_ADDRESS,
|
|
0,
|
|
-int256(repayAmount_),
|
|
address(0),
|
|
address(0),
|
|
new bytes(0) // not needed for native token
|
|
);
|
|
|
|
// 4. pay out rest of balance to owner
|
|
Address.sendValue(payable(claimTo_), claimedAmount_ - repayAmount_);
|
|
|
|
// 5. delete mapping
|
|
delete claims[claimTo_][requestIdFrom_];
|
|
|
|
// 6. emit event
|
|
emit LogClaim(claimTo_, requestIdFrom_, claimedAmount_, repayAmount_);
|
|
}
|
|
|
|
/// @notice accept ERC721 token transfers ONLY from LIDO_WITHDRAWAL_QUEUE
|
|
function onERC721Received(
|
|
address /** operator_ */,
|
|
address /** from_ */,
|
|
uint256 /** tokenId_ */,
|
|
bytes memory /** data_ */
|
|
) public view returns (bytes4) {
|
|
if (msg.sender == address(LIDO_WITHDRAWAL_QUEUE)) {
|
|
return this.onERC721Received.selector;
|
|
}
|
|
|
|
revert StETHQueueError(ErrorTypes.StETH__InvalidERC721Transfer);
|
|
}
|
|
|
|
/// @notice liquidityCallback as used by Liquidity -> But unsupported in this contract as it only ever uses native
|
|
/// token as borrowed asset, which is repaid directly via `msg.value`. Always reverts.
|
|
function liquidityCallback(
|
|
address /** token_ */,
|
|
uint256 /** amount_ */,
|
|
bytes calldata /** data_ */
|
|
) external pure {
|
|
revert StETHQueueError(ErrorTypes.StETH__UnexpectedLiquidityCallback);
|
|
}
|
|
}
|