diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index efb8c1a..281ab15 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit efb8c1af6e97a5236fa65b62ef81dbbf67254c18 +Subproject commit 281ab158866c4d7dcf31dcd90efd8fce1cdbb38a diff --git a/src/workshop_2/ICircuitBreaker.sol b/src/workshop_2/ICircuitBreaker.sol new file mode 100644 index 0000000..cef541e --- /dev/null +++ b/src/workshop_2/ICircuitBreaker.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +/// @title Circuit Breaker +/// @dev See https://eips.ethereum.org/EIPS/eip-[EIP NUMBER] +interface ICircuitBreaker { + /** + * + * @custom:section Events + * + */ + + /// @dev MUST be emitted in `registerAsset` when an asset is registered + /// @param asset MUST be the address of the asset for which to set rate limit parameters. + /// For any EIP-20 token, MUST be an EIP-20 token contract. + /// For the native asset (ETH on mainnet), MUST be address 0x0000000000000000000000000000000000000001 equivalent to + /// address(1). + /// @param metricThreshold The threshold metric which defines when a rate limit is triggered + /// @param minAmountToLimit The minimum amount of nominal asset liquidity at which point rate limits can be + /// triggered + event AssetRegistered(address indexed asset, uint256 metricThreshold, uint256 minAmountToLimit); + + /// @dev MUST be emitted in `onTokenInflow` and `onNativeAssetInflow` during asset inflow into a protected contract + /// @param token MUST be the address of the asset flowing in. + /// For any EIP-20 token, MUST be an EIP-20 token contract. + /// For the native asset (ETH on mainnet), MUST be address 0x0000000000000000000000000000000000000001 equivalent to + /// address(1). + /// @param amount MUST equal the amount of asset transferred into the protected contract + event AssetInflow(address indexed token, uint256 indexed amount); + + /// @dev MUST be emitted in `onTokenOutflow` and `onNativeAssetOutflow` when a rate limit is triggered + /// @param asset MUST be the address of the asset triggering the rate limit. + /// For any EIP-20 token, MUST be an EIP-20 token contract. + /// For the native asset (ETH on mainnet), MUST be address 0x0000000000000000000000000000000000000001 equivalent to + /// address(1). + /// @param timestamp MUST equal the block.timestamp at the time of rate limit breach + event AssetRateLimitBreached(address indexed asset, uint256 timestamp); + + /// @dev MUST be emitted in `onTokenOutflow` and `onNativeAssetOutflow` when an asset is successfully withdrawn + /// @param asset MUST be the address of the asset withdrawn. + /// For any EIP-20 token, MUST be an EIP-20 token contract. + /// For the native asset (ETH on mainnet), MUST be address 0x0000000000000000000000000000000000000001 equivalent to + /// address(1). + /// @param recipient MUST be the address of the recipient withdrawing the assets + /// @param amount MUST be the amount of assets being withdrawn + event AssetWithdraw(address indexed asset, address indexed recipient, uint256 amount); + + /// @dev MUST be emitted in `claimLockedFunds` when a recipient claims locked funds + /// @param asset MUST be the address of the asset claimed. + /// For any EIP-20 token, MUST be an EIP-20 token contract. + /// For the native asset (ETH on mainnet), MUST be address 0x0000000000000000000000000000000000000001 equivalent to + /// address(1). + /// @param recipient MUST be the address of the recipient claiming the assets + event LockedFundsClaimed(address indexed asset, address indexed recipient); + + /// @dev MUST be emitted in `setAdmin` when a new admin is set + /// @param newAdmin MUST be the new admin address + event AdminSet(address indexed newAdmin); + + /// @dev MUST be emitted in `startGracePeriod` when a new grace period is successfully started + /// @param gracePeriodEnd MUST be the end timestamp of the new grace period + event GracePeriodStarted(uint256 gracePeriodEnd); + + /** + * + * @custom:section Write functions + * + */ + + /// @notice Register rate limit parameters for a given asset + /// @dev Each asset that will be rate limited MUST be registered using this function, including the native asset + /// (ETH on mainnet). + /// If an asset is not registered, it will not be subject to rate limiting or circuit breaking and unlimited + /// immediate withdrawals MUST be allowed. + /// MUST revert if the caller is not the current admin. + /// MUST revert if the asset has already been registered. + /// @param _asset The address of the asset for which to set rate limit parameters. + /// To set the rate limit parameters for any EIP-20 token, MUST be an EIP-20 token contract. + /// To set rate limit parameters For the native asset, MUST be address 0x0000000000000000000000000000000000000001 + /// equivalent to address(1). + /// @param _metricThreshold The threshold metric which defines when a rate limit is triggered. + /// This is intentionally left open to allow for various implementations, including percentage-based (see reference + /// implementation), nominal, and more. + /// MUST be greater than 0. + /// @param _minAmountToLimit The minimum amount of nominal asset liquidity at which point rate limits can be + /// triggered. + /// This limits potential false positives triggered either by minor assets with low liquidity or by low liquidity + /// during early stages of protocol launch. + /// Below this amount, withdrawals of this asset MUST NOT trigger a rate limit. + /// However, if a rate limit is triggered, assets below the minimum trigger amount to limit MUST still be locked. + function registerAsset(address _asset, uint256 _metricThreshold, uint256 _minAmountToLimit) external; + + /// @notice Modify rate limit parameters for a given asset + /// @dev MAY be used only after registering an asset. + /// MUST revert if asset is not previously registered with the `registerAsset` method. + /// MUST revert if the caller is not the current admin. + /// @param _asset The address of the asset contract for which to set rate limit parameters. + /// To update the rate limit parameters for any EIP-20 token, MUST be an EIP-20 token contract. + /// To update the rate limit parameters For the native asset (ETH on mainnet), MUST be address + /// 0x0000000000000000000000000000000000000001 equivalent to address(1). + /// @param _metricThreshold The threshold metric which defines when a rate limit is triggered. + /// This is left open to allow for various implementations, including percentage-based (see reference + /// implementation), nominal, and more. + /// MUST be greater than 0. + /// @param _minAmountToLimit The minimum amount of nominal asset liquidity at which point rate limits can be + /// triggered. + /// This limits potential false positives caused both by minor assets with low liquidity and by low liquidity during + /// early stages of protocol launch. + /// Below this amount, withdrawals of this asset MUST NOT trigger a rate limit. + /// However, if a rate limit is triggered, assets below the minimum amount to limit MUST still be locked. + function updateAssetParams(address _asset, uint256 _metricThreshold, uint256 _minAmountToLimit) external; + + /// @notice Record EIP-20 token inflow into a protected contract + /// @dev This method MUST be called from all protected contract methods where an EIP-20 token is transferred in from + /// a user. + /// MUST revert if caller is not a protected contract. + /// MUST revert if circuit breaker is not operational. + /// @param _token MUST be an EIP-20 token contract + /// @param _amount MUST equal the amount of token transferred into the protected contract + function onTokenInflow(address _token, uint256 _amount) external; + + /// @notice Record EIP-20 token outflow from a protected contract and transfer tokens to recipient if rate limit is + /// not triggered + /// @dev This method MUST be called from all protected contract methods where an EIP-20 token is transferred out to + /// a user. + /// Before calling this method, the protected contract MUST transfer the EIP-20 tokens to the circuit breaker + /// contract. + /// For an example, see ProtectedContract.sol in the reference implementation. + /// MUST revert if caller is not a protected contract. + /// MUST revert if circuit breaker is not operational. + /// If the token is not registered, this method MUST NOT revert and MUST transfer the tokens to the recipient. + /// If a rate limit is not triggered or the circuit breaker is in grace period, this method MUST NOT revert and MUST + /// transfer the tokens to the recipient. + /// If a rate limit is triggered and the circuit breaker is not in grace period and `_revertOnRateLimit` is TRUE, + /// this method MUST revert. + /// If a rate limit is triggered and the circuit breaker is not in grace period and `_revertOnRateLimit` is FALSE + /// and caller is a protected contract, this method MUST NOT revert. + /// If a rate limit is triggered and the circuit breaker is not in grace period, this method MUST record the locked + /// funds in the internal accounting of the circuit breaker implementation. + /// @param _token MUST be an EIP-20 token contract + /// @param _amount MUST equal the amount of tokens transferred out of the protected contract + /// @param _recipient MUST be the address of the recipient of the transferred tokens from the protected contract + /// @param _revertOnRateLimit MUST be TRUE to revert if a rate limit is triggered or FALSE to return without + /// reverting if a rate limit is triggered (delayed settlement) + function onTokenOutflow(address _token, uint256 _amount, address _recipient, bool _revertOnRateLimit) external; + + /// @notice Record native asset (ETH on mainnet) inflow into a protected contract + /// @dev This method MUST be called from all protected contract methods where native asset is transferred in from a + /// user. + /// MUST revert if caller is not a protected contract. + /// MUST revert if circuit breaker is not operational. + /// @param _amount MUST equal the amount of native asset transferred into the protected contract + function onNativeAssetInflow(uint256 _amount) external; + + /// @notice Record native asset (ETH on mainnet) outflow from a protected contract and transfer native asset to + /// recipient if rate limit is not triggered + /// @dev This method MUST be called from all protected contract methods where native asset is transferred out to a + /// user. + /// When calling this method, the protected contract MUST send the native asset to the circuit breaker contract in + /// the same call. + /// For an example, see ProtectedContract.sol in the reference implementation. + /// MUST revert if caller is not a protected contract. + /// MUST revert if circuit breaker is not operational. + /// If native asset is not registered, this method MUST NOT revert and MUST transfer the native asset to the + /// recipient. + /// If a rate limit is not triggered or the circuit breaker is in grace period, this method MUST NOT revert and MUST + /// transfer the native asset to the recipient. + /// If a rate limit is triggered and the circuit breaker is not in grace period and `_revertOnRateLimit` is TRUE, + /// this method MUST revert. + /// If a rate limit is triggered and the circuit breaker is not in grace period and `_revertOnRateLimit` is FALSE + /// and caller is a protected contract, this method MUST NOT revert. + /// If a rate limit is triggered and the circuit breaker is not in grace period, this method MUST record the locked + /// funds in the internal accounting of the circuit breaker implementation. + /// @param _recipient MUST be the address of the recipient of the transferred native asset from the protected + /// contract + /// @param _revertOnRateLimit MUST be TRUE to revert if a rate limit is triggered or FALSE to return without + /// reverting if a rate limit is triggered (delayed settlement) + function onNativeAssetOutflow(address _recipient, bool _revertOnRateLimit) external payable; + + /// @notice Allow users to claim locked funds when rate limit is resolved + /// @dev When a asset is transferred out during a rate limit period, the settlement may be delayed and the asset + /// custodied in the circuit breaker. + /// This method allows users to claim funds that were delayed in settlement after the rate limit is resolved or a + /// grace period is activated. + /// MUST revert if the recipient does not have locked funds for a given asset. + /// MUST revert if circuit breaker is rate limited or is not operational. + /// MUST transfer tokens or native asset (ETH on mainnet) to the recipient on successful call. + /// MUST update internal accounting of circuit breaker implementation to reflect withdrawn balance on successful + /// call. + /// @param _asset To claim locked EIP-20 tokens, this MUST be an EIP-20 token contract. + /// To claim native asset, this MUST be address 0x0000000000000000000000000000000000000001 equivalent to address(1). + /// @param _recipient MUST be the address of the recipient of the locked funds from the circuit breaker + function claimLockedFunds(address _asset, address _recipient) external; + + /// @notice Set the admin of the contract to govern the circuit breaker + /// @dev The admin SHOULD represent the governance contract of the protected protocol. + /// The admin has authority to: withdraw locked funds, set grace periods, register asset parameters, update asset + /// parameters, + /// set new admin, override rate limit, add protected contracts, remove protected contracts. + /// MUST revert if the caller is not the current admin. + /// MUST revert if `_newAdmin` is address(0). + /// MUST update the circuit breaker admin to the new admin in the stored state of the implementation on successful + /// call. + /// @param _newAdmin MUST be the address of the new admin + function setAdmin(address _newAdmin) external; + + /// @notice Override a rate limit + /// @dev This method MAY be called when the protocol admin (typically governance) is certain that a rate limit is + /// the result of a false positive. + /// MUST revert if caller is not the current admin. + /// MUST allow the grace period to extend for the full withdrawal period to not trigger the rate limit again if the + /// rate limit is removed just before the withdrawal period ends. + /// MUST revert if the circuit breaker is not currently rate limited. + function overrideRateLimit() external; + + /// @notice Override an expired rate limit + /// @dev This method MAY be called by anyone once the cooldown period is complete. + /// MUST revert if the cooldown period is not complete. + /// MUST revert if the circuit breaker is not currently rate limited. + function overrideExpiredRateLimit() external; + + /// @notice Add new protected contracts + /// @dev MUST be used to add protected contracts. Protected contracts MUST be part of your protocol. + /// Protected contracts have the authority to trigger rate limits and withdraw assets. + /// MUST revert if caller is not the current admin. + /// MUST store protected contracts in the stored state of the circuit breaker implementation. + /// @param _ProtectedContracts an array of addresses of protected contracts to add + function addProtectedContracts(address[] calldata _ProtectedContracts) external; + + /// @notice Remove protected contracts + /// @dev MAY be used to remove protected contracts. Protected contracts MUST be part of your protocol. + /// Protected contracts have the authority to trigger rate limits and withdraw assets. + /// MUST revert if caller is not the current admin. + /// MUST remove protected contracts from stored state in the circuit breaker implementation. + /// @param _ProtectedContracts an array of addresses of protected contracts to remove + function removeProtectedContracts(address[] calldata _ProtectedContracts) external; + + /// @notice Set a custom grace period + /// @dev MAY be called by admin to set a custom grace period during which rate limits will not be active. + /// MUST revert if caller is not the current admin. + /// MUST start grace period until end timestamp. + /// @param _gracePeriodEndTimestamp The ending timestamp of the grace period + /// MUST be greater than the current block.timestamp + function startGracePeriod(uint256 _gracePeriodEndTimestamp) external; + + /// @notice Lock the circuit breaker + /// @dev MAY be called by admin to lock the circuit breaker + /// While the protocol is not operational: inflows, outflows, and claiming locked funds MUST revert + function markAsNotOperational() external; + + /// @notice Migrates locked funds after exploit for recovery + /// @dev MUST revert if the protocol is operational. + /// MUST revert if caller is not the current admin. + /// MUST transfer assets to recovery recipient on successful call. + /// @param _assets Array of assets to recover. + /// For any EIP-20 token, MUST be an EIP-20 token contract. + /// For the native asset (ETH on mainnet), MUST be address 0x0000000000000000000000000000000000000001 equivalent to + /// address(1). + /// @param _recoveryRecipient The address of the recipient to receive recovered funds. Often this will be a multisig + /// wallet. + function migrateFundsAfterExploit(address[] calldata _assets, address _recoveryRecipient) external; + + /** + * + * @custom:section Read-only functions + * + */ + + /// @notice View funds locked for a given recipient and asset + /// @param recipient The address of the recipient + /// @param asset To view the balance of locked EIP-20 tokens, this MUST be an EIP-20 token contract. + /// To claim native ETH, this MUST be address 0x0000000000000000000000000000000000000001 equivalent to address(1). + /// @return amount The locked balance for the given recipient and asset + function lockedFunds(address recipient, address asset) external view returns (uint256 amount); + + /// @notice Check if a given address is a protected contract + /// @param account The address of the contract to check + /// @return protectionActive MUST be TRUE if the contract is protected, FALSE if it is not protected + function isProtectedContract(address account) external view returns (bool protectionActive); + + /// @notice View the admin of the circuit breaker + /// @dev This SHOULD be the governance contract for your protocol + /// @return admin The admin of the circuit breaker + function admin() external view returns (address); + + /// @notice Check is the circuit breaker is rate limited + /// @return isRateLimited MUST be TRUE if the circuit breaker is rate limited, FALSE if it is not rate limited + function isRateLimited() external view returns (bool); + + /// @notice View the rate limit cooldown period + /// @dev The duration of a rate limit once triggered + /// @return rateLimitCooldownPeriod The rate limit cooldown period + function rateLimitCooldownPeriod() external view returns (uint256); + + /// @notice View the last rate limit timestamp + /// @return lastRateLimitTimestamp MUST return the last rate limit timestamp + function lastRateLimitTimestamp() external view returns (uint256); + + /// @notice View the grace period end timestamp + /// @return gracePeriodEndTimestamp MUST return the grace period end timestamp + function gracePeriodEndTimestamp() external view returns (uint256); + + /// @notice Check if a rate limit is currently triggered for a given asset + /// @param _asset To check if triggered for EIP-20 tokens, this MUST be an EIP-20 token contract. + /// To check if triggered For the native asset (ETH on mainnet), this MUST be address + /// 0x0000000000000000000000000000000000000001 equivalent to address(1). + /// @return isRateLimitTriggered MUST return TRUE if a rate limit is currently triggered for given asset, FALSE if + /// not + function isRateLimitTriggered(address _asset) external view returns (bool); + + /// @notice Check if the circuit breaker is currently in grace period + /// @return isInGracePeriod MUST return TRUE if the circuit breaker is currently in grace period, FALSE otherwise + function isInGracePeriod() external view returns (bool); + + /// @notice Check if the circuit breaker is operational + /// @return isOperational MUST return TRUE if the circuit breaker is operational (not exploited), FALSE otherwise + function isOperational() external view returns (bool); +} diff --git a/src/workshop_2/IPriceOracle.sol b/src/workshop_2/IPriceOracle.sol new file mode 100644 index 0000000..d16deef --- /dev/null +++ b/src/workshop_2/IPriceOracle.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.19; + +interface IPriceOracle { + /// @notice Returns the name of the price oracle. + function name() external view returns (string memory); + + /// @notice Returns the quote for a given amount of base asset in quote asset. + /// @param amount The amount of base asset. + /// @param base The address of the base asset. + /// @param quote The address of the quote asset. + /// @return out The quote amount in quote asset. + function getQuote(uint256 amount, address base, address quote) external view returns (uint256 out); + + /// @notice Returns the bid and ask quotes for a given amount of base asset in quote asset. + /// @param amount The amount of base asset. + /// @param base The address of the base asset. + /// @param quote The address of the quote asset. + /// @return bidOut The bid quote amount in quote asset. + /// @return askOut The ask quote amount in quote asset. + function getQuotes( + uint256 amount, + address base, + address quote + ) external view returns (uint256 bidOut, uint256 askOut); + + /// @notice Returns the tick for a given amount of base asset in quote asset. + /// @param amount The amount of base asset. + /// @param base The address of the base asset. + /// @param quote The address of the quote asset. + /// @return tick The tick value. + function getTick(uint256 amount, address base, address quote) external view returns (uint256 tick); + + /// @notice Returns the bid and ask ticks for a given amount of base asset in quote asset. + /// @param amount The amount of base asset. + /// @param base The address of the base asset. + /// @param quote The address of the quote asset. + /// @return bidTick The bid tick value. + /// @return askTick The ask tick value. + function getTicks( + uint256 amount, + address base, + address quote + ) external view returns (uint256 bidTick, uint256 askTick); + + error PO_BaseUnsupported(); + error PO_QuoteUnsupported(); + error PO_Overflow(); + error PO_NoPath(); +} diff --git a/src/workshop_2/IWorkshopVault.sol b/src/workshop_2/IWorkshopVault.sol index ce389ec..fb59487 100644 --- a/src/workshop_2/IWorkshopVault.sol +++ b/src/workshop_2/IWorkshopVault.sol @@ -26,4 +26,6 @@ interface IWorkshopVault is IVault { // [ASSIGNMENT] optional: integrate with an oracle of choice in checkAccountStatus() and liquidate() // [ASSIGNMENT] optional: implement a circuit breaker in checkVaultStatus(), may be EIP-7265 inspired // [ASSIGNMENT] optional: add EIP-7540 compatibility for RWAs + function doCheckVaultStatus(bytes memory snapshot) external; + function doTakeVaultSnapshot() external returns (bytes memory snapshot); } diff --git a/src/workshop_2/WorkshopVault.sol b/src/workshop_2/WorkshopVault.sol index d0a3f4e..fac8c12 100644 --- a/src/workshop_2/WorkshopVault.sol +++ b/src/workshop_2/WorkshopVault.sol @@ -3,13 +3,49 @@ pragma solidity ^0.8.19; import "openzeppelin/token/ERC20/extensions/ERC4626.sol"; +import {Math} from "openzeppelin/utils/math/Math.sol"; +import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; import "evc/interfaces/IEthereumVaultConnector.sol"; import "evc/interfaces/IVault.sol"; import "./IWorkshopVault.sol"; contract WorkshopVault is ERC4626, IVault, IWorkshopVault { + using Math for uint256; + using SafeERC20 for IERC20; + IEVC internal immutable evc; + event Borrow(address indexed caller, address indexed owner, uint256 assets); + event Repay(address indexed caller, address indexed receiver, uint256 assets); + + uint256 internal constant SECONDS_PER_YEAR = 365.2425 * 86400; // Gregorian calendar + int96 internal constant MAX_ALLOWED_INTEREST_RATE = int96(int256(uint256(5 * 1e27) / SECONDS_PER_YEAR)); // 500% APR + int96 internal constant MIN_ALLOWED_INTEREST_RATE = 0; + uint256 internal constant ONE = 1e27; + + error OutstandingDebt(); + error ControllerDisabled(); + error CollateralDisabled(); + error SelfLiquidation(); + error AccountUnhealthy(); + error SnapshotNotTaken(); + error SupplyCapExceeded(); + error BorrowCapExceeded(); + + uint256 public supplyCap; + mapping(ERC4626 vault => uint256) internal collateralFactor; + + bytes private snapshot; + int96 internal interestRate; + uint256 internal interestRate256; + uint256 internal lastInterestUpdate; + uint256 internal interestAccumulator; + uint256 public borrowCap; + uint256 public totalBorrowed; + mapping(address account => uint256 assets) internal owed; + mapping(address account => uint256) internal userInterestAccumulator; + uint256 private locked = 1; + constructor( IEVC _evc, IERC20 _asset, @@ -17,9 +53,24 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { string memory _symbol ) ERC4626(_asset) ERC20(_name, _symbol) { evc = _evc; + lastInterestUpdate = block.timestamp; + interestAccumulator = ONE; + interestRate256 = 3; // 3% APY + } + + modifier nonReentrant() virtual { + require(locked == 1, "REENTRANCY"); + + locked = 2; + + _; + + locked = 1; } // [ASSIGNMENT]: what is the purpose of this modifier? + // The modifier checks if (msg.sender) is the EVC, if it's not the EVC address it uses the EVC onBehalfOf + // (msg.sender) to make calls into a target contract with the encoded data modifier callThroughEVC() { if (msg.sender == address(evc)) { _; @@ -33,7 +84,13 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { } // [ASSIGNMENT]: why the account status check might not be necessary in certain situations? + // It is not called if there is no Controller enabled for the Account at the time of the checks, + // AccountStatusCheck is used for ensuring borrowers remain solvent. + // Therefore it's not necessary for checking minting or deposits, because those functions aren't related to + // borrowing // [ASSIGNMENT]: is the vault status check always necessary? why? + // VaultStatusCheck is optional but if implemented as in our case, it's necessary to always be required by the Vault + // after each operation affecting the Vault's state modifier withChecks(address account) { _; @@ -44,9 +101,31 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { } } + function getAccountOwner(address account) internal view returns (address owner) { + try evc.getAccountOwner(account) returns (address _owner) { + owner = _owner; + } catch { + owner = account; + } + } + + function _increaseOwed(address account, uint256 assets) internal virtual { + owed[account] = debtOf(account) + assets; + totalBorrowed += assets; + userInterestAccumulator[account] = interestAccumulator; + } + + function _decreaseOwed(address account, uint256 assets) internal virtual { + owed[account] = debtOf(account) - assets; + totalBorrowed -= assets; + userInterestAccumulator[account] = interestAccumulator; + } + // [ASSIGNMENT]: can this function be used to authenticate the account for the sake of the borrow-related // operations? why? + // No, this function doesnot retrieve the message sender in the context of the EVC for a borrow operation // [ASSIGNMENT]: if the answer to the above is "no", how this function could be modified to allow safe borrowing? + // We should implement _msgSenderForBorrow(), where the function reverts if the vault is not enabled as a controller function _msgSender() internal view virtual override returns (address) { if (msg.sender == address(evc)) { (address onBehalfOfAccount,) = evc.getCurrentOnBehalfOfAccount(address(0)); @@ -56,13 +135,144 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { } } + function _msgSenderForBorrow() internal view returns (address) { + address onBehalfOfAccount = msg.sender; + bool controllerEnabled; + + if (msg.sender == address(evc)) { + (onBehalfOfAccount, controllerEnabled) = evc.getCurrentOnBehalfOfAccount(address(this)); + } else { + controllerEnabled = evc.isControllerEnabled(onBehalfOfAccount, address(this)); + } + + if (!controllerEnabled) { + revert ControllerDisabled(); + } + + return onBehalfOfAccount; + } + + function doTakeVaultSnapshot() public virtual override returns (bytes memory) { + (uint256 currentTotalBorrowed,) = _accrueInterest(); + + // make total supply and total borrows snapshot: + return abi.encode(_convertToAssets(totalSupply(), Math.Rounding.Floor), currentTotalBorrowed); + } + + function takeVaultSnapshot() internal { + if (snapshot.length == 0) { + snapshot = doTakeVaultSnapshot(); + } + } + // IVault // [ASSIGNMENT]: why this function is necessary? is it safe to unconditionally disable the controller? - function disableController() external { - evc.disableController(_msgSender()); + // This function is used to remove/disable a controller from the vault, and it can only be called by evc. + // even though accountStatus checks pass, it's not safe to unconditionally disable because it might change the order + // of controllers in storage + function disableController() external override nonReentrant { + address msgSender = _msgSender(); + if (debtOf(msgSender) == 0) { + evc.disableController(msgSender); + } else { + revert OutstandingDebt(); + } + } + + function debtOf(address account) public view virtual returns (uint256) { + // Take into account the interest rate accrual. + uint256 debt = owed[account]; + + if (debt == 0) return 0; + + (, uint256 currentInterestAccumulator,) = _accrueInterestCalculate(); + return (debt * currentInterestAccumulator) / userInterestAccumulator[account]; + } + + function _accrueInterest() internal virtual returns (uint256, uint256) { + (uint256 currentTotalBorrowed, uint256 currentInterestAccumulator, bool shouldUpdate) = + _accrueInterestCalculate(); + + if (shouldUpdate) { + totalBorrowed = currentTotalBorrowed; + interestAccumulator = currentInterestAccumulator; + lastInterestUpdate = block.timestamp; + } + + return (currentTotalBorrowed, currentInterestAccumulator); + } + + function _accrueInterestCalculate() internal view virtual returns (uint256, uint256, bool) { + uint256 timeElapsed = block.timestamp - lastInterestUpdate; + uint256 oldTotalBorrowed = totalBorrowed; + uint256 oldInterestAccumulator = interestAccumulator; + + if (timeElapsed == 0) { + return (oldTotalBorrowed, oldInterestAccumulator, false); + } + + // uint256 newInterestAccumulator = ( + // pow(uint256(int256(interestRate) + int256(ONE)), timeElapsed, ONE) + // * oldInterestAccumulator + // ) / ONE; + uint256 newInterestAccumulator = uint256(int256(interestRate) + int256(ONE)) * timeElapsed / 1 days; + + uint256 newTotalBorrowed = (oldTotalBorrowed * newInterestAccumulator) / oldInterestAccumulator; + + return (newTotalBorrowed, newInterestAccumulator, true); + } + + // This function is overridden to take into account the fact that some of the assets may be borrowed. + function maxWithdraw(address owner) public view virtual override returns (uint256) { + uint256 totAssets = totalAssets(); + uint256 ownerAssets = _convertToAssets(balanceOf(owner), Math.Rounding.Floor); + // return _convertToAssets(balanceOf(owner), Math.Rounding.Floor); + return ownerAssets > totAssets ? totAssets : ownerAssets; + } + + // This function is overridden to take into account the fact that some of the assets may be borrowed. + function maxRedeem(address owner) public view virtual override returns (uint256) { + uint256 totAssets = totalAssets(); + uint256 ownerShares = balanceOf(owner); + // return ownerShares; + return _convertToAssets(ownerShares, Math.Rounding.Floor) > totAssets + ? _convertToShares(totAssets, Math.Rounding.Floor) + : ownerShares; + } + + /// @dev This function is overridden to take into account the fact that some of the assets may be borrowed. + function convertToShares(uint256 assets) public view virtual override returns (uint256) { + return _convertToShares(assets, Math.Rounding.Floor); + } + + function convertToAssets(uint256 shares) public view virtual override returns (uint256) { + return _convertToAssets(shares, Math.Rounding.Floor); + } + + function _convertToShares( + uint256 assets, + Math.Rounding rounding + ) internal view virtual override returns (uint256) { + (uint256 currentTotalBorrowed,,) = _accrueInterestCalculate(); + return + assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + currentTotalBorrowed + 1, rounding); + } + + /// @dev This function is overridden to take into account the fact that some of the assets may be borrowed. + function _convertToAssets( + uint256 shares, + Math.Rounding rounding + ) internal view virtual override returns (uint256) { + (uint256 currentTotalBorrowed,,) = _accrueInterestCalculate(); + + return + shares.mulDiv(totalAssets() + currentTotalBorrowed + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); // } // [ASSIGNMENT]: provide a couple use cases for this function + // to check whether it's the EVC calling + // to check whether checks are in progress + // to check account health and for calculating the collateral and liability function checkAccountStatus( address account, address[] calldata collaterals @@ -71,35 +281,111 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { require(evc.areChecksInProgress(), "can only be called when checks in progress"); // some custom logic evaluating the account health + uint256 liabilityAssets = debtOf(account); + + if (liabilityAssets == 0) return IVault.checkAccountStatus.selector; + + // let's say that it's only possible to borrow against + // the same asset up to 90% of its value + for (uint256 i = 0; i < collaterals.length; ++i) { + if (collaterals[i] == address(this)) { + uint256 collateral = _convertToAssets(balanceOf(account), Math.Rounding.Floor); + uint256 maxLiability = (collateral * 9) / 10; + + if (liabilityAssets <= maxLiability) { + return IVault.checkAccountStatus.selector; + } + } + } + + revert AccountUnhealthy(); - return IVault.checkAccountStatus.selector; + // return IVault.checkAccountStatus.selector; } // [ASSIGNMENT]: provide a couple use cases for this function + // to check whether it's the EVC calling + // to check vault health + // to check risk parameters or any other constraints set by the vault's + // to check that the snapshot status is valid function checkVaultStatus() public virtual returns (bytes4 magicValue) { require(msg.sender == address(evc), "only evc can call this"); require(evc.areChecksInProgress(), "can only be called when checks in progress"); // some custom logic evaluating the vault health + doCheckVaultStatus(snapshot); + delete snapshot; // [ASSIGNMENT]: what can be done if the vault status check needs access to the initial state of the vault in // order to evaluate the vault health? + // Integrate snapshot functionality, and access the snapshot to compare it with the current vault state return IVault.checkVaultStatus.selector; } + function doCheckVaultStatus(bytes memory oldSnapshot) public virtual override { + // sanity check in case the snapshot hasn't been taken + if (oldSnapshot.length == 0) revert SnapshotNotTaken(); + + // use the vault status hook to update the interest rate (it should happen only once per transaction). + // EVC.forgiveVaultStatus check should never be used for this vault, otherwise the interest rate will not be + // updated. + _updateInterest(); + + // validate the vault state here: + (uint256 initialSupply, uint256 initialBorrowed) = abi.decode(oldSnapshot, (uint256, uint256)); + uint256 finalSupply = _convertToAssets(totalSupply(), Math.Rounding.Floor); + uint256 finalBorrowed = totalBorrowed; + + // the supply cap can be implemented like this: + if (supplyCap != 0 && finalSupply > supplyCap && finalSupply > initialSupply) { + revert SupplyCapExceeded(); + } + + // or the borrow cap can be implemented like this: + if (borrowCap != 0 && finalBorrowed > borrowCap && finalBorrowed > initialBorrowed) { + revert BorrowCapExceeded(); + } + } + function deposit( uint256 assets, address receiver - ) public virtual override callThroughEVC withChecks(address(0)) returns (uint256 shares) { - return super.deposit(assets, receiver); + ) public virtual override callThroughEVC withChecks(address(0)) nonReentrant returns (uint256 shares) { + address msgSender = _msgSender(); + + takeVaultSnapshot(); + + // Check for rounding error since we round down in previewDeposit. + require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); + + // Need to transfer before minting or ERC777s could reenter. + IERC20(asset()).safeTransferFrom(msgSender, address(this), assets); + + _mint(receiver, shares); + + emit Deposit(msgSender, receiver, assets, shares); + + // return super.deposit(assets, receiver); } function mint( uint256 shares, address receiver - ) public virtual override callThroughEVC withChecks(address(0)) returns (uint256 assets) { - return super.mint(shares, receiver); + ) public virtual override callThroughEVC withChecks(address(0)) nonReentrant returns (uint256 assets) { + address msgSender = _msgSender(); + + takeVaultSnapshot(); + + assets = previewMint(shares); + + IERC20(asset()).safeTransferFrom(msgSender, address(this), assets); + + _mint(receiver, shares); + + emit Deposit(msgSender, receiver, assets, shares); + + // return super.mint(shares, receiver); } function withdraw( @@ -107,6 +393,12 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { address receiver, address owner ) public virtual override callThroughEVC withChecks(owner) returns (uint256 shares) { + takeVaultSnapshot(); + + receiver = getAccountOwner(receiver); + + _burn(owner, shares); + return super.withdraw(assets, receiver, owner); } @@ -115,6 +407,7 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { address receiver, address owner ) public virtual override callThroughEVC withChecks(owner) returns (uint256 assets) { + takeVaultSnapshot(); return super.redeem(shares, receiver, owner); } @@ -134,8 +427,158 @@ contract WorkshopVault is ERC4626, IVault, IWorkshopVault { } // IWorkshopVault - function borrow(uint256 assets, address receiver) external {} - function repay(uint256 assets, address receiver) external {} - function pullDebt(address from, uint256 assets) external returns (bool) {} - function liquidate(address violator, address collateral) external {} + function borrow( + uint256 assets, + address receiver + ) external virtual callThroughEVC withChecks(_msgSenderForBorrow()) nonReentrant { + address msgSender = _msgSenderForBorrow(); + + takeVaultSnapshot(); + + require(assets != 0, "ZERO_ASSETS"); + + receiver = getAccountOwner(receiver); + + _increaseOwed(msgSender, assets); + + emit Borrow(msgSender, receiver, assets); + + // SafeERC20.safeTransfer(ERC20(asset()), receiver, assets); + ERC20(asset()).transfer(receiver, assets); + } + + function repay( + uint256 assets, + address receiver + ) external virtual callThroughEVC withChecks(address(0)) nonReentrant { + address msgSender = _msgSender(); + + if (!evc.isControllerEnabled(receiver, address(this))) { + revert ControllerDisabled(); + } + + takeVaultSnapshot(); + + require(assets != 0, "ZERO_ASSETS"); + + // SafeERC20.safeTransferFrom(ERC20(asset()), msgSender, address(this), assets); + ERC20(asset()).transferFrom(msgSender, address(this), assets); + + _decreaseOwed(receiver, assets); + + emit Repay(msgSender, receiver, assets); + } + + function pullDebt( + address from, + uint256 assets + ) external callThroughEVC withChecks(_msgSenderForBorrow()) nonReentrant returns (bool) { + address msgSender = _msgSenderForBorrow(); + + if (!evc.isControllerEnabled(from, address(this))) { + revert ControllerDisabled(); + } + + takeVaultSnapshot(); + + require(assets != 0, "ZERO_AMOUNT"); + require(msgSender != from, "SELF_DEBT_PULL"); + + _decreaseOwed(from, assets); + _increaseOwed(msgSender, assets); + + emit Repay(msgSender, from, assets); + emit Borrow(msgSender, msgSender, assets); + + return true; + } + + function liquidate( + address violator, + address collateral + ) external virtual override callThroughEVC nonReentrant withChecks(_msgSenderForBorrow()) { + address msgSender = _msgSenderForBorrow(); + + if (msgSender == violator) { + revert SelfLiquidation(); + } + + // sanity check: the violator must be under control of the EVC + if (!evc.isControllerEnabled(violator, address(this))) { + revert ControllerDisabled(); + } + + // do not allow to seize the assets for collateral without a collateral factor. + uint256 cf = collateralFactor[ERC4626(collateral)]; + if (cf == 0) { + revert CollateralDisabled(); + } + + takeVaultSnapshot(); + + uint256 seizeShares = ERC4626(collateral).convertToShares(debtOf(violator)); + + _decreaseOwed(violator, seizeShares); + _increaseOwed(msgSender, seizeShares); + + emit Repay(msgSender, violator, seizeShares); + emit Borrow(msgSender, msgSender, seizeShares); + + if (collateral == address(this)) { + // if the liquidator tries to seize the assets from this vault, + // we need to be sure that the violator has enabled this vault as collateral + if (!evc.isCollateralEnabled(violator, collateral)) { + revert CollateralDisabled(); + } + + ERC20(asset()).transferFrom(violator, msgSender, seizeShares); + } else { + evc.forgiveAccountStatusCheck(violator); + } + } + + function setInterestRate(uint256 _interestRate256) internal { + interestRate256 = _interestRate256; + } + + function computeInterestRate(address market, address asset, uint32 utilisation) internal returns (int96) { + int96 rate = computeInterestRateImpl(market, asset, utilisation); + + if (rate > MAX_ALLOWED_INTEREST_RATE) { + rate = MAX_ALLOWED_INTEREST_RATE; + } else if (rate < MIN_ALLOWED_INTEREST_RATE) { + rate = MIN_ALLOWED_INTEREST_RATE; + } + + return rate; + } + + function computeInterestRateImpl(address, address, uint32) internal virtual returns (int96) { + return int96(int256(uint256((1e27 * interestRate256) / 100) / (86400 * 365))); // not SECONDS_PER_YEAR to avoid + // breaking tests + } + + function _updateInterest() internal virtual { + uint256 borrowed = totalBorrowed; + uint256 poolAssets = totalAssets() + borrowed; + + uint32 utilisation; + if (poolAssets != 0) { + utilisation = uint32((borrowed * type(uint32).max) / poolAssets); + } + + interestRate = computeInterestRate(address(this), address(ERC20(asset())), utilisation); + } + + function pow(uint256 base, uint256 exp, uint256 modulus) internal pure returns (uint256 result) { + result = 1; + base %= modulus; + while (exp > 0) { + if (exp & 1 == 1) { + result = mulmod(result, base, modulus); + } + base = mulmod(base, base, modulus); + exp >>= 1; + } + } } diff --git a/src/workshop_2/WorkshopVaultCircuitBreaker.sol b/src/workshop_2/WorkshopVaultCircuitBreaker.sol new file mode 100644 index 0000000..6498596 --- /dev/null +++ b/src/workshop_2/WorkshopVaultCircuitBreaker.sol @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.19; + +import {LimiterLib, LimitStatus, Limiter, LiqChangeNode} from "./utils/LimiterLib.sol"; +import "./WorkshopVault.sol"; +import "./ICircuitBreaker.sol"; + +/// @title WorkshopVaultCircuitBreaker +/// @notice This contract extends WorkshopVault with additional feature like Circuit Breaker +contract WorkshopVaultCircuitBreaker is WorkshopVault { + using SafeERC20 for IERC20; + + address private _owner; + mapping(address => bool) private _allowed; + bool private _emergency; + + // Circuit Breaker + ICircuitBreaker public circuitBreaker; + + constructor( + address _circuitBreaker, + IEVC _evc, + ERC20 _asset, + string memory _name, + string memory _symbol + ) WorkshopVault(_evc, _asset, _name, _symbol) { + circuitBreaker = ICircuitBreaker(_circuitBreaker); + } + + // Internal function to be used when tokens are deposited + // Transfers the tokens from sender to recipient and then calls the circuitBreaker's onTokenInflow + function cbInflowTransfer(address _token, address _to, uint256 _amount) internal { + // Transfer the tokens safely from sender to recipient + ERC20(_token).transfer(_to, _amount); + // Call the circuitBreaker's onTokenInflow + circuitBreaker.onTokenInflow(_token, _amount); + } + + function cbInflowSafeTransferFrom(address _token, address _sender, address _recipient, uint256 _amount) internal { + // Transfer the tokens safely from sender to recipient + IERC20(_token).safeTransferFrom(_sender, _recipient, _amount); + // Call the circuitBreaker's onTokenInflow + circuitBreaker.onTokenInflow(_token, _amount); + } + + // Internal function to be used when tokens are withdrawn + // Transfers the tokens to the circuitBreaker and then calls the circuitBreaker's onTokenOutflow + function cbOutflowSafeTransfer( + address _token, + address _recipient, + uint256 _amount, + bool _revertOnRateLimit + ) internal { + // Transfer the tokens safely to the circuitBreaker + IERC20(_token).safeTransfer(address(circuitBreaker), _amount); + // Call the circuitBreaker's onTokenOutflow + circuitBreaker.onTokenOutflow(_token, _amount, _recipient, _revertOnRateLimit); + } + + function cbInflowNative() internal { + // Transfer the tokens safely from sender to recipient + circuitBreaker.onNativeAssetInflow(msg.value); + } + + function cbOutflowNative(address _recipient, uint256 _amount, bool _revertOnRateLimit) internal { + // Transfer the native tokens safely through the circuitBreaker + circuitBreaker.onNativeAssetOutflow{value: _amount}(_recipient, _revertOnRateLimit); + } + + function doCheckVaultStatus(bytes memory oldSnapshot) public virtual override { + // sanity check in case the snapshot hasn't been taken + if (oldSnapshot.length == 0) revert SnapshotNotTaken(); + + // use the vault status hook to update the interest rate (it should happen only once per transaction). + // EVC.forgiveVaultStatus check should never be used for this vault, otherwise the interest rate will not be + // updated. + _updateInterest(); + + // validate the vault state here: + (uint256 initialSupply, uint256 initialBorrowed) = abi.decode(oldSnapshot, (uint256, uint256)); + uint256 finalSupply = _convertToAssets(totalSupply(), Math.Rounding.Floor); + uint256 finalBorrowed = totalBorrowed; + + // the supply cap can be implemented like this: + if (supplyCap != 0 && finalSupply > supplyCap && finalSupply > initialSupply) { + revert SupplyCapExceeded(); + } + + // or the borrow cap can be implemented like this: + if (borrowCap != 0 && finalBorrowed > borrowCap && finalBorrowed > initialBorrowed) { + revert BorrowCapExceeded(); + } + + // cb if limit exceeded + } + + function deposit( + uint256 assets, + address receiver + ) public virtual override callThroughEVC withChecks(address(0)) nonReentrant returns (uint256 shares) { + address msgSender = _msgSender(); + + takeVaultSnapshot(); + + // Check for rounding error since we round down in previewDeposit. + require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); + + cbInflowSafeTransferFrom(address(IERC20(asset())), msgSender, address(this), assets); + // Need to transfer before minting or ERC777s could reenter. + // IERC20(asset()).safeTransferFrom(msgSender, address(this), assets); + + _mint(receiver, shares); + + emit Deposit(msgSender, receiver, assets, shares); + } + + function mint( + uint256 shares, + address receiver + ) public virtual override callThroughEVC withChecks(address(0)) nonReentrant returns (uint256 assets) { + address msgSender = _msgSender(); + + takeVaultSnapshot(); + + assets = previewMint(shares); + + // IERC20(asset()).safeTransferFrom(msgSender, address(this), assets); + cbInflowSafeTransferFrom(address(IERC20(asset())), msgSender, address(this), assets); + + _mint(receiver, shares); + + emit Deposit(msgSender, receiver, assets, shares); + } + // IWorkshopVault + + function borrow( + uint256 assets, + address receiver + ) external override callThroughEVC withChecks(_msgSenderForBorrow()) nonReentrant { + address msgSender = _msgSenderForBorrow(); + + takeVaultSnapshot(); + + require(assets != 0, "ZERO_ASSETS"); + + receiver = getAccountOwner(receiver); + + _increaseOwed(msgSender, assets); + + emit Borrow(msgSender, receiver, assets); + + // SafeERC20.safeTransfer(ERC20(asset()), receiver, assets); + // ERC20(asset()).transfer(receiver, assets); + cbInflowTransfer(address(ERC20(asset())), receiver, assets); + } +} + +contract CircuitBreaker is ICircuitBreaker { + using SafeERC20 for IERC20; + using LimiterLib for Limiter; + //////////////////////////////////////////////////////////////// + // STATE VARIABLES // + //////////////////////////////////////////////////////////////// + + mapping(address => Limiter limiter) public tokenLimiters; + + /** + * @notice Funds locked if rate limited reached + */ + mapping(address recipient => mapping(address asset => uint256 amount)) public lockedFunds; + + mapping(address account => bool protectionActive) public isProtectedContract; + + address public admin; + + bool public isRateLimited; + + uint256 public rateLimitCooldownPeriod; + + uint256 public lastRateLimitTimestamp; + + uint256 public gracePeriodEndTimestamp; + + // Using address(1) as a proxy for native token (ETH, BNB, etc), address(0) could be problematic + address public immutable NATIVE_ADDRESS_PROXY = address(1); + + uint256 public immutable WITHDRAWAL_PERIOD; + + uint256 public immutable TICK_LENGTH; + + bool public isOperational = true; + + //////////////////////////////////////////////////////////////// + // EVENTS // + //////////////////////////////////////////////////////////////// + + /** + * @notice Non-EIP standard events + */ + event TokenBacklogCleaned(address indexed token, uint256 timestamp); + + //////////////////////////////////////////////////////////////// + // ERRORS // + //////////////////////////////////////////////////////////////// + + error NotAProtectedContract(); + error NotAdmin(); + error InvalidAdminAddress(); + error NoLockedFunds(); + error RateLimited(); + error NotRateLimited(); + error TokenNotRateLimited(); + error CooldownPeriodNotReached(); + error NativeTransferFailed(); + error InvalidRecipientAddress(); + error InvalidGracePeriodEnd(); + error ProtocolWasExploited(); + error NotExploited(); + + //////////////////////////////////////////////////////////////// + // MODIFIERS // + //////////////////////////////////////////////////////////////// + + modifier onlyProtected() { + if (!isProtectedContract[msg.sender]) revert NotAProtectedContract(); + _; + } + + modifier onlyAdmin() { + if (msg.sender != admin) revert NotAdmin(); + _; + } + + /** + * @notice When the isOperational flag is set to false, the protocol is considered locked and will + * revert all future deposits, withdrawals, and claims to locked funds. + * The admin should migrate the funds from the underlying protocol and what is remaining + * in the CircuitBreaker contract to a multisig. This multisig should then be used to refund users pro-rata. + * (Social Consensus) + */ + modifier onlyOperational() { + if (!isOperational) revert ProtocolWasExploited(); + _; + } + + /** + * @notice gracePeriod refers to the time after a rate limit trigger and then overriden where withdrawals are + * still allowed. + * @dev For example a false positive rate limit trigger, then it is overriden, so withdrawals are still + * allowed for a period of time. + * Before the rate limit is enforced again, it should be set to be at least your largest + * withdrawalPeriod length + */ + constructor( + address _admin, + uint256 _rateLimitCooldownPeriod, + uint256 _withdrawlPeriod, + uint256 _liquidityTickLength + ) { + admin = _admin; + rateLimitCooldownPeriod = _rateLimitCooldownPeriod; + WITHDRAWAL_PERIOD = _withdrawlPeriod; + TICK_LENGTH = _liquidityTickLength; + } + + //////////////////////////////////////////////////////////////// + // FUNCTIONS // + //////////////////////////////////////////////////////////////// + + /** + * @dev Give protected contracts one function to call for convenience + */ + function onTokenInflow(address _token, uint256 _amount) external onlyProtected onlyOperational { + _onTokenInflow(_token, _amount); + } + + function onTokenOutflow( + address _token, + uint256 _amount, + address _recipient, + bool _revertOnRateLimit + ) external onlyProtected onlyOperational { + _onTokenOutflow(_token, _amount, _recipient, _revertOnRateLimit); + } + + function onNativeAssetInflow(uint256 _amount) external onlyProtected onlyOperational { + _onTokenInflow(NATIVE_ADDRESS_PROXY, _amount); + } + + function onNativeAssetOutflow( + address _recipient, + bool _revertOnRateLimit + ) external payable onlyProtected onlyOperational { + _onTokenOutflow(NATIVE_ADDRESS_PROXY, msg.value, _recipient, _revertOnRateLimit); + } + + /** + * @notice Allow users to claim locked funds when rate limit is resolved + * use address(1) for native token claims + */ + function claimLockedFunds(address _asset, address _recipient) external onlyOperational { + if (lockedFunds[_recipient][_asset] == 0) revert NoLockedFunds(); + if (isRateLimited) revert RateLimited(); + + uint256 amount = lockedFunds[_recipient][_asset]; + lockedFunds[_recipient][_asset] = 0; + + emit LockedFundsClaimed(_asset, _recipient); + + _safeTransferIncludingNative(_asset, _recipient, amount); + } + + /** + * @dev Due to potential inactivity, the linked list may grow to where + * it is better to clear the backlog in advance to save gas for the users + * this is a public function so that anyone can call it as it is not user sensitive + */ + function clearBackLog(address _token, uint256 _maxIterations) external { + tokenLimiters[_token].sync(WITHDRAWAL_PERIOD, _maxIterations); + emit TokenBacklogCleaned(_token, block.timestamp); + } + + function overrideExpiredRateLimit() external { + if (!isRateLimited) revert NotRateLimited(); + if (block.timestamp - lastRateLimitTimestamp < rateLimitCooldownPeriod) { + revert CooldownPeriodNotReached(); + } + + isRateLimited = false; + } + + function registerAsset( + address _asset, + uint256 _minLiqRetainedBps, + uint256 _limitBeginThreshold + ) external onlyAdmin { + tokenLimiters[_asset].init(_minLiqRetainedBps, _limitBeginThreshold); + emit AssetRegistered(_asset, _minLiqRetainedBps, _limitBeginThreshold); + } + + function updateAssetParams( + address _asset, + uint256 _minLiqRetainedBps, + uint256 _limitBeginThreshold + ) external onlyAdmin { + Limiter storage limiter = tokenLimiters[_asset]; + limiter.updateParams(_minLiqRetainedBps, _limitBeginThreshold); + limiter.sync(WITHDRAWAL_PERIOD); + } + + function overrideRateLimit() external onlyAdmin { + if (!isRateLimited) revert NotRateLimited(); + isRateLimited = false; + // Allow the grace period to extend for the full withdrawal period to not trigger rate limit again + // if the rate limit is removed just before the withdrawal period ends + gracePeriodEndTimestamp = lastRateLimitTimestamp + WITHDRAWAL_PERIOD; + } + + function addProtectedContracts(address[] calldata _ProtectedContracts) external onlyAdmin { + for (uint256 i = 0; i < _ProtectedContracts.length; i++) { + isProtectedContract[_ProtectedContracts[i]] = true; + } + } + + function removeProtectedContracts(address[] calldata _ProtectedContracts) external onlyAdmin { + for (uint256 i = 0; i < _ProtectedContracts.length; i++) { + isProtectedContract[_ProtectedContracts[i]] = false; + } + } + + function startGracePeriod(uint256 _gracePeriodEndTimestamp) external onlyAdmin { + if (_gracePeriodEndTimestamp <= block.timestamp) revert InvalidGracePeriodEnd(); + gracePeriodEndTimestamp = _gracePeriodEndTimestamp; + emit GracePeriodStarted(_gracePeriodEndTimestamp); + } + + function setAdmin(address _newAdmin) external onlyAdmin { + if (_newAdmin == address(0)) revert InvalidAdminAddress(); + admin = _newAdmin; + emit AdminSet(_newAdmin); + } + + function tokenLiquidityChanges( + address _token, + uint256 _tickTimestamp + ) external view returns (uint256 nextTimestamp, int256 amount) { + LiqChangeNode storage node = tokenLimiters[_token].listNodes[_tickTimestamp]; + nextTimestamp = node.nextTimestamp; + amount = node.amount; + } + + function isRateLimitTriggered(address _asset) public view returns (bool) { + return tokenLimiters[_asset].status() == LimitStatus.Triggered; + } + + function isInGracePeriod() public view returns (bool) { + return block.timestamp <= gracePeriodEndTimestamp; + } + + function markAsNotOperational() external onlyAdmin { + isOperational = false; + } + + function migrateFundsAfterExploit(address[] calldata _assets, address _recoveryRecipient) external onlyAdmin { + if (isOperational) revert NotExploited(); + for (uint256 i = 0; i < _assets.length; i++) { + if (_assets[i] == NATIVE_ADDRESS_PROXY) { + uint256 amount = address(this).balance; + _safeTransferIncludingNative(_assets[i], _recoveryRecipient, amount); + } else { + uint256 amount = IERC20(_assets[i]).balanceOf(address(this)); + _safeTransferIncludingNative(_assets[i], _recoveryRecipient, amount); + } + } + } + + //////////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + //////////////////////////////////////////////////////////////// + + function _onTokenInflow(address _token, uint256 _amount) internal { + /// @dev uint256 could overflow into negative + Limiter storage limiter = tokenLimiters[_token]; + + limiter.recordChange(int256(_amount), WITHDRAWAL_PERIOD, TICK_LENGTH); + emit AssetInflow(_token, _amount); + } + + function _onTokenOutflow(address _token, uint256 _amount, address _recipient, bool _revertOnRateLimit) internal { + Limiter storage limiter = tokenLimiters[_token]; + // Check if the token has enforced rate limited + if (!limiter.initialized()) { + // if it is not rate limited, just transfer the tokens + _safeTransferIncludingNative(_token, _recipient, _amount); + return; + } + limiter.recordChange(-int256(_amount), WITHDRAWAL_PERIOD, TICK_LENGTH); + // Check if currently rate limited, if so, add to locked funds claimable when resolved + if (isRateLimited) { + if (_revertOnRateLimit) { + revert RateLimited(); + } + lockedFunds[_recipient][_token] += _amount; + return; + } + + // Check if rate limit is triggered after withdrawal and not in grace period + // (grace period allows for withdrawals to be made if rate limit is triggered but overriden) + if (limiter.status() == LimitStatus.Triggered && !isInGracePeriod()) { + if (_revertOnRateLimit) { + revert RateLimited(); + } + // if it is, set rate limited to true + isRateLimited = true; + lastRateLimitTimestamp = block.timestamp; + // add to locked funds claimable when resolved + lockedFunds[_recipient][_token] += _amount; + + emit AssetRateLimitBreached(_token, block.timestamp); + + return; + } + + // if everything is good, transfer the tokens + _safeTransferIncludingNative(_token, _recipient, _amount); + + emit AssetWithdraw(_token, _recipient, _amount); + } + + function _safeTransferIncludingNative(address _token, address _recipient, uint256 _amount) internal { + if (_amount == 0) return; + + if (_token == NATIVE_ADDRESS_PROXY) { + (bool success,) = _recipient.call{value: _amount}(""); + if (!success) revert NativeTransferFailed(); + } else { + IERC20(_token).safeTransfer(_recipient, _amount); + } + } +} diff --git a/src/workshop_2/WorkshopVaultOracle.sol b/src/workshop_2/WorkshopVaultOracle.sol new file mode 100644 index 0000000..ba9ab96 --- /dev/null +++ b/src/workshop_2/WorkshopVaultOracle.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.19; + +import "./IPriceOracle.sol"; +import "./WorkshopVault.sol"; + +/// @title VaultRegularBorrowable +/// @notice This contract extends VaultSimpleBorrowable with additional features like interest rate accrual and +/// recognition of external collateral vaults. +contract VaultRegularBorrowable is WorkshopVault { + uint256 internal constant COLLATERAL_FACTOR_SCALE = 100; + uint256 internal constant MAX_LIQUIDATION_INCENTIVE = 20; + uint256 internal constant TARGET_HEALTH_FACTOR = 125; + uint256 internal constant HARD_LIQUIDATION_THRESHOLD = 1; + + // oracle + ERC20 public referenceAsset; + IPriceOracle public oracle; + + error NoLiquidationOpportunity(); + error RepayAssetsInsufficient(); + error RepayAssetsExceeded(); + error ViolatorStatusCheckDeferred(); + error InvalidCollateralFactor(); + error SharesSeizureFailed(); + + constructor( + IEVC _evc, + ERC20 _asset, + IPriceOracle _oracle, + ERC20 _referenceAsset, + string memory _name, + string memory _symbol + ) WorkshopVault(_evc, _asset, _name, _symbol) { + oracle = _oracle; + referenceAsset = _referenceAsset; + lastInterestUpdate = block.timestamp; + interestAccumulator = ONE; + } + + function checkAccountStatus( + address account, + address[] calldata collaterals + ) public virtual override returns (bytes4 magicValue) { + require(msg.sender == address(evc), "only evc can call this"); + require(evc.areChecksInProgress(), "can only be called when checks in progress"); + + // some custom logic evaluating the account health + if (debtOf(account) > 0) { + (, uint256 liabilityValue, uint256 collateralValue) = _calculateLiabilityAndCollateral(account, collaterals); + + if (liabilityValue > collateralValue) { + revert AccountUnhealthy(); + } + } + + return IVault.checkAccountStatus.selector; + } + + function liquidate( + address violator, + address collateral, + uint256 repayAssets + ) external callThroughEVC nonReentrant withChecks(_msgSenderForBorrow()) { + address msgSender = _msgSenderForBorrow(); + + if (msgSender == violator) { + revert SelfLiquidation(); + } + + // sanity check: the violator must be under control of the EVC + if (!evc.isControllerEnabled(violator, address(this))) { + revert ControllerDisabled(); + } + + // do not allow to seize the assets for collateral without a collateral factor. + // note that a user can enable any address as collateral, even if it's not recognized + // as such (cf == 0) + uint256 cf = collateralFactor[ERC4626(collateral)]; + if (cf == 0) { + revert CollateralDisabled(); + } + + takeVaultSnapshot(); + + uint256 seizeShares; + { + (uint256 liabilityAssets, uint256 liabilityValue, uint256 collateralValue) = + _calculateLiabilityAndCollateral(violator, evc.getCollaterals(violator)); + + // trying to repay more than the violator owes + if (repayAssets > liabilityAssets) { + revert RepayAssetsExceeded(); + } + + // check if violator's account is unhealthy + if (collateralValue >= liabilityValue) { + revert NoLiquidationOpportunity(); + } + + // calculate dynamic liquidation incentive + uint256 liquidationIncentive = 100 - (100 * collateralValue) / liabilityValue; + + if (liquidationIncentive > MAX_LIQUIDATION_INCENTIVE) { + liquidationIncentive = MAX_LIQUIDATION_INCENTIVE; + } + + // calculate the max repay value that will bring the violator back to target health factor + uint256 maxRepayValue = (TARGET_HEALTH_FACTOR * liabilityValue - 100 * collateralValue) + / (TARGET_HEALTH_FACTOR - (cf * (100 + liquidationIncentive)) / 100); + + // get the desired value of repay assets + uint256 repayValue = + IPriceOracle(oracle).getQuote(repayAssets, address(ERC20(asset())), address(referenceAsset)); + + // check if the liquidator is not trying to repay too much. + if ( + repayValue > maxRepayValue && repayAssets > HARD_LIQUIDATION_THRESHOLD * 10 ** ERC20(asset()).decimals() + ) { + revert RepayAssetsExceeded(); + } + + address collateralAsset = address(ERC4626(collateral).asset()); + uint256 one = 10 ** ERC20(collateralAsset).decimals(); + + uint256 seizeValue = (repayValue * (100 + liquidationIncentive)) / 100; + + uint256 seizeAssets = + (seizeValue * one) / IPriceOracle(oracle).getQuote(one, collateralAsset, address(referenceAsset)); + + seizeShares = ERC4626(collateral).convertToShares(seizeAssets); + + if (seizeShares == 0) { + revert RepayAssetsInsufficient(); + } + } + + _decreaseOwed(violator, repayAssets); + _increaseOwed(msgSender, repayAssets); + + emit Repay(msgSender, violator, repayAssets); + emit Borrow(msgSender, msgSender, repayAssets); + + if (collateral == address(this)) { + // if the liquidator tries to seize the assets from this vault, + // we need to be sure that the violator has enabled this vault as collateral + if (!evc.isCollateralEnabled(violator, collateral)) { + revert CollateralDisabled(); + } + + ERC20(asset()).transferFrom(violator, msgSender, seizeShares); + } else { + // Control the collateral in order to transfer shares from the violator's vault to the liquidator. + bytes memory result = evc.controlCollateral( + collateral, violator, 0, abi.encodeCall(ERC20(asset()).transfer, (msgSender, seizeShares)) + ); + + if (!abi.decode(result, (bool))) { + revert SharesSeizureFailed(); + } + + // Allow violator to have unhealthy state after the liquidation + evc.forgiveAccountStatusCheck(violator); + } + } + + function _calculateLiabilityAndCollateral( + address account, + address[] memory collaterals + ) internal view returns (uint256 liabilityAssets, uint256 liabilityValue, uint256 collateralValue) { + liabilityAssets = debtOf(account); + + liabilityValue = + IPriceOracle(oracle).getQuote(liabilityAssets, address(ERC20(asset())), address(referenceAsset)); + + for (uint256 i = 0; i < collaterals.length; ++i) { + ERC4626 collateral = ERC4626(collaterals[i]); + uint256 cf = collateralFactor[collateral]; + + if (cf != 0) { + uint256 collateralShares = collateral.balanceOf(account); + + if (collateralShares > 0) { + uint256 collateralAssets = collateral.convertToAssets(collateralShares); + + collateralValue += ( + IPriceOracle(oracle).getQuote( + collateralAssets, address(collateral.asset()), address(referenceAsset) + ) * cf + ) / COLLATERAL_FACTOR_SCALE; + } + } + } + } +} diff --git a/src/workshop_2/utils/LimiterLib.sol b/src/workshop_2/utils/LimiterLib.sol new file mode 100644 index 0000000..7b0f43c --- /dev/null +++ b/src/workshop_2/utils/LimiterLib.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +struct LiqChangeNode { + uint256 nextTimestamp; + int256 amount; +} + +struct Limiter { + uint256 minLiqRetainedBps; + uint256 limitBeginThreshold; + int256 liqTotal; + int256 liqInPeriod; + uint256 listHead; + uint256 listTail; + mapping(uint256 tick => LiqChangeNode node) listNodes; +} + +uint256 constant BPS_DENOMINATOR = 10000; + +enum LimitStatus { + Uninitialized, + Inactive, + Ok, + Triggered +} + +library LimiterLib { + error InvalidMinimumLiquidityThreshold(); + error LimiterAlreadyInitialized(); + error LimiterNotInitialized(); + + function init(Limiter storage limiter, uint256 minLiqRetainedBps, uint256 limitBeginThreshold) internal { + if (minLiqRetainedBps == 0 || minLiqRetainedBps > BPS_DENOMINATOR) { + revert InvalidMinimumLiquidityThreshold(); + } + if (initialized(limiter)) revert LimiterAlreadyInitialized(); + limiter.minLiqRetainedBps = minLiqRetainedBps; + limiter.limitBeginThreshold = limitBeginThreshold; + } + + function updateParams(Limiter storage limiter, uint256 minLiqRetainedBps, uint256 limitBeginThreshold) internal { + if (minLiqRetainedBps == 0 || minLiqRetainedBps > BPS_DENOMINATOR) { + revert InvalidMinimumLiquidityThreshold(); + } + if (!initialized(limiter)) revert LimiterNotInitialized(); + limiter.minLiqRetainedBps = minLiqRetainedBps; + limiter.limitBeginThreshold = limitBeginThreshold; + } + + function recordChange( + Limiter storage limiter, + int256 amount, + uint256 withdrawalPeriod, + uint256 tickLength + ) internal { + // If token does not have a rate limit, do nothing + if (!initialized(limiter)) { + return; + } + + uint256 currentTickTimestamp = getTickTimestamp(block.timestamp, tickLength); + limiter.liqInPeriod += amount; + + uint256 listHead = limiter.listHead; + if (listHead == 0) { + // if there is no head, set the head to the new inflow + limiter.listHead = currentTickTimestamp; + limiter.listTail = currentTickTimestamp; + limiter.listNodes[currentTickTimestamp] = LiqChangeNode({amount: amount, nextTimestamp: 0}); + } else { + // if there is a head, check if the new inflow is within the period + // if it is, add it to the head + // if it is not, add it to the tail + if (block.timestamp - listHead >= withdrawalPeriod) { + sync(limiter, withdrawalPeriod); + } + + // check if tail is the same as block.timestamp (multiple txs in same block) + uint256 listTail = limiter.listTail; + if (listTail == currentTickTimestamp) { + // add amount + limiter.listNodes[currentTickTimestamp].amount += amount; + } else { + // add to tail + limiter.listNodes[listTail].nextTimestamp = currentTickTimestamp; + limiter.listNodes[currentTickTimestamp] = LiqChangeNode({amount: amount, nextTimestamp: 0}); + limiter.listTail = currentTickTimestamp; + } + } + } + + function sync(Limiter storage limiter, uint256 withdrawalPeriod) internal { + sync(limiter, withdrawalPeriod, type(uint256).max); + } + + function sync(Limiter storage limiter, uint256 withdrawalPeriod, uint256 totalIters) internal { + uint256 currentHead = limiter.listHead; + int256 totalChange = 0; + uint256 iter = 0; + + while (currentHead != 0 && block.timestamp - currentHead >= withdrawalPeriod && iter < totalIters) { + LiqChangeNode storage node = limiter.listNodes[currentHead]; + totalChange += node.amount; + uint256 nextTimestamp = node.nextTimestamp; + // Clear data + limiter.listNodes[currentHead]; + currentHead = nextTimestamp; + // forgefmt: disable-next-item + unchecked { + ++iter; + } + } + + if (currentHead == 0) { + // If the list is empty, set the tail and head to current times + limiter.listHead = block.timestamp; + limiter.listTail = block.timestamp; + } else { + limiter.listHead = currentHead; + } + limiter.liqTotal += totalChange; + limiter.liqInPeriod -= totalChange; + } + + function status(Limiter storage limiter) internal view returns (LimitStatus) { + if (!initialized(limiter)) { + return LimitStatus.Uninitialized; + } + int256 currentLiq = limiter.liqTotal; + + // Only enforce rate limit if there is significant liquidity + if (limiter.limitBeginThreshold > uint256(currentLiq)) { + return LimitStatus.Inactive; + } + + int256 futureLiq = currentLiq + limiter.liqInPeriod; + // NOTE: uint256 to int256 conversion here is safe + int256 minLiq = (currentLiq * int256(limiter.minLiqRetainedBps)) / int256(BPS_DENOMINATOR); + + return futureLiq < minLiq ? LimitStatus.Triggered : LimitStatus.Ok; + } + + function initialized(Limiter storage limiter) internal view returns (bool) { + return limiter.minLiqRetainedBps > 0; + } + + function getTickTimestamp(uint256 t, uint256 tickLength) internal pure returns (uint256) { + return t - (t % tickLength); + } +} diff --git a/src/workshop_3/PositionManager.sol b/src/workshop_3/PositionManager.sol index 2836d51..649feec 100644 --- a/src/workshop_3/PositionManager.sol +++ b/src/workshop_3/PositionManager.sol @@ -3,5 +3,87 @@ pragma solidity ^0.8.19; import "evc-playground/vaults/VaultRegularBorrowable.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -contract PositionManager {} +contract PositionManager is AccessControl { + using SafeTransferLib for ERC20; + + bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE"); + + struct Vault { + address vault; + uint256 apy; + } + + IEVC internal evc; + Vault[] public vaults; + mapping(address => uint256) public userBalance; + mapping(address => uint256) public lastRebalanced; + + constructor(address _keeper, IEVC _evc) { + grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + grantRole(KEEPER_ROLE, _keeper); + evc = _evc; + } + + // Only accounts with the DEFAULT_ADMIN_ROLE can add new vaults + // This function allows adding new vaults to the system. + function addVault(address vault) public onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 apy = uint256(VaultRegularBorrowable(vault).getInterestRate()); + vaults.push(Vault({vault: vault, apy: apy})); + } + + // Only accounts with the KEEPER_ROLE can call the rebalance function + // This function allows keepers to rebalance their assets. + // Before doing so, it checks that at least one day has passed since the last rebalance. + // It then finds the vault with the highest APY and transfers the assets to that vault. + function rebalance(address _oldVault, address _user) public onlyRole(KEEPER_ROLE) { + require(block.timestamp >= lastRebalanced[_user] + 1 days, "Cannot rebalance more than once per day"); + + uint256 bestVaultIndex = findBestVault(); + Vault storage bestVault = vaults[bestVaultIndex]; + + // The vault with the highest APY + address _vault = bestVault.vault; + + // The asset token of old vault + ERC20 token = ERC4626(_oldVault).asset(); + + // Allowed amount for tranfer + uint256 amount = VaultRegularBorrowable(_oldVault).maxWithdraw(_user); + userBalance[_user] = amount; + + // Withdraw from old vault to Position manager + evc.call(address(_oldVault), _user, 0, abi.encodeWithSelector(VaultSimple.withdraw.selector, amount, _user)); + + // Approve token for rebalancing + token.approve(_vault, userBalance[_user]); + // Deposits a certain amount of assets for a receiver to the highest APY vault + VaultRegularBorrowable(_vault).deposit(userBalance[_user], _user); + + lastRebalanced[_user] = block.timestamp; + } + + // This function iterates over all vaults and returns the index of the vault with the highest APY. + function findBestVault() private view returns (uint256) { + uint256 bestVaultIndex = 0; + uint256 bestApy = 0; + + // Update the apy of all stored vaults + for (uint256 i = 0; i < vaults.length; i++) { + Vault memory vault = vaults[i]; + vault.apy = uint256(VaultRegularBorrowable(vault.vault).getInterestRate()); + } + + for (uint256 i = 0; i < vaults.length; i++) { + Vault memory vault = vaults[i]; + + if (vault.apy > bestApy) { + bestVaultIndex = i; + bestApy = vault.apy; + } + } + + return bestVaultIndex; + } +} diff --git a/test/workshop_2/WorkshopVaultCircuitBreaker.t.sol b/test/workshop_2/WorkshopVaultCircuitBreaker.t.sol new file mode 100644 index 0000000..4061d20 --- /dev/null +++ b/test/workshop_2/WorkshopVaultCircuitBreaker.t.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.19; + +import {ERC4626Test} from "a16z-erc4626-tests/ERC4626.test.sol"; +import "openzeppelin/mocks/token/ERC20Mock.sol"; +import "evc/EthereumVaultConnector.sol"; +import "../../src/workshop_2/WorkshopVault.sol"; +import {CircuitBreaker, WorkshopVaultCircuitBreaker} from "../../src/workshop_2/WorkshopVaultCircuitBreaker.sol"; +import {LimiterLib} from "../../src/workshop_2/utils/LimiterLib.sol"; + +contract TestVault is WorkshopVaultCircuitBreaker { + bool internal shouldRunOriginalAccountStatusCheck; + bool internal shouldRunOriginalVaultStatusCheck; + + constructor( + address _circuitBreaker, + IEVC _evc, + ERC20 _asset, + string memory _name, + string memory _symbol + ) WorkshopVaultCircuitBreaker(_circuitBreaker, _evc, _asset, _name, _symbol) {} + + function setShouldRunOriginalAccountStatusCheck(bool _shouldRunOriginalAccountStatusCheck) external { + shouldRunOriginalAccountStatusCheck = _shouldRunOriginalAccountStatusCheck; + } + + function setShouldRunOriginalVaultStatusCheck(bool _shouldRunOriginalVaultStatusCheck) external { + shouldRunOriginalVaultStatusCheck = _shouldRunOriginalVaultStatusCheck; + } + + function checkAccountStatus( + address account, + address[] calldata collaterals + ) public override returns (bytes4 magicValue) { + return shouldRunOriginalAccountStatusCheck + ? super.checkAccountStatus(account, collaterals) + : this.checkAccountStatus.selector; + } + + function checkVaultStatus() public override returns (bytes4 magicValue) { + return shouldRunOriginalVaultStatusCheck ? super.checkVaultStatus() : this.checkVaultStatus.selector; + } +} + +contract VaultTest is ERC4626Test { + IEVC _evc_; + address internal NATIVE_ADDRESS_PROXY = address(1); + CircuitBreaker internal _circuitBreaker_; + address internal alice = vm.addr(0x1); + address internal bob = vm.addr(0x2); + address internal admin = vm.addr(0x3); + + function setUp() public override { + _circuitBreaker_ = new CircuitBreaker(admin, 3 days, 4 hours, 5 minutes); + _evc_ = new EthereumVaultConnector(); + _underlying_ = address(new ERC20Mock()); + _delta_ = 0; + _vaultMayBeEmpty = false; + _unlimitedAmount = false; + _vault_ = address(new TestVault(address(_circuitBreaker_), _evc_, ERC20(_underlying_), "Vault", "VLT")); + + address[] memory addresses = new address[](1); + addresses[0] = address(_vault_); + + vm.prank(admin); + _circuitBreaker_.addProtectedContracts(addresses); + vm.prank(admin); + // Protect ERC20Token with 70% max drawdown per 4 hours + _circuitBreaker_.registerAsset(address(_underlying_), 7000, 1000e18); + vm.prank(admin); + _circuitBreaker_.registerAsset(NATIVE_ADDRESS_PROXY, 7000, 1000e18); + vm.warp(1 hours); + } + + function test_CB_DepositWithEVC() public { + // vm.assume(charlie != address(0) && charlie != address(_evc_) && charlie != address(_vault_)); + // vm.assume(amount > 10000e18); + uint256 amount = 10000e18; + uint256 amountDeposited = 10e18; + + // uint256 amountToBorrow = amount / 2; + + ERC20 underlying = ERC20(_underlying_); + WorkshopVaultCircuitBreaker vault = WorkshopVaultCircuitBreaker(_vault_); + + // mint some assets to alice + ERC20Mock(_underlying_).mint(alice, amount); + + // alice approves the vault to spend her assets + vm.prank(alice); + underlying.approve(address(vault), type(uint256).max); + + // make bob an operator of alice's account + vm.prank(alice); + _evc_.setAccountOperator(alice, bob, true); + + // // alice deposits assets through the EVC + // vm.prank(alice); + // _evc_.call(address(vault), alice, 0, abi.encodeWithSelector(IERC4626.deposit.selector, amount, alice)); + + // bob deposits assets on alice's behalf + vm.prank(bob); + _evc_.call(address(vault), alice, 0, abi.encodeWithSelector(IERC4626.deposit.selector, amountDeposited, alice)); + + // verify alice's balance + assertEq(underlying.balanceOf(alice), amount - amountDeposited); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), amountDeposited); + + // alice tries to borrow assets from the vault, should fail due to controller disabled + vm.prank(alice); + vm.expectRevert(); + vault.borrow(amountDeposited, alice); + + // alice enables controller + vm.prank(alice); + _evc_.enableController(alice, address(vault)); + + // // alice tries to borrow again, now it should succeed + // vm.prank(alice); + // vault.borrow(amountToBorrow, alice); + + // verify alice's balance. despite amount borrowed, she should still hold shares worth the full amount + // assertEq(underlying.balanceOf(alice), amountToBorrow); + // assertEq(vault.convertToAssets(vault.balanceOf(alice)), amount); + + // repay the amount borrowed. if interest accrues, alice should still have outstanding debt + // vm.prank(alice); + // vault.repay(amount, alice); + + // // verify maxWithdraw and maxRedeem functions + // assertEq(vault.convertToAssets(vault.maxRedeem(alice)), amount - amountToBorrow); + assertEq(vault.maxWithdraw(alice), amountDeposited); + // vm.prank(alice); + // // + // _evc_.call( + // address(vault), + // alice, + // 0, + // abi.encodeWithSelector(IERC4626.withdraw.selector, amountDeposited - 9e18 , alice, address(vault)) + // ); + // assertEq(underlying.balanceOf(alice), amount - 9e18); + // // vault.withdraw(amountDeposited,alice,address(vault)); + // vm.prank(alice); + // _evc_.call(address(vault), alice, 0, abi.encodeWithSelector(IERC4626.deposit.selector, amountDeposited , + // alice)); + // assertEq(underlying.balanceOf(alice), amount - amountDeposited); + + assertEq(_circuitBreaker_.isRateLimitTriggered(address(underlying)), false); + vm.warp(1 hours); + vm.prank(bob); + // vault.withdraw(amountToBorrow - amountToBorrow / 2,alice,address(vault)); + // _evc_.call( + // address(vault), + // alice, + // 0, + // abi.encodeWithSelector(WorkshopVault.repay.selector, amountToBorrow - amountToBorrow / 2 , alice) + // ); + assertEq(_circuitBreaker_.isRateLimitTriggered(address(underlying)), false); + + (,, int256 liqTotal, int256 liqInPeriod, uint256 head, uint256 tail) = + _circuitBreaker_.tokenLimiters(address(underlying)); + assertEq(head, tail); + assertEq(liqTotal, 0); + assertEq(liqInPeriod, 10e18); + + (uint256 nextTimestamp, int256 amountx) = _circuitBreaker_.tokenLiquidityChanges(address(underlying), head); + assertEq(nextTimestamp, 0); + assertEq(amountx, 10e18); + + vm.warp(1 hours); + vm.prank(alice); + _evc_.call(address(vault), alice, 0, abi.encodeWithSelector(IERC4626.deposit.selector, 110e18, alice)); + assertEq(_circuitBreaker_.isRateLimitTriggered(address(underlying)), false); + (,, liqTotal, liqInPeriod,,) = _circuitBreaker_.tokenLimiters(address(underlying)); + assertEq(liqTotal, 0); + assertEq(liqInPeriod, 120e18); + + // All the previous deposits are now out of the window and accounted for in the historacle + vm.warp(10 hours); + vm.prank(alice); + _evc_.call(address(vault), alice, 0, abi.encodeWithSelector(IERC4626.deposit.selector, 10e18, alice)); + assertEq(_circuitBreaker_.isRateLimitTriggered(address(underlying)), false); + (,, liqTotal, liqInPeriod, head, tail) = _circuitBreaker_.tokenLimiters(address(underlying)); + assertEq(liqTotal, 120e18); + assertEq(liqInPeriod, 10e18); + + assertEq(head, block.timestamp); + assertEq(tail, block.timestamp); + assertEq(head % 5 minutes, 0); + assertEq(tail % 5 minutes, 0); + } +}