From 611dad40573598b87b2e6964b7a20695f685d53f Mon Sep 17 00:00:00 2001 From: sherlock-admin Date: Fri, 15 Nov 2024 16:38:18 +0000 Subject: [PATCH] Fix Review --- protocol-v2/foundry.toml | 2 +- protocol-v2/script/Deploy.s.sol | 15 +- protocol-v2/script/pool/InitializePool.s.sol | 20 +- protocol-v2/src/Pool.sol | 235 ++++++++--- protocol-v2/src/Position.sol | 23 +- protocol-v2/src/PositionManager.sol | 124 +++--- protocol-v2/src/RiskEngine.sol | 95 +++-- protocol-v2/src/RiskModule.sol | 389 ++++++++++-------- protocol-v2/src/SuperPool.sol | 235 ++++++----- protocol-v2/src/SuperPoolFactory.sol | 11 +- protocol-v2/src/interfaces/IRateModel.sol | 5 +- protocol-v2/src/irm/KinkedRateModel.sol | 12 +- protocol-v2/src/irm/LinearRateModel.sol | 6 +- protocol-v2/src/lens/PortfolioLens.sol | 31 +- protocol-v2/src/lens/SuperPoolLens.sol | 20 +- protocol-v2/src/oracle/ChainlinkEthOracle.sol | 58 ++- protocol-v2/src/oracle/ChainlinkUsdOracle.sol | 90 ++-- protocol-v2/src/oracle/RedstoneOracle.sol | 62 ++- protocol-v2/test/BaseTest.t.sol | 30 +- protocol-v2/test/core/Pool.t.sol | 62 ++- protocol-v2/test/core/Position.t.sol | 34 +- protocol-v2/test/core/PositionManager.t.sol | 44 +- protocol-v2/test/core/RiskEngine.t.sol | 25 +- protocol-v2/test/core/RiskModule.t.sol | 21 +- protocol-v2/test/core/Superpool.t.sol | 17 +- protocol-v2/test/integration/BigTest.t.sol | 58 +-- .../test/integration/LiquidationTest.t.sol | 16 +- .../test/integration/PortfolioLens.t.sol | 2 +- .../test/integration/SuperPoolLens.t.sol | 14 +- protocol-v2/test/lib/ERC6909.t.sol | 20 +- protocol-v2/test/mocks/MockERC20.sol | 4 +- .../test/repro/guardian/FailedRepayAll.t.sol | 6 +- 32 files changed, 1095 insertions(+), 691 deletions(-) diff --git a/protocol-v2/foundry.toml b/protocol-v2/foundry.toml index 77f80a5..1ee75c8 100644 --- a/protocol-v2/foundry.toml +++ b/protocol-v2/foundry.toml @@ -48,5 +48,5 @@ bracket_spacing = true override_spacing = false contract_new_lines = false number_underscore = "thousands" -multiline_func_header = "params_first" +multiline_func_header = "all" single_line_statement_blocks = "single" diff --git a/protocol-v2/script/Deploy.s.sol b/protocol-v2/script/Deploy.s.sol index 484adb6..0949a8a 100644 --- a/protocol-v2/script/Deploy.s.sol +++ b/protocol-v2/script/Deploy.s.sol @@ -84,32 +84,31 @@ contract Deploy is BaseScript { // risk riskEngine = new RiskEngine(address(registry), params.minLtv, params.maxLtv); riskEngine.transferOwnership(params.owner); - riskModule = new RiskModule(address(registry), params.liquidationDiscount); + riskModule = new RiskModule(address(registry), params.liquidationDiscount, params.liquidationFee); // pool poolImpl = address(new Pool()); bytes memory poolInitData = abi.encodeWithSelector( Pool.initialize.selector, params.owner, - params.defaultInterestFee, - params.defaultOriginationFee, address(registry), params.feeRecipient, + params.minDebt, params.minBorrow, - params.minDebt + params.defaultInterestFee, + params.defaultOriginationFee ); pool = Pool(address(new TransparentUpgradeableProxy(poolImpl, params.proxyAdmin, poolInitData))); // super pool factory superPoolFactory = new SuperPoolFactory(address(pool)); // position manager positionManagerImpl = address(new PositionManager()); - bytes memory posmgrInitData = abi.encodeWithSelector( - PositionManager.initialize.selector, params.owner, address(registry), params.liquidationFee - ); + bytes memory posmgrInitData = + abi.encodeWithSelector(PositionManager.initialize.selector, params.owner, address(registry)); positionManager = PositionManager( address(new TransparentUpgradeableProxy(positionManagerImpl, params.proxyAdmin, posmgrInitData)) ); // position - address positionImpl = address(new Position(address(pool), address(positionManager))); + address positionImpl = address(new Position(address(pool), address(positionManager), address(riskEngine))); positionBeacon = address(new UpgradeableBeacon(positionImpl)); // lens superPoolLens = new SuperPoolLens(address(pool), address(riskEngine)); diff --git a/protocol-v2/script/pool/InitializePool.s.sol b/protocol-v2/script/pool/InitializePool.s.sol index 4336c7f..078c4e0 100644 --- a/protocol-v2/script/pool/InitializePool.s.sol +++ b/protocol-v2/script/pool/InitializePool.s.sol @@ -3,35 +3,35 @@ pragma solidity ^0.8.24; import { BaseScript } from "../BaseScript.s.sol"; import { console2 } from "forge-std/console2.sol"; + +import { IERC20 } from "forge-std/interfaces/IERC20.sol"; import { Pool } from "src/Pool.sol"; contract InitializePool is BaseScript { address pool; - address owner; address asset; bytes32 rateModelKey; - uint128 interestFee; - uint128 originationFee; - uint128 poolCap; + uint256 borrowCap; + uint256 depositCap; + uint256 initialDepositAmt; function run() public { getParams(); - vm.broadcast(vm.envUint("PRIVATE_KEY")); - uint256 poolId = Pool(pool).initializePool(owner, asset, poolCap, rateModelKey); + IERC20(asset).approve(pool, initialDepositAmt); + uint256 poolId = Pool(pool).initializePool(owner, asset, rateModelKey, depositCap, borrowCap, initialDepositAmt); console2.log("poolId: ", poolId); } function getParams() internal { string memory config = getConfig(); - pool = vm.parseJsonAddress(config, "$.InitializePool.pool"); owner = vm.parseJsonAddress(config, "$.InitializePool.owner"); asset = vm.parseJsonAddress(config, "$.InitializePool.asset"); rateModelKey = vm.parseJsonBytes32(config, "$.InitializePool.rateModelKey"); - interestFee = uint128(vm.parseJsonUint(config, "$.InitializePool.interestFee")); - originationFee = uint128(vm.parseJsonUint(config, "$.InitializePool.originationFee")); - poolCap = uint128(vm.parseJsonUint(config, "$.InitializePool.poolCap")); + borrowCap = vm.parseJsonUint(config, "$.InitializePool.borrowCap"); + depositCap = vm.parseJsonUint(config, "$.InitializePool.depositCap"); + initialDepositAmt = (vm.parseJsonUint(config, "$.InitializePool.initialDepositAmt")); } } diff --git a/protocol-v2/src/Pool.sol b/protocol-v2/src/Pool.sol index d036714..d226b0e 100644 --- a/protocol-v2/src/Pool.sol +++ b/protocol-v2/src/Pool.sol @@ -1,10 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - Pool -//////////////////////////////////////////////////////////////*/ - // types import { Registry } from "./Registry.sol"; import { RiskEngine } from "./RiskEngine.sol"; @@ -19,13 +15,21 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; // contracts import { ERC6909 } from "./lib/ERC6909.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; /// @title Pool /// @notice Singleton pool for all pools that superpools lend to and positions borrow from -contract Pool is OwnableUpgradeable, ERC6909 { +contract Pool is OwnableUpgradeable, PausableUpgradeable, ERC6909 { using Math for uint256; using SafeERC20 for IERC20; + address private constant DEAD_ADDRESS = 0x000000000000000000000000000000000000dEaD; + /// @notice Maximum amount of deposit shares per base pool + uint256 public constant MAX_DEPOSIT_SHARES = type(uint112).max; + /// @notice Maximum amount of borrow shares per base pool + uint256 public constant MAX_BORROW_SHARES = type(uint112).max; + /// @notice Minimum amount of initial shares to be burned + uint256 public constant MIN_BURNED_SHARES = 1_000_000; /// @notice Timelock delay for pool rate model modification uint256 public constant TIMELOCK_DURATION = 24 * 60 * 60; // 24 hours /// @notice Timelock deadline to enforce timely updates @@ -40,9 +44,9 @@ contract Pool is OwnableUpgradeable, ERC6909 { 0x5b6696788621a5d6b5e3b02a69896b9dd824ebf1631584f038a393c29b6d7555; /// @notice Initial interest fee for pools - uint128 public defaultInterestFee; + uint256 public defaultInterestFee; /// @notice Initial origination fee for pools - uint128 public defaultOriginationFee; + uint256 public defaultOriginationFee; /// @notice Sentiment registry address public registry; @@ -69,10 +73,11 @@ contract Pool is OwnableUpgradeable, ERC6909 { bool isPaused; address asset; address rateModel; - uint128 poolCap; - uint128 lastUpdated; - uint128 interestFee; - uint128 originationFee; + uint256 borrowCap; + uint256 depositCap; + uint256 lastUpdated; + uint256 interestFee; + uint256 originationFee; uint256 totalBorrowAssets; uint256 totalBorrowShares; uint256 totalDepositAssets; @@ -92,24 +97,32 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @notice Fetch pending rate model updates for a given pool id mapping(uint256 poolId => RateModelUpdate rateModelUpdate) public rateModelUpdateFor; + /// @notice Position Manager addresss was updated + event PositionManagerSet(address positionManager); + /// @notice Risk Engine address was updated + event RiskEngineSet(address riskEngine); /// @notice Minimum debt amount set event MinDebtSet(uint256 minDebt); /// @notice Minimum borrow amount set event MinBorrowSet(uint256 minBorrow); /// @notice Registry address was set event RegistrySet(address registry); + /// @notice Pool fee recipient set + event FeeRecipientSet(address feeRecipient); /// @notice Paused state of a pool was toggled event PoolPauseToggled(uint256 poolId, bool paused); /// @notice Asset cap for a pool was set - event PoolCapSet(uint256 indexed poolId, uint128 poolCap); + event PoolCapSet(uint256 indexed poolId, uint256 poolCap); + /// @notice Borrow debt ceiling for a pool was set + event BorrowCapSet(uint256 indexed poolId, uint256 borrowCap); /// @notice Owner for a pool was set event PoolOwnerSet(uint256 indexed poolId, address owner); /// @notice Rate model for a pool was updated event RateModelUpdated(uint256 indexed poolId, address rateModel); /// @notice Interest fee for a pool was updated - event InterestFeeSet(uint256 indexed poolId, uint128 interestFee); + event InterestFeeSet(uint256 indexed poolId, uint256 interestFee); /// @notice Origination fee for a pool was updated - event OriginationFeeSet(uint256 indexed poolId, uint128 originationFee); + event OriginationFeeSet(uint256 indexed poolId, uint256 originationFee); /// @notice Pending rate model update for a pool was rejected event RateModelUpdateRejected(uint256 indexed poolId, address rateModel); /// @notice Rate model update for a pool was proposed @@ -137,6 +150,12 @@ contract Pool is OwnableUpgradeable, ERC6909 { error Pool_ZeroAddressOwner(); /// @notice Pool is paused error Pool_PoolPaused(uint256 poolId); + /// @notice Total Base Pool shares exceeds MAX_DEPOSIT_SHARES + error Pool_MaxDepositShares(uint256 poolId); + /// @notice Total borrow shares exceed MAX_BORROW_SHARES + error Pool_MaxBorrowShares(uint256 poolId); + /// @notice Pool borrow cap exceeded + error Pool_BorrowCapExceeded(uint256 poolId); /// @notice Pool deposits exceed asset cap error Pool_PoolCapExceeded(uint256 poolId); /// @notice No pending rate model update for the pool @@ -144,7 +163,7 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @notice Attempt to initialize an already existing pool error Pool_PoolAlreadyInitialized(uint256 poolId); /// @notice Attempt to redeem zero shares worth of assets from the pool - error Pool_ZeroShareRedeem(uint256 poolId, uint256 assets); + error Pool_ZeroShareWithdraw(uint256 poolId, uint256 assets); /// @notice Attempt to repay zero shares worth of assets to the pool error Pool_ZeroSharesRepay(uint256 poolId, uint256 amt); /// @notice Attempt to borrow zero shares worth of assets from the pool @@ -171,6 +190,12 @@ contract Pool is OwnableUpgradeable, ERC6909 { error Pool_DebtTooLow(uint256 poolId, address asset, uint256 amt); /// @notice No oracle found for pool asset error Pool_OracleNotFound(address asset); + /// @notice Fee recipient must be non-zero + error Pool_ZeroFeeRecipient(); + /// @notice Pool has zero assets and non-zero shares + error Pool_ZeroAssetsNonZeroShares(uint256 poolId); + /// @notice Less than MIN_BURNED_SHARES burned during pool initialization + error Pool_MinBurnedShares(uint256 shares); constructor() { _disableInitializers(); @@ -182,15 +207,22 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @param feeRecipient_ Sentiment fee receiver function initialize( address owner_, - uint128 defaultInterestFee_, - uint128 defaultOriginationFee_, address registry_, address feeRecipient_, + uint256 minDebt_, uint256 minBorrow_, - uint256 minDebt_ - ) public initializer { + uint256 defaultInterestFee_, + uint256 defaultOriginationFee_ + ) + public + initializer + { _transferOwnership(owner_); + if (defaultInterestFee_ > 1e18) revert Pool_FeeTooHigh(); + if (defaultOriginationFee_ > 1e18) revert Pool_FeeTooHigh(); + if (feeRecipient_ == address(0)) revert Pool_ZeroFeeRecipient(); + defaultInterestFee = defaultInterestFee_; defaultOriginationFee = defaultOriginationFee_; registry = registry_; @@ -204,6 +236,8 @@ contract Pool is OwnableUpgradeable, ERC6909 { function updateFromRegistry() public { positionManager = Registry(registry).addressFor(SENTIMENT_POSITION_MANAGER_KEY); riskEngine = Registry(registry).addressFor(SENTIMENT_RISK_ENGINE_KEY); + emit PositionManagerSet(positionManager); + emit RiskEngineSet(riskEngine); } /// @notice Fetch amount of liquid assets currently held in a given pool @@ -258,6 +292,16 @@ contract Pool is OwnableUpgradeable, ERC6909 { return poolDataFor[poolId].rateModel; } + /// @notice Fetch pool cap for a given pool + function getPoolCapFor(uint256 poolId) public view returns (uint256) { + return poolDataFor[poolId].depositCap; + } + + /// @notice Fetch borrow cap for a given pool + function getBorrowCapFor(uint256 poolId) public view returns (uint256) { + return poolDataFor[poolId].borrowCap; + } + /// @notice Fetch the debt asset address for a given pool function getPoolAssetFor(uint256 poolId) public view returns (address) { return poolDataFor[poolId].asset; @@ -268,7 +312,11 @@ contract Pool is OwnableUpgradeable, ERC6909 { uint256 assets, uint256 totalAssets, uint256 totalShares - ) external pure returns (uint256 shares) { + ) + external + pure + returns (uint256 shares) + { shares = _convertToShares(assets, totalAssets, totalShares, Math.Rounding.Down); } @@ -277,7 +325,11 @@ contract Pool is OwnableUpgradeable, ERC6909 { uint256 totalAssets, uint256 totalShares, Math.Rounding rounding - ) internal pure returns (uint256 shares) { + ) + internal + pure + returns (uint256 shares) + { if (totalAssets == 0) return assets; shares = assets.mulDiv(totalShares, totalAssets, rounding); } @@ -287,7 +339,11 @@ contract Pool is OwnableUpgradeable, ERC6909 { uint256 shares, uint256 totalAssets, uint256 totalShares - ) external pure returns (uint256 assets) { + ) + external + pure + returns (uint256 assets) + { assets = _convertToAssets(shares, totalAssets, totalShares, Math.Rounding.Down); } @@ -296,7 +352,11 @@ contract Pool is OwnableUpgradeable, ERC6909 { uint256 totalAssets, uint256 totalShares, Math.Rounding rounding - ) internal pure returns (uint256 assets) { + ) + internal + pure + returns (uint256 assets) + { if (totalShares == 0) return shares; assets = shares.mulDiv(totalAssets, totalShares, rounding); } @@ -306,10 +366,11 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @param assets Amount of assets to be deposited /// @param receiver Address to deposit assets on behalf of /// @return shares Amount of pool deposit shares minted - function deposit(uint256 poolId, uint256 assets, address receiver) public returns (uint256 shares) { + function deposit(uint256 poolId, uint256 assets, address receiver) public whenNotPaused returns (uint256 shares) { PoolData storage pool = poolDataFor[poolId]; if (pool.isPaused) revert Pool_PoolPaused(poolId); + if (pool.totalDepositAssets == 0 && pool.totalDepositShares != 0) revert Pool_ZeroAssetsNonZeroShares(poolId); // update state to accrue interest since the last time accrue() was called accrue(pool, poolId); @@ -317,13 +378,13 @@ contract Pool is OwnableUpgradeable, ERC6909 { // Need to transfer before or ERC777s could reenter, or bypass the pool cap IERC20(pool.asset).safeTransferFrom(msg.sender, address(this), assets); - if (pool.totalDepositAssets + assets > pool.poolCap) revert Pool_PoolCapExceeded(poolId); - shares = _convertToShares(assets, pool.totalDepositAssets, pool.totalDepositShares, Math.Rounding.Down); if (shares == 0) revert Pool_ZeroSharesDeposit(poolId, assets); pool.totalDepositAssets += assets; pool.totalDepositShares += shares; + if (pool.totalDepositAssets > pool.depositCap) revert Pool_PoolCapExceeded(poolId); + if (pool.totalDepositShares > MAX_DEPOSIT_SHARES) revert Pool_MaxDepositShares(poolId); _mint(receiver, poolId, shares); @@ -341,15 +402,20 @@ contract Pool is OwnableUpgradeable, ERC6909 { uint256 assets, address receiver, address owner - ) public returns (uint256 shares) { + ) + public + whenNotPaused + returns (uint256 shares) + { PoolData storage pool = poolDataFor[poolId]; + if (pool.isPaused) revert Pool_PoolPaused(poolId); // update state to accrue interest since the last time accrue() was called accrue(pool, poolId); shares = _convertToShares(assets, pool.totalDepositAssets, pool.totalDepositShares, Math.Rounding.Up); // check for rounding error since convertToShares rounds down - if (shares == 0) revert Pool_ZeroShareRedeem(poolId, assets); + if (shares == 0) revert Pool_ZeroShareWithdraw(poolId, assets); if (msg.sender != owner && !isOperator[owner][msg.sender]) { uint256 allowed = allowance[owner][msg.sender][poolId]; @@ -378,6 +444,8 @@ contract Pool is OwnableUpgradeable, ERC6909 { } function simulateAccrue(PoolData storage pool) internal view returns (uint256, uint256) { + if (block.timestamp == pool.lastUpdated) return (0, 0); + uint256 interestAccrued = IRateModel(pool.rateModel).getInterestAccrued( pool.lastUpdated, pool.totalBorrowAssets, pool.totalDepositAssets ); @@ -410,14 +478,22 @@ contract Pool is OwnableUpgradeable, ERC6909 { // store a timestamp for this accrue() call // used to compute the pending interest next time accrue() is called - pool.lastUpdated = uint128(block.timestamp); + pool.lastUpdated = block.timestamp; } /// @notice Mint borrow shares and send borrowed assets to the borrowing position /// @param position the position to mint shares to /// @param amt the amount of assets to borrow, denominated in notional asset units /// @return borrowShares the amount of shares minted - function borrow(uint256 poolId, address position, uint256 amt) external returns (uint256 borrowShares) { + function borrow( + uint256 poolId, + address position, + uint256 amt + ) + external + whenNotPaused + returns (uint256 borrowShares) + { PoolData storage pool = poolDataFor[poolId]; if (pool.isPaused) revert Pool_PoolPaused(poolId); @@ -426,7 +502,9 @@ contract Pool is OwnableUpgradeable, ERC6909 { if (msg.sender != positionManager) revert Pool_OnlyPositionManager(poolId, msg.sender); // revert if borrow amount is too low - if (_getValueOf(pool.asset, amt) < minBorrow) revert Pool_BorrowAmountTooLow(poolId, pool.asset, amt); + if (RiskEngine(riskEngine).getValueInEth(pool.asset, amt) < minBorrow) { + revert Pool_BorrowAmountTooLow(poolId, pool.asset, amt); + } // update state to accrue interest since the last time accrue() was called accrue(pool, poolId); @@ -449,17 +527,19 @@ contract Pool is OwnableUpgradeable, ERC6909 { pool.totalBorrowShares + borrowShares, Math.Rounding.Down ); - if (_getValueOf(pool.asset, newBorrowAssets) < minDebt) { + if (RiskEngine(riskEngine).getValueInEth(pool.asset, newBorrowAssets) < minDebt) { revert Pool_DebtTooLow(poolId, pool.asset, newBorrowAssets); } // update total pool debt, denominated in notional asset units and shares pool.totalBorrowAssets += amt; pool.totalBorrowShares += borrowShares; - - // update position debt, denominated in borrow shares borrowSharesOf[poolId][position] += borrowShares; + // total borrow shares and total borrow assets checks + if (pool.totalBorrowAssets > pool.borrowCap) revert Pool_BorrowCapExceeded(poolId); + if (pool.totalBorrowShares > MAX_BORROW_SHARES) revert Pool_MaxBorrowShares(poolId); + // compute origination fee amt // [ROUND] origination fee is rounded down, in favor of the borrower uint256 fee = amt.mulDiv(pool.originationFee, 1e18); @@ -479,7 +559,15 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @param position the position for which debt is being repaid /// @param amt the notional amount of debt asset repaid /// @return remainingShares remaining debt in borrow shares owed by the position - function repay(uint256 poolId, address position, uint256 amt) external returns (uint256 remainingShares) { + function repay( + uint256 poolId, + address position, + uint256 amt + ) + external + whenNotPaused + returns (uint256 remainingShares) + { PoolData storage pool = poolDataFor[poolId]; // the only way to call repay() is through the position manager @@ -508,7 +596,7 @@ contract Pool is OwnableUpgradeable, ERC6909 { uint256 newBorrowAssets = _convertToAssets( remainingShares, pool.totalBorrowAssets - amt, pool.totalBorrowShares - borrowShares, Math.Rounding.Down ); - if (_getValueOf(pool.asset, newBorrowAssets) < minDebt) { + if (RiskEngine(riskEngine).getValueInEth(pool.asset, newBorrowAssets) < minDebt) { revert Pool_DebtTooLow(poolId, pool.asset, newBorrowAssets); } } @@ -525,7 +613,7 @@ contract Pool is OwnableUpgradeable, ERC6909 { return remainingShares; } - function rebalanceBadDebt(uint256 poolId, address position) external { + function rebalanceBadDebt(uint256 poolId, address position) external whenNotPaused { PoolData storage pool = poolDataFor[poolId]; accrue(pool, poolId); @@ -548,26 +636,27 @@ contract Pool is OwnableUpgradeable, ERC6909 { borrowSharesOf[poolId][position] = 0; } - function _getValueOf(address asset, uint256 amt) internal view returns (uint256) { - address oracle = RiskEngine(riskEngine).getOracleFor(asset); - return IOracle(oracle).getValueInEth(asset, amt); - } - /// @notice Initialize a new pool /// @param owner Pool owner /// @param asset Pool debt asset - /// @param poolCap Pool asset cap + /// @param depositCap Pool asset cap + /// @param borrowCap Pool debt ceiling /// @param rateModelKey Registry key for interest rate model /// @return poolId Pool id for initialized pool function initializePool( address owner, address asset, - uint128 poolCap, - bytes32 rateModelKey - ) external returns (uint256 poolId) { + bytes32 rateModelKey, + uint256 depositCap, + uint256 borrowCap, + uint256 initialDepositAmt + ) + external + whenNotPaused + returns (uint256 poolId) + { if (owner == address(0)) revert Pool_ZeroAddressOwner(); - - if (RiskEngine(riskEngine).getOracleFor(asset) == address(0)) revert Pool_OracleNotFound(asset); + if (RiskEngine(riskEngine).oracleFor(asset) == address(0)) revert Pool_OracleNotFound(asset); address rateModel = Registry(registry).rateModelFor(rateModelKey); if (rateModel == address(0)) revert Pool_RateModelNotFound(rateModelKey); @@ -580,8 +669,9 @@ contract Pool is OwnableUpgradeable, ERC6909 { isPaused: false, asset: asset, rateModel: rateModel, - poolCap: poolCap, - lastUpdated: uint128(block.timestamp), + borrowCap: borrowCap, + depositCap: depositCap, + lastUpdated: block.timestamp, interestFee: defaultInterestFee, originationFee: defaultOriginationFee, totalBorrowAssets: 0, @@ -589,12 +679,16 @@ contract Pool is OwnableUpgradeable, ERC6909 { totalDepositAssets: 0, totalDepositShares: 0 }); - poolDataFor[poolId] = poolData; + // burn initial deposit, assume msg.sender has approved + uint256 shares = deposit(poolId, initialDepositAmt, DEAD_ADDRESS); + if (shares < MIN_BURNED_SHARES) revert Pool_MinBurnedShares(shares); + emit PoolInitialized(poolId, owner, asset); emit RateModelUpdated(poolId, rateModel); - emit PoolCapSet(poolId, poolCap); + emit PoolCapSet(poolId, depositCap); + emit BorrowCapSet(poolId, borrowCap); } /// @notice Toggle paused state for a pool to restrict deposit and borrows @@ -605,11 +699,24 @@ contract Pool is OwnableUpgradeable, ERC6909 { emit PoolPauseToggled(poolId, pool.isPaused); } + /// @notice Pause all pools and functions + function togglePauseAll() external onlyOwner { + if (PausableUpgradeable.paused()) PausableUpgradeable._unpause(); + else PausableUpgradeable._pause(); + } + /// @notice Update pool asset cap to restrict total amount of assets deposited - function setPoolCap(uint256 poolId, uint128 poolCap) external { + function setPoolCap(uint256 poolId, uint256 depositCap) external { if (msg.sender != ownerOf[poolId]) revert Pool_OnlyPoolOwner(poolId, msg.sender); - poolDataFor[poolId].poolCap = poolCap; - emit PoolCapSet(poolId, poolCap); + poolDataFor[poolId].depositCap = depositCap; + emit PoolCapSet(poolId, depositCap); + } + + /// @notice Update pool borrow cap to restrict total amount of assets borrowed + function setBorrowCap(uint256 poolId, uint256 borrowCap) external { + if (msg.sender != ownerOf[poolId]) revert Pool_OnlyPoolOwner(poolId, msg.sender); + poolDataFor[poolId].borrowCap = borrowCap; + emit BorrowCapSet(poolId, borrowCap); } /// @notice Update base pool owner @@ -683,10 +790,10 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @notice Set interest fee for given pool /// @param poolId Pool id /// @param interestFee New interest fee - function setInterestFee(uint256 poolId, uint128 interestFee) external onlyOwner { + function setInterestFee(uint256 poolId, uint256 interestFee) external onlyOwner { + if (interestFee > 1e18) revert Pool_FeeTooHigh(); PoolData storage pool = poolDataFor[poolId]; accrue(pool, poolId); - if (interestFee > 1e18) revert Pool_FeeTooHigh(); pool.interestFee = interestFee; emit InterestFeeSet(poolId, interestFee); } @@ -694,7 +801,7 @@ contract Pool is OwnableUpgradeable, ERC6909 { /// @notice Set origination fee for given pool /// @param poolId Pool id /// @param originationFee New origination fee - function setOriginationFee(uint256 poolId, uint128 originationFee) external onlyOwner { + function setOriginationFee(uint256 poolId, uint256 originationFee) external onlyOwner { if (originationFee > 1e18) revert Pool_FeeTooHigh(); poolDataFor[poolId].originationFee = originationFee; emit OriginationFeeSet(poolId, originationFee); @@ -712,12 +819,20 @@ contract Pool is OwnableUpgradeable, ERC6909 { emit MinDebtSet(newMinDebt); } - function setDefaultOriginationFee(uint128 newDefaultOriginationFee) external onlyOwner { + function setFeeRecipient(address newFeeRecipient) external onlyOwner { + if (newFeeRecipient == address(0)) revert Pool_ZeroFeeRecipient(); + feeRecipient = newFeeRecipient; + emit FeeRecipientSet(newFeeRecipient); + } + + function setDefaultOriginationFee(uint256 newDefaultOriginationFee) external onlyOwner { + if (newDefaultOriginationFee > 1e18) revert Pool_FeeTooHigh(); defaultOriginationFee = newDefaultOriginationFee; emit DefaultOriginationFeeSet(newDefaultOriginationFee); } - function setDefaultInterestFee(uint128 newDefaultInterestFee) external onlyOwner { + function setDefaultInterestFee(uint256 newDefaultInterestFee) external onlyOwner { + if (newDefaultInterestFee > 1e18) revert Pool_FeeTooHigh(); defaultInterestFee = newDefaultInterestFee; emit DefaultInterestFeeSet(newDefaultInterestFee); } diff --git a/protocol-v2/src/Position.sol b/protocol-v2/src/Position.sol index df7e1a8..5652a36 100644 --- a/protocol-v2/src/Position.sol +++ b/protocol-v2/src/Position.sol @@ -1,11 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - Position -//////////////////////////////////////////////////////////////*/ - import { Pool } from "./Pool.sol"; +import { RiskEngine } from "./RiskEngine.sol"; import { IterableSet } from "./lib/IterableSet.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -18,7 +15,6 @@ contract Position { /// @notice Position implementation version uint256 public constant VERSION = 1; - /// @notice Maximum number of assets that a position can hold at once uint256 public constant MAX_ASSETS = 5; /// @notice Maximum number of pools that a position can borrow from at once @@ -26,6 +22,8 @@ contract Position { /// @notice Sentiment Pool Pool public immutable POOL; + /// @notice Sentiment Risk Engine + RiskEngine public immutable RISK_ENGINE; /// @notice Sentiment Position Manager address public immutable POSITION_MANAGER; @@ -42,12 +40,15 @@ contract Position { error Position_ExecFailed(address position, address target); /// @notice Function access restricted to Sentiment Position Manager error Position_OnlyPositionManager(address position, address sender); + /// @notice Invalid base pool pair borrow + error Position_InvalidPoolPair(uint256 poolA, uint256 poolB); /// @param pool Sentiment Singleton Pool /// @param positionManager Sentiment Postion Manager - constructor(address pool, address positionManager) { + constructor(address pool, address positionManager, address riskEngine) { POOL = Pool(pool); POSITION_MANAGER = positionManager; + RISK_ENGINE = RiskEngine(riskEngine); } // positions can receive and hold ether to perform external operations. @@ -120,6 +121,16 @@ contract Position { /// @dev Position assumes that this is done after debt assets have been transferred and /// Pool.borrow() has already been called function borrow(uint256 poolId, uint256) external onlyPositionManager { + // check if existing debt pools allow co-borrowing with given pool + uint256[] memory pools = debtPools.getElements(); + uint256 debtPoolsLen = debtPools.length(); + for (uint256 i; i < debtPoolsLen; ++i) { + if (poolId == pools[i]) continue; + if (RISK_ENGINE.isAllowedPair(poolId, pools[i]) && RISK_ENGINE.isAllowedPair(pools[i], poolId)) continue; + revert Position_InvalidPoolPair(poolId, pools[i]); + } + + // update debt pools set debtPools.insert(poolId); if (debtPools.length() > MAX_DEBT_POOLS) revert Position_MaxDebtPoolsExceeded(address(this)); } diff --git a/protocol-v2/src/PositionManager.sol b/protocol-v2/src/PositionManager.sol index 0dfb686..317ac00 100644 --- a/protocol-v2/src/PositionManager.sol +++ b/protocol-v2/src/PositionManager.sol @@ -1,10 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - Position Manager -//////////////////////////////////////////////////////////////*/ - // types import { Pool } from "./Pool.sol"; import { Position } from "./Position.sol"; @@ -74,6 +70,9 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus using Math for uint256; using SafeERC20 for IERC20; + uint256 internal constant WAD = 1e18; + uint256 internal constant BAD_DEBT_LIQUIDATION_FEE = 0; + // keccak(SENTIMENT_POOL_KEY) bytes32 public constant SENTIMENT_POOL_KEY = 0x1a99cbf6006db18a0e08427ff11db78f3ea1054bc5b9d48122aae8d206c09728; // keccak(SENTIMENT_RISK_ENGINE_KEY) @@ -92,10 +91,6 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus /// @notice Position Beacon address public positionBeacon; - /// @notice Liquidation fee in percentage, scaled by 18 decimals - /// @dev accrued to the protocol on every liquidation - uint256 public liquidationFee; - /// @notice Fetch owner for given position mapping(address position => address owner) public ownerOf; @@ -118,12 +113,16 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus /// @notice Check if a position can interact with a given target-function pair mapping(address target => mapping(bytes4 method => bool isAllowed)) public isKnownFunc; + /// @notice Pool address was updated + event PoolSet(address pool); /// @notice Position Beacon address was updated event BeaconSet(address beacon); /// @notice Protocol registry address was updated event RegistrySet(address registry); - /// @notice Protocol liquidation fee was updated - event LiquidationFeeSet(uint256 liquidationFee); + /// @notice Risk Engine address was updated + event RiskEngineSet(address riskEngine); + /// @notice Position authorization was toggled + event AuthToggled(address indexed position, address indexed user, bool isAuth); /// @notice Known state of an address was toggled event ToggleKnownAsset(address indexed asset, bool isAllowed); /// @notice Known state of an address was toggled @@ -179,8 +178,6 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus error PositionManager_OnlyPositionAuthorized(address position, address sender); /// @notice Predicted position address does not match with deployed address error PositionManager_PredictedPositionMismatch(address position, address predicted); - /// @notice Seized asset does not belong to to the position's asset list - error PositionManager_SeizeInvalidAsset(address position, address asset); constructor() { _disableInitializers(); @@ -189,15 +186,13 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus /// @notice Initializer for TransparentUpgradeableProxy /// @param owner_ PositionManager Owner /// @param registry_ Sentiment Registry - /// @param liquidationFee_ Protocol liquidation fee - function initialize(address owner_, address registry_, uint256 liquidationFee_) public initializer { + function initialize(address owner_, address registry_) public initializer { ReentrancyGuardUpgradeable.__ReentrancyGuard_init(); OwnableUpgradeable.__Ownable_init(); PausableUpgradeable.__Pausable_init(); _transferOwnership(owner_); registry = Registry(registry_); - liquidationFee = liquidationFee_; } /// @notice Fetch and update module addreses from the registry @@ -205,6 +200,9 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus pool = Pool(registry.addressFor(SENTIMENT_POOL_KEY)); riskEngine = RiskEngine(registry.addressFor(SENTIMENT_RISK_ENGINE_KEY)); positionBeacon = registry.addressFor(SENTIMENT_POSITION_BEACON_KEY); + emit PoolSet(address(pool)); + emit RiskEngineSet(address(riskEngine)); + emit BeaconSet(positionBeacon); } /// @notice Toggle pause state of the PositionManager @@ -221,28 +219,29 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus // update authz status in storage isAuth[position][user] = !isAuth[position][user]; + emit AuthToggled(position, user, isAuth[position][user]); } /// @notice Process a single action on a given position /// @param position Position address /// @param action Action config - function process(address position, Action calldata action) external nonReentrant whenNotPaused { + function process(address position, Action calldata action) external nonReentrant { _process(position, action); - if (!riskEngine.isPositionHealthy(position)) revert PositionManager_HealthCheckFailed(position); + if (riskEngine.getPositionHealthFactor(position) < WAD) revert PositionManager_HealthCheckFailed(position); } /// @notice Procces a batch of actions on a given position /// @dev only one position can be operated on in one txn, including creation /// @param position Position address /// @param actions List of actions to process - function processBatch(address position, Action[] calldata actions) external nonReentrant whenNotPaused { + function processBatch(address position, Action[] calldata actions) external nonReentrant { // loop over actions and process them sequentially based on operation uint256 actionsLength = actions.length; for (uint256 i; i < actionsLength; ++i) { _process(position, actions[i]); } // after all the actions are processed, the position should be within risk thresholds - if (!riskEngine.isPositionHealthy(position)) revert PositionManager_HealthCheckFailed(position); + if (riskEngine.getPositionHealthFactor(position) < WAD) revert PositionManager_HealthCheckFailed(position); } function _process(address position, Action calldata action) internal { @@ -265,7 +264,7 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus /// @dev deterministically deploy a new beacon proxy representing a position /// @dev the target field in the action is the new owner of the position - function newPosition(address predictedAddress, bytes calldata data) internal { + function newPosition(address predictedAddress, bytes calldata data) internal whenNotPaused { // data -> abi.encodePacked(address, bytes32) // owner -> [:20] owner to create the position on behalf of // salt -> [20:52] create2 salt for position @@ -286,7 +285,7 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus } /// @dev Operate on a position by interaction with external contracts using arbitrary calldata - function exec(address position, bytes calldata data) internal { + function exec(address position, bytes calldata data) internal whenNotPaused { // exec data is encodePacked (address, uint256, bytes) // target -> [0:20] contract address to be called by the position // value -> [20:52] the ether amount to be sent with the call @@ -304,7 +303,7 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus } /// @dev Transfer assets out of a position - function transfer(address position, bytes calldata data) internal { + function transfer(address position, bytes calldata data) internal whenNotPaused { // data -> abi.encodePacked(address, address, uint256) // recipient -> [0:20] address that will receive the transferred tokens // asset -> [20:40] address of token to be transferred @@ -381,7 +380,7 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus } /// @dev Increase position debt via borrowing - function borrow(address position, bytes calldata data) internal { + function borrow(address position, bytes calldata data) internal whenNotPaused { // data -> abi.encodePacked(uint256, uint256) // poolId -> [0:32] pool to borrow from // amt -> [32:64] notional amount to be borrowed @@ -415,7 +414,7 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus } /// @dev Remove a token address from the set of position assets - function removeToken(address position, bytes calldata data) internal { + function removeToken(address position, bytes calldata data) internal whenNotPaused { // data -> abi.encodePacked(address) // asset -> address of asset to be deregistered as collateral address asset = address(bytes20(data[0:20])); @@ -427,34 +426,40 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus /// @param position Position address /// @param debtData DebtData object for debts to be repaid /// @param assetData AssetData object for assets to be seized + /// @dev DebtData must be sorted by poolId, AssetData must be sorted by asset (ascending) function liquidate( address position, DebtData[] calldata debtData, AssetData[] calldata assetData - ) external nonReentrant { - riskEngine.validateLiquidation(position, debtData, assetData); + ) + external + nonReentrant + { + (uint256 prevHealthFactor, uint256 liqFee, DebtData[] memory repayData, AssetData[] memory seizeData) = + riskEngine.validateLiquidation(position, debtData, assetData); // liquidate - _transferAssetsToLiquidator(position, assetData); - _repayPositionDebt(position, debtData); + _transferAssetsToLiquidator(position, liqFee, seizeData); + _repayPositionDebt(position, repayData); + + // verify that position health improves + uint256 healthFactor = riskEngine.getPositionHealthFactor(position); + if (healthFactor <= prevHealthFactor) revert PositionManager_HealthCheckFailed(position); - // position should be within risk thresholds after liquidation - if (!riskEngine.isPositionHealthy(position)) revert PositionManager_HealthCheckFailed(position); emit Liquidation(position, msg.sender, ownerOf[position]); } - function liquidateBadDebt(address position) external onlyOwner { - riskEngine.validateBadDebt(position); + /// @notice Liquidate a position with bad debt + /// @dev Bad debt positions cannot be liquidated partially + function liquidateBadDebt(address position, DebtData[] calldata debtData) external nonReentrant { + (DebtData[] memory repayData, AssetData[] memory seizeData) = + riskEngine.validateBadDebtLiquidation(position, debtData); - // transfer any remaining position assets to the PositionManager owner - address[] memory positionAssets = Position(payable(position)).getPositionAssets(); - uint256 positionAssetsLength = positionAssets.length; - for (uint256 i; i < positionAssetsLength; ++i) { - uint256 amt = IERC20(positionAssets[i]).balanceOf(position); - try Position(payable(position)).transfer(owner(), positionAssets[i], amt) { } catch { } - } + // liquidator repays some of the bad debt, and receives all of the position assets + _transferAssetsToLiquidator(position, BAD_DEBT_LIQUIDATION_FEE, seizeData); // zero protocol fee + _repayPositionDebt(position, repayData); - // clear all debt associated with the given position + // settle remaining bad debt for the given position uint256[] memory debtPools = Position(payable(position)).getDebtPools(); uint256 debtPoolsLength = debtPools.length; for (uint256 i; i < debtPoolsLength; ++i) { @@ -463,25 +468,27 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus } } - function _transferAssetsToLiquidator(address position, AssetData[] calldata assetData) internal { + function _transferAssetsToLiquidator( + address position, + uint256 liquidationFee, + AssetData[] memory assetData + ) + internal + { // transfer position assets to the liquidator and accrue protocol liquidation fees uint256 assetDataLength = assetData.length; for (uint256 i; i < assetDataLength; ++i) { - // ensure assetData[i] is in the position asset list - if (Position(payable(position)).hasAsset(assetData[i].asset) == false) { - revert PositionManager_SeizeInvalidAsset(position, assetData[i].asset); + uint256 feeAssets; + if (liquidationFee > 0) { + feeAssets = liquidationFee.mulDiv(assetData[i].amt, WAD); // compute fee assets + Position(payable(position)).transfer(owner(), assetData[i].asset, feeAssets); // transfer fee assets } - // compute fee amt - // [ROUND] liquidation fee is rounded down, in favor of the liquidator - uint256 fee = liquidationFee.mulDiv(assetData[i].amt, 1e18); - // transfer fee amt to protocol - Position(payable(position)).transfer(owner(), assetData[i].asset, fee); - // transfer difference to the liquidator - Position(payable(position)).transfer(msg.sender, assetData[i].asset, assetData[i].amt - fee); + // transfer assets to the liquidator + Position(payable(position)).transfer(msg.sender, assetData[i].asset, assetData[i].amt - feeAssets); } } - function _repayPositionDebt(address position, DebtData[] calldata debtData) internal { + function _repayPositionDebt(address position, DebtData[] memory debtData) internal { // sequentially repay position debts // assumes the position manager is approved to pull assets from the liquidator uint256 debtDataLength = debtData.length; @@ -489,7 +496,6 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus uint256 poolId = debtData[i].poolId; address poolAsset = pool.getPoolAssetFor(poolId); uint256 amt = debtData[i].amt; - if (amt == type(uint256).max) amt = pool.getBorrowsOf(poolId, position); // transfer debt asset from the liquidator to the pool IERC20(poolAsset).safeTransferFrom(msg.sender, address(pool), amt); // trigger pool repayment which assumes successful transfer of repaid assets @@ -499,12 +505,6 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus } } - /// @notice Set the position beacon used to point to the position implementation - function setBeacon(address _positionBeacon) external onlyOwner { - positionBeacon = _positionBeacon; - emit BeaconSet(_positionBeacon); - } - /// @notice Set the protocol registry address function setRegistry(address _registry) external onlyOwner { registry = Registry(_registry); @@ -512,12 +512,6 @@ contract PositionManager is ReentrancyGuardUpgradeable, OwnableUpgradeable, Paus emit RegistrySet(_registry); } - /// @notice Update the protocol liquidation fee - function setLiquidationFee(uint256 _liquidationFee) external onlyOwner { - liquidationFee = _liquidationFee; - emit LiquidationFeeSet(_liquidationFee); - } - /// @notice Toggle asset inclusion in the known asset universe function toggleKnownAsset(address asset) external onlyOwner { isKnownAsset[asset] = !isKnownAsset[asset]; diff --git a/protocol-v2/src/RiskEngine.sol b/protocol-v2/src/RiskEngine.sol index a5cbe73..fc7d8c2 100644 --- a/protocol-v2/src/RiskEngine.sol +++ b/protocol-v2/src/RiskEngine.sol @@ -1,15 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - RiskEngine -//////////////////////////////////////////////////////////////*/ - // types import { Pool } from "./Pool.sol"; import { AssetData, DebtData } from "./PositionManager.sol"; import { Registry } from "./Registry.sol"; import { RiskModule } from "./RiskModule.sol"; +import { IOracle } from "./interfaces/IOracle.sol"; // contracts import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; @@ -47,23 +44,25 @@ contract RiskEngine is Ownable { uint256 public maxLtv; /// @notice Sentiment Registry - Registry public immutable REGISTRY; + Registry public registry; /// @notice Sentiment Singleton Pool Pool public pool; /// @notice Sentiment Risk Module RiskModule public riskModule; /// @dev Asset to Oracle mapping - mapping(address asset => address oracle) internal oracleFor; - + mapping(address asset => address oracle) public oracleFor; /// @notice Fetch the ltv for a given asset in a pool mapping(uint256 poolId => mapping(address asset => uint256 ltv)) public ltvFor; - /// @notice Fetch pending LTV update details for a given pool and asset pair, if any mapping(uint256 poolId => mapping(address asset => LtvUpdate ltvUpdate)) public ltvUpdateFor; + /// @notice Check if poolA lends to positions that also borrow from poolB + mapping(uint256 poolA => mapping(uint256 poolB => bool isAllowed)) public isAllowedPair; /// @notice Pool address was updated event PoolSet(address pool); + /// @notice Registry address was updated + event RegistrySet(address registry); /// @notice Risk Module address was updated event RiskModuleSet(address riskModule); /// @notice Protocol LTV bounds were updated @@ -76,6 +75,8 @@ contract RiskEngine is Ownable { event LtvUpdateAccepted(uint256 indexed poolId, address indexed asset, uint256 ltv); /// @notice LTV update was requested event LtvUpdateRequested(uint256 indexed poolId, address indexed asset, LtvUpdate ltvUpdate); + /// @notice Allowed base pool pair toggled + event PoolPairToggled(uint256 indexed poolA, uint256 indexed poolB, bool isAllowed); /// @notice There is no oracle associated with the given asset error RiskEngine_NoOracleFound(address asset); @@ -95,6 +96,10 @@ contract RiskEngine is Ownable { error RiskEngine_MaxLtvTooHigh(); /// @notice Pool LTV for the asset being lent out must be zero error RiskEngine_CannotBorrowPoolAsset(uint256 poolId); + /// @notice Min Ltv is not less than Max Ltv + error RiskEngine_InvalidLtvLimits(uint256 minLtv, uint256 maxLtv); + /// @notice Base pool has not been initialized + error RiskEngine_InvalidBasePool(uint256 poolId); /// @param registry_ Sentiment Registry /// @param minLtv_ Minimum LTV bound @@ -102,8 +107,9 @@ contract RiskEngine is Ownable { constructor(address registry_, uint256 minLtv_, uint256 maxLtv_) Ownable() { if (minLtv_ == 0) revert RiskEngine_MinLtvTooLow(); if (maxLtv_ >= 1e18) revert RiskEngine_MaxLtvTooHigh(); + if (minLtv_ >= maxLtv_) revert RiskEngine_InvalidLtvLimits(minLtv_, maxLtv_); - REGISTRY = Registry(registry_); + registry = Registry(registry_); minLtv = minLtv_; maxLtv = maxLtv_; @@ -112,37 +118,48 @@ contract RiskEngine is Ownable { /// @notice Fetch and update module addreses from the registry function updateFromRegistry() external { - pool = Pool(REGISTRY.addressFor(SENTIMENT_POOL_KEY)); - riskModule = RiskModule(REGISTRY.addressFor(SENTIMENT_RISK_MODULE_KEY)); - + pool = Pool(registry.addressFor(SENTIMENT_POOL_KEY)); + riskModule = RiskModule(registry.addressFor(SENTIMENT_RISK_MODULE_KEY)); emit PoolSet(address(pool)); emit RiskModuleSet(address(riskModule)); } - /// @notice Fetch oracle address for a given asset - function getOracleFor(address asset) public view returns (address) { + /// @notice Fetch value of given asset amount in ETH + function getValueInEth(address asset, uint256 amt) public view returns (uint256) { + if (amt == 0) return 0; address oracle = oracleFor[asset]; if (oracle == address(0)) revert RiskEngine_NoOracleFound(asset); - return oracle; + return IOracle(oracle).getValueInEth(asset, amt); } - /// @notice Check if the given position is healthy - function isPositionHealthy(address position) external view returns (bool) { - // call health check implementation based on position type - return riskModule.isPositionHealthy(position); + /// @notice Fetch position health factor + function getPositionHealthFactor(address position) external view returns (uint256) { + return riskModule.getPositionHealthFactor(position); } - /// @notice Valid liquidator data and value of assets seized + /// @notice Validate liquidator data and value of assets seized function validateLiquidation( address position, DebtData[] calldata debtData, AssetData[] calldata assetData - ) external view { - riskModule.validateLiquidation(position, debtData, assetData); + ) + external + view + returns (uint256, uint256, DebtData[] memory, AssetData[] memory) + { + return riskModule.validateLiquidation(position, debtData, assetData); } - function validateBadDebt(address position) external view { - riskModule.validateBadDebt(position); + /// @notice Validate liquidator data for assets to be repaid + function validateBadDebtLiquidation( + address position, + DebtData[] calldata debtData + ) + external + view + returns (DebtData[] memory, AssetData[] memory) + { + return riskModule.validateBadDebtLiquidation(position, debtData); } /// @notice Fetch risk-associated data for a given position @@ -154,12 +171,13 @@ contract RiskEngine is Ownable { return riskModule.getRiskData(position); } - function getTotalAssetValue(address position) external view returns (uint256) { - return riskModule.getTotalAssetValue(position); - } - - function getTotalDebtValue(address position) external view returns (uint256) { - return riskModule.getTotalDebtValue(position); + /// @notice Allow poolA to lend against positions that also borrow from poolB + /// @dev When toggled or untoggled, only applies to future borrows + function toggleAllowedPoolPair(uint256 poolA, uint256 poolB) external { + if (pool.ownerOf(poolA) != msg.sender) revert RiskEngine_OnlyPoolOwner(poolA, msg.sender); + if (pool.ownerOf(poolB) == address(0)) revert RiskEngine_InvalidBasePool(poolB); + isAllowedPair[poolA][poolB] = !isAllowedPair[poolA][poolB]; + emit PoolPairToggled(poolA, poolB, isAllowedPair[poolA][poolB]); } /// @notice Propose an LTV update for a given Pool-Asset pair @@ -189,6 +207,7 @@ contract RiskEngine is Ownable { /// @notice Apply a pending LTV update function acceptLtvUpdate(uint256 poolId, address asset) external { if (msg.sender != pool.ownerOf(poolId)) revert RiskEngine_OnlyPoolOwner(poolId, msg.sender); + if (oracleFor[asset] == address(0)) revert RiskEngine_NoOracleFound(asset); LtvUpdate memory ltvUpdate = ltvUpdateFor[poolId][asset]; @@ -222,6 +241,7 @@ contract RiskEngine is Ownable { function setLtvBounds(uint256 _minLtv, uint256 _maxLtv) external onlyOwner { if (_minLtv == 0) revert RiskEngine_MinLtvTooLow(); if (_maxLtv >= 1e18) revert RiskEngine_MaxLtvTooHigh(); + if (_minLtv >= _maxLtv) revert RiskEngine_InvalidLtvLimits(_minLtv, _maxLtv); minLtv = _minLtv; maxLtv = _maxLtv; @@ -229,15 +249,6 @@ contract RiskEngine is Ownable { emit LtvBoundsSet(_minLtv, _maxLtv); } - /// @notice Set the risk module used to store risk logic for positions - /// @dev only callable by RiskEngine owner - /// @param _riskModule the address of the risk module implementation - function setRiskModule(address _riskModule) external onlyOwner { - riskModule = RiskModule(_riskModule); - - emit RiskModuleSet(_riskModule); - } - /// @notice Set the oracle address used to price a given asset /// @dev Does not support ERC777s, rebasing and fee-on-transfer tokens function setOracle(address asset, address oracle) external onlyOwner { @@ -245,4 +256,10 @@ contract RiskEngine is Ownable { emit OracleSet(asset, oracle); } + + /// @notice Update the registry associated with this Risk Engine + function setRegistry(address newRegistry) external onlyOwner { + registry = Registry(newRegistry); + emit RegistrySet(newRegistry); + } } diff --git a/protocol-v2/src/RiskModule.sol b/protocol-v2/src/RiskModule.sol index b182943..7fbbd05 100644 --- a/protocol-v2/src/RiskModule.sol +++ b/protocol-v2/src/RiskModule.sol @@ -1,10 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - RiskModule -//////////////////////////////////////////////////////////////*/ - // types import { Pool } from "./Pool.sol"; import { Position } from "./Position.sol"; @@ -21,6 +17,7 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; contract RiskModule { using Math for uint256; + uint256 internal constant WAD = 1e18; /// @notice Sentiment Registry Pool registry key hash /// @dev keccak(SENTIMENT_POOL_KEY) bytes32 public constant SENTIMENT_POOL_KEY = 0x1a99cbf6006db18a0e08427ff11db78f3ea1054bc5b9d48122aae8d206c09728; @@ -29,6 +26,8 @@ contract RiskModule { bytes32 public constant SENTIMENT_RISK_ENGINE_KEY = 0x5b6696788621a5d6b5e3b02a69896b9dd824ebf1631584f038a393c29b6d7555; + /// @notice Protocol liquidation fee, out of 1e18 + uint256 public immutable LIQUIDATION_FEE; /// @notice The discount on assets when liquidating, out of 1e18 uint256 public immutable LIQUIDATION_DISCOUNT; /// @notice The updateable registry as a part of the 2step initialization process @@ -38,6 +37,11 @@ contract RiskModule { /// @notice Sentiment Risk Engine RiskEngine public riskEngine; + /// @notice Pool address was updated + event PoolSet(address pool); + /// @notice Risk Engine address was updated + event RiskEngineSet(address riskEngine); + /// @notice Value of assets seized by the liquidator exceeds liquidation discount error RiskModule_SeizedTooMuch(uint256 seizedValue, uint256 maxSeizedValue); /// @notice Position contains an asset that is not supported by a pool that it borrows from @@ -48,60 +52,125 @@ contract RiskModule { error RiskModule_LiquidateHealthyPosition(address position); /// @notice Position does not have any bad debt error RiskModule_NoBadDebt(address position); + /// @notice Seized asset does not belong to to the position's asset list + error RiskModule_SeizeInvalidAsset(address position, address asset); + /// @notice Liquidation DebtData is invalid + error RiskModule_InvalidDebtData(uint256 poolId); + /// @notice Liquidation AssetData is invalid + error RiskModule_InvalidAssetData(address asset); /// @notice Constructor for Risk Module, which should be registered with the RiskEngine /// @param registry_ The address of the registry contract /// @param liquidationDiscount_ The discount on assets when liquidating, out of 1e18 - constructor(address registry_, uint256 liquidationDiscount_) { + constructor(address registry_, uint256 liquidationDiscount_, uint256 liquidationFee_) { REGISTRY = Registry(registry_); LIQUIDATION_DISCOUNT = liquidationDiscount_; + LIQUIDATION_FEE = liquidationFee_; } /// @notice Updates the pool and risk engine from the registry function updateFromRegistry() external { pool = Pool(REGISTRY.addressFor(SENTIMENT_POOL_KEY)); riskEngine = RiskEngine(REGISTRY.addressFor(SENTIMENT_RISK_ENGINE_KEY)); + emit PoolSet(address(pool)); + emit RiskEngineSet(address(riskEngine)); } - /// @notice Evaluates whether a given position is healthy based on the debt and asset values - function isPositionHealthy(address position) public view returns (bool) { - // a position can have four states: - // 1. (zero debt, zero assets) -> healthy - // 2. (zero debt, non-zero assets) -> healthy - // 3. (non-zero debt, zero assets) -> unhealthy - // 4. (non-zero assets, non-zero debt) -> determined by weighted ltv - - (uint256 totalDebtValue, uint256[] memory debtPools, uint256[] memory debtValueForPool) = - _getPositionDebtData(position); - if (totalDebtValue == 0) return true; // (zero debt, zero assets) AND (zero debt, non-zero assets) - - (uint256 totalAssetValue, address[] memory positionAssets, uint256[] memory positionAssetWeight) = - _getPositionAssetData(position); - if (totalAssetValue == 0) return false; // (non-zero debt, zero assets) - - uint256 minReqAssetValue = - _getMinReqAssetValue(debtPools, debtValueForPool, positionAssets, positionAssetWeight, position); - return totalAssetValue >= minReqAssetValue; // (non-zero debt, non-zero assets) + /// @notice Fetch position health factor + function getPositionHealthFactor(address position) public view returns (uint256) { + // a position can have multiple states: + // 1. (zero debt, zero assets) -> max health + // 2. (zero debt, non-zero assets) -> max health + // 3. (non-zero debt, zero assets) -> invalid state, zero health + // 4. (non-zero debt, non-zero assets) AND (debt > assets) -> bad debt, zero health + // 5. (non-zero debt, non-zero assets) AND (assets >= debt) -> determined by weighted ltv + + (uint256 totalAssets, uint256 totalDebt, uint256 weightedLtv) = getRiskData(position); + if (totalDebt == 0) return type(uint256).max; // (zero debt, zero assets) AND (zero debt, non-zero assets) + if (totalDebt > totalAssets) return 0; // (non-zero debt, zero assets) AND bad debt + return weightedLtv.mulDiv(totalAssets, totalDebt); // (non-zero debt, non-zero assets) AND no bad debt } - /// @notice Fetch risk-associated data for a given position - /// @param position The address of the position to get the risk data for - /// @return totalAssetValue The total asset value of the position - /// @return totalDebtValue The total debt value of the position - /// @return minReqAssetValue The minimum required asset value for the position to be healthy - function getRiskData(address position) external view returns (uint256, uint256, uint256) { - (uint256 totalAssetValue, address[] memory positionAssets, uint256[] memory positionAssetWeight) = - _getPositionAssetData(position); + /// @notice Fetch risk data for a position - total assets and debt in ETH, and its weighted LTV + /// @dev weightedLtv is zero if either total assets or total debt is zero + function getRiskData(address position) public view returns (uint256, uint256, uint256) { + (uint256 totalDebt, uint256[] memory debtPools, uint256[] memory debtValue) = getDebtData(position); + (uint256 totalAssets, address[] memory positionAssets, uint256[] memory assetValue) = getAssetData(position); + uint256 weightedLtv = + _getWeightedLtv(position, totalDebt, debtPools, debtValue, totalAssets, positionAssets, assetValue); + return (totalAssets, totalDebt, weightedLtv); + } - (uint256 totalDebtValue, uint256[] memory debtPools, uint256[] memory debtValueForPool) = - _getPositionDebtData(position); + /// @notice Fetch debt data for position - total debt in ETH, active debt pools, and debt for each pool in ETH + function getDebtData(address position) public view returns (uint256, uint256[] memory, uint256[] memory) { + uint256 totalDebt; + uint256[] memory debtPools = Position(payable(position)).getDebtPools(); + uint256[] memory debtValue = new uint256[](debtPools.length); - if (totalAssetValue == 0 || totalDebtValue == 0) return (totalAssetValue, totalDebtValue, 0); + uint256 debtPoolsLength = debtPools.length; + for (uint256 i; i < debtPoolsLength; ++i) { + address poolAsset = pool.getPoolAssetFor(debtPools[i]); + uint256 borrowAmt = pool.getBorrowsOf(debtPools[i], position); + uint256 debtInEth = riskEngine.getValueInEth(poolAsset, borrowAmt); + debtValue[i] = debtInEth; + totalDebt += debtInEth; + } + return (totalDebt, debtPools, debtValue); + } - uint256 minReqAssetValue = - _getMinReqAssetValue(debtPools, debtValueForPool, positionAssets, positionAssetWeight, position); + /// @notice Fetch asset data for a position - total assets in ETH, position assets, and value of each asset in ETH + function getAssetData(address position) public view returns (uint256, address[] memory, uint256[] memory) { + uint256 totalAssets; + address[] memory positionAssets = Position(payable(position)).getPositionAssets(); + uint256 positionAssetsLength = positionAssets.length; + uint256[] memory assetValue = new uint256[](positionAssetsLength); - return (totalAssetValue, totalDebtValue, minReqAssetValue); + for (uint256 i; i < positionAssetsLength; ++i) { + uint256 amt = IERC20(positionAssets[i]).balanceOf(position); + uint256 assetsInEth = riskEngine.getValueInEth(positionAssets[i], amt); + assetValue[i] = assetsInEth; + totalAssets += assetsInEth; + } + return (totalAssets, positionAssets, assetValue); + } + + /// @notice Fetch weighted Ltv for a position + function getWeightedLtv(address position) public view returns (uint256) { + (uint256 totalDebt, uint256[] memory debtPools, uint256[] memory debtValue) = getDebtData(position); + (uint256 totalAssets, address[] memory positionAssets, uint256[] memory assetValue) = getAssetData(position); + return _getWeightedLtv(position, totalDebt, debtPools, debtValue, totalAssets, positionAssets, assetValue); + } + + function _getWeightedLtv( + address position, + uint256 totalDebt, + uint256[] memory debtPools, + uint256[] memory debtValue, + uint256 totalAssets, + address[] memory positionAssets, + uint256[] memory assetValue + ) + internal + view + returns (uint256 weightedLtv) + { + // handle empty, zero-debt, bad debt, and invalid position states + if (totalDebt == 0 || totalAssets == 0 || totalDebt > totalAssets) return 0; + + uint256 debtPoolsLen = debtPools.length; + uint256 positionAssetsLen = positionAssets.length; + // O(debtPools.length * positionAssets.length) + for (uint256 i; i < debtPoolsLen; ++i) { + for (uint256 j; j < positionAssetsLen; ++j) { + uint256 ltv = riskEngine.ltvFor(debtPools[i], positionAssets[j]); + // every position asset must have a non-zero ltv in every debt pool + if (ltv == 0) revert RiskModule_UnsupportedAsset(position, debtPools[i], positionAssets[j]); + // ltv is weighted over two dimensions - proportion of debt value owed to a pool as a share of the + // total position debt and proportion of position asset value as a share of total position value + weightedLtv += debtValue[i].mulDiv(assetValue[j], WAD).mulDiv(ltv, WAD); + } + } + weightedLtv = weightedLtv.mulDiv(WAD, totalAssets).mulDiv(WAD, totalDebt); } /// @notice Used to validate liquidator data and value of assets seized @@ -112,168 +181,152 @@ contract RiskModule { address position, DebtData[] calldata debtData, AssetData[] calldata assetData - ) external view { - // position must breach risk thresholds before liquidation - if (isPositionHealthy(position)) revert RiskModule_LiquidateHealthyPosition(position); - - _validateSeizedAssetValue(position, debtData, assetData, LIQUIDATION_DISCOUNT); - } - - /// @notice Verify if a given position has bad debt - function validateBadDebt(address position) external view { - uint256 totalDebtValue = getTotalDebtValue(position); - uint256 totalAssetValue = getTotalAssetValue(position); - if (totalAssetValue > totalDebtValue) revert RiskModule_NoBadDebt(position); - } - - function _validateSeizedAssetValue( - address position, - DebtData[] calldata debtData, - AssetData[] calldata assetData, - uint256 discount - ) internal view { - // compute value of debt repaid by the liquidator - uint256 debtRepaidValue; - uint256 debtLength = debtData.length; - for (uint256 i; i < debtLength; ++i) { - uint256 poolId = debtData[i].poolId; - uint256 amt = debtData[i].amt; - if (amt == type(uint256).max) amt = pool.getBorrowsOf(poolId, position); - address poolAsset = pool.getPoolAssetFor(poolId); - IOracle oracle = IOracle(riskEngine.getOracleFor(poolAsset)); - debtRepaidValue += oracle.getValueInEth(poolAsset, amt); - } - - // compute value of assets seized by the liquidator - uint256 assetSeizedValue; - uint256 assetDataLength = assetData.length; - for (uint256 i; i < assetDataLength; ++i) { - IOracle oracle = IOracle(riskEngine.getOracleFor(assetData[i].asset)); - assetSeizedValue += oracle.getValueInEth(assetData[i].asset, assetData[i].amt); - } - - // max asset value that can be seized by the liquidator - uint256 maxSeizedAssetValue = debtRepaidValue.mulDiv(1e18, (1e18 - discount)); - if (assetSeizedValue > maxSeizedAssetValue) { - revert RiskModule_SeizedTooMuch(assetSeizedValue, maxSeizedAssetValue); + ) + external + view + returns (uint256, uint256, DebtData[] memory, AssetData[] memory) + { + // ensure position is unhealthy + uint256 healthFactor = getPositionHealthFactor(position); + if (healthFactor >= WAD) revert RiskModule_LiquidateHealthyPosition(position); + + // parse data for repayment and seizure + (uint256 totalRepayValue, DebtData[] memory repayData) = _getRepayData(position, debtData); + (uint256 totalSeizeValue, AssetData[] memory seizeData) = _getSeizeData(position, assetData); + + // verify liquidator does not seize too much + uint256 maxSeizeValue = totalRepayValue.mulDiv(1e18, (1e18 - LIQUIDATION_DISCOUNT)); + if (totalSeizeValue > maxSeizeValue) revert RiskModule_SeizedTooMuch(totalSeizeValue, maxSeizeValue); + + // compute protocol liquidation fee as a portion of liquidator profit, if any + uint256 liqFee; + if (totalSeizeValue > totalRepayValue) { + liqFee = (totalSeizeValue - totalRepayValue).mulDiv(LIQUIDATION_FEE, totalSeizeValue); } - } - /// @notice Gets the ETH debt value a given position owes to a particular pool - function getDebtValueForPool(address position, uint256 poolId) public view returns (uint256) { - address asset = pool.getPoolAssetFor(poolId); - IOracle oracle = IOracle(riskEngine.getOracleFor(asset)); - return oracle.getValueInEth(asset, pool.getBorrowsOf(poolId, position)); + return (healthFactor, liqFee, repayData, seizeData); } - /// @notice Gets the total debt owed by a position in ETH - function getTotalDebtValue(address position) public view returns (uint256) { - uint256[] memory debtPools = Position(payable(position)).getDebtPools(); - - uint256 totalDebtValue; - uint256 debtPoolsLength = debtPools.length; - for (uint256 i; i < debtPoolsLength; ++i) { - totalDebtValue += getDebtValueForPool(position, debtPools[i]); - } + /// @notice validate bad debt liquidation call + /// @dev Positions with bad debt cannot be partially liquidated + function validateBadDebtLiquidation( + address position, + DebtData[] calldata debtData + ) + external + view + returns (DebtData[] memory, AssetData[] memory) + { + // verify position has bad debt + (uint256 totalAssetValue, uint256 totalDebtValue,) = getRiskData(position); + if (totalAssetValue >= totalDebtValue) revert RiskModule_NoBadDebt(position); - return totalDebtValue; - } + // parse repayment data + (uint256 totalRepayValue, DebtData[] memory repayData) = _getRepayData(position, debtData); - /// @notice Gets the ETH value for a particular asset in a given position - function getAssetValue(address position, address asset) public view returns (uint256) { - IOracle oracle = IOracle(riskEngine.getOracleFor(asset)); - uint256 amt = IERC20(asset).balanceOf(position); - return oracle.getValueInEth(asset, amt); - } + // verify that liquidator repays enough to seize all position assets + uint256 maxSeizeValue = totalRepayValue.mulDiv(1e18, (1e18 - LIQUIDATION_DISCOUNT)); + if (totalAssetValue > maxSeizeValue) revert RiskModule_SeizedTooMuch(totalAssetValue, maxSeizeValue); - /// @notice Gets the total ETH value of assets in a position - function getTotalAssetValue(address position) public view returns (uint256) { - address[] memory positionAssets = Position(payable(position)).getPositionAssets(); + // generate asset seizure data - since bad debt liquidations are not partial, all assets are seized + AssetData[] memory seizeData = _getBadDebtSeizeData(position); - uint256 totalAssetValue; - uint256 positionAssetsLength = positionAssets.length; - for (uint256 i; i < positionAssetsLength; ++i) { - totalAssetValue += getAssetValue(position, positionAssets[i]); - } - - return totalAssetValue; + return (repayData, seizeData); } - function _getPositionDebtData(address position) + function _getRepayData( + address position, + DebtData[] calldata debtData + ) internal view - returns (uint256, uint256[] memory, uint256[] memory) + returns (uint256 totalRepayValue, DebtData[] memory repayData) { - uint256 totalDebtValue; - uint256[] memory debtPools = Position(payable(position)).getDebtPools(); - uint256[] memory debtValueForPool = new uint256[](debtPools.length); - - uint256 debtPoolsLength = debtPools.length; - for (uint256 i; i < debtPoolsLength; ++i) { - uint256 debt = getDebtValueForPool(position, debtPools[i]); - debtValueForPool[i] = debt; - totalDebtValue += debt; + _validateDebtData(position, debtData); + uint256 debtDataLen = debtData.length; + repayData = debtData; // copy debtData and replace all type(uint).max with repay amounts + for (uint256 i; i < debtDataLen; ++i) { + uint256 poolId = repayData[i].poolId; + uint256 repayAmt = repayData[i].amt; + if (repayAmt == type(uint256).max) { + repayAmt = pool.getBorrowsOf(poolId, position); + repayData[i].amt = repayAmt; + } + totalRepayValue += riskEngine.getValueInEth(pool.getPoolAssetFor(poolId), repayAmt); } - - return (totalDebtValue, debtPools, debtValueForPool); } - function _getPositionAssetData(address position) + function _getSeizeData( + address position, + AssetData[] calldata assetData + ) internal view - returns (uint256, address[] memory, uint256[] memory) + returns (uint256 totalSeizeValue, AssetData[] memory seizeData) { - uint256 totalAssetValue; + _validateAssetData(position, assetData); + uint256 assetDataLen = assetData.length; + seizeData = assetData; // copy assetData and replace all type(uint).max with position asset balances + for (uint256 i; i < assetDataLen; ++i) { + address asset = seizeData[i].asset; + // ensure assetData[i] is in the position asset list + if (Position(payable(position)).hasAsset(asset) == false) { + revert RiskModule_SeizeInvalidAsset(position, asset); + } + uint256 seizeAmt = seizeData[i].amt; + if (seizeAmt == type(uint256).max) { + seizeAmt = IERC20(asset).balanceOf(position); + seizeData[i].amt = seizeAmt; + } + totalSeizeValue += riskEngine.getValueInEth(asset, seizeAmt); + } + } + // since bad debt liquidations cannot be partial, all position assets are seized + function _getBadDebtSeizeData(address position) internal view returns (AssetData[] memory) { address[] memory positionAssets = Position(payable(position)).getPositionAssets(); uint256 positionAssetsLength = positionAssets.length; - uint256[] memory positionAssetData = new uint256[](positionAssetsLength); + AssetData[] memory seizeData = new AssetData[](positionAssets.length); for (uint256 i; i < positionAssetsLength; ++i) { - uint256 assets = getAssetValue(position, positionAssets[i]); - // positionAssetData[i] stores value of positionAssets[i] in eth - positionAssetData[i] = assets; - totalAssetValue += assets; + address asset = positionAssets[i]; + uint256 amt = IERC20(positionAssets[i]).balanceOf(position); + seizeData[i] = AssetData({ asset: asset, amt: amt }); } + return seizeData; + } - if (totalAssetValue == 0) return (0, positionAssets, positionAssetData); + // ensure DebtData has no duplicates by enforcing an ascending order of poolIds + // ensure repaid pools are in the debt array for the position + function _validateDebtData(address position, DebtData[] memory debtData) internal view { + uint256 debtDataLen = debtData.length; + if (debtDataLen == 0) return; - for (uint256 i; i < positionAssetsLength; ++i) { - // positionAssetData[i] stores weight of positionAsset[i] - // wt of positionAsset[i] = (value of positionAsset[i]) / (total position assets value) - positionAssetData[i] = positionAssetData[i].mulDiv(1e18, totalAssetValue); - } + uint256 lastPoolId = debtData[0].poolId; + if (Position(payable(position)).hasDebt(lastPoolId) == false) revert RiskModule_InvalidDebtData(lastPoolId); - return (totalAssetValue, positionAssets, positionAssetData); + for (uint256 i = 1; i < debtDataLen; ++i) { + uint256 poolId = debtData[i].poolId; + if (poolId <= lastPoolId) revert RiskModule_InvalidDebtData(poolId); + if (Position(payable(position)).hasDebt(poolId) == false) revert RiskModule_InvalidDebtData(poolId); + lastPoolId = poolId; + } } - function _getMinReqAssetValue( - uint256[] memory debtPools, - uint256[] memory debtValuleForPool, - address[] memory positionAssets, - uint256[] memory wt, - address position - ) internal view returns (uint256) { - uint256 minReqAssetValue; - - // O(pools.len * positionAssets.len) - uint256 debtPoolsLength = debtPools.length; - uint256 positionAssetsLength = positionAssets.length; - for (uint256 i; i < debtPoolsLength; ++i) { - for (uint256 j; j < positionAssetsLength; ++j) { - uint256 ltv = riskEngine.ltvFor(debtPools[i], positionAssets[j]); + // ensure assetData has no duplicates by enforcing an ascending order of assets + // ensure seized assets are in the assets array for the position + function _validateAssetData(address position, AssetData[] memory assetData) internal view { + uint256 assetDataLen = assetData.length; + if (assetDataLen == 0) return; - // revert with pool id and the asset that is not supported by the pool - if (ltv == 0) revert RiskModule_UnsupportedAsset(position, debtPools[i], positionAssets[j]); + address lastAsset = assetData[0].asset; + if (Position(payable(position)).hasAsset(lastAsset) == false) revert RiskModule_InvalidAssetData(lastAsset); - // debt is weighted in proportion to value of position assets. if your position - // consists of 60% A and 40% B, then 60% of the debt is assigned to be backed by A - // and 40% by B. this is iteratively computed for each pool the position borrows from - minReqAssetValue += debtValuleForPool[i].mulDiv(wt[j], ltv, Math.Rounding.Up); - } + for (uint256 i = 1; i < assetDataLen; ++i) { + address asset = assetData[i].asset; + if (asset <= lastAsset) revert RiskModule_InvalidAssetData(asset); + if (Position(payable(position)).hasAsset(asset) == false) revert RiskModule_InvalidAssetData(asset); + lastAsset = asset; } - - if (minReqAssetValue == 0) revert RiskModule_ZeroMinReqAssets(); - return minReqAssetValue; } } diff --git a/protocol-v2/src/SuperPool.sol b/protocol-v2/src/SuperPool.sol index 8b01c5a..3ef0495 100644 --- a/protocol-v2/src/SuperPool.sol +++ b/protocol-v2/src/SuperPool.sol @@ -1,10 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - SuperPool -//////////////////////////////////////////////////////////////*/ - // types import { Pool } from "./Pool.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -30,6 +26,8 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { uint256 internal constant WAD = 1e18; /// @notice The maximum length of the deposit and withdraw queues uint256 public constant MAX_QUEUE_LENGTH = 10; + /// @notice The maximum supply of deposit shares for the SuperPool + uint256 public constant MAX_DEPOSIT_SHARES = type(uint112).max; /// @notice Timelock delay for fee modification uint256 public constant TIMELOCK_DURATION = 24 * 60 * 60; // 24 hours /// @notice Timelock deadline to enforce timely updates @@ -40,10 +38,13 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { Pool public immutable POOL; /// @notice The asset that is deposited in the superpool, and in turns its underling pools IERC20 internal immutable ASSET; + /// @notice The fee, out of 1e18, taken from interest earned uint256 public fee; /// @notice The address that recieves all fees, taken in shares address public feeRecipient; + /// @notice Virtual asset balance of the SuperPool + uint256 public idleAssets; /// @notice The maximum amount of assets that can be deposited in the SuperPool uint256 public superPoolCap; /// @notice The total amount of assets in the SuperPool @@ -52,19 +53,13 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { uint256[] public depositQueue; /// @notice The queue of pool ids, in order, for withdrawing assets uint256[] public withdrawQueue; + /// @notice The caps of the pools, indexed by pool id /// @dev poolCapFor[x] == 0 -> x is not part of the queue mapping(uint256 poolId => uint256 cap) public poolCapFor; /// @notice The addresses that are allowed to reallocate assets mapping(address user => bool isAllocator) public isAllocator; - struct PendingFeeUpdate { - uint256 fee; - uint256 validAfter; - } - - PendingFeeUpdate pendingFeeUpdate; - /// @notice Pool added to the deposit and withdraw queue event PoolAdded(uint256 poolId); /// @notice Pool removed from the deposit and withdraw queue @@ -77,10 +72,6 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { event SuperPoolCapUpdated(uint256 superPoolCap); /// @notice SuperPool fee recipient was updated event SuperPoolFeeRecipientUpdated(address feeRecipient); - /// @notice SuperPool fee update was requested - event SuperPoolFeeUpdateRequested(uint256 fee); - /// @notice SuperPool fee update was rejected - event SuperPoolFeeUpdateRejected(uint256 fee); /// @notice Allocator status for a given address was updated event AllocatorUpdated(address allocator, bool isAllocator); /// @notice Assets were deposited to the SuperPool @@ -132,6 +123,8 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { error SuperPool_ZeroPoolCap(uint256 poolId); /// @notice Reordered queue length does not match original queue length error SuperPool_ReorderQueueLength(); + /// @notice Total SuperPool shares exceeded MAX_DEPOSIT_SHARES + error SuperPool_MaxDepositShares(); /// @notice This function should only be called by the SuperPool Factory /// @param pool_ The address of the singelton pool contract @@ -149,12 +142,15 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { uint256 superPoolCap_, string memory name_, string memory symbol_ - ) Ownable() ERC20(name_, symbol_) { + ) + Ownable() + ERC20(name_, symbol_) + { POOL = Pool(pool_); ASSET = IERC20(asset_); DECIMALS = _tryGetAssetDecimals(ASSET); - if (fee > 1e18) revert SuperPool_FeeTooHigh(); + if (fee_ > 1e18) revert SuperPool_FeeTooHigh(); fee = fee_; feeRecipient = feeRecipient_; superPoolCap = superPoolCap_; @@ -178,13 +174,11 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { /// @notice Fetch the total amount of assets under control of the SuperPool function totalAssets() public view returns (uint256) { - uint256 assets = ASSET.balanceOf(address(this)); - + uint256 assets = idleAssets; uint256 depositQueueLength = depositQueue.length; for (uint256 i; i < depositQueueLength; ++i) { assets += POOL.getAssetsOf(depositQueue[i], address(this)); } - return assets; } @@ -205,25 +199,31 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { } /// @notice Fetch the maximum amount of assets that can be deposited in the SuperPool - function maxDeposit(address) public view returns (uint256) { - return _maxDeposit(totalAssets()); + function maxDeposit(address receiver) public view returns (uint256) { + if (receiver == address(0) || Pausable.paused()) return 0; + (uint256 feeShares, uint256 newTotalAssets) = simulateAccrue(); + return _maxDeposit(feeShares, newTotalAssets); } /// @notice Fetch the maximum amount of shares that can be minted from the SuperPool - function maxMint(address) public view returns (uint256) { + function maxMint(address receiver) public view returns (uint256) { + if (receiver == address(0) || Pausable.paused()) return 0; (uint256 feeShares, uint256 newTotalAssets) = simulateAccrue(); - return - _convertToShares(_maxDeposit(newTotalAssets), newTotalAssets, totalSupply() + feeShares, Math.Rounding.Down); + return _convertToShares( + _maxDeposit(feeShares, newTotalAssets), newTotalAssets, totalSupply() + feeShares, Math.Rounding.Down + ); } /// @notice Fetch the maximum amount of assets that can be withdrawn by a depositor function maxWithdraw(address owner) public view returns (uint256) { + if (Pausable.paused()) return 0; (uint256 feeShares, uint256 newTotalAssets) = simulateAccrue(); return _maxWithdraw(owner, newTotalAssets, totalSupply() + feeShares); } /// @notice Fetch the maximum amount of shares that can be redeemed by a depositor function maxRedeem(address owner) public view returns (uint256) { + if (Pausable.paused()) return 0; (uint256 feeShares, uint256 newTotalAssets) = simulateAccrue(); uint256 newTotalShares = totalSupply() + feeShares; return _convertToShares( @@ -255,22 +255,24 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { /// @param assets The amount of assets to deposit /// @param receiver The address to receive the shares /// @return shares The amount of shares minted - function deposit(uint256 assets, address receiver) public nonReentrant returns (uint256 shares) { + function deposit(uint256 assets, address receiver) public nonReentrant whenNotPaused returns (uint256 shares) { accrue(); - shares = _convertToShares(assets, lastTotalAssets, totalSupply(), Math.Rounding.Down); + uint256 lastTotalShares = totalSupply(); + shares = _convertToShares(assets, lastTotalAssets, lastTotalShares, Math.Rounding.Down); if (shares == 0) revert SuperPool_ZeroShareDeposit(address(this), assets); - _deposit(receiver, assets, shares); + _deposit(receiver, assets, shares, lastTotalShares); } /// @notice Mints shares into the SuperPool /// @param shares The amount of shares to mint /// @param receiver The address to receive the shares /// @return assets The amount of assets deposited - function mint(uint256 shares, address receiver) public nonReentrant returns (uint256 assets) { + function mint(uint256 shares, address receiver) public nonReentrant whenNotPaused returns (uint256 assets) { accrue(); - assets = _convertToAssets(shares, lastTotalAssets, totalSupply(), Math.Rounding.Up); + uint256 lastTotalShares = totalSupply(); + assets = _convertToAssets(shares, lastTotalAssets, lastTotalShares, Math.Rounding.Up); if (assets == 0) revert SuperPool_ZeroAssetMint(address(this), shares); - _deposit(receiver, assets, shares); + _deposit(receiver, assets, shares, lastTotalShares); } /// @notice Withdraws assets from the SuperPool @@ -278,7 +280,16 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { /// @param receiver The address to receive the assets /// @param owner The address to withdraw the assets from /// @return shares The amount of shares burned - function withdraw(uint256 assets, address receiver, address owner) public nonReentrant returns (uint256 shares) { + function withdraw( + uint256 assets, + address receiver, + address owner + ) + public + nonReentrant + whenNotPaused + returns (uint256 shares) + { accrue(); shares = _convertToShares(assets, lastTotalAssets, totalSupply(), Math.Rounding.Up); if (shares == 0) revert SuperPool_ZeroShareWithdraw(address(this), assets); @@ -290,7 +301,16 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { /// @param receiver The address to receive the assets /// @param owner The address to redeem the shares from /// @return assets The amount of assets redeemed - function redeem(uint256 shares, address receiver, address owner) public nonReentrant returns (uint256 assets) { + function redeem( + uint256 shares, + address receiver, + address owner + ) + public + nonReentrant + whenNotPaused + returns (uint256 assets) + { accrue(); assets = _convertToAssets(shares, lastTotalAssets, totalSupply(), Math.Rounding.Down); if (assets == 0) revert SuperPool_ZeroAssetRedeem(address(this), shares); @@ -326,7 +346,10 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { function removePool(uint256 poolId, bool forceRemove) external onlyOwner { if (poolCapFor[poolId] == 0) return; // no op if pool is not in queue uint256 assetsInPool = POOL.getAssetsOf(poolId, address(this)); - if (forceRemove && assetsInPool > 0) POOL.withdraw(poolId, assetsInPool, address(this), address(this)); + if (forceRemove && assetsInPool > 0) { + POOL.withdraw(poolId, assetsInPool, address(this), address(this)); + idleAssets += assetsInPool; + } _removePool(poolId); poolCapFor[poolId] = 0; emit PoolCapSet(poolId, 0); @@ -361,44 +384,14 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { emit AllocatorUpdated(allocator, isAllocator[allocator]); } - /// @notice Propose a new fee update for the SuperPool - /// @dev overwrites any pending or expired updates - function requestFeeUpdate(uint256 _fee) external onlyOwner { - if (fee > 1e18) revert SuperPool_FeeTooHigh(); - pendingFeeUpdate = PendingFeeUpdate({ fee: _fee, validAfter: block.timestamp + TIMELOCK_DURATION }); - emit SuperPoolFeeUpdateRequested(_fee); - } - - /// @notice Apply a pending fee update after sanity checks - function acceptFeeUpdate() external onlyOwner { - uint256 newFee = pendingFeeUpdate.fee; - uint256 validAfter = pendingFeeUpdate.validAfter; - - // revert if there is no update to apply - if (validAfter == 0) revert SuperPool_NoFeeUpdate(); + /// @notice Update SuperPool fee + function setSuperpoolFee(uint256 _fee) external onlyOwner { + if (_fee > 1e18) revert SuperPool_FeeTooHigh(); + if (_fee != 0 && feeRecipient == address(0)) revert SuperPool_ZeroFeeRecipient(); - // revert if called before timelock delay has passed - if (block.timestamp < validAfter) revert SuperPool_TimelockPending(block.timestamp, validAfter); - - // revert if timelock deadline has passed - if (block.timestamp > validAfter + TIMELOCK_DEADLINE) { - revert SuperPool_TimelockExpired(block.timestamp, validAfter); - } - - // superpools with non zero fees cannot have zero fee recipients - if (newFee != 0 && feeRecipient == address(0)) revert SuperPool_ZeroFeeRecipient(); - - // update fee accrue(); - fee = newFee; - emit SuperPoolFeeUpdated(newFee); - delete pendingFeeUpdate; - } - - /// @notice Reject pending fee update - function rejectFeeUpdate() external onlyOwner { - emit SuperPoolFeeUpdateRejected(pendingFeeUpdate.fee); - delete pendingFeeUpdate; + fee = _fee; + emit SuperPoolFeeUpdated(_fee); } /// @notice Sets the cap of the total amount of assets in the SuperPool @@ -429,6 +422,8 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { /// @param withdraws A list of poolIds, and the amount to withdraw from them /// @param deposits A list of poolIds, and the amount to deposit to them function reallocate(ReallocateParams[] calldata withdraws, ReallocateParams[] calldata deposits) external { + accrue(); + if (!isAllocator[msg.sender] && msg.sender != Ownable.owner()) { revert SuperPool_OnlyAllocatorOrOwner(address(this), msg.sender); } @@ -437,6 +432,7 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { for (uint256 i; i < withdrawsLength; ++i) { if (poolCapFor[withdraws[i].poolId] == 0) revert SuperPool_PoolNotInQueue(withdraws[i].poolId); POOL.withdraw(withdraws[i].poolId, withdraws[i].assets, address(this), address(this)); + idleAssets += withdraws[i].assets; } uint256 depositsLength = deposits.length; @@ -446,9 +442,10 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { if (poolCap == 0) revert SuperPool_PoolNotInQueue(deposits[i].poolId); // respect pool cap uint256 assetsInPool = POOL.getAssetsOf(deposits[i].poolId, address(this)); - if (assetsInPool + deposits[i].assets < poolCap) { - ASSET.approve(address(POOL), deposits[i].assets); + if (assetsInPool + deposits[i].assets <= poolCap) { + ASSET.forceApprove(address(POOL), deposits[i].assets); POOL.deposit(deposits[i].poolId, deposits[i].assets, address(this)); + idleAssets -= deposits[i].assets; } } } @@ -458,7 +455,12 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { uint256 _totalAssets, uint256 _totalShares, Math.Rounding _rounding - ) public view virtual returns (uint256 shares) { + ) + public + view + virtual + returns (uint256 shares) + { shares = _assets.mulDiv(_totalShares + 1, _totalAssets + 1, _rounding); } @@ -467,17 +469,24 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { uint256 _totalAssets, uint256 _totalShares, Math.Rounding _rounding - ) public view virtual returns (uint256 assets) { + ) + public + view + virtual + returns (uint256 assets) + { assets = _shares.mulDiv(_totalAssets + 1, _totalShares + 1, _rounding); } function _maxWithdraw(address _owner, uint256 _totalAssets, uint256 _totalShares) internal view returns (uint256) { - uint256 totalLiquidity; // max assets that can be withdrawn based on superpool and underlying pool liquidity + uint256 totalLiquidity = idleAssets; // max withdraw based on superpool and underlying pool liquidity uint256 depositQueueLength = depositQueue.length; for (uint256 i; i < depositQueueLength; ++i) { - totalLiquidity += POOL.getLiquidityOf(depositQueue[i]); + uint256 maxWithdrawFromPool = POOL.getAssetsOf(depositQueue[i], address(this)); // superpool assets in pool + uint256 poolLiquidity = POOL.getLiquidityOf(depositQueue[i]); + if (poolLiquidity < maxWithdrawFromPool) maxWithdrawFromPool = poolLiquidity; // minimum of two + totalLiquidity += maxWithdrawFromPool; } - totalLiquidity += ASSET.balanceOf(address(this)); // unallocated assets in the superpool // return the minimum of totalLiquidity and _owner balance uint256 userAssets = _convertToAssets(ERC20.balanceOf(_owner), _totalAssets, _totalShares, Math.Rounding.Down); @@ -485,22 +494,28 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { } /// @notice Fetch the maximum amount of assets that can be deposited in the SuperPool - function _maxDeposit(uint256 _totalAssets) public view returns (uint256) { - return superPoolCap > _totalAssets ? (superPoolCap - _totalAssets) : 0; + function _maxDeposit(uint256 _feeShares, uint256 _totalAssets) public view returns (uint256) { + if (_totalAssets >= superPoolCap) return 0; // SuperPool has too many assets + // deposit() reverts when deposited assets are less than one share worth + // check that remaining asset capacity is worth more than one share + // check that total shares after deposit does not exceed MAX_DEPOSIT_SHARES + uint256 maxAssets = superPoolCap - _totalAssets; + uint256 totalShares = totalSupply() + _feeShares; // total deposit shares after accrue but before deposit + uint256 shares = _convertToShares(maxAssets, _totalAssets, totalShares, Math.Rounding.Down); + if (shares == 0) return 0; + if (shares + totalShares > MAX_DEPOSIT_SHARES) return 0; + return maxAssets; } /// @dev Internal function to process ERC4626 deposits and mints - /// @param receiver The address to receive the shares - /// @param assets The amount of assets to deposit - /// @param shares The amount of shares to mint, should be equivalent to assets - - function _deposit(address receiver, uint256 assets, uint256 shares) internal { - // assume that lastTotalAssets are up to date + function _deposit(address receiver, uint256 assets, uint256 shares, uint256 lastTotalShares) internal { + // assume lastTotalAssets and lastTotalShares are up to date if (lastTotalAssets + assets > superPoolCap) revert SuperPool_SuperPoolCapReached(); + if (shares + lastTotalShares > MAX_DEPOSIT_SHARES) revert SuperPool_MaxDepositShares(); // Need to transfer before minting or ERC777s could reenter. ASSET.safeTransferFrom(msg.sender, address(this), assets); ERC20._mint(receiver, shares); - _supplyToPools(assets); + idleAssets += _supplyToPools(assets); lastTotalAssets += assets; emit Deposit(msg.sender, receiver, assets, shares); } @@ -514,6 +529,7 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { _withdrawFromPools(assets); if (msg.sender != owner) ERC20._spendAllowance(owner, msg.sender, shares); ERC20._burn(owner, shares); + idleAssets -= assets; lastTotalAssets -= assets; ASSET.safeTransfer(receiver, assets); emit Withdraw(msg.sender, receiver, owner, assets, shares); @@ -521,35 +537,53 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { /// @dev Internal function to loop through all pools, depositing assets sequentially until the cap is reached /// @param assets The amount of assets to deposit - function _supplyToPools(uint256 assets) internal { + function _supplyToPools(uint256 assets) internal returns (uint256) { uint256 depositQueueLength = depositQueue.length; for (uint256 i; i < depositQueueLength; ++i) { uint256 poolId = depositQueue[i]; + uint256 depositAmt = assets; // try to deposit as much as possible + + // respect superpool cap for given pool id + uint256 poolCap = poolCapFor[poolId]; uint256 assetsInPool = POOL.getAssetsOf(poolId, address(this)); + if (poolCap > assetsInPool) { + uint256 superPoolCapLeft = poolCap - assetsInPool; + if (superPoolCapLeft < depositAmt) depositAmt = superPoolCapLeft; + } else { + depositAmt = 0; + } + + // respect basepool cap for given pool id + uint256 basePoolCap = POOL.getPoolCapFor(poolId); + uint256 basePoolTotalAssets = POOL.getTotalAssets(poolId); + if (basePoolCap > basePoolTotalAssets) { + uint256 basePoolCapLeft = basePoolCap - basePoolTotalAssets; + if (basePoolCapLeft < depositAmt) depositAmt = basePoolCapLeft; + } else { + depositAmt = 0; + } - if (assetsInPool < poolCapFor[poolId]) { - uint256 supplyAmt = poolCapFor[poolId] - assetsInPool; - if (assets < supplyAmt) supplyAmt = assets; - ASSET.forceApprove(address(POOL), supplyAmt); + if (depositAmt > 0) { + ASSET.forceApprove(address(POOL), depositAmt); // skip and move to the next pool in queue if deposit reverts - try POOL.deposit(poolId, supplyAmt, address(this)) { - assets -= supplyAmt; + try POOL.deposit(poolId, depositAmt, address(this)) { + assets -= depositAmt; } catch { } - if (assets == 0) return; + if (assets == 0) return 0; } } + return assets; // remaining assets stay idle in the SuperPool } /// @dev Internal function to loop through all pools, withdrawing assets first from available balance /// then sequentially until the cap is reached /// @param assets The amount of assets to withdraw function _withdrawFromPools(uint256 assets) internal { - uint256 assetsInSuperpool = ASSET.balanceOf(address(this)); + if (idleAssets >= assets) return; - if (assetsInSuperpool >= assets) return; - else assets -= assetsInSuperpool; + assets -= idleAssets; uint256 withdrawQueueLength = withdrawQueue.length; for (uint256 i; i < withdrawQueueLength; ++i) { @@ -569,6 +603,7 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { if (withdrawAmt > 0) { try POOL.withdraw(poolId, withdrawAmt, address(this), address(this)) { assets -= withdrawAmt; + idleAssets += withdrawAmt; } catch { } } @@ -610,7 +645,11 @@ contract SuperPool is Ownable, Pausable, ReentrancyGuard, ERC20 { function _reorderQueue( uint256[] storage queue, uint256[] calldata indexes - ) internal view returns (uint256[] memory newQueue) { + ) + internal + view + returns (uint256[] memory newQueue) + { uint256 indexesLength = indexes.length; if (indexesLength != queue.length) revert SuperPool_ReorderQueueLength(); bool[] memory seen = new bool[](indexesLength); diff --git a/protocol-v2/src/SuperPoolFactory.sol b/protocol-v2/src/SuperPoolFactory.sol index 7852bd5..e94e8a9 100644 --- a/protocol-v2/src/SuperPoolFactory.sol +++ b/protocol-v2/src/SuperPoolFactory.sol @@ -17,7 +17,7 @@ contract SuperPoolFactory { address private constant DEAD_ADDRESS = 0x000000000000000000000000000000000000dEaD; /// @notice Minimum amount of initial shares to be burned - uint256 public constant MIN_BURNED_SHARES = 1000; + uint256 public constant MIN_BURNED_SHARES = 1_000_000; /// @notice All Pools exist on the Singleton Pool Contract, which is fixed per factory address public immutable POOL; @@ -62,7 +62,10 @@ contract SuperPoolFactory { uint256 initialDepositAmt, string calldata name, string calldata symbol - ) external returns (address) { + ) + external + returns (address) + { if (fee != 0 && feeRecipient == address(0)) revert SuperPoolFactory_ZeroFeeRecipient(); SuperPool superPool = new SuperPool(POOL, asset, feeRecipient, fee, superPoolCap, name, symbol); superPool.transferOwnership(owner); @@ -70,10 +73,10 @@ contract SuperPoolFactory { // burn initial deposit IERC20(asset).safeTransferFrom(msg.sender, address(this), initialDepositAmt); // assume approval - IERC20(asset).approve(address(superPool), initialDepositAmt); + IERC20(asset).forceApprove(address(superPool), initialDepositAmt); uint256 shares = superPool.deposit(initialDepositAmt, address(this)); if (shares < MIN_BURNED_SHARES) revert SuperPoolFactory_TooFewInitialShares(shares); - IERC20(superPool).transfer(DEAD_ADDRESS, shares); + IERC20(superPool).safeTransfer(DEAD_ADDRESS, shares); emit SuperPoolDeployed(owner, address(superPool), asset, name, symbol); return address(superPool); diff --git a/protocol-v2/src/interfaces/IRateModel.sol b/protocol-v2/src/interfaces/IRateModel.sol index cb9fb7a..9ea4195 100644 --- a/protocol-v2/src/interfaces/IRateModel.sol +++ b/protocol-v2/src/interfaces/IRateModel.sol @@ -18,7 +18,10 @@ interface IRateModel { uint256 lastUpdated, uint256 totalBorrows, uint256 totalAssets - ) external view returns (uint256 interestAccrued); + ) + external + view + returns (uint256 interestAccrued); /// @notice Fetch the instantaneous borrow interest rate for a given pool state /// @param totalBorrows Total amount of assets borrowed from the pool diff --git a/protocol-v2/src/irm/KinkedRateModel.sol b/protocol-v2/src/irm/KinkedRateModel.sol index 2309089..0ac71c2 100644 --- a/protocol-v2/src/irm/KinkedRateModel.sol +++ b/protocol-v2/src/irm/KinkedRateModel.sol @@ -28,7 +28,7 @@ contract KinkedRateModel is IRateModel { uint256 private immutable MAX_EXCESS_UTIL; // 1e18 - OPTIMAL_UTIL constructor(uint256 minRate, uint256 slope1, uint256 slope2, uint256 optimalUtil) { - assert(optimalUtil < 1e18); // optimal utilisation < 100% + assert(optimalUtil > 0 && optimalUtil < 1e18); // optimal utilisation < 100% and > 0 MIN_RATE_1 = minRate; SLOPE_1 = slope1; @@ -43,7 +43,11 @@ contract KinkedRateModel is IRateModel { uint256 lastUpdated, uint256 totalBorrows, uint256 totalAssets - ) external view returns (uint256) { + ) + external + view + returns (uint256) + { uint256 rateFactor = ((block.timestamp - lastUpdated)).mulDiv( getInterestRate(totalBorrows, totalAssets), SECONDS_PER_YEAR, Math.Rounding.Up ); // rateFactor = time delta * apr / secs_per_year @@ -55,7 +59,7 @@ contract KinkedRateModel is IRateModel { function getInterestRate(uint256 totalBorrows, uint256 totalAssets) public view returns (uint256) { uint256 util = (totalAssets == 0) ? 0 : totalBorrows.mulDiv(1e18, totalAssets, Math.Rounding.Up); - if (util <= OPTIMAL_UTIL) return MIN_RATE_1 + SLOPE_1.mulDiv(util, OPTIMAL_UTIL, Math.Rounding.Down); - else return MIN_RATE_2 + SLOPE_2.mulDiv((util - OPTIMAL_UTIL), MAX_EXCESS_UTIL, Math.Rounding.Down); + if (util <= OPTIMAL_UTIL) return MIN_RATE_1 + SLOPE_1.mulDiv(util, OPTIMAL_UTIL, Math.Rounding.Up); + else return MIN_RATE_2 + SLOPE_2.mulDiv((util - OPTIMAL_UTIL), MAX_EXCESS_UTIL, Math.Rounding.Up); } } diff --git a/protocol-v2/src/irm/LinearRateModel.sol b/protocol-v2/src/irm/LinearRateModel.sol index 9d999d2..9535be1 100644 --- a/protocol-v2/src/irm/LinearRateModel.sol +++ b/protocol-v2/src/irm/LinearRateModel.sol @@ -45,7 +45,11 @@ contract LinearRateModel is IRateModel { uint256 lastUpdated, uint256 totalBorrows, uint256 totalAssets - ) external view returns (uint256) { + ) + external + view + returns (uint256) + { // [ROUND] rateFactor is rounded up, in favor of the protocol // rateFactor = time delta * apr / secs_per_year uint256 rateFactor = ((block.timestamp - lastUpdated)).mulDiv( diff --git a/protocol-v2/src/lens/PortfolioLens.sol b/protocol-v2/src/lens/PortfolioLens.sol index 62260ea..d4d7edd 100644 --- a/protocol-v2/src/lens/PortfolioLens.sol +++ b/protocol-v2/src/lens/PortfolioLens.sol @@ -166,7 +166,7 @@ contract PortfolioLens { /// @dev Compute the ETH value scaled to 18 decimals for a given amount of an asset function _getValueInEth(address asset, uint256 amt) internal view returns (uint256) { - IOracle oracle = IOracle(RISK_ENGINE.getOracleFor(asset)); + IOracle oracle = IOracle(RISK_ENGINE.oracleFor(asset)); // oracles could revert, but lens calls must not try oracle.getValueInEth(asset, amt) returns (uint256 valueInEth) { @@ -175,4 +175,33 @@ contract PortfolioLens { return 0; } } + + /// @notice Gets the total debt owed by a position in ETH + function getTotalDebtValue(address position) public view returns (uint256) { + uint256[] memory debtPools = Position(payable(position)).getDebtPools(); + + uint256 totalDebtValue; + uint256 debtPoolsLength = debtPools.length; + for (uint256 i; i < debtPoolsLength; ++i) { + address poolAsset = POOL.getPoolAssetFor(debtPools[i]); + uint256 borrowAmt = POOL.getBorrowsOf(debtPools[i], position); + totalDebtValue += RISK_ENGINE.getValueInEth(poolAsset, borrowAmt); + } + + return totalDebtValue; + } + + /// @notice Gets the total ETH value of assets in a position + function getTotalAssetValue(address position) public view returns (uint256) { + address[] memory positionAssets = Position(payable(position)).getPositionAssets(); + + uint256 totalAssetValue; + uint256 positionAssetsLength = positionAssets.length; + for (uint256 i; i < positionAssetsLength; ++i) { + uint256 amt = IERC20(positionAssets[i]).balanceOf(position); + totalAssetValue += RISK_ENGINE.getValueInEth(positionAssets[i], amt); + } + + return totalAssetValue; + } } diff --git a/protocol-v2/src/lens/SuperPoolLens.sol b/protocol-v2/src/lens/SuperPoolLens.sol index 8cef1c4..e201e79 100644 --- a/protocol-v2/src/lens/SuperPoolLens.sol +++ b/protocol-v2/src/lens/SuperPoolLens.sol @@ -92,7 +92,11 @@ contract SuperPoolLens { function getPoolDepositData( address superPool, uint256 poolId - ) public view returns (PoolDepositData memory poolDepositData) { + ) + public + view + returns (PoolDepositData memory poolDepositData) + { address asset = POOL.getPoolAssetFor(poolId); uint256 amount = POOL.getAssetsOf(poolId, superPool); @@ -121,7 +125,11 @@ contract SuperPoolLens { function getUserMultiDepositData( address user, address[] calldata superPools - ) public view returns (UserMultiDepositData memory userMultiDepositData) { + ) + public + view + returns (UserMultiDepositData memory userMultiDepositData) + { UserDepositData[] memory deposits = new UserDepositData[](superPools.length); uint256 totalValueInEth; @@ -167,7 +175,11 @@ contract SuperPoolLens { function getUserDepositData( address user, address _superPool - ) public view returns (UserDepositData memory userDepositData) { + ) + public + view + returns (UserDepositData memory userDepositData) + { SuperPool superPool = SuperPool(_superPool); address asset = address(superPool.asset()); uint256 amount = superPool.previewRedeem(superPool.balanceOf(user)); @@ -212,7 +224,7 @@ contract SuperPoolLens { /// @dev Compute the ETH value scaled to 18 decimals for a given amount of an asset function _getValueInEth(address asset, uint256 amt) internal view returns (uint256) { - IOracle oracle = IOracle(RISK_ENGINE.getOracleFor(asset)); + IOracle oracle = IOracle(RISK_ENGINE.oracleFor(asset)); // oracles could revert, but lens calls must not try oracle.getValueInEth(asset, amt) returns (uint256 valueInEth) { diff --git a/protocol-v2/src/oracle/ChainlinkEthOracle.sol b/protocol-v2/src/oracle/ChainlinkEthOracle.sol index 27dda5d..ee51ebd 100644 --- a/protocol-v2/src/oracle/ChainlinkEthOracle.sol +++ b/protocol-v2/src/oracle/ChainlinkEthOracle.sol @@ -12,13 +12,13 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; /// @title IAggregatorV3 /// @notice Chainlink Aggregator v3 interface -interface IAggegregatorV3 { +interface IAggregatorV3 { function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); - function decimals() external view returns (uint256); + function decimals() external view returns (uint8); } /// @title ChainlinkEthOracle @@ -30,17 +30,20 @@ contract ChainlinkEthOracle is Ownable, IOracle { uint256 public constant SEQ_GRACE_PERIOD = 3600; // 1 hour /// @notice Chainlink arbitrum sequencer uptime feed - IAggegregatorV3 public immutable ARB_SEQ_FEED; + IAggregatorV3 public immutable ARB_SEQ_FEED; - /// @notice Fetch the ETH-denominated price feed associated with a given asset - /// @dev returns address(0) if there is no associated feed - mapping(address asset => address feed) public priceFeedFor; + struct PriceFeed { + address feed; + uint256 feedDecimals; + uint256 assetDecimals; + uint256 stalePriceThreshold; + } - /// @notice Prices older than the stale price threshold are considered invalid - mapping(address feed => uint256 stalePriceThreshold) public stalePriceThresholdFor; + /// @notice Fetch configured feed data for a given asset + mapping(address asset => PriceFeed feed) public priceFeedFor; /// @notice An Eth-denomiated chainlink feed has been associated with an asset - event FeedSet(address indexed asset, address feed); + event FeedSet(address indexed asset, PriceFeed feedData); /// @notice L2 sequencer is experiencing downtime error ChainlinkEthOracle_SequencerDown(); @@ -52,11 +55,13 @@ contract ChainlinkEthOracle is Ownable, IOracle { error ChainlinkEthOracle_NonPositivePrice(address asset); /// @notice Invalid oracle update round error ChainlinkEthOracle_InvalidRound(); + /// @notice Missing price feed + error ChainlinkEthOracle_MissingPriceFeed(address asset); /// @param owner Oracle owner address /// @param arbSeqFeed Chainlink arbitrum sequencer feed constructor(address owner, address arbSeqFeed) Ownable() { - ARB_SEQ_FEED = IAggegregatorV3(arbSeqFeed); + ARB_SEQ_FEED = IAggregatorV3(arbSeqFeed); _transferOwnership(owner); } @@ -67,8 +72,9 @@ contract ChainlinkEthOracle is Ownable, IOracle { function getValueInEth(address asset, uint256 amt) external view returns (uint256) { _checkSequencerFeed(); + PriceFeed storage priceFeed = priceFeedFor[asset]; // [ROUND] price is rounded down. this is used for both debt and asset math, neutral effect. - return amt.mulDiv(_getPriceWithSanityChecks(asset), (10 ** IERC20Metadata(asset).decimals())); + return amt.mulDiv(_getPriceWithSanityChecks(asset, priceFeed), (10 ** priceFeed.assetDecimals)); } /// @notice Set Chainlink ETH-denominated feed for an asset @@ -77,10 +83,14 @@ contract ChainlinkEthOracle is Ownable, IOracle { /// @param stalePriceThreshold prices older than this duration are considered invalid, denominated in seconds /// @dev stalePriceThreshold must be equal or greater to the feed's heartbeat function setFeed(address asset, address feed, uint256 stalePriceThreshold) external onlyOwner { - assert(IAggegregatorV3(feed).decimals() == 18); - priceFeedFor[asset] = feed; - stalePriceThresholdFor[feed] = stalePriceThreshold; - emit FeedSet(asset, feed); + PriceFeed memory feedData = PriceFeed({ + feed: feed, + feedDecimals: IAggregatorV3(feed).decimals(), + assetDecimals: IERC20Metadata(asset).decimals(), + stalePriceThreshold: stalePriceThreshold + }); + priceFeedFor[asset] = feedData; + emit FeedSet(asset, feedData); } /// @dev Check L2 sequencer health @@ -96,11 +106,19 @@ contract ChainlinkEthOracle is Ownable, IOracle { } /// @dev Fetch price from chainlink feed with sanity checks - function _getPriceWithSanityChecks(address asset) private view returns (uint256) { - address feed = priceFeedFor[asset]; - (, int256 price,, uint256 updatedAt,) = IAggegregatorV3(feed).latestRoundData(); + function _getPriceWithSanityChecks(address asset, PriceFeed storage priceFeed) private view returns (uint256) { + // check if feed exists + address feed = priceFeed.feed; + if (feed == address(0)) revert ChainlinkEthOracle_MissingPriceFeed(asset); + + // fetch price with checks + (, int256 price,, uint256 updatedAt,) = IAggregatorV3(feed).latestRoundData(); if (price <= 0) revert ChainlinkEthOracle_NonPositivePrice(asset); - if (updatedAt < block.timestamp - stalePriceThresholdFor[feed]) revert ChainlinkEthOracle_StalePrice(asset); - return uint256(price); + if (updatedAt < block.timestamp - priceFeed.stalePriceThreshold) revert ChainlinkEthOracle_StalePrice(asset); + + // scale price to 18 decimals + uint256 feedDecimals = priceFeed.feedDecimals; + if (feedDecimals <= 18) return uint256(price) * (10 ** (18 - feedDecimals)); + else return uint256(price) / (10 ** (feedDecimals - 18)); } } diff --git a/protocol-v2/src/oracle/ChainlinkUsdOracle.sol b/protocol-v2/src/oracle/ChainlinkUsdOracle.sol index ecfb06e..bfaa55d 100644 --- a/protocol-v2/src/oracle/ChainlinkUsdOracle.sol +++ b/protocol-v2/src/oracle/ChainlinkUsdOracle.sol @@ -1,22 +1,20 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/*////////////////////////////////////////////////////////////// - ChainlinkUsdOracle -//////////////////////////////////////////////////////////////*/ - import { IOracle } from "../interfaces/IOracle.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -interface IAggegregatorV3 { +/// @title IAggregatorV3 +/// @notice Chainlink Aggregator v3 interface +interface IAggregatorV3 { function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); - function decimals() external view returns (uint256); + function decimals() external view returns (uint8); } /// @title ChainlinkUsdOracle @@ -26,25 +24,25 @@ contract ChainlinkUsdOracle is Ownable, IOracle { /// @dev internal alias for native ETH address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - /// @notice L2 sequencer uptime grace period during which prices are treated as stale uint256 public constant SEQ_GRACE_PERIOD = 3600; // 1 hour /// @notice Chainlink arbitrum sequencer uptime feed - IAggegregatorV3 public immutable ARB_SEQ_FEED; + IAggregatorV3 public immutable ARB_SEQ_FEED; - /// @notice Chainlink ETH/USD price feed - IAggegregatorV3 public immutable ETH_USD_FEED; + struct PriceFeed { + address feed; + uint256 feedDecimals; + uint256 assetDecimals; + uint256 stalePriceThreshold; + } /// @notice Fetch the ETH-denominated price feed associated with a given asset /// @dev returns address(0) if there is no associated feed - mapping(address asset => address feed) public priceFeedFor; - - /// @notice Prices older than the stale price threshold are considered invalid - mapping(address feed => uint256 stalePriceThreshold) public stalePriceThresholdFor; + mapping(address asset => PriceFeed feed) public priceFeedFor; /// @notice New Usd-denomiated chainlink feed has been associated with an asset - event FeedSet(address indexed asset, address feed); + event FeedSet(address indexed asset, PriceFeed feedData); /// @notice L2 sequencer is experiencing downtime error ChainlinkUsdOracle_SequencerDown(); @@ -56,16 +54,24 @@ contract ChainlinkUsdOracle is Ownable, IOracle { error ChainlinkUsdOracle_NonPositivePrice(address asset); /// @notice Invalid oracle update round error ChainlinkUsdOracle_InvalidRound(); + /// @notice Missing price feed + error ChainlinkUsdOracle_MissingPriceFeed(address asset); /// @param owner Oracle owner address /// @param arbSeqFeed Chainlink arbitrum sequencer feed /// @param ethUsdFeed Chainlink ETH/USD price feed /// @param ethUsdThreshold Stale price threshold for ETH/USD feed constructor(address owner, address arbSeqFeed, address ethUsdFeed, uint256 ethUsdThreshold) Ownable() { - ARB_SEQ_FEED = IAggegregatorV3(arbSeqFeed); - ETH_USD_FEED = IAggegregatorV3(ethUsdFeed); - priceFeedFor[ETH] = ethUsdFeed; - stalePriceThresholdFor[ETH] = ethUsdThreshold; + ARB_SEQ_FEED = IAggregatorV3(arbSeqFeed); + + PriceFeed memory feed = PriceFeed({ + feed: ethUsdFeed, + feedDecimals: IAggregatorV3(ethUsdFeed).decimals(), + assetDecimals: 18, + stalePriceThreshold: ethUsdThreshold + }); + priceFeedFor[ETH] = feed; + emit FeedSet(ETH, feed); _transferOwnership(owner); } @@ -75,15 +81,19 @@ contract ChainlinkUsdOracle is Ownable, IOracle { /// @param amt Amount of the given asset to be priced function getValueInEth(address asset, uint256 amt) external view returns (uint256) { _checkSequencerFeed(); + PriceFeed storage priceFeed = priceFeedFor[asset]; - uint256 ethUsdPrice = _getPriceWithSanityChecks(ETH); - uint256 assetUsdPrice = _getPriceWithSanityChecks(asset); + // fetch asset/usd and eth/usd price scaled to 18 decimals + uint256 assetUsdPrice = _getPriceWithSanityChecks(asset, priceFeed); + uint256 ethUsdPrice = _getPriceWithSanityChecks(ETH, priceFeedFor[ETH]); - uint256 decimals = IERC20Metadata(asset).decimals(); + // scale amt to 18 decimals + uint256 scaledAmt; + uint256 assetDecimals = priceFeed.assetDecimals; + if (assetDecimals <= 18) scaledAmt = amt * (10 ** (18 - assetDecimals)); + else scaledAmt = amt / (10 ** (assetDecimals - 18)); - // [ROUND] price is rounded down. this is used for both debt and asset math, no effect - if (decimals <= 18) return (amt * 10 ** (18 - decimals)).mulDiv(uint256(assetUsdPrice), uint256(ethUsdPrice)); - else return (amt / (10 ** decimals - 18)).mulDiv(uint256(assetUsdPrice), uint256(ethUsdPrice)); + return scaledAmt.mulDiv(assetUsdPrice, ethUsdPrice); } /// @notice Set Chainlink ETH-denominated feed for an asset @@ -92,10 +102,14 @@ contract ChainlinkUsdOracle is Ownable, IOracle { /// @param stalePriceThreshold prices older than this duration are considered invalid, denominated in seconds /// @dev stalePriceThreshold must be equal or greater to the feed's heartbeat function setFeed(address asset, address feed, uint256 stalePriceThreshold) external onlyOwner { - assert(IAggegregatorV3(feed).decimals() == 8); - priceFeedFor[asset] = feed; - stalePriceThresholdFor[feed] = stalePriceThreshold; - emit FeedSet(asset, feed); + PriceFeed memory feedData = PriceFeed({ + feed: feed, + feedDecimals: IAggregatorV3(feed).decimals(), + assetDecimals: IERC20Metadata(asset).decimals(), + stalePriceThreshold: stalePriceThreshold + }); + priceFeedFor[asset] = feedData; + emit FeedSet(asset, feedData); } /// @dev Check L2 sequencer health @@ -111,11 +125,19 @@ contract ChainlinkUsdOracle is Ownable, IOracle { } /// @dev Fetch price from chainlink feed with sanity checks - function _getPriceWithSanityChecks(address asset) private view returns (uint256) { - address feed = priceFeedFor[asset]; - (, int256 price,, uint256 updatedAt,) = IAggegregatorV3(feed).latestRoundData(); + function _getPriceWithSanityChecks(address asset, PriceFeed storage priceFeed) private view returns (uint256) { + // check if feed exists + address feed = priceFeed.feed; + if (feed == address(0)) revert ChainlinkUsdOracle_MissingPriceFeed(asset); + + // fetch price with checks + (, int256 price,, uint256 updatedAt,) = IAggregatorV3(feed).latestRoundData(); if (price <= 0) revert ChainlinkUsdOracle_NonPositivePrice(asset); - if (updatedAt < block.timestamp - stalePriceThresholdFor[feed]) revert ChainlinkUsdOracle_StalePrice(asset); - return uint256(price); + if (updatedAt < block.timestamp - priceFeed.stalePriceThreshold) revert ChainlinkUsdOracle_StalePrice(asset); + + // scale price to 18 decimals + uint256 feedDecimals = priceFeed.feedDecimals; + if (feedDecimals <= 18) return uint256(price) * (10 ** (18 - feedDecimals)); + else return uint256(price) / (10 ** (feedDecimals - 18)); } } diff --git a/protocol-v2/src/oracle/RedstoneOracle.sol b/protocol-v2/src/oracle/RedstoneOracle.sol index 666f467..6f255c8 100644 --- a/protocol-v2/src/oracle/RedstoneOracle.sol +++ b/protocol-v2/src/oracle/RedstoneOracle.sol @@ -9,18 +9,18 @@ import { PrimaryProdDataServiceConsumerBase } from // libraries import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -contract RedstoneCoreOracle is PrimaryProdDataServiceConsumerBase, IOracle { +contract RedstoneOracle is PrimaryProdDataServiceConsumerBase, IOracle { using Math for uint256; - uint256 internal constant THREE_MINUTES = 60 * 3; - - // stale price threshold, prices older than this period are considered stale - // the oracle can misreport stale prices for feeds with longer hearbeats + address internal constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; uint256 public constant STALE_PRICE_THRESHOLD = 3600; // 1 hour address public immutable ASSET; uint256 public immutable ASSET_DECIMALS; + uint256 public immutable ETH_FEED_DECIMALS; + uint256 public immutable ASSET_FEED_DECIMALS; + bytes32 public immutable ETH_FEED_ID; bytes32 public immutable ASSET_FEED_ID; @@ -32,36 +32,56 @@ contract RedstoneCoreOracle is PrimaryProdDataServiceConsumerBase, IOracle { // dataFeedIds[0] -> redstone feed id for ETH bytes32[] internal dataFeedIds = new bytes32[](2); - error RedstoneCoreOracle_StalePrice(address asset); - - constructor(address asset, bytes32 assetFeedId, bytes32 ethFeedId) { + error RedstoneOracle_ZeroPrice(address asset); + error RedstoneOracle_StalePrice(address asset); + error RedstoneOracle_InvalidTimestamp(uint256 timestamp); + + constructor( + address asset, + bytes32 assetFeedId, + bytes32 ethFeedId, + uint256 assetFeedDecimals, + uint256 ethFeedDecimals + ) { ASSET = asset; ASSET_DECIMALS = IERC20Metadata(asset).decimals(); ASSET_FEED_ID = assetFeedId; ETH_FEED_ID = ethFeedId; + ASSET_FEED_DECIMALS = assetFeedDecimals; + ETH_FEED_DECIMALS = ethFeedDecimals; + dataFeedIds[0] = assetFeedId; dataFeedIds[1] = ethFeedId; } function updatePrice() external { - // values[0] -> price of ASSET/USD - // values[1] -> price of ETH/USD - // values are scaled to 8 decimals - uint256[] memory values = getOracleNumericValuesFromTxMsg(dataFeedIds); - - assetUsdPrice = values[0]; - ethUsdPrice = values[1]; - - // RedstoneDefaultLibs.sol enforces that prices are not older than 3 mins. since it is not - // possible to retrieve timestamps for individual prices being passed, we consider the worst - // case and assume both prices are 3 mins old - priceTimestamp = block.timestamp - THREE_MINUTES; + // fetch ASSET/USD and ETH/USD price with package timestamp + // values[0] -> ASSET/USD price with ASSET_FEED_DECIMALS decimals + // values[1] -> ETH/USD price with ETH_FEED_DECIMALS decimals + // timestamp -> data package timestamp in milliseconds + (uint256[] memory values, uint256 timestamp) = getOracleNumericValuesAndTimestampFromTxMsg(dataFeedIds); + + // non-zero price checks + if (values[0] == 0) revert RedstoneOracle_ZeroPrice(ASSET); + if (values[1] == 0) revert RedstoneOracle_ZeroPrice(ETH); + + // scale ASSET/USD price to 18 decimals + if (ASSET_FEED_DECIMALS <= 18) assetUsdPrice = values[0] * (10 ** (18 - ASSET_FEED_DECIMALS)); + else assetUsdPrice = values[0] / (10 ** (ASSET_FEED_DECIMALS - 18)); + + // scale ETH/USD price to 18 decimals + if (ETH_FEED_DECIMALS <= 18) ethUsdPrice = values[1] * (10 ** (18 - ETH_FEED_DECIMALS)); + else ethUsdPrice = values[1] / (10 ** (ETH_FEED_DECIMALS - 18)); + + // update price timestamp with checks + if (priceTimestamp > timestamp) revert RedstoneOracle_InvalidTimestamp(timestamp); + priceTimestamp = timestamp; } function getValueInEth(address, uint256 amt) external view returns (uint256) { - if (priceTimestamp < block.timestamp - STALE_PRICE_THRESHOLD) revert RedstoneCoreOracle_StalePrice(ASSET); + if (priceTimestamp < block.timestamp - STALE_PRICE_THRESHOLD) revert RedstoneOracle_StalePrice(ASSET); // scale amt to 18 decimals if (ASSET_DECIMALS <= 18) amt = amt * 10 ** (18 - ASSET_DECIMALS); diff --git a/protocol-v2/test/BaseTest.t.sol b/protocol-v2/test/BaseTest.t.sol index 39f3676..dca328b 100644 --- a/protocol-v2/test/BaseTest.t.sol +++ b/protocol-v2/test/BaseTest.t.sol @@ -103,17 +103,27 @@ contract BaseTest is Test { Registry(protocol.registry()).setRateModel(LINEAR_RATE_MODEL2_KEY, linearRateModel2); vm.stopPrank(); + asset1.mint(poolOwner, 4e7); + asset2.mint(poolOwner, 1e7); + vm.startPrank(poolOwner); - fixedRatePool = - protocol.pool().initializePool(poolOwner, address(asset1), type(uint128).max, FIXED_RATE_MODEL_KEY); - linearRatePool = - protocol.pool().initializePool(poolOwner, address(asset1), type(uint128).max, LINEAR_RATE_MODEL_KEY); - fixedRatePool2 = - protocol.pool().initializePool(poolOwner, address(asset1), type(uint128).max, FIXED_RATE_MODEL2_KEY); - linearRatePool2 = - protocol.pool().initializePool(poolOwner, address(asset1), type(uint128).max, LINEAR_RATE_MODEL2_KEY); - alternateAssetPool = - protocol.pool().initializePool(poolOwner, address(asset2), type(uint128).max, FIXED_RATE_MODEL_KEY); + asset1.approve(address(protocol.pool()), type(uint256).max); + asset2.approve(address(protocol.pool()), type(uint256).max); + fixedRatePool = protocol.pool().initializePool( + poolOwner, address(asset1), FIXED_RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + linearRatePool = protocol.pool().initializePool( + poolOwner, address(asset1), LINEAR_RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + fixedRatePool2 = protocol.pool().initializePool( + poolOwner, address(asset1), FIXED_RATE_MODEL2_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + linearRatePool2 = protocol.pool().initializePool( + poolOwner, address(asset1), LINEAR_RATE_MODEL2_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + alternateAssetPool = protocol.pool().initializePool( + poolOwner, address(asset2), FIXED_RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); vm.stopPrank(); } diff --git a/protocol-v2/test/core/Pool.t.sol b/protocol-v2/test/core/Pool.t.sol index 79b2a55..3e3ce90 100644 --- a/protocol-v2/test/core/Pool.t.sol +++ b/protocol-v2/test/core/Pool.t.sol @@ -6,6 +6,8 @@ import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/O import { FixedPriceOracle } from "src/oracle/FixedPriceOracle.sol"; contract PoolUnitTests is BaseTest { + uint256 private constant BURNED_SHARES = 4e7; // change if new pools are added + // keccak(SENTIMENT_POSITION_MANAGER_KEY) bytes32 public constant SENTIMENT_POSITION_MANAGER_KEY = 0xd4927490fbcbcafca716cca8e8c8b7d19cda785679d224b14f15ce2a9a93e148; @@ -25,30 +27,37 @@ contract PoolUnitTests is BaseTest { riskEngine.setOracle(address(asset1), address(asset1Oracle)); } - function testIntializePool() public { + function testInitializePool() public { // test constructor address poolImpl = address(new Pool()); Pool testPool = Pool(address(new TransparentUpgradeableProxy(poolImpl, protocolOwner, new bytes(0)))); - testPool.initialize(protocolOwner, 0, 0, address(registry), address(0), 0, 0); + testPool.initialize(protocolOwner, address(registry), address(0xdEaD), 0, 0, 0, 0); assertEq(testPool.registry(), address(registry)); address rateModel = address(new LinearRateModel(1e18, 2e18)); bytes32 RATE_MODEL_KEY = 0xc6e8fa81936202e651519e9ac3074fa4a42c65daad3fded162373ba224d6ea96; vm.prank(protocolOwner); registry.setRateModel(RATE_MODEL_KEY, rateModel); - uint256 id = testPool.initializePool(poolOwner, address(asset1), type(uint128).max, RATE_MODEL_KEY); + asset1.mint(address(this), 1e7); + asset1.approve(address(testPool), 1e7); + uint256 id = testPool.initializePool( + poolOwner, address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); assertEq(rateModel, testPool.getRateModelFor(id)); } - /// @dev Foundry "fails" keyword function testFailsDoubleInit() public { address rateModel = address(new LinearRateModel(1e18, 2e18)); bytes32 RATE_MODEL_KEY = 0xc6e8fa81936202e651519e9ac3074fa4a42c65daad3fded162373ba224d6ea96; vm.prank(protocolOwner); registry.setRateModel(RATE_MODEL_KEY, rateModel); - pool.initializePool(poolOwner, address(asset1), type(uint128).max, RATE_MODEL_KEY); - pool.initializePool(poolOwner, address(asset1), type(uint128).max, RATE_MODEL_KEY); + asset1.mint(poolOwner, 2e7); + vm.startPrank(poolOwner); + asset1.approve(address(pool), 2e7); + pool.initializePool(poolOwner, address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7); + pool.initializePool(poolOwner, address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7); + vm.stopPrank(); } function testCannotFrontRunDeployment() public { @@ -58,11 +67,20 @@ contract PoolUnitTests is BaseTest { vm.prank(protocolOwner); registry.setRateModel(RATE_MODEL_KEY, rateModel); - vm.prank(poolOwner); - uint256 id = pool.initializePool(poolOwner, address(asset1), type(uint128).max, RATE_MODEL_KEY); + asset1.mint(poolOwner, 1e7); + vm.startPrank(poolOwner); + asset1.approve(address(pool), 1e7); + uint256 id = + pool.initializePool(poolOwner, address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7); + vm.stopPrank(); - vm.prank(notPoolOwner); - uint256 id2 = pool.initializePool(notPoolOwner, address(asset1), type(uint128).max, RATE_MODEL_KEY); + asset1.mint(notPoolOwner, 1e7); + vm.startPrank(notPoolOwner); + asset1.approve(address(pool), 1e7); + uint256 id2 = pool.initializePool( + notPoolOwner, address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + vm.stopPrank(); assert(id != id2); } @@ -127,7 +145,7 @@ contract PoolUnitTests is BaseTest { function testCannotWithdrawNoShares() public { vm.startPrank(user); - vm.expectRevert(abi.encodeWithSelector(Pool.Pool_ZeroShareRedeem.selector, linearRatePool, 0)); + vm.expectRevert(abi.encodeWithSelector(Pool.Pool_ZeroShareWithdraw.selector, linearRatePool, 0)); pool.withdraw(linearRatePool, 0, user, user); } @@ -193,7 +211,7 @@ contract PoolUnitTests is BaseTest { pool.borrow(linearRatePool, user, assets / 5); assertEq(pool.getAssetsOf(linearRatePool, user), assets); - assertApproxEqAbs(asset1.balanceOf(address(pool)), assets * 4 / 5, 1); + assertApproxEqAbs(asset1.balanceOf(address(pool)), BURNED_SHARES + assets * 4 / 5, 1); assertApproxEqAbs(asset1.balanceOf(user), assets / 5, 1); assertEq(pool.getBorrowsOf(linearRatePool, user), assets / 5); @@ -217,7 +235,7 @@ contract PoolUnitTests is BaseTest { function testTimeIncreasesDebt(uint96 assets) public { testBorrowWorksAsIntended(assets); - (,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); uint256 time = block.timestamp + 1 days; vm.warp(time + 86_400 * 7); @@ -225,7 +243,7 @@ contract PoolUnitTests is BaseTest { pool.accrue(linearRatePool); - (,,,,,,, uint256 newTotalBorrowAssets, uint256 newTotalBorrowShares,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 newTotalBorrowAssets, uint256 newTotalBorrowShares,,) = pool.poolDataFor(linearRatePool); assertEq(newTotalBorrowShares, totalBorrowShares); assertGt(newTotalBorrowAssets, totalBorrowAssets); @@ -234,7 +252,7 @@ contract PoolUnitTests is BaseTest { function testCanWithdrawEarnedInterest(uint96 assets) public { testTimeIncreasesDebt(assets); - (,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); assertGt(totalBorrowAssets, totalBorrowShares); @@ -296,13 +314,13 @@ contract PoolUnitTests is BaseTest { } function testOwnerCanPause() public { - (bool isPaused,,,,,,,,,,) = pool.poolDataFor(linearRatePool); + (bool isPaused,,,,,,,,,,,) = pool.poolDataFor(linearRatePool); assertFalse(isPaused); vm.prank(poolOwner); pool.togglePause(linearRatePool); - (isPaused,,,,,,,,,,) = pool.poolDataFor(linearRatePool); + (isPaused,,,,,,,,,,,) = pool.poolDataFor(linearRatePool); assertTrue(isPaused); } @@ -314,14 +332,14 @@ contract PoolUnitTests is BaseTest { pool.togglePause(linearRatePool); } - function testOwnerCanSetCap(uint128 newPoolCap) public { - (,,, uint256 poolCap,,,,,,,) = pool.poolDataFor(linearRatePool); - assert(poolCap == type(uint128).max); + function testOwnerCanSetCap(uint256 newPoolCap) public { + (,,,, uint256 poolCap,,,,,,,) = pool.poolDataFor(linearRatePool); + assert(poolCap == type(uint256).max); vm.prank(poolOwner); pool.setPoolCap(linearRatePool, newPoolCap); - (,,, poolCap,,,,,,,) = pool.poolDataFor(linearRatePool); + (,,,, poolCap,,,,,,,) = pool.poolDataFor(linearRatePool); assertEq(poolCap, newPoolCap); } @@ -379,7 +397,7 @@ contract PoolUnitTests is BaseTest { vm.prank(poolOwner); pool.acceptRateModelUpdate(linearRatePool); - (,, address rateModel,,,,,,,,) = pool.poolDataFor(linearRatePool); + (,, address rateModel,,,,,,,,,) = pool.poolDataFor(linearRatePool); assertEq(rateModel, newRateModel); } diff --git a/protocol-v2/test/core/Position.t.sol b/protocol-v2/test/core/Position.t.sol index 4ee99bf..5c96093 100644 --- a/protocol-v2/test/core/Position.t.sol +++ b/protocol-v2/test/core/Position.t.sol @@ -6,10 +6,14 @@ import "../BaseTest.t.sol"; import { FixedRateModel } from "../../src/irm/FixedRateModel.sol"; import { LinearRateModel } from "../../src/irm/LinearRateModel.sol"; import { MockERC20 } from "../mocks/MockERC20.sol"; + +import { StdStorage, stdStorage } from "forge-std/Test.sol"; import { Action, Operation } from "src/PositionManager.sol"; import { FixedPriceOracle } from "src/oracle/FixedPriceOracle.sol"; contract PositionUnitTests is BaseTest { + using stdStorage for StdStorage; + Pool pool; address payable position; RiskEngine riskEngine; @@ -58,9 +62,10 @@ contract PositionUnitTests is BaseTest { address pool2 = makeAddr("pool2"); address positionManager2 = makeAddr("positionManager2"); - Position newPosition = new Position(pool2, positionManager2); + Position newPosition = new Position(pool2, positionManager2, address(riskEngine)); assertEq(address(newPosition.POOL()), pool2); + assertEq(address(newPosition.RISK_ENGINE()), address(riskEngine)); assertEq(address(newPosition.POSITION_MANAGER()), positionManager2); } @@ -93,6 +98,25 @@ contract PositionUnitTests is BaseTest { Position(position).exec(address(0x0), 0, bytes("")); } + function testBorrowInvalidPair(uint256 poolA, uint256 poolB) public { + vm.assume(poolA > 0); + vm.assume(poolB > 0); + vm.assume(poolA != poolB); + + vm.startPrank(address(positionManager)); + Position(position).borrow(poolA, 10_000 ether); + + vm.expectRevert(); + Position(position).borrow(poolB, 10_000 ether); + + stdstore.target(address(riskEngine)).sig("isAllowedPair(uint256,uint256)").with_key(poolA).with_key(poolB) + .checked_write(true); + stdstore.target(address(riskEngine)).sig("isAllowedPair(uint256,uint256)").with_key(poolB).with_key(poolA) + .checked_write(true); + + Position(position).borrow(poolB, 10_000 ether); + } + function testCannotExceedPoolMaxLength() public { vm.startPrank(address(positionManager)); @@ -103,6 +127,14 @@ contract PositionUnitTests is BaseTest { vm.expectRevert(); Position(position).addToken(address(vm.addr(6))); + for (uint256 i = 1; i <= 7; i++) { + for (uint256 j = 1; j <= 7; j++) { + if (i == j) continue; + stdstore.target(address(riskEngine)).sig("isAllowedPair(uint256,uint256)").with_key(i).with_key(j) + .checked_write(true); + } + } + for (uint256 i = 1; i < 6; i++) { Position(position).borrow(i, 10_000 ether); } diff --git a/protocol-v2/test/core/PositionManager.t.sol b/protocol-v2/test/core/PositionManager.t.sol index b8b6c13..397af76 100644 --- a/protocol-v2/test/core/PositionManager.t.sol +++ b/protocol-v2/test/core/PositionManager.t.sol @@ -76,7 +76,7 @@ contract PositionManagerUnitTests is BaseTest { address beacon = address((new TransparentUpgradeableProxy(positionManagerImpl, positionOwner, new bytes(0)))); positionManager = PositionManager(beacon); // setup proxy - PositionManager(positionManager).initialize(protocolOwner, address(registry), 550); + PositionManager(positionManager).initialize(protocolOwner, address(registry)); registry.setAddress(SENTIMENT_POSITION_BEACON_KEY, beacon); registry.setAddress(SENTIMENT_POOL_KEY, address(pool)); @@ -152,9 +152,7 @@ contract PositionManagerUnitTests is BaseTest { PositionManager(positionManager).processBatch(position, actions); (uint256 totalAssetValue, uint256 totalDebtValue, uint256 minReqAssetValue) = riskEngine.getRiskData(position); - assertEq( - totalAssetValue, IOracle(riskEngine.getOracleFor(address(asset2))).getValueInEth(address(asset2), amount) - ); + assertEq(totalAssetValue, riskEngine.getValueInEth(address(asset2), amount)); assertEq(totalDebtValue, 0); assertEq(minReqAssetValue, 0); assertEq(asset2.balanceOf(address(position)), amount); @@ -206,13 +204,13 @@ contract PositionManagerUnitTests is BaseTest { actions[0] = action; uint256 initialAssetBalance = asset1.balanceOf(position); - (,,,,,,, uint256 totalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 totalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); assertEq(address(positionManager.pool()), address(pool)); PositionManager(positionManager).processBatch(position, actions); assertGt(asset1.balanceOf(position), initialAssetBalance); - (,,,,,,, uint256 newTotalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 newTotalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); assertEq(newTotalBorrowAssets, totalBorrowAssets + 2 ether); } @@ -259,7 +257,11 @@ contract PositionManagerUnitTests is BaseTest { vm.prank(protocolOwner); registry.setRateModel(RATE_MODEL_KEY, rateModel); - uint256 corruptPool = pool.initializePool(address(0xdead), address(asset1), type(uint128).max, RATE_MODEL_KEY); + asset1.mint(address(this), 1e7); + asset1.approve(address(pool), 1e7); + uint256 corruptPool = pool.initializePool( + address(0xdead), address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); vm.startPrank(positionOwner); bytes memory data = abi.encode(corruptPool, 2 ether); @@ -282,12 +284,12 @@ contract PositionManagerUnitTests is BaseTest { Action[] memory actions = new Action[](1); actions[0] = action; - (,,,,,,, uint256 totalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 totalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); uint256 initialBorrow = pool.getBorrowsOf(linearRatePool, position); PositionManager(positionManager).processBatch(position, actions); - (,,,,,,, uint256 newTotalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 newTotalBorrowAssets,,,) = pool.poolDataFor(linearRatePool); uint256 borrow = pool.getBorrowsOf(linearRatePool, position); @@ -394,30 +396,6 @@ contract PositionManagerUnitTests is BaseTest { assertEq(testContract.ping(), 1); } - // Test setting the beacon address - function testSetBeacon() public { - address newBeacon = makeAddr("newBeacon"); - vm.prank(positionManager.owner()); - positionManager.setBeacon(newBeacon); - assertEq(positionManager.positionBeacon(), newBeacon, "Beacon address should be updated"); - - vm.startPrank(makeAddr("nonOwner")); // Non-owner address - vm.expectRevert(); - positionManager.setBeacon(newBeacon); - } - - // Test setting the liquidation fee - function testSetLiquidationFee() public { - uint256 newFee = 100; // Example fee - vm.prank(positionManager.owner()); - positionManager.setLiquidationFee(newFee); - assertEq(positionManager.liquidationFee(), newFee, "Liquidation fee should be updated"); - - vm.startPrank(makeAddr("nonOwner")); // Non-owner address - vm.expectRevert(); - positionManager.setLiquidationFee(newFee); - } - // Test toggling known asset function testToggleKnownAsset() public { address target = makeAddr("target"); diff --git a/protocol-v2/test/core/RiskEngine.t.sol b/protocol-v2/test/core/RiskEngine.t.sol index 549cb75..3d1c464 100644 --- a/protocol-v2/test/core/RiskEngine.t.sol +++ b/protocol-v2/test/core/RiskEngine.t.sol @@ -38,15 +38,14 @@ contract RiskEngineUnitTests is BaseTest { function testRiskEngineInit() public { RiskEngine testRiskEngine = new RiskEngine(address(registry), 0.2e18, 0.8e18); - assertEq(address(testRiskEngine.REGISTRY()), address(registry)); + assertEq(address(testRiskEngine.registry()), address(registry)); assertEq(testRiskEngine.minLtv(), 0.2e18); assertEq(testRiskEngine.maxLtv(), 0.8e18); } - function testNoOracleFound(address asset) public { + function testNoOracleFound(address asset) public view { vm.assume(asset != address(asset1) && asset != address(asset2)); - vm.expectRevert(abi.encodeWithSelector(RiskEngine.RiskEngine_NoOracleFound.selector, asset)); - riskEngine.getOracleFor(asset); + assertEq(riskEngine.oracleFor(asset), address(0)); } function testOwnerCanUpdateLTV() public { @@ -91,10 +90,12 @@ contract RiskEngineUnitTests is BaseTest { assertEq(riskEngine.ltvFor(linearRatePool, address(asset2)), 0.75e18); } - function testNoLTVUpdate(address asset) public { + function testNoLTVUpdate() public { vm.prank(poolOwner); - vm.expectRevert(abi.encodeWithSelector(RiskEngine.RiskEngine_NoLtvUpdate.selector, linearRatePool, asset)); - riskEngine.acceptLtvUpdate(linearRatePool, asset); + vm.expectRevert( + abi.encodeWithSelector(RiskEngine.RiskEngine_NoLtvUpdate.selector, linearRatePool, address(asset1)) + ); + riskEngine.acceptLtvUpdate(linearRatePool, address(asset1)); } function testNonOwnerCannotUpdateLTV() public { @@ -125,16 +126,6 @@ contract RiskEngineUnitTests is BaseTest { assertEq(riskEngine.ltvFor(linearRatePool, address(asset1)), 0); } - function testCanUpdateRiskModule() public { - vm.prank(protocolOwner); - riskEngine.setRiskModule(address(0x3828342)); - assertEq(address(riskEngine.riskModule()), address(0x3828342)); - - vm.startPrank(address(0x21)); - vm.expectRevert(); - riskEngine.setRiskModule(address(0x821813)); - } - function testCannotUpdateLTVBeforeTimelock() public { vm.startPrank(poolOwner); riskEngine.requestLtvUpdate(linearRatePool, address(asset2), 0.75e18); diff --git a/protocol-v2/test/core/RiskModule.t.sol b/protocol-v2/test/core/RiskModule.t.sol index c65a8fe..85fc6b4 100644 --- a/protocol-v2/test/core/RiskModule.t.sol +++ b/protocol-v2/test/core/RiskModule.t.sol @@ -6,8 +6,10 @@ import { MockERC20 } from "../mocks/MockERC20.sol"; import { Pool } from "src/Pool.sol"; import { Action } from "src/PositionManager.sol"; import { PositionManager } from "src/PositionManager.sol"; + import { RiskEngine } from "src/RiskEngine.sol"; import { RiskModule } from "src/RiskModule.sol"; +import { PortfolioLens } from "src/lens/PortfolioLens.sol"; import { FixedPriceOracle } from "src/oracle/FixedPriceOracle.sol"; contract RiskModuleUnitTests is BaseTest { @@ -15,6 +17,7 @@ contract RiskModuleUnitTests is BaseTest { address position; RiskEngine riskEngine; RiskModule riskModule; + PortfolioLens portfolioLens; PositionManager positionManager; FixedPriceOracle oneEthOracle; @@ -27,6 +30,7 @@ contract RiskModuleUnitTests is BaseTest { pool = protocol.pool(); riskEngine = protocol.riskEngine(); riskModule = protocol.riskModule(); + portfolioLens = protocol.portfolioLens(); positionManager = protocol.positionManager(); vm.startPrank(protocolOwner); @@ -51,18 +55,19 @@ contract RiskModuleUnitTests is BaseTest { vm.stopPrank(); } - function testRiskModuleInit(address testRegistry, uint256 liqDiscount) public { - RiskModule testRiskModule = new RiskModule(testRegistry, liqDiscount); + function testRiskModuleInit(address testRegistry, uint256 liqDiscount, uint256 liquidationFee) public { + RiskModule testRiskModule = new RiskModule(testRegistry, liqDiscount, liquidationFee); assertEq(address(testRiskModule.REGISTRY()), testRegistry); assertEq(testRiskModule.LIQUIDATION_DISCOUNT(), liqDiscount); + assertEq(testRiskModule.LIQUIDATION_FEE(), liquidationFee); } function testAssetValueFuncs() public { vm.startPrank(user); asset2.approve(address(positionManager), 1e18); - // deposit 1e18 asset2, borrow 1e18 asset1 + // deposit 1e18 asset2 Action[] memory actions = new Action[](3); (position, actions[0]) = newPosition(user, bytes32(uint256(0x123456789))); actions[1] = deposit(address(asset2), 1e18); @@ -70,8 +75,8 @@ contract RiskModuleUnitTests is BaseTest { positionManager.processBatch(position, actions); vm.stopPrank(); - assertEq(riskModule.getTotalAssetValue(position), 1e18); - assertEq(riskModule.getAssetValue(position, address(asset2)), 1e18); + assertEq(portfolioLens.getTotalAssetValue(position), 1e18); + assertEq(riskEngine.getValueInEth(address(asset2), asset2.balanceOf(position)), 1e18); } function testDebtValueFuncs() public { @@ -91,8 +96,10 @@ contract RiskModuleUnitTests is BaseTest { positionManager.processBatch(position, actions); vm.stopPrank(); - assertEq(riskModule.getTotalDebtValue(position), 1e18); - assertEq(riskModule.getDebtValueForPool(position, fixedRatePool), 1e18); + assertEq(portfolioLens.getTotalDebtValue(position), 1e18); + address poolAsset = pool.getPoolAssetFor(fixedRatePool); + uint256 borrowAmt = pool.getBorrowsOf(fixedRatePool, position); + assertEq(riskEngine.getValueInEth(poolAsset, borrowAmt), 1e18); } function testUnsupportedAsset() public { diff --git a/protocol-v2/test/core/Superpool.t.sol b/protocol-v2/test/core/Superpool.t.sol index f0edf14..b37ec9c 100644 --- a/protocol-v2/test/core/Superpool.t.sol +++ b/protocol-v2/test/core/Superpool.t.sol @@ -6,7 +6,7 @@ import { console2 } from "forge-std/console2.sol"; import { FixedPriceOracle } from "src/oracle/FixedPriceOracle.sol"; contract SuperPoolUnitTests is BaseTest { - uint256 initialDepositAmt = 1e5; + uint256 initialDepositAmt = 1e7; Pool pool; Registry registry; @@ -98,8 +98,11 @@ contract SuperPoolUnitTests is BaseTest { vm.prank(protocolOwner); Registry(registry).setRateModel(RATE_MODEL_KEY, linearRateModel); + asset1.mint(poolOwner, 1e7); vm.startPrank(poolOwner); - uint256 linearPool = pool.initializePool(poolOwner, address(asset1), type(uint128).max, RATE_MODEL_KEY); + asset1.approve(address(pool), 1e7); + uint256 linearPool = + pool.initializePool(poolOwner, address(asset1), RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7); superPool.addPool(linearPool, 50 ether); vm.stopPrank(); } @@ -163,7 +166,7 @@ contract SuperPoolUnitTests is BaseTest { uint256 shares = superPool.deposit(100 ether, user); assertEq(shares, expectedShares); - assertEq(asset1.balanceOf(address(pool)), 100 ether); + assertGe(asset1.balanceOf(address(pool)), 100 ether); vm.stopPrank(); } @@ -250,7 +253,7 @@ contract SuperPoolUnitTests is BaseTest { uint256 shares = superPool.deposit(200 ether, user); assertEq(shares, expectedShares); - assertEq(asset1.balanceOf(address(pool)), 200 ether); + assertGe(asset1.balanceOf(address(pool)), 200 ether); } function testDepositMoreThanPoolCap() public { @@ -316,15 +319,13 @@ contract SuperPoolUnitTests is BaseTest { function testSetSuperPoolFee() public { vm.startPrank(poolOwner); - superPool.requestFeeUpdate(0.04 ether); - vm.warp(26 hours); - superPool.acceptFeeUpdate(); + superPool.setSuperpoolFee(0.04 ether); assertEq(superPool.fee(), 0.04 ether); vm.stopPrank(); vm.startPrank(user); vm.expectRevert(); - superPool.requestFeeUpdate(0.04 ether); + superPool.setSuperpoolFee(0.04 ether); } function testToggleAllocator() public { diff --git a/protocol-v2/test/integration/BigTest.t.sol b/protocol-v2/test/integration/BigTest.t.sol index 0ff0f81..8edf50e 100644 --- a/protocol-v2/test/integration/BigTest.t.sol +++ b/protocol-v2/test/integration/BigTest.t.sol @@ -60,10 +60,18 @@ contract BigTest is BaseTest { registry.setRateModel(BIG_RATE_MODEL3_KEY, fixedRateModel2); vm.stopPrank(); + asset1.mint(poolOwner, 3e7); vm.startPrank(poolOwner); - fixedRatePool = pool.initializePool(poolOwner, address(asset1), type(uint128).max, BIG_RATE_MODEL_KEY); - linearRatePool = pool.initializePool(poolOwner, address(asset1), type(uint128).max, BIG_RATE_MODEL2_KEY); - fixedRatePool2 = pool.initializePool(poolOwner, address(asset1), type(uint128).max, BIG_RATE_MODEL3_KEY); + asset1.approve(address(pool), 3e7); + fixedRatePool = pool.initializePool( + poolOwner, address(asset1), BIG_RATE_MODEL_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + linearRatePool = pool.initializePool( + poolOwner, address(asset1), BIG_RATE_MODEL2_KEY, type(uint256).max, type(uint256).max, 1e7 + ); + fixedRatePool2 = pool.initializePool( + poolOwner, address(asset1), BIG_RATE_MODEL3_KEY, type(uint256).max, type(uint256).max, 1e7 + ); vm.stopPrank(); vm.startPrank(poolOwner); @@ -81,6 +89,9 @@ contract BigTest is BaseTest { riskEngine.acceptLtvUpdate(fixedRatePool2, address(asset3)); riskEngine.requestLtvUpdate(fixedRatePool2, address(asset2), 0.75e18); riskEngine.acceptLtvUpdate(fixedRatePool2, address(asset2)); + + riskEngine.toggleAllowedPoolPair(fixedRatePool, linearRatePool); + riskEngine.toggleAllowedPoolPair(linearRatePool, fixedRatePool); vm.stopPrank(); } @@ -94,14 +105,15 @@ contract BigTest is BaseTest { // 7. User should have profit from the borrowed amount // 8. feeTo should make money address feeTo = makeAddr("feeTo"); + uint256 initialDepositAmt = 1e7; vm.prank(protocolOwner); - asset1.mint(address(this), 1e5); - asset1.approve(address(superPoolFactory), 1e5); + asset1.mint(address(this), initialDepositAmt); + asset1.approve(address(superPoolFactory), initialDepositAmt); SuperPool superPool = SuperPool( superPoolFactory.deploySuperPool( - poolOwner, address(asset1), feeTo, 0.01 ether, 1_000_000 ether, 1e5, "test", "test" + poolOwner, address(asset1), feeTo, 0.01 ether, 1_000_000 ether, initialDepositAmt, "test", "test" ) ); @@ -130,21 +142,16 @@ contract BigTest is BaseTest { (address position, Action memory _newPosition) = newPosition(user2, "test"); positionManager.process(position, _newPosition); - Action memory addNewCollateral = addToken(address(asset2)); - Action memory depositCollateral = deposit(address(asset2), 300 ether); - Action memory borrowAct = borrow(fixedRatePool, 15 ether); - Action memory approveAct = approve(address(mockswap), address(asset1), 15 ether); - bytes memory data = abi.encodeWithSelector(SWAP_FUNC_SELECTOR, address(asset1), address(asset3), 15 ether); - Action memory execAct = exec(address(mockswap), 0, data); - Action memory addAsset3 = addToken(address(asset3)); - - Action[] memory actions = new Action[](6); - actions[0] = addNewCollateral; - actions[1] = depositCollateral; - actions[2] = borrowAct; - actions[3] = approveAct; - actions[4] = execAct; - actions[5] = addAsset3; + bytes memory data = abi.encodeWithSelector(SWAP_FUNC_SELECTOR, address(asset1), address(asset3), 30 ether); + + Action[] memory actions = new Action[](7); + actions[0] = addToken(address(asset2)); + actions[1] = deposit(address(asset2), 300 ether); + actions[2] = borrow(fixedRatePool, 15 ether); + actions[3] = borrow(linearRatePool, 15 ether); + actions[4] = approve(address(mockswap), address(asset1), 30 ether); + actions[5] = exec(address(mockswap), 0, data); + actions[6] = addToken(address(asset3)); positionManager.processBatch(position, actions); vm.stopPrank(); @@ -157,17 +164,22 @@ contract BigTest is BaseTest { vm.startPrank(user2); pool.accrue(fixedRatePool); uint256 debt = pool.getBorrowsOf(fixedRatePool, position); + uint256 debt2 = pool.getBorrowsOf(linearRatePool, position); - asset1.mint(position, debt); + asset1.mint(position, debt + debt2); Action memory _repay = Action({ op: Operation.Repay, data: abi.encode(fixedRatePool, debt) }); positionManager.process(position, _repay); + + _repay = Action({ op: Operation.Repay, data: abi.encode(linearRatePool, debt2) }); + positionManager.process(position, _repay); vm.stopPrank(); // 7. User should have profit from the borrowed amount vm.startPrank(user); superPool.accrue(); - assertTrue(superPool.maxWithdraw(user) > initialAmountCanBeWithdrawn); + uint256 superPoolMaxWithdraw = superPool.maxWithdraw(user); + assertTrue(superPoolMaxWithdraw > initialAmountCanBeWithdrawn); vm.stopPrank(); } } diff --git a/protocol-v2/test/integration/LiquidationTest.t.sol b/protocol-v2/test/integration/LiquidationTest.t.sol index beaca63..08d94a3 100644 --- a/protocol-v2/test/integration/LiquidationTest.t.sol +++ b/protocol-v2/test/integration/LiquidationTest.t.sol @@ -64,13 +64,13 @@ contract LiquidationTest is BaseTest { actions[6] = addToken(address(asset3)); positionManager.processBatch(position, actions); vm.stopPrank(); - assertTrue(riskEngine.isPositionHealthy(position)); + assertGe(riskEngine.getPositionHealthFactor(position), 1e18); - (uint256 totalAssetValue, uint256 totalDebtValue, uint256 minReqAssetValue) = riskEngine.getRiskData(position); + (uint256 totalAssetValue, uint256 totalDebtValue, uint256 weightedLtv) = riskEngine.getRiskData(position); assertEq(totalAssetValue, 2e18); assertEq(totalDebtValue, 1e18); - assertEq(minReqAssetValue, 2e18); + assertEq(weightedLtv, 5e17); // construct liquidator data DebtData memory debtData = DebtData({ poolId: fixedRatePool, amt: type(uint256).max }); @@ -94,7 +94,7 @@ contract LiquidationTest is BaseTest { FixedPriceOracle pointOneEthOracle = new FixedPriceOracle(0.1e18); vm.prank(protocolOwner); riskEngine.setOracle(address(asset2), address(pointOneEthOracle)); - assertFalse(riskEngine.isPositionHealthy(position)); + assertLt(riskEngine.getPositionHealthFactor(position), 1e18); // liquidate vm.startPrank(liquidator); @@ -119,19 +119,19 @@ contract LiquidationTest is BaseTest { actions[6] = addToken(address(asset3)); positionManager.processBatch(position, actions); vm.stopPrank(); - assertTrue(riskEngine.isPositionHealthy(position)); + assertGe(riskEngine.getPositionHealthFactor(position), 1e18); - (uint256 totalAssetValue, uint256 totalDebtValue, uint256 minReqAssetValue) = riskEngine.getRiskData(position); + (uint256 totalAssetValue, uint256 totalDebtValue, uint256 weightedLtv) = riskEngine.getRiskData(position); assertEq(totalAssetValue, 2e18); assertEq(totalDebtValue, 1e18); - assertEq(minReqAssetValue, 2e18); + assertEq(weightedLtv, 5e17); // modify asset2 price from 1eth to 0.1eth FixedPriceOracle pointOneEthOracle = new FixedPriceOracle(0.1e18); vm.prank(protocolOwner); riskEngine.setOracle(address(asset2), address(pointOneEthOracle)); - assertFalse(riskEngine.isPositionHealthy(position)); + assertLt(riskEngine.getPositionHealthFactor(position), 1e18); // construct liquidator data DebtData memory debtData = DebtData({ poolId: fixedRatePool, amt: 0.1e18 }); diff --git a/protocol-v2/test/integration/PortfolioLens.t.sol b/protocol-v2/test/integration/PortfolioLens.t.sol index fce9d3f..482ef69 100644 --- a/protocol-v2/test/integration/PortfolioLens.t.sol +++ b/protocol-v2/test/integration/PortfolioLens.t.sol @@ -64,7 +64,7 @@ contract PortfolioLensTest is BaseTest { actions[6] = addToken(address(asset3)); positionManager.processBatch(position, actions); vm.stopPrank(); - assertTrue(riskEngine.isPositionHealthy(position)); + assertGe(riskEngine.getPositionHealthFactor(position), 1e18); vm.stopPrank(); positions.push(position); diff --git a/protocol-v2/test/integration/SuperPoolLens.t.sol b/protocol-v2/test/integration/SuperPoolLens.t.sol index df1c96b..176e961 100644 --- a/protocol-v2/test/integration/SuperPoolLens.t.sol +++ b/protocol-v2/test/integration/SuperPoolLens.t.sol @@ -7,7 +7,7 @@ import { SuperPoolLens } from "src/lens/SuperPoolLens.sol"; import { FixedPriceOracle } from "src/oracle/FixedPriceOracle.sol"; contract SuperPoolLensTests is BaseTest { - uint256 initialDepositAmt = 1e5; + uint256 initialDepositAmt = 1e7; Pool pool; RiskEngine riskEngine; @@ -135,7 +135,7 @@ contract SuperPoolLensTests is BaseTest { assertEq(userDepositData.superPool, address(superPool1)); assertEq(userDepositData.amount, uint256(50e18)); assertEq(userDepositData.valueInEth, uint256(50e18)); - assertEq(userDepositData.interestRate, 999_999_999_999_999_000); + assertEq(userDepositData.interestRate, 999_999_999_999_900_000); } function testUserDepositData() public view { @@ -144,26 +144,26 @@ contract SuperPoolLensTests is BaseTest { assertEq(userMultiDepositData.owner, user); assertEq(userMultiDepositData.totalValueInEth, uint256(100e18)); - assertEq(userMultiDepositData.interestRate, 999_999_999_999_998_500); + assertEq(userMultiDepositData.interestRate, 999_999_999_999_850_000); assertEq(userMultiDepositData.deposits[0].owner, user); assertEq(userMultiDepositData.deposits[0].asset, address(asset1)); assertEq(userMultiDepositData.deposits[0].superPool, address(superPool1)); assertEq(userMultiDepositData.deposits[0].amount, uint256(50e18)); assertEq(userMultiDepositData.deposits[0].valueInEth, uint256(50e18)); - assertEq(userMultiDepositData.deposits[0].interestRate, 999_999_999_999_999_000); + assertEq(userMultiDepositData.deposits[0].interestRate, 999_999_999_999_900_000); assertEq(userMultiDepositData.deposits[1].owner, user); assertEq(userMultiDepositData.deposits[1].asset, address(asset2)); assertEq(userMultiDepositData.deposits[1].superPool, address(superPool2)); assertEq(userMultiDepositData.deposits[0].amount, uint256(50e18)); assertEq(userMultiDepositData.deposits[0].valueInEth, uint256(50e18)); - assertEq(userMultiDepositData.deposits[0].interestRate, 999_999_999_999_999_000); + assertEq(userMultiDepositData.deposits[0].interestRate, 999_999_999_999_900_000); } function testSuperPoolInterestRate() public view { - assertEq(superPoolLens.getSuperPoolInterestRate(address(superPool1)), 999_999_999_999_999_000); - assertEq(superPoolLens.getSuperPoolInterestRate(address(superPool2)), 999_999_999_999_998_000); + assertEq(superPoolLens.getSuperPoolInterestRate(address(superPool1)), 999_999_999_999_900_000); + assertEq(superPoolLens.getSuperPoolInterestRate(address(superPool2)), 999_999_999_999_800_000); } function testEmptySuperPoolInterestRate() public { diff --git a/protocol-v2/test/lib/ERC6909.t.sol b/protocol-v2/test/lib/ERC6909.t.sol index b8c0961..4bfb220 100644 --- a/protocol-v2/test/lib/ERC6909.t.sol +++ b/protocol-v2/test/lib/ERC6909.t.sol @@ -199,7 +199,9 @@ contract ERC6909Test is Test { uint256 id, uint256 mintAmount, uint256 transferAmount - ) public { + ) + public + { transferAmount = bound(transferAmount, 0, mintAmount); token.mint(sender, id, mintAmount); @@ -248,7 +250,9 @@ contract ERC6909Test is Test { uint256 id, uint256 mintAmount, uint256 transferAmount - ) public { + ) + public + { transferAmount = bound(transferAmount, 0, mintAmount); token.mint(sender, id, mintAmount); @@ -274,7 +278,9 @@ contract ERC6909Test is Test { uint256 id, uint256 mintAmount, uint256 transferAmount - ) public { + ) + public + { transferAmount = bound(transferAmount, 0, mintAmount); token.mint(sender, id, mintAmount); @@ -319,7 +325,9 @@ contract ERC6909Test is Test { address receiver, uint256 id, uint256 amount - ) public { + ) + public + { amount = bound(amount, 1, type(uint256).max); vm.prank(sender); @@ -331,7 +339,9 @@ contract ERC6909Test is Test { address receiver, uint256 id, uint256 amount - ) public { + ) + public + { amount = bound(amount, 1, type(uint256).max); uint256 overflowAmount = type(uint256).max - amount + 1; diff --git a/protocol-v2/test/mocks/MockERC20.sol b/protocol-v2/test/mocks/MockERC20.sol index c470501..49da214 100644 --- a/protocol-v2/test/mocks/MockERC20.sol +++ b/protocol-v2/test/mocks/MockERC20.sol @@ -113,7 +113,9 @@ contract MockERC20 { uint8 v, bytes32 r, bytes32 s - ) public { + ) + public + { require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED"); // Unchecked because the only math done is incrementing diff --git a/protocol-v2/test/repro/guardian/FailedRepayAll.t.sol b/protocol-v2/test/repro/guardian/FailedRepayAll.t.sol index 8c96264..b59c20a 100644 --- a/protocol-v2/test/repro/guardian/FailedRepayAll.t.sol +++ b/protocol-v2/test/repro/guardian/FailedRepayAll.t.sol @@ -97,7 +97,7 @@ contract FailedRepayAll is BaseTest { function testTimeIncreasesDebt(uint96 assets) public { testBorrowWorksAsIntended(assets); - (,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); uint256 time = block.timestamp + 1 days; vm.warp(time + 86_400 * 365); @@ -105,7 +105,7 @@ contract FailedRepayAll is BaseTest { pool.accrue(linearRatePool); - (,,,,,,, uint256 newTotalBorrowAssets, uint256 newTotalBorrowShares,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 newTotalBorrowAssets, uint256 newTotalBorrowShares,,) = pool.poolDataFor(linearRatePool); assertEq(newTotalBorrowShares, totalBorrowShares); assertGt(newTotalBorrowAssets, totalBorrowAssets); @@ -114,7 +114,7 @@ contract FailedRepayAll is BaseTest { function test_poc_RepayFail() public { // Underlying pool has some actions that changes share:asset ratio testTimeIncreasesDebt(10e18); - (,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); + (,,,,,,,, uint256 totalBorrowAssets, uint256 totalBorrowShares,,) = pool.poolDataFor(linearRatePool); assertGt(totalBorrowAssets, totalBorrowShares); // Mint some tokens to position owner