diff --git a/.github/workflows/deploy-market.yaml b/.github/workflows/deploy-market.yaml index a41584ded..c2066d7ec 100644 --- a/.github/workflows/deploy-market.yaml +++ b/.github/workflows/deploy-market.yaml @@ -61,7 +61,7 @@ jobs: with: wallet_connect_project_id: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} requested_network: "${{ inputs.network }}" - ethereum_url: "${{ fromJSON('{\"linea\":\"${LINEA_QUICKNODE_LINK}\",\"ronin\":\"${RONIN_QUICKNODE_LINK}\",\"unichain\":\"${UNICHAIN_QUICKNODE_LINK}\",\"mantle\":\"${MANTLE_QUICKNODE_LINK}\",\"optimism\":\"${OPTIMISM_QUICKNODE_LINK}\",\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"sepolia\":\"${SEPOLIA_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://rpc.scroll.io\"}')[github.event.inputs.network] }}" + ethereum_url: "${{ fromJSON('{\"linea\":\"${LINEA_QUICKNODE_LINK}\",\"ronin\":\"${RONIN_QUICKNODE_LINK}\",\"unichain\":\"${UNICHAIN_QUICKNODE_LINK}\",\"mantle\":\"${MANTLE_QUICKNODE_LINK}\",\"optimism\":\"${OPTIMISM_QUICKNODE_LINK}\",\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"sepolia\":\"${SEPOLIA_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://scroll.drpc.org\"}')[github.event.inputs.network] }}" port: 8585 if: github.event.inputs.eth_pk == '' diff --git a/.github/workflows/enact-migration.yaml b/.github/workflows/enact-migration.yaml index faa035cbf..73b48a0cb 100644 --- a/.github/workflows/enact-migration.yaml +++ b/.github/workflows/enact-migration.yaml @@ -105,7 +105,7 @@ jobs: with: wallet_connect_project_id: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} requested_network: "${{ inputs.network }}" - ethereum_url: "${{ fromJSON('{\"linea\":\"${LINEA_QUICKNODE_LINK}\",\"ronin\":\"${RONIN_QUICKNODE_LINK}\",\"unichain\":\"${UNICHAIN_QUICKNODE_LINK}\",\"mantle\":\"${MANTLE_QUICKNODE_LINK}\",\"optimism\":\"${OPTIMISM_QUICKNODE_LINK}\",\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"sepolia\":\"${SEPOLIA_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://rpc.scroll.io\"}')[github.event.inputs.network] }}" + ethereum_url: "${{ fromJSON('{\"linea\":\"${LINEA_QUICKNODE_LINK}\",\"ronin\":\"${RONIN_QUICKNODE_LINK}\",\"unichain\":\"${UNICHAIN_QUICKNODE_LINK}\",\"mantle\":\"${MANTLE_QUICKNODE_LINK}\",\"optimism\":\"${OPTIMISM_QUICKNODE_LINK}\",\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"sepolia\":\"${SEPOLIA_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://scroll.drpc.org\"}')[github.event.inputs.network] }}" port: 8585 if: github.event.inputs.eth_pk == '' diff --git a/.github/workflows/prepare-migration.yaml b/.github/workflows/prepare-migration.yaml index 042e66729..27392ffcc 100644 --- a/.github/workflows/prepare-migration.yaml +++ b/.github/workflows/prepare-migration.yaml @@ -75,7 +75,7 @@ jobs: with: wallet_connect_project_id: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} requested_network: "${{ inputs.network }}" - ethereum_url: "${{ fromJSON('{\"linea\":\"${LINEA_QUICKNODE_LINK}\",\"ronin\":\"${RONIN_QUICKNODE_LINK}\",\"unichain\":\"${UNICHAIN_QUICKNODE_LINK}\",\"mantle\":\"${MANTLE_QUICKNODE_LINK}\",\"optimism\":\"${OPTIMISM_QUICKNODE_LINK}\",\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"sepolia\":\"${SEPOLIA_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://rpc.scroll.io\"}')[github.event.inputs.network] }}" + ethereum_url: "${{ fromJSON('{\"linea\":\"${LINEA_QUICKNODE_LINK}\",\"ronin\":\"${RONIN_QUICKNODE_LINK}\",\"unichain\":\"${UNICHAIN_QUICKNODE_LINK}\",\"mantle\":\"${MANTLE_QUICKNODE_LINK}\",\"optimism\":\"${OPTIMISM_QUICKNODE_LINK}\",\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"sepolia\":\"${SEPOLIA_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://scroll.drpc.org\"}')[github.event.inputs.network] }}" port: 8585 if: github.event.inputs.eth_pk == '' diff --git a/.github/workflows/run-slither.yaml b/.github/workflows/run-slither.yaml index 18355f755..b595e761c 100644 --- a/.github/workflows/run-slither.yaml +++ b/.github/workflows/run-slither.yaml @@ -18,9 +18,6 @@ jobs: with: python-version: '3.x' - - name: Install solc - run: sudo add-apt-repository ppa:ethereum/ethereum;sudo add-apt-repository ppa:ethereum/ethereum-dev;sudo apt-get update;sudo apt-get install solc - - name: Install packages run: pip install slither-analyzer solc-select diff --git a/contracts/AssetList.sol b/contracts/AssetList.sol index 0165efcb2..206a68133 100644 --- a/contracts/AssetList.sol +++ b/contracts/AssetList.sol @@ -137,9 +137,18 @@ contract AssetList { if (IPriceFeed(priceFeed).decimals() != PRICE_FEED_DECIMALS) revert CometMainInterface.BadDecimals(); if (IERC20NonStandard(asset).decimals() != decimals_) revert CometMainInterface.BadDecimals(); - // Ensure collateral factors are within range - if (assetConfig.borrowCollateralFactor >= assetConfig.liquidateCollateralFactor) revert CometMainInterface.BorrowCFTooLarge(); - if (assetConfig.liquidateCollateralFactor > MAX_COLLATERAL_FACTOR) revert CometMainInterface.LiquidateCFTooLarge(); + // Valid collateral factor configurations: + // 1. Both zero => fully de-listed + // 2. borrowCF=0, liquidateCF>0 => soft de-list (no new borrows, controlled liquidation wind-down) + // 3. Both non-zero, properly ordered => active collateral + // Invalid: borrowCF>0, liquidateCF=0 => reverts (borrow power without liquidation coverage) + if (assetConfig.borrowCollateralFactor != 0 && assetConfig.liquidateCollateralFactor == 0) { + revert CometMainInterface.BorrowCFTooLarge(); + } else if (assetConfig.borrowCollateralFactor != 0 && assetConfig.liquidateCollateralFactor != 0) { + // Ensure collateral factors are within range + if (assetConfig.borrowCollateralFactor > assetConfig.liquidateCollateralFactor) revert CometMainInterface.BorrowCFTooLarge(); + if (assetConfig.liquidateCollateralFactor > MAX_COLLATERAL_FACTOR) revert CometMainInterface.LiquidateCFTooLarge(); + } unchecked { // Keep 4 decimals for each factor @@ -148,8 +157,12 @@ contract AssetList { uint16 liquidateCollateralFactor = uint16(assetConfig.liquidateCollateralFactor / descale); uint16 liquidationFactor = uint16(assetConfig.liquidationFactor / descale); - // Be nice and check descaled values are still within range - if (borrowCollateralFactor >= liquidateCollateralFactor) revert CometMainInterface.BorrowCFTooLarge(); + if (borrowCollateralFactor != 0 && liquidateCollateralFactor == 0) { + revert CometMainInterface.BorrowCFTooLarge(); + } else if (borrowCollateralFactor != 0 && liquidateCollateralFactor != 0) { + // Be nice and check descaled values are still within range + if (borrowCollateralFactor >= liquidateCollateralFactor) revert CometMainInterface.BorrowCFTooLarge(); + } // Keep whole units of asset for supply cap uint64 supplyCap = uint64(assetConfig.supplyCap / (10 ** decimals_)); diff --git a/contracts/CometCore.sol b/contracts/CometCore.sol index 534f2701b..7dd909cbc 100644 --- a/contracts/CometCore.sol +++ b/contracts/CometCore.sol @@ -38,6 +38,24 @@ abstract contract CometCore is CometConfiguration, CometStorage, CometMath { uint8 internal constant PAUSE_ABSORB_OFFSET = 3; uint8 internal constant PAUSE_BUY_OFFSET = 4; + /// @dev Offsets for specific actions in the extended pause flag bit array + /// @dev Offset of pause lenders withdraw + uint24 internal constant PAUSE_LENDERS_WITHDRAW_OFFSET = 0; + /// @dev Offset of pause borrowers withdraw + uint24 internal constant PAUSE_BORROWERS_WITHDRAW_OFFSET = 1; + /// @dev Offset of pause collateral supply + uint24 internal constant PAUSE_COLLATERAL_SUPPLY_OFFSET = 2; + /// @dev Offset of pause base supply + uint24 internal constant PAUSE_BASE_SUPPLY_OFFSET = 3; + /// @dev Offset of pause lenders transfer + uint24 internal constant PAUSE_LENDERS_TRANSFER_OFFSET = 4; + /// @dev Offset of pause borrowers transfer + uint24 internal constant PAUSE_BORROWERS_TRANSFER_OFFSET = 5; + /// @dev Offset of pause collateral transfer + uint24 internal constant PAUSE_COLLATERALS_TRANSFER_OFFSET = 6; + /// @dev Offset of pause collateral withdraw + uint24 internal constant PAUSE_COLLATERALS_WITHDRAW_OFFSET = 7; + /// @dev The decimals required for a price feed uint8 internal constant PRICE_FEED_DECIMALS = 8; diff --git a/contracts/CometExt.sol b/contracts/CometExt.sol index a5a75c68e..83e34867b 100644 --- a/contracts/CometExt.sol +++ b/contracts/CometExt.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.15; import "./CometExtInterface.sol"; +import "./CometMainInterface.sol"; contract CometExt is CometExtInterface { /** Public constants **/ @@ -29,6 +30,27 @@ contract CometExt is CometExtInterface { /// @dev The ERC20 symbol for wrapped base token bytes32 internal immutable symbol32; + /** Modifiers **/ + + /** + * @dev Modifier to check if the sender is the governor or pause guardian + */ + modifier onlyGovernorOrPauseGuardian() { + if (msg.sender != CometMainInterface(address(this)).governor() && + msg.sender != CometMainInterface(address(this)).pauseGuardian()) + revert OnlyPauseGuardianOrGovernor(); + _; + } + + /** + * @dev Modifier to check if the asset index is valid + * @param assetIndex The index of the asset + */ + modifier isValidAssetIndex(uint24 assetIndex) { + if (assetIndex >= CometMainInterface(address(this)).numAssets()) revert InvalidAssetIndex(); + _; + } + /** * @notice Construct a new protocol instance * @param config The mapping of initial/constant parameters @@ -205,4 +227,198 @@ contract CometExt is CometExtInterface { if (block.timestamp >= expiry) revert SignatureExpired(); allowInternal(signatory, manager, isAllowed_); } + + /*////////////////////////////////////////////////////////////// + EXTENDED PAUSE CONTROL + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Set the status of a pause offset + * @param offset The offset to set + * @param paused The new status of the pause offset + */ + function setPauseFlag(uint24 offset, bool paused) internal { + paused ? extendedPauseFlags |= (uint24(1) << offset) : extendedPauseFlags &= ~(uint24(1) << offset); + } + + /** + * @notice Get the current status of a pause offset + * @param offset The offset to check + * @return The current status of the pause offset + */ + function currentPauseOffsetStatus(uint24 offset) internal view returns (bool) { + return (extendedPauseFlags & (uint24(1) << offset)) != 0; + } + + /** + * @notice Check if the collateral asset is deactivated + * @param assetIndex The index of the collateral asset + * @return Whether the collateral asset is deactivated + */ + function isCollateralDeactivated(uint24 assetIndex) public view returns (bool) { + return (deactivatedCollaterals & (uint24(1) << assetIndex) != 0) == true; + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseLendersWithdraw(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_LENDERS_WITHDRAW_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_LENDERS_WITHDRAW_OFFSET, paused); + + setPauseFlag(PAUSE_LENDERS_WITHDRAW_OFFSET, paused); + + emit LendersWithdrawPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseBorrowersWithdraw(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_BORROWERS_WITHDRAW_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_BORROWERS_WITHDRAW_OFFSET, paused); + + setPauseFlag(PAUSE_BORROWERS_WITHDRAW_OFFSET, paused); + + emit BorrowersWithdrawPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseCollateralWithdraw(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_COLLATERALS_WITHDRAW_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_COLLATERALS_WITHDRAW_OFFSET, paused); + + setPauseFlag(PAUSE_COLLATERALS_WITHDRAW_OFFSET, paused); + + emit CollateralWithdrawPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseCollateralAssetWithdraw(uint24 assetIndex, bool paused) override external onlyGovernorOrPauseGuardian isValidAssetIndex(assetIndex) { + if ((collateralsWithdrawPauseFlags & (uint24(1) << assetIndex) != 0) == paused) revert CollateralAssetOffsetStatusAlreadySet(collateralsWithdrawPauseFlags, assetIndex, paused); + + paused ? collateralsWithdrawPauseFlags |= (uint24(1) << assetIndex) : collateralsWithdrawPauseFlags &= ~(uint24(1) << assetIndex); + + emit CollateralAssetWithdrawPauseAction(assetIndex, paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseCollateralSupply(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_COLLATERAL_SUPPLY_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_COLLATERAL_SUPPLY_OFFSET, paused); + + setPauseFlag(PAUSE_COLLATERAL_SUPPLY_OFFSET, paused); + + emit CollateralSupplyPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseBaseSupply(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_BASE_SUPPLY_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_BASE_SUPPLY_OFFSET, paused); + + setPauseFlag(PAUSE_BASE_SUPPLY_OFFSET, paused); + + emit BaseSupplyPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseCollateralAssetSupply(uint24 assetIndex, bool paused) override external onlyGovernorOrPauseGuardian isValidAssetIndex(assetIndex) { + if ((collateralsSupplyPauseFlags & (uint24(1) << assetIndex) != 0) == paused) revert CollateralAssetOffsetStatusAlreadySet(collateralsSupplyPauseFlags, assetIndex, paused); + if (!paused && isCollateralDeactivated(assetIndex)) revert CollateralIsDeactivated(assetIndex); + + paused ? collateralsSupplyPauseFlags |= (uint24(1) << assetIndex) : collateralsSupplyPauseFlags &= ~(uint24(1) << assetIndex); + + emit CollateralAssetSupplyPauseAction(assetIndex, paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseLendersTransfer(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_LENDERS_TRANSFER_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_LENDERS_TRANSFER_OFFSET, paused); + + setPauseFlag(PAUSE_LENDERS_TRANSFER_OFFSET, paused); + + emit LendersTransferPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseBorrowersTransfer(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_BORROWERS_TRANSFER_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_BORROWERS_TRANSFER_OFFSET, paused); + + setPauseFlag(PAUSE_BORROWERS_TRANSFER_OFFSET, paused); + + emit BorrowersTransferPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseCollateralTransfer(bool paused) override external onlyGovernorOrPauseGuardian { + if (currentPauseOffsetStatus(PAUSE_COLLATERALS_TRANSFER_OFFSET) == paused) revert OffsetStatusAlreadySet(PAUSE_COLLATERALS_TRANSFER_OFFSET, paused); + + setPauseFlag(PAUSE_COLLATERALS_TRANSFER_OFFSET, paused); + + emit CollateralTransferPauseAction(paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function pauseCollateralAssetTransfer(uint24 assetIndex, bool paused) override external onlyGovernorOrPauseGuardian isValidAssetIndex(assetIndex) { + if ((collateralsTransferPauseFlags & (uint24(1) << assetIndex) != 0) == paused) revert CollateralAssetOffsetStatusAlreadySet(collateralsTransferPauseFlags, assetIndex, paused); + if (!paused && isCollateralDeactivated(assetIndex)) revert CollateralIsDeactivated(assetIndex); + + paused ? collateralsTransferPauseFlags |= (uint24(1) << assetIndex) : collateralsTransferPauseFlags &= ~(uint24(1) << assetIndex); + + emit CollateralAssetTransferPauseAction(assetIndex, paused); + } + + /** + * @inheritdoc CometExtInterface + */ + function deactivateCollateral(uint24 assetIndex) override external isValidAssetIndex(assetIndex) { + if (msg.sender != CometMainInterface(address(this)).pauseGuardian()) revert OnlyPauseGuardian(); + if (isCollateralDeactivated(assetIndex)) revert CollateralIsDeactivated(assetIndex); + + // Mark collateral as deactivated + deactivatedCollaterals |= (uint24(1) << assetIndex); + emit CollateralDeactivated(assetIndex); + + // Pause supply of this collateral + collateralsSupplyPauseFlags |= (uint24(1) << assetIndex); + emit CollateralAssetSupplyPauseAction(assetIndex, true); + + // Pause transfer of this collateral + collateralsTransferPauseFlags |= (uint24(1) << assetIndex); + emit CollateralAssetTransferPauseAction(assetIndex, true); + } + + /** + * @inheritdoc CometExtInterface + */ + function activateCollateral(uint24 assetIndex) override external isValidAssetIndex(assetIndex) { + if (msg.sender != CometMainInterface(address(this)).governor()) revert OnlyGovernor(); + if ((deactivatedCollaterals & (uint24(1) << assetIndex) != 0) == false) revert CollateralIsActivated(assetIndex); + + // Mark collateral as activated + deactivatedCollaterals &= ~(uint24(1) << assetIndex); + emit CollateralActivated(assetIndex); + + // Unpause supply of this collateral + collateralsSupplyPauseFlags &= ~(uint24(1) << assetIndex); + emit CollateralAssetSupplyPauseAction(assetIndex, false); + + // Unpause transfer of this collateral + collateralsTransferPauseFlags &= ~(uint24(1) << assetIndex); + emit CollateralAssetTransferPauseAction(assetIndex, false); + } } \ No newline at end of file diff --git a/contracts/CometExtInterface.sol b/contracts/CometExtInterface.sol index d1a9526f6..866ff4e35 100644 --- a/contracts/CometExtInterface.sol +++ b/contracts/CometExtInterface.sol @@ -16,9 +16,121 @@ abstract contract CometExtInterface is CometCore { error InvalidValueV(); error SignatureExpired(); + /** + * @dev Error thrown when the caller is not the pause guardian or governor + */ + error OnlyPauseGuardianOrGovernor(); + /** + * @dev Error thrown when the offset status is already set + * @param offset The offset that is already set + * @param status The status of the offset + */ + error OffsetStatusAlreadySet(uint24 offset, bool status); + /** + * @dev Error thrown when the collateral asset offset status is already set + * @param offset The offset that is already set + * @param assetIndex The index of the collateral asset + * @param status The status of the offset + */ + error CollateralAssetOffsetStatusAlreadySet(uint24 offset, uint24 assetIndex, bool status); + /** + * @dev Error thrown when the asset index is invalid + */ + error InvalidAssetIndex(); + /** + * @dev Error thrown when the caller is not the pause guardian + */ + error OnlyPauseGuardian(); + /** + * @dev Error thrown when the caller is not the governor + */ + error OnlyGovernor(); + /** + * @dev Error thrown when the collateral asset is already deactivated + * @param assetIndex The index of the collateral asset + */ + error CollateralIsDeactivated(uint24 assetIndex); + /** + * @dev Error thrown when the collateral asset is already activated + * @param assetIndex The index of the collateral asset + */ + error CollateralIsActivated(uint24 assetIndex); + function allow(address manager, bool isAllowed) virtual external; function allowBySig(address owner, address manager, bool isAllowed, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) virtual external; + /*////////////////////////////////////////////////////////////// + PAUSE CONTROL + //////////////////////////////////////////////////////////////*/ + /** + * @notice Pauses or unpauses the ability for lenders to withdraw their assets. + * @param paused Whether to pause (`true`) or unpause (`false`) lenders' withdrawals. + */ + function pauseLendersWithdraw(bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability for borrowers to withdraw their assets. + * @param paused Whether to pause (`true`) or unpause (`false`) borrowers' withdrawals. + */ + function pauseBorrowersWithdraw(bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability to withdraw collateral. + * @param paused Whether to pause (`true`) or unpause (`false`) collateral withdrawals. + */ + function pauseCollateralWithdraw(bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability to withdraw a specific collateral asset. + * @param assetIndex The index of the collateral asset to pause/unpause. + * @param paused Whether to pause (`true`) or unpause (`false`) withdrawals for the specified collateral asset. + */ + function pauseCollateralAssetWithdraw(uint24 assetIndex, bool paused) virtual external; + /** + * @notice Pauses or unpauses the supply of collateral. + * @param paused Whether to pause (`true`) or unpause (`false`) collateral supply actions. + */ + function pauseCollateralSupply(bool paused) virtual external; + /** + * @notice Pauses or unpauses the supply of base assets. + * @param paused Whether to pause (`true`) or unpause (`false`) base asset supply actions. + */ + function pauseBaseSupply(bool paused) virtual external; + /** + * @notice Pauses or unpauses the supply of a specific collateral asset. + * @param assetIndex The index of the collateral asset to pause/unpause. + * @param paused Whether to pause (`true`) or unpause (`false`) supply actions for the specified collateral asset. + */ + function pauseCollateralAssetSupply(uint24 assetIndex, bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability for lenders to transfer their assets. + * @param paused Whether to pause (`true`) or unpause (`false`) lenders' transfer actions. + */ + function pauseLendersTransfer(bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability for borrowers to transfer their assets. + * @param paused Whether to pause (`true`) or unpause (`false`) borrowers' transfer actions. + */ + function pauseBorrowersTransfer(bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability to transfer collateral. + * @param paused Whether to pause (`true`) or unpause (`false`) collateral transfer actions. + */ + function pauseCollateralTransfer(bool paused) virtual external; + /** + * @notice Pauses or unpauses the ability to transfer a specific collateral asset. + * @param assetIndex The index of the collateral asset to pause/unpause. + * @param paused Whether to pause (`true`) or unpause (`false`) transfer actions for the specified collateral asset. + */ + function pauseCollateralAssetTransfer(uint24 assetIndex, bool paused) virtual external; + /** + * @notice Deactivates a collateral asset. + * @param assetIndex The index of the collateral asset to deactivate. + */ + function deactivateCollateral(uint24 assetIndex) virtual external; + /** + * @notice Activates a collateral asset. + * @param assetIndex The index of the collateral asset to activate. + */ + function activateCollateral(uint24 assetIndex) virtual external; + function collateralBalanceOf(address account, address asset) virtual external view returns (uint128); function baseTrackingAccrued(address account) virtual external view returns (uint64); @@ -65,4 +177,72 @@ abstract contract CometExtInterface is CometCore { function allowance(address owner, address spender) virtual external view returns (uint256); event Approval(address indexed owner, address indexed spender, uint256 amount); + /** + * @notice Emitted when the pause status for lenders' withdrawals is changed + * @param lendersWithdrawPaused Whether lenders' withdrawals are now paused + */ + event LendersWithdrawPauseAction(bool lendersWithdrawPaused); + /** + * @notice Emitted when the pause status for borrowers' withdrawals is changed + * @param borrowersWithdrawPaused Whether borrowers' withdrawals are now paused + */ + event BorrowersWithdrawPauseAction(bool borrowersWithdrawPaused); + /** + * @notice Emitted when the pause status for collateral withdrawals is changed + * @param collateralWithdrawPaused Whether collateral withdrawals are now paused + */ + event CollateralWithdrawPauseAction(bool collateralWithdrawPaused); + /** + * @notice Emitted when the pause status for a specific collateral asset's withdrawals is changed + * @param assetIndex The index of the collateral asset + * @param collateralAssetWithdrawPaused Whether withdrawals for this collateral asset are now paused + */ + event CollateralAssetWithdrawPauseAction(uint24 assetIndex, bool collateralAssetWithdrawPaused); + /** + * @notice Emitted when the pause status for collateral supply is changed + * @param collateralSupplyPaused Whether collateral supply is now paused + */ + event CollateralSupplyPauseAction(bool collateralSupplyPaused); + /** + * @notice Emitted when the pause status for a specific collateral asset's supply is changed + * @param assetIndex The index of the collateral asset + * @param collateralAssetSupplyPaused Whether supply for this collateral asset is now paused + */ + event CollateralAssetSupplyPauseAction(uint24 assetIndex, bool collateralAssetSupplyPaused); + /** + * @notice Emitted when the pause status for base supply is changed + * @param baseSupplyPaused Whether base supply is now paused + */ + event BaseSupplyPauseAction(bool baseSupplyPaused); + /** + * @notice Emitted when the pause status for lenders' transfers is changed + * @param lendersTransferPaused Whether lenders' transfers are now paused + */ + event LendersTransferPauseAction(bool lendersTransferPaused); + /** + * @notice Emitted when the pause status for borrowers' transfers is changed + * @param borrowersTransferPaused Whether borrowers' transfers are now paused + */ + event BorrowersTransferPauseAction(bool borrowersTransferPaused); + /** + * @notice Emitted when the pause status for collateral transfers is changed + * @param collateralTransferPaused Whether collateral transfers are now paused + */ + event CollateralTransferPauseAction(bool collateralTransferPaused); + /** + * @notice Emitted when the pause status for a specific collateral asset's transfers is changed + * @param assetIndex The index of the collateral asset + * @param collateralAssetTransferPaused Whether transfers for this collateral asset are now paused + */ + event CollateralAssetTransferPauseAction(uint24 assetIndex, bool collateralAssetTransferPaused); + /** + * @notice Emitted when a collateral asset is deactivated + * @param assetIndex The index of the collateral asset that was deactivated + */ + event CollateralDeactivated(uint24 assetIndex); + /** + * @notice Emitted when a collateral asset is activated + * @param assetIndex The index of the collateral asset that was activated + */ + event CollateralActivated(uint24 assetIndex); } \ No newline at end of file diff --git a/contracts/CometMainInterface.sol b/contracts/CometMainInterface.sol index 5347b22f7..7dbd23d90 100644 --- a/contracts/CometMainInterface.sol +++ b/contracts/CometMainInterface.sol @@ -34,6 +34,40 @@ abstract contract CometMainInterface is CometCore { error TransferOutFailed(); error Unauthorized(); + /// @dev Error emitted when the utilization exceeds the supported utilization + error ExceedsSupportedUtilization(); + /// @notice Error emitted when base supply is paused + error BaseSupplyPaused(); + /// @notice Error emitted when collateral supply is paused + error CollateralSupplyPaused(); + /// @notice Error emitted when a specific collateral asset supply is paused + /// @param assetIndex The index of the collateral asset + error CollateralAssetSupplyPaused(uint24 assetIndex); + /// @notice Error emitted when borrowers' transfers are paused + error BorrowersTransferPaused(); + /// @notice Error emitted when lenders' transfers are paused + error LendersTransferPaused(); + /// @notice Error emitted when collateral transfers are paused + error CollateralTransferPaused(); + /// @notice Error emitted when a specific collateral asset transfer is paused + /// @param assetIndex The index of the collateral asset + error CollateralAssetTransferPaused(uint24 assetIndex); + /// @notice Error emitted when borrowers' withdrawals are paused + error BorrowersWithdrawPaused(); + /// @notice Error emitted when lenders' withdrawals are paused + error LendersWithdrawPaused(); + /// @notice Error emitted when collateral withdrawals are paused + error CollateralWithdrawPaused(); + /// @notice Error emitted when a specific collateral asset withdrawal is paused + /// @param assetIndex The index of the collateral asset + error CollateralAssetWithdrawPaused(uint24 assetIndex); + /// @notice Error emitted when a user with debt tries to transfer and their position uses deactivated collateral + error DeactivatedCollateralTransferBlocked(); + /// @notice Error emitted when trying to borrow or increase debt using deactivated collateral + error DeactivatedCollateralBorrowBlocked(); + /// @notice Error emitted when deactivated token balance is > 0 on the balance of the account + error TokenIsDeactivated(address asset); + event Supply(address indexed from, address indexed dst, uint amount); event Transfer(address indexed from, address indexed to, uint amount); event Withdraw(address indexed src, address indexed to, uint amount); diff --git a/contracts/CometStorage.sol b/contracts/CometStorage.sol index 6a97c7bc5..d6797dc0e 100644 --- a/contracts/CometStorage.sol +++ b/contracts/CometStorage.sol @@ -73,4 +73,34 @@ contract CometStorage { /// @notice Mapping of magic liquidator points mapping(address => LiquidatorPoints) public liquidatorPoints; + + /** + * @notice The extended pause flags represented as a bitmap + * @dev Each bit represents a pause flag for a different action + */ + uint24 public extendedPauseFlags; + + /** + * @notice The collaterals withdraw pause flags represented as a bitmap + * @dev Each bit represents a pause flag for an asset index + */ + uint24 public collateralsWithdrawPauseFlags; + + /** + * @notice The collaterals supply pause flags represented as a bitmap + * @dev Each bit represents a pause flag for an asset index + */ + uint24 public collateralsSupplyPauseFlags; + + /** + * @notice The collaterals transfer pause flags represented as a bitmap + * @dev Each bit represents a pause flag for an asset index + */ + uint24 public collateralsTransferPauseFlags; + + /** + * @notice The deactivated collaterals flags represented as a bitmap + * @dev Each bit represents whether a collateral asset is deactivated + */ + uint24 public deactivatedCollaterals; } diff --git a/contracts/CometWithExtendedAssetList.sol b/contracts/CometWithExtendedAssetList.sol index 5e5659ec8..07b7b9bf6 100644 --- a/contracts/CometWithExtendedAssetList.sol +++ b/contracts/CometWithExtendedAssetList.sol @@ -108,6 +108,11 @@ contract CometWithExtendedAssetList is CometMainInterface { uint8 internal constant MAX_ASSETS_FOR_ASSET_LIST = 24; + /// @dev The protocol only supports 200% utilization on which borrows are allowed + /// It keeps healthy state of the market, with no over-utilization leading to illiquidity, + /// and keeps protocol reserves from exhaustion + uint256 public constant MAX_SUPPORTED_UTILIZATION = 2e18; + /** * @notice Construct a new protocol instance * @param config The mapping of initial/constant parameters @@ -261,6 +266,19 @@ contract CometWithExtendedAssetList is CometMainInterface { baseSupplyIndex_ += safe64(mulFactor(baseSupplyIndex_, supplyRate * timeElapsed)); baseBorrowIndex_ += safe64(mulFactor(baseBorrowIndex_, borrowRate * timeElapsed)); } + + /// @dev Prevent lenders' illiquidity when there are no borrowers + /// In markets with reserves and lenders but no borrows, lenders earn the base supply rate + /// funded from reserves. Without this cap, totalSupply() could exceed the actual token balance, + /// making it impossible for lenders to withdraw their full entitled amount. + /// This safeguard recalculates the supply index to match the available balance exactly, + /// ensuring withdrawals remain possible even when interest accrual outpaces reserves. + if (totalBorrowBase == 0 && totalSupplyBase > 0) { + uint256 baseBalance = IERC20NonStandard(baseToken).balanceOf(address(this)); + if (presentValueSupply(baseSupplyIndex_, totalSupplyBase) > baseBalance) + baseSupplyIndex_ = safe64((baseBalance * BASE_INDEX_SCALE) / totalSupplyBase); + } + return (baseSupplyIndex_, baseBorrowIndex_); } @@ -283,21 +301,46 @@ contract CometWithExtendedAssetList is CometMainInterface { } /** - * @notice Accrue interest and rewards for an account - **/ - function accrueAccount(address account) override external { + * @dev Accrue interest and rewards for an account + * @param account The account to accrue interest and rewards for + * @dev Function is internal to allow accrual for account inside supplying, transferring and borrowing collateral functions + */ + function accrueAccountInternal(address account) internal { accrueInternal(); UserBasic memory basic = userBasic[account]; updateBasePrincipal(account, basic, basic.principal); } + /** + * @notice Accrue interest and rewards for an account + * @param account The account to accrue interest and rewards for + * @dev This function is splitted to allow accrueAccountInternal to be called from other functions + **/ + function accrueAccount(address account) override external { + accrueAccountInternal(account); + } + /** * @dev Note: Does not accrue interest first * @param utilization The utilization to check the supply rate for * @return The per second supply rate at `utilization` */ function getSupplyRate(uint utilization) override public view returns (uint64) { + /// No supply - no supply interest + if (totalSupplyBase == 0) return 0; + + /// In several situations new market with reserves and have lenders, but may not have borrows + /// In such case, lenders will farm on this market on the base supply per second, until reserves are exhausted + /// So, we limit the farming possibility by the size of reserves: + /// - for the new market with no borrows, the balance consists of reserves and supplied base asset + /// - totalSupply() will grow based on the base rate until it will reach the available balance + /// - once it happens - we cut off the supply rate to avoid illiquidity (when lenders will not be able to + /// withdraw as there is no tokens on the Comet balance + if (utilization == 0 && supplyPerSecondInterestRateBase != 0) { + if (presentValueSupply(baseSupplyIndex, totalSupplyBase) >= IERC20NonStandard(baseToken).balanceOf(address(this))) return 0; + } + if (utilization <= supplyKink) { // interestRateBase + interestRateSlopeLow * utilization return safe64(supplyPerSecondInterestRateBase + mulFactor(supplyPerSecondInterestRateSlopeLow, utilization)); @@ -313,6 +356,9 @@ contract CometWithExtendedAssetList is CometMainInterface { * @return The per second borrow rate at `utilization` */ function getBorrowRate(uint utilization) override public view returns (uint64) { + /// No borrow - no borrow interest + if (totalBorrowBase == 0) return 0; + if (utilization <= borrowKink) { // interestRateBase + interestRateSlopeLow * utilization return safe64(borrowPerSecondInterestRateBase + mulFactor(borrowPerSecondInterestRateSlopeLow, utilization)); @@ -387,14 +433,40 @@ contract CometWithExtendedAssetList is CometMainInterface { uint64(baseScale) ); - for (uint8 i = 0; i < numAssets; ) { + AssetInfo memory asset; + uint256 newAmount; + for (uint8 i; i < numAssets; ) { if (isInAsset(assetsIn, i, _reserved)) { if (liquidity >= 0) { return true; } - AssetInfo memory asset = getAssetInfo(i); - uint newAmount = mulPrice( + asset = getAssetInfo(i); + + // Block ALL borrow-side actions when the borrower still holds deactivated collateral. + // This revert is intentionally broad: it prevents borrowing, withdrawing other + // collateral, and transferring — even if the remaining active collateral would + // pass the collateralization check on its own. The purpose is to force the + // borrower to withdraw the deactivated collateral FIRST before doing anything + // else (see the deactivation lifecycle comment on isCollateralDeactivated). + // + // If the borrower cannot withdraw the deactivated collateral without becoming + // under-collateralized, they are stuck and must wait for liquidation. + if (isCollateralDeactivated(asset.offset)) revert TokenIsDeactivated(asset.asset); + + // Skip assets with borrowCollateralFactor == 0 — they provide no + // borrowing power, so mulFactor(value, 0) would add nothing to liquidity. + // More critically, this avoids calling getPrice() for their price feed: + // if a non-contributing asset's oracle reverts (stale, broken, decommissioned), + // it would otherwise block the entire collateralization check, paralyzing + // borrows and transfers for every account that holds that asset — even though + // the asset has zero influence on their borrow capacity. + if (asset.borrowCollateralFactor == 0) { + unchecked { i++; } + continue; + } + + newAmount = mulPrice( userCollateral[account][asset.asset].balance, getPrice(asset.priceFeed), asset.scale @@ -414,43 +486,74 @@ contract CometWithExtendedAssetList is CometMainInterface { * @notice Check whether an account has enough collateral to not be liquidated * @param account The address to check * @return Whether the account is minimally collateralized enough to not be liquidated + * + * @dev Intentionally does NOT check isCollateralDeactivated. Unlike isBorrowCollateralized, + * which reverts on deactivated collateral to block borrower actions, this function + * must always return a result so that liquidation remains possible. A stuck borrower + * who cannot withdraw deactivated collateral (see withdrawCollateral) relies on + * liquidation as their only exit path. If isLiquidatable reverted on deactivated + * collateral, the borrower would be permanently frozen with no way out. + * + * When liquidateCollateralFactor is 0 for the deactivated asset, it contributes + * nothing to the liquidity calculation, making the account easier to liquidate. */ function isLiquidatable(address account) override public view returns (bool) { + (bool liquidatable, ,) = isLiquidatableInternal(account); + return liquidatable; + } + + function isLiquidatableInternal(address account) internal view returns ( + bool liquidatable, + uint256 basePrice, + uint256[] memory assetPrices + ) { int104 principal = userBasic[account].principal; - if (principal >= 0) { - return false; - } + if (principal >= 0) return (false, basePrice, assetPrices); + assetPrices = new uint256[](numAssets); uint16 assetsIn = userBasic[account].assetsIn; uint8 _reserved = userBasic[account]._reserved; + basePrice = getPrice(baseTokenPriceFeed); int liquidity = signedMulPrice( presentValue(principal), - getPrice(baseTokenPriceFeed), + basePrice, uint64(baseScale) ); - for (uint8 i = 0; i < numAssets; ) { + AssetInfo memory asset; + uint256 newAmount; + for (uint8 i; i < numAssets; ) { if (isInAsset(assetsIn, i, _reserved)) { - if (liquidity >= 0) { - return false; + if (liquidity >= 0) return (false, basePrice, assetPrices); + + asset = getAssetInfo(i); + assetPrices[i] = getPrice(asset.priceFeed); + + // Skip assets with liquidateCollateralFactor == 0 — they do not count + // toward the liquidation collateral threshold, so including them would + // add nothing to liquidity (mulFactor(value, 0) == 0). + // More critically, this avoids calling getPrice() for their price feed: + // if a non-contributing asset's oracle reverts (stale, broken, decommissioned), + // it would otherwise block the entire liquidation check, preventing + // liquidations for every account that holds that asset — even though + // the asset has zero influence on their liquidation status. + if (asset.liquidateCollateralFactor == 0) { + unchecked { i++; } + continue; } - AssetInfo memory asset = getAssetInfo(i); - uint newAmount = mulPrice( + newAmount = mulPrice( userCollateral[account][asset.asset].balance, - getPrice(asset.priceFeed), + assetPrices[i], asset.scale ); - liquidity += signed256(mulFactor( - newAmount, - asset.liquidateCollateralFactor - )); + liquidity += signed256(mulFactor(newAmount, asset.liquidateCollateralFactor)); } unchecked { i++; } } - return liquidity < 0; + return (liquidity < 0, basePrice, assetPrices); } /** @@ -548,6 +651,118 @@ contract CometWithExtendedAssetList is CometMainInterface { return toBool(pauseFlags & (uint8(1) << PAUSE_BUY_OFFSET)); } + /** + * @return Whether or not lenders withdraw actions are paused + */ + function isLendersWithdrawPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_LENDERS_WITHDRAW_OFFSET)) != 0; + } + + /** + * @return Whether or not borrowers withdraw actions are paused + */ + function isBorrowersWithdrawPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_BORROWERS_WITHDRAW_OFFSET)) != 0; + } + + /** + * @param assetIndex The index of the asset (offset) + * @return Whether or not collateral asset withdraw actions are paused + */ + function isCollateralAssetWithdrawPaused(uint24 assetIndex) public view returns (bool) { + return (collateralsWithdrawPauseFlags & (uint24(1) << assetIndex)) != 0; + } + + /** + * @return Whether or not collateral withdraw actions are paused + */ + function isCollateralWithdrawPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_COLLATERALS_WITHDRAW_OFFSET)) != 0; + } + + /** + * @return Whether or not collateral supply actions are paused + */ + function isCollateralSupplyPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_COLLATERAL_SUPPLY_OFFSET)) != 0; + } + + /** + * @return Whether or not base supply actions are paused + */ + function isBaseSupplyPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_BASE_SUPPLY_OFFSET)) != 0; + } + + /** + * @param assetIndex The index of the asset (offset) + * @return Whether or not collateral asset supply actions are paused + */ + function isCollateralAssetSupplyPaused(uint24 assetIndex) public view returns (bool) { + return (collateralsSupplyPauseFlags & (uint24(1) << assetIndex)) != 0; + } + + /** + * @return Whether or not lenders transfer actions are paused + */ + function isLendersTransferPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_LENDERS_TRANSFER_OFFSET)) != 0; + } + + /** + * @return Whether or not borrowers transfer actions are paused + */ + function isBorrowersTransferPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_BORROWERS_TRANSFER_OFFSET)) != 0; + } + + /** + * @param assetIndex The index of the asset (offset) + * @return Whether or not collateral asset transfer actions are paused + */ + function isCollateralAssetTransferPaused(uint24 assetIndex) public view returns (bool) { + return (collateralsTransferPauseFlags & (uint24(1) << assetIndex)) != 0; + } + + /** + * @return Whether or not collateral transfer actions are paused + */ + function isCollateralTransferPaused() public view returns (bool) { + return (extendedPauseFlags & (uint24(1) << PAUSE_COLLATERALS_TRANSFER_OFFSET)) != 0; + } + + /** + * @notice Check if a collateral asset is deactivated + * @param assetIndex The index of the asset + * @return Whether the collateral asset is deactivated + * + * Deactivation is an emergency action only. It can be called and executed + * immediately by the pause guardian via `deactivateCollateral`. + * When executed, the asset's bit is set in `deactivatedCollaterals`, and + * supply and transfer for that collateral are paused. + * + * ─── Impact on borrowers holding deactivated collateral ───────────────────── + * + * If a borrower still has debt and still holds deactivated collateral, borrow-side + * actions are blocked because `isBorrowCollateralized` reverts with + * `TokenIsDeactivated` when that asset is encountered in `assetsIn`. + * + * The borrower then has two options: + * + * 1. Repay debt until principal is > 0 (i.e. no borrow position). This avoids + * collateral liquidity checks in `isBorrowCollateralized`, allowing the borrower + * to withdraw the deactivated collateral. + * + * 2. Wait for liquidation (`absorbInternal`), where collateral is seized and debt + * is absorbed according to the protocol's liquidation rules. + * + * If a user is not a borrower (no debt / principal >= 0), they can withdraw + * deactivated collateral without these borrow-side restrictions. + */ + function isCollateralDeactivated(uint24 assetIndex) public view returns (bool) { + return (deactivatedCollaterals & (uint24(1) << assetIndex)) != 0; + } + /** * @dev Multiply a number by a factor */ @@ -706,7 +921,7 @@ contract CometWithExtendedAssetList is CometMainInterface { * @param amount The quantity to supply */ function supply(address asset, uint amount) override external { - return supplyInternal(msg.sender, msg.sender, msg.sender, asset, amount); + return supplyInternal(msg.sender, msg.sender, asset, amount); } /** @@ -716,7 +931,7 @@ contract CometWithExtendedAssetList is CometMainInterface { * @param amount The quantity to supply */ function supplyTo(address dst, address asset, uint amount) override external { - return supplyInternal(msg.sender, msg.sender, dst, asset, amount); + return supplyInternal(msg.sender, dst, asset, amount); } /** @@ -727,23 +942,25 @@ contract CometWithExtendedAssetList is CometMainInterface { * @param amount The quantity to supply */ function supplyFrom(address from, address dst, address asset, uint amount) override external { - return supplyInternal(msg.sender, from, dst, asset, amount); + return supplyInternal(from, dst, asset, amount); } /** * @dev Supply either collateral or base asset, depending on the asset, if operator is allowed * @dev Note: Specifying an `amount` of uint256.max will repay all of `dst`'s accrued base borrow balance */ - function supplyInternal(address operator, address from, address dst, address asset, uint amount) internal nonReentrant { + function supplyInternal(address from, address dst, address asset, uint amount) internal nonReentrant { if (isSupplyPaused()) revert Paused(); - if (!hasPermission(from, operator)) revert Unauthorized(); + if (!hasPermission(from, msg.sender)) revert Unauthorized(); if (asset == baseToken) { + if (isBaseSupplyPaused()) revert BaseSupplyPaused(); if (amount == type(uint256).max) { amount = borrowBalanceOf(dst); } return supplyBase(from, dst, amount); } else { + if (isCollateralSupplyPaused()) revert CollateralSupplyPaused(); return supplyCollateral(from, dst, asset, safe128(amount)); } } @@ -753,7 +970,6 @@ contract CometWithExtendedAssetList is CometMainInterface { */ function supplyBase(address from, address dst, uint256 amount) internal { amount = doTransferIn(baseToken, from, amount); - accrueInternal(); UserBasic memory dstUser = userBasic[dst]; @@ -779,9 +995,14 @@ contract CometWithExtendedAssetList is CometMainInterface { * @dev Supply an amount of collateral asset from `from` to dst */ function supplyCollateral(address from, address dst, address asset, uint128 amount) internal { + AssetInfo memory assetInfo = getAssetInfoByAddress(asset); + uint8 offset = assetInfo.offset; + + if (isCollateralAssetSupplyPaused(offset)) revert CollateralAssetSupplyPaused(offset); + accrueAccountInternal(dst); + amount = safe128(doTransferIn(asset, from, amount)); - AssetInfo memory assetInfo = getAssetInfoByAddress(asset); TotalsCollateral memory totals = totalsCollateral[asset]; totals.totalSupplyAsset += amount; if (totals.totalSupplyAsset > assetInfo.supplyCap) revert SupplyCapExceeded(); @@ -856,6 +1077,7 @@ contract CometWithExtendedAssetList is CometMainInterface { } return transferBase(src, dst, amount); } else { + if (isCollateralTransferPaused()) revert CollateralTransferPaused(); return transferCollateral(src, dst, asset, safe128(amount)); } } @@ -887,8 +1109,22 @@ contract CometWithExtendedAssetList is CometMainInterface { updateBasePrincipal(dst, dstUser, dstPrincipalNew); if (srcBalance < 0) { + if (isBorrowersTransferPaused()) revert BorrowersTransferPaused(); if (uint256(-srcBalance) < baseBorrowMin) revert BorrowTooSmall(); if (!isBorrowCollateralized(src)) revert NotCollateralized(); + + /// @dev Guard against utilization being pushed above the supported ceiling via a borrow-side transferBase. + /// When the source account is in a borrow position, the supply credited to the destination is new + /// liquidity that the destination can immediately withdraw. To capture this worst case, utilization is + /// evaluated against total supply *excluding* the destination's newly credited amount — i.e. the supply + /// that would remain if the destination withdrew right away. This prevents a pattern where a borrower + /// transfers base to a fresh account that then withdraws, draining pool liquidity and pushing + /// utilization beyond MAX_SUPPORTED_UTILIZATION. + uint256 totalSupplyWithoutDst = presentValueSupply(baseSupplyIndex, totalSupplyBase - supplyAmount); + uint256 presentTotalBorrow = presentValueBorrow(baseBorrowIndex, totalBorrowBase); + if (totalSupplyWithoutDst > 0 && presentTotalBorrow * FACTOR_SCALE / totalSupplyWithoutDst > MAX_SUPPORTED_UTILIZATION) revert ExceedsSupportedUtilization(); + } else { + if (isLendersTransferPaused()) revert LendersTransferPaused(); } if (withdrawAmount > 0) { @@ -913,10 +1149,14 @@ contract CometWithExtendedAssetList is CometMainInterface { userCollateral[dst][asset].balance = dstCollateralNew; AssetInfo memory assetInfo = getAssetInfoByAddress(asset); + uint8 offset = assetInfo.offset; + + if (isCollateralAssetTransferPaused(offset)) revert CollateralAssetTransferPaused(offset); + accrueAccountInternal(src); + accrueAccountInternal(dst); updateAssetsIn(src, assetInfo, srcCollateral, srcCollateralNew); updateAssetsIn(dst, assetInfo, dstCollateral, dstCollateralNew); - // Note: no accrue interest, BorrowCF < LiquidationCF covers small changes if (!isBorrowCollateralized(src)) revert NotCollateralized(); emit TransferCollateral(src, dst, asset, amount); @@ -966,6 +1206,7 @@ contract CometWithExtendedAssetList is CometMainInterface { } return withdrawBase(src, to, amount); } else { + if (isCollateralWithdrawPaused()) revert CollateralWithdrawPaused(); return withdrawCollateral(src, to, asset, safe128(amount)); } } @@ -989,8 +1230,14 @@ contract CometWithExtendedAssetList is CometMainInterface { updateBasePrincipal(src, srcUser, srcPrincipalNew); if (srcBalance < 0) { + if (isBorrowersWithdrawPaused()) revert BorrowersWithdrawPaused(); if (uint256(-srcBalance) < baseBorrowMin) revert BorrowTooSmall(); if (!isBorrowCollateralized(src)) revert NotCollateralized(); + /// @dev safeguard against the over-utilization leading to illiquidity and reserves exhaustion + /// At this point totals are updated and it is a borrow case, so we can check resulting utilization + if (getUtilization() > MAX_SUPPORTED_UTILIZATION) revert ExceedsSupportedUtilization(); + } else { + if (isLendersWithdrawPaused()) revert LendersWithdrawPaused(); } doTransferOut(baseToken, to, amount); @@ -1004,8 +1251,23 @@ contract CometWithExtendedAssetList is CometMainInterface { /** * @dev Withdraw an amount of collateral asset from src to `to` + * + * Note on deactivated collateral: + * This is the path a borrower must use to remove deactivated collateral from their + * account before they can resume normal operations (see deactivation lifecycle on + * isCollateralDeactivated). If the borrower withdraws ALL of the deactivated asset, + * updateAssetsIn clears its bit from `assetsIn`, so the subsequent + * isBorrowCollateralized call no longer encounters the deactivated asset. + * + * However, if removing the deactivated collateral leaves the borrower under- + * collateralized (remaining active collateral is insufficient for the borrow), + * isBorrowCollateralized reverts with NotCollateralized — the borrower is stuck. + * In this case the borrower has no choice but to wait for liquidation, which will + * seize all collateral (including deactivated) and absorb the debt. */ function withdrawCollateral(address src, address to, address asset, uint128 amount) internal { + accrueAccountInternal(src); + uint128 srcCollateral = userCollateral[src][asset].balance; uint128 srcCollateralNew = srcCollateral - amount; @@ -1013,9 +1275,11 @@ contract CometWithExtendedAssetList is CometMainInterface { userCollateral[src][asset].balance = srcCollateralNew; AssetInfo memory assetInfo = getAssetInfoByAddress(asset); + uint8 offset = assetInfo.offset; + if (isCollateralAssetWithdrawPaused(offset)) revert CollateralAssetWithdrawPaused(offset); + updateAssetsIn(src, assetInfo, srcCollateral, srcCollateralNew); - // Note: no accrue interest, BorrowCF < LiquidationCF covers small changes if (!isBorrowCollateralized(src)) revert NotCollateralized(); doTransferOut(asset, to, amount); @@ -1052,9 +1316,24 @@ contract CometWithExtendedAssetList is CometMainInterface { /** * @dev Transfer user's collateral and debt to the protocol itself. + * + * Note on deactivated collateral: + * All collateral is seized — including deactivated assets. The tokens are moved from + * the user's balance to the protocol's reserves (balance zeroed, totals reduced). + * + * For deactivated assets whose liquidationFactor has been set to 0: + * - mulFactor(value, 0) == 0, so the asset's value does NOT offset the borrower's debt. + * - The borrower receives less base-asset cashback than they would if the collateral + * were still active, because only active collateral contributes to deltaValue. + * + * After absorption, the protocol (Comet) holds the seized deactivated collateral tokens. + * Governance can later handle them (e.g. via withdrawReserves or a future re-listing). + * The borrower's assetsIn is reset to 0, debt is absorbed, and any residual value from + * active collateral is credited as a positive base balance (cashback). */ function absorbInternal(address absorber, address account) internal { - if (!isLiquidatable(account)) revert NotLiquidatable(); + (bool liquidatable, uint256 basePrice, uint256[] memory assetPrices) = isLiquidatableInternal(account); + if (!liquidatable) revert NotLiquidatable(); UserBasic memory accountUser = userBasic[account]; int104 oldPrincipal = accountUser.principal; @@ -1062,18 +1341,34 @@ contract CometWithExtendedAssetList is CometMainInterface { uint16 assetsIn = accountUser.assetsIn; uint8 _reserved = accountUser._reserved; - uint256 basePrice = getPrice(baseTokenPriceFeed); - uint256 deltaValue = 0; - - for (uint8 i = 0; i < numAssets; ) { + AssetInfo memory assetInfo; + uint256 deltaValue; + address asset; + uint128 seizeAmount; + uint256 value; + for (uint8 i; i < numAssets; ) { if (isInAsset(assetsIn, i, _reserved)) { - AssetInfo memory assetInfo = getAssetInfo(i); - address asset = assetInfo.asset; - uint128 seizeAmount = userCollateral[account][asset].balance; + assetInfo = getAssetInfo(i); + + // Skip assets with liquidationFactor == 0 — they are non-liquidatable and + // must not be seized during absorption. This serves three purposes: + // 1. The collateral remains with the borrower: non-liquidatable assets should + // not be confiscated, and their value should not offset the account's debt. + // 2. Avoids calling getPrice() on their price feed below: if the oracle is + // disabled or reverting, it would otherwise block absorption of the entire + // account, preventing liquidation even for assets that *should* be seized. + // 3. mulFactor(value, 0) would contribute nothing to deltaValue anyway. + if (assetInfo.liquidationFactor == 0) { + unchecked { i++; } + continue; + } + + asset = assetInfo.asset; + seizeAmount = userCollateral[account][asset].balance; userCollateral[account][asset].balance = 0; totalsCollateral[asset].totalSupplyAsset -= seizeAmount; - uint256 value = mulPrice(seizeAmount, getPrice(assetInfo.priceFeed), assetInfo.scale); + value = mulPrice(seizeAmount, assetPrices[i], assetInfo.scale); deltaValue += mulFactor(value, assetInfo.liquidationFactor); emit AbsorbCollateral(absorber, account, asset, seizeAmount, value); @@ -1148,15 +1443,41 @@ contract CometWithExtendedAssetList is CometMainInterface { */ function quoteCollateral(address asset, uint baseAmount) override public view returns (uint) { AssetInfo memory assetInfo = getAssetInfoByAddress(asset); - uint256 assetPrice = getPrice(assetInfo.priceFeed); - // Store front discount is derived from the collateral asset's liquidationFactor and storeFrontPriceFactor - // discount = storeFrontPriceFactor * (1e18 - liquidationFactor) - uint256 discountFactor = mulFactor(storeFrontPriceFactor, FACTOR_SCALE - assetInfo.liquidationFactor); - uint256 assetPriceDiscounted = mulFactor(assetPrice, FACTOR_SCALE - discountFactor); + + // NOTE: This getPrice() call is intentionally left unguarded. Unlike isBorrowCollateralized + // and isLiquidatable — where we skip zero-factor assets to prevent a broken price feed + // from paralyzing collateral checks — quoteCollateral is only called from buyCollateral, + // which is a voluntary action by an external buyer. If the asset's price feed is disabled + // or reverting, it is acceptable (and safer) for the quote to revert: the protocol should + // not sell collateral whose price it cannot verify. + uint256 assetPriceDiscounted = getPrice(assetInfo.priceFeed); + + // Only apply the store front discount for assets that participate in liquidation + // (i.e. liquidationFactor > 0). Assets with liquidationFactor == 0 are non-liquidatable: + // they are skipped during absorption (see absorbInternal) and therefore should not + // receive a liquidation discount when purchased via buyCollateral. + // + // Additionally, if liquidationFactor == 0 the discount math would compute + // discountFactor = storeFrontPriceFactor * (FACTOR_SCALE - 0) / FACTOR_SCALE + // = storeFrontPriceFactor, + // and when storeFrontPriceFactor == FACTOR_SCALE (100%) that yields + // assetPrice = assetPrice * (FACTOR_SCALE - FACTOR_SCALE) / FACTOR_SCALE = 0, + // which would cause a division-by-zero revert in the return statement below. + // + // By skipping the discount, the protocol sells such collateral at the fair oracle + // price — no liquidation incentive is needed for non-liquidatable assets. + // Market price will be used if liquidationFactor == 0 + if (assetInfo.liquidationFactor != 0) { + // Store front discount is derived from the collateral asset's liquidationFactor and storeFrontPriceFactor + // discount = storeFrontPriceFactor * (1e18 - liquidationFactor) + uint256 discountFactor = mulFactor(storeFrontPriceFactor, FACTOR_SCALE - assetInfo.liquidationFactor); + assetPriceDiscounted = mulFactor(assetPriceDiscounted, FACTOR_SCALE - discountFactor); + } + uint256 basePrice = getPrice(baseTokenPriceFeed); // # of collateral assets // = (TotalValueOfBaseAmount / DiscountedPriceOfCollateralAsset) * assetScale - // = ((basePrice * baseAmount / baseScale) / assetPriceDiscounted) * assetScale + // = ((basePrice * baseAmount / baseScale) / assetPrice) * assetScale return basePrice * baseAmount * assetInfo.scale / assetPriceDiscounted / baseScale; } diff --git a/contracts/test/CometHarnessInterfaceExtendedAssetList.sol b/contracts/test/CometHarnessInterfaceExtendedAssetList.sol index 7c53d1353..4ac6916f0 100644 --- a/contracts/test/CometHarnessInterfaceExtendedAssetList.sol +++ b/contracts/test/CometHarnessInterfaceExtendedAssetList.sol @@ -14,4 +14,17 @@ abstract contract CometHarnessInterfaceExtendedAssetList is CometInterface { function updateAssetsInExternal(address account, address asset, uint128 initialUserBalance, uint128 finalUserBalance) virtual external; function getAssetList(address account) virtual external view returns (address[] memory); function assetList() virtual external view returns (address); + function isLendersWithdrawPaused() virtual external view returns (bool); + function isBorrowersWithdrawPaused() virtual external view returns (bool); + function isCollateralAssetWithdrawPaused(uint24 assetIndex) virtual external view returns (bool); + function isCollateralSupplyPaused() virtual external view returns (bool); + function isBaseSupplyPaused() virtual external view returns (bool); + function isCollateralAssetSupplyPaused(uint24 assetIndex) virtual external view returns (bool); + function isLendersTransferPaused() virtual external view returns (bool); + function isBorrowersTransferPaused() virtual external view returns (bool); + function isCollateralAssetTransferPaused(uint24 assetIndex) virtual external view returns (bool); + function isCollateralTransferPaused() virtual external view returns (bool); + function isCollateralWithdrawPaused() virtual external view returns (bool); + function MAX_SUPPORTED_UTILIZATION() virtual external view returns (uint256); + function isCollateralDeactivated(uint24 assetIndex) virtual external view returns (bool); } diff --git a/contracts/test/PriceFeedWithRevert.sol b/contracts/test/PriceFeedWithRevert.sol new file mode 100644 index 000000000..1333a684a --- /dev/null +++ b/contracts/test/PriceFeedWithRevert.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "../vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; + +contract PriceFeedWithRevert is AggregatorV3Interface { + string public constant override description = "Mock Chainlink price aggregator"; + + uint public constant override version = 1; + + uint8 public immutable override decimals; + + uint80 internal roundId; + int256 internal answer; + uint256 internal startedAt; + uint256 internal updatedAt; + uint80 internal answeredInRound; + + error Reverted(); + + constructor(int answer_, uint8 decimals_) { + answer = answer_; + decimals = decimals_; + } + + function setRoundData( + uint80 roundId_, + int256 answer_, + uint256 startedAt_, + uint256 updatedAt_, + uint80 answeredInRound_ + ) public { + roundId = roundId_; + answer = answer_; + startedAt = startedAt_; + updatedAt = updatedAt_; + answeredInRound = answeredInRound_; + } + + function getRoundData(uint80 roundId_) override external view returns (uint80, int256, uint256, uint256, uint80) { + return (roundId_, answer, startedAt, updatedAt, answeredInRound); + } + + function latestRoundData() override external pure returns (uint80, int256, uint256, uint256, uint80) { + revert Reverted(); + } +} diff --git a/deployments/arbitrum/usdc/relations.ts b/deployments/arbitrum/usdc/relations.ts index 9a8535766..0e4bb52be 100644 --- a/deployments/arbitrum/usdc/relations.ts +++ b/deployments/arbitrum/usdc/relations.ts @@ -11,6 +11,13 @@ export default { TransparentUpgradeableProxy: { artifact: 'contracts/ERC20.sol:ERC20' }, + AdminUpgradableProxy: { + delegates: { + field: { + slot: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' + } + } + }, // Native USDC '0xaf88d065e77c8cC2239327C5EDb3A432268e5831': { artifact: 'contracts/ERC20.sol:ERC20', diff --git a/deployments/arbitrum/usdc/roots.json b/deployments/arbitrum/usdc/roots.json index 54603b0c6..1ae6e7f63 100644 --- a/deployments/arbitrum/usdc/roots.json +++ b/deployments/arbitrum/usdc/roots.json @@ -4,5 +4,6 @@ "rewards": "0x88730d254A2f7e6AC8388c3198aFd694bA9f7fae", "bridgeReceiver": "0x42480C37B249e33aABaf4c22B20235656bd38068", "bulker": "0xbdE8F31D2DdDA895264e27DD990faB3DC87b372d", - "CCTPMessageTransmitter": "0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca" + "CCTPTokenMessenger": "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d", + "CCTPMessageTransmitter": "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64" } \ No newline at end of file diff --git a/deployments/base/aero/migrations/1761125221_upgrade_to_capo_price_feeds.ts b/deployments/base/aero/migrations/1761125221_upgrade_to_capo_price_feeds.ts new file mode 100644 index 000000000..d58fd5fb7 --- /dev/null +++ b/deployments/base/aero/migrations/1761125221_upgrade_to_capo_price_feeds.ts @@ -0,0 +1,160 @@ +import { expect } from 'chai'; +import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; +import { migration } from '../../../../plugins/deployment_manager/Migration'; +import { calldata, proposal, exp } from '../../../../src/deploy'; +import { utils } from 'ethers'; +import { AggregatorV3Interface } from '../../../../build/types'; + +const ETH_USD_PRICE_FEED = '0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70'; + +const WSTETH_ADDRESS = '0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452'; +const WSTETH_STETH_PRICE_FEED_ADDRESS = '0xB88BAc61a4Ca37C43a3725912B1f472c9A5bc061'; + +const FEED_DECIMALS = 8; +const blockToFetch = 36000000; + +let newWstETHPriceFeed: string; +let oldWstETHPriceFeed: string; + +export default migration('1761125221_upgrade_to_capo_price_feeds', { + async prepare(deploymentManager: DeploymentManager) { + const { timelock } = await deploymentManager.getContracts(); + const blockToFetchTimestamp = (await deploymentManager.hre.ethers.provider.getBlock(blockToFetch))!.timestamp; + + //1. wstEth + const rateProviderWstEth = await deploymentManager.existing('wstEth:priceFeed', WSTETH_STETH_PRICE_FEED_ADDRESS, 'base', 'contracts/capo/contracts/interfaces/AggregatorV3Interface.sol:AggregatorV3Interface') as AggregatorV3Interface; + const [, currentRatioWstEth] = await rateProviderWstEth.latestRoundData({ blockTag: blockToFetch }); + + const wstEthCapoPriceFeed = await deploymentManager.deploy( + 'wstETH:priceFeed', + 'capo/contracts/ChainlinkCorrelatedAssetsPriceOracle.sol', + [ + timelock.address, + ETH_USD_PRICE_FEED, + WSTETH_STETH_PRICE_FEED_ADDRESS, + 'wstETH / USD CAPO Price Feed', + FEED_DECIMALS, + 3600, + { + snapshotRatio: currentRatioWstEth, + snapshotTimestamp: blockToFetchTimestamp, + maxYearlyRatioGrowthPercent: exp(0.0404, 4) + } + ], + true + ); + + return { + wstEthCapoPriceFeedAddress: wstEthCapoPriceFeed.address + }; + }, + + async enact(deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager, { + wstEthCapoPriceFeedAddress + }) { + newWstETHPriceFeed = wstEthCapoPriceFeedAddress; + + const trace = deploymentManager.tracer(); + + const { + configurator, + comet, + bridgeReceiver, + cometAdmin + } = await deploymentManager.getContracts(); + + const { + governor, + baseL1CrossDomainMessenger + } = await govDeploymentManager.getContracts(); + + const updateWstEthPriceFeedCalldata = await calldata( + configurator.populateTransaction.updateAssetPriceFeed( + comet.address, + WSTETH_ADDRESS, + wstEthCapoPriceFeedAddress + ) + ); + + const deployAndUpgradeToCalldata = await calldata( + cometAdmin.populateTransaction.deployAndUpgradeTo( + configurator.address, + comet.address + ) + ); + + const l2ProposalData = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [configurator.address, cometAdmin.address], + [0, 0], + ['updateAssetPriceFeed(address,address,address)', 'deployAndUpgradeTo(address,address)'], + [updateWstEthPriceFeedCalldata, deployAndUpgradeToCalldata], + ] + ); + + [,, oldWstETHPriceFeed] = await comet.getAssetInfoByAddress(WSTETH_ADDRESS); + + const mainnetActions = [ + { + contract: baseL1CrossDomainMessenger, + signature: 'sendMessage(address,bytes,uint32)', + args: [ + bridgeReceiver.address, + l2ProposalData, + 3_000_000 + ] + }, + ]; + + const description = `# Update wstETH price feed in cAEROv3 on Base with CAPO implementation. + +## Proposal summary + +This proposal updates existing price feeds for wstETH on the AERO market on Base. + +### CAPO summary + +CAPO is a price oracle adapter designed to support assets that grow gradually relative to a base asset - such as liquid staking tokens that accumulate yield over time. It provides a mechanism to track this expected growth while protecting downstream protocol from sudden or manipulated price spikes. wstETH price feed is updated to their CAPO implementations. + +Further detailed information can be found on the corresponding [proposal pull request](https://github.com/compound-finance/comet/pull/1038) and [forum discussion for CAPO](https://www.comp.xyz/t/woof-correlated-assets-price-oracle-capo/6245). + +### CAPO audit + +CAPO has been audited by [OpenZeppelin](https://www.comp.xyz/t/capo-price-feed-audit/6631, as well as the LST / LRT implementation [here](https://www.comp.xyz/t/capo-lst-lrt-audit/7118). + +## Proposal actions + +The first action updates wstETH price feed to the CAPO implementation. This sends the encoded 'updateAssetPriceFeed' and 'deployAndUpgradeTo' calls across the bridge to the governance receiver on Base. +`; + + const txn = await deploymentManager.retry(async () => + trace( + await governor.propose(...(await proposal(mainnetActions, description))) + ) + ); + const event = txn.events.find( + (event: { event: string }) => event.event === 'ProposalCreated' + ); + const [proposalId] = event.args; + trace(`Created proposal ${proposalId}.`); + }, + + async enacted(deploymentManager: DeploymentManager): Promise { + return true; + }, + + async verify(deploymentManager: DeploymentManager) { + const { comet, configurator } = await deploymentManager.getContracts(); + + const wstETHIndexInComet = await configurator.getAssetIndex(comet.address, WSTETH_ADDRESS); + + // Check if the price feeds are set correctly. + const wstETHInCometInfo = await comet.getAssetInfoByAddress(WSTETH_ADDRESS); + const wstETHInConfiguratorInfoWETHComet = (await configurator.getConfiguration(comet.address)).assetConfigs[wstETHIndexInComet]; + + expect(wstETHInCometInfo.priceFeed).to.eq(newWstETHPriceFeed); + expect(wstETHInConfiguratorInfoWETHComet.priceFeed).to.eq(newWstETHPriceFeed); + expect(await comet.getPrice(newWstETHPriceFeed)).to.equal(await comet.getPrice(oldWstETHPriceFeed)); + }, +}); diff --git a/deployments/base/weth/migrations/1761228877_upgrade_to_capo_price_feeds.ts b/deployments/base/weth/migrations/1761228877_upgrade_to_capo_price_feeds.ts new file mode 100644 index 000000000..56cffa4f0 --- /dev/null +++ b/deployments/base/weth/migrations/1761228877_upgrade_to_capo_price_feeds.ts @@ -0,0 +1,319 @@ +import { expect } from 'chai'; +import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; +import { migration } from '../../../../plugins/deployment_manager/Migration'; +import { calldata, proposal, exp } from '../../../../src/deploy'; +import { utils } from 'ethers'; +import { AggregatorV3Interface } from '../../../../build/types'; + +const WSTETH_ADDRESS = '0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452'; +const WSTETH_STETH_PRICE_FEED_ADDRESS = '0xB88BAc61a4Ca37C43a3725912B1f472c9A5bc061'; + +const EZETH_ADDRESS = '0x2416092f143378750bb29b79ed961ab195cceea5'; +const EZETH_TO_ETH_PRICE_FEED_ADDRESS = '0xC4300B7CF0646F0Fe4C5B2ACFCCC4dCA1346f5d8'; + +const WRSETH_ADDRESS = '0xEDfa23602D0EC14714057867A78d01e94176BEA0'; +const WRSETH_ORACLE = '0xe8dD07CCf5BC4922424140E44Eb970F5950725ef'; + +const WEETH_ADDRESS = '0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A'; +const WEETH_STETH_PRICE_FEED_ADDRESS = '0x35e9D7001819Ea3B39Da906aE6b06A62cfe2c181'; + +const blockToFetch = 36000000; + +let newWstETHToETHPriceFeed: string; +let newEzETHToETHPriceFeed: string; +let newWrsEthToETHPriceFeed: string; +let newWeEthToETHPriceFeed: string; + +let oldWstETHToETHPriceFeed: string; +let oldEzETHToETHPriceFeed: string; +let oldWrsEthToETHPriceFeed: string; +let oldWeEthToETHPriceFeed: string; + +const FEED_DECIMALS = 8; +export default migration('1761228877_upgrade_to_capo_price_feeds', { + async prepare(deploymentManager: DeploymentManager) { + const { timelock } = await deploymentManager.getContracts(); + const blockToFetchTimestamp = (await deploymentManager.hre.ethers.provider.getBlock(blockToFetch))!.timestamp; + const constantPriceFeed = await deploymentManager.fromDep('WETH:priceFeed', 'base', 'weth'); + + //1. wstEth + const rateProviderWstEth = await deploymentManager.existing('wstETH:_rateProvider', WSTETH_STETH_PRICE_FEED_ADDRESS, 'base', 'contracts/capo/contracts/interfaces/AggregatorV3Interface.sol:AggregatorV3Interface') as AggregatorV3Interface; + const [, currentRatioWstEth] = await rateProviderWstEth.latestRoundData({blockTag: blockToFetch}); + + const wstEthCapoPriceFeed = await deploymentManager.deploy( + 'wstETH:priceFeed', + 'capo/contracts/ChainlinkCorrelatedAssetsPriceOracle.sol', + [ + timelock.address, + constantPriceFeed.address, + WSTETH_STETH_PRICE_FEED_ADDRESS, + 'wstETH / ETH CAPO Price Feed', + FEED_DECIMALS, + 3600, + { + snapshotRatio: currentRatioWstEth, + snapshotTimestamp: blockToFetchTimestamp, + maxYearlyRatioGrowthPercent: exp(0.0404, 4) + } + ], + true + ); + + //2. ezEth + const rateProviderEzEth = await deploymentManager.existing('ezETH:_rateProvider', EZETH_TO_ETH_PRICE_FEED_ADDRESS, 'base', 'contracts/capo/contracts/interfaces/AggregatorV3Interface.sol:AggregatorV3Interface') as AggregatorV3Interface; + const [, currentRatioEzEth] = await rateProviderEzEth.latestRoundData({blockTag: blockToFetch}); + const ezEthCapoPriceFeed = await deploymentManager.deploy( + 'ezETH:priceFeed', + 'capo/contracts/ChainlinkCorrelatedAssetsPriceOracle.sol', + [ + timelock.address, + constantPriceFeed.address, + EZETH_TO_ETH_PRICE_FEED_ADDRESS, + 'ezETH / ETH CAPO Price Feed', + FEED_DECIMALS, + 3600, + { + snapshotRatio: currentRatioEzEth, + snapshotTimestamp: blockToFetchTimestamp, + maxYearlyRatioGrowthPercent: exp(0.0707, 4) + } + ], + true + ); + + const rateProviderRsEth = await deploymentManager.existing('rsETH:_rateProvider', WRSETH_ORACLE, 'base', 'contracts/capo/contracts/interfaces/AggregatorV3Interface.sol:AggregatorV3Interface') as AggregatorV3Interface; + const [, currentRatioWrsEth] = await rateProviderRsEth.latestRoundData({blockTag: blockToFetch}); + const rsEthCapoPriceFeed = await deploymentManager.deploy( + 'rsETH:priceFeed', + 'capo/contracts/ChainlinkCorrelatedAssetsPriceOracle.sol', + [ + timelock.address, + constantPriceFeed.address, + WRSETH_ORACLE, + 'rsETH / ETH CAPO Price Feed', + FEED_DECIMALS, + 3600, + { + snapshotRatio: currentRatioWrsEth, + snapshotTimestamp: blockToFetchTimestamp, + maxYearlyRatioGrowthPercent: exp(0.0554, 4) + } + ], + true + ); + + + const rateProviderWeEth = await deploymentManager.existing('weETH:_rateProvider', WEETH_STETH_PRICE_FEED_ADDRESS, 'base', 'contracts/capo/contracts/interfaces/AggregatorV3Interface.sol:AggregatorV3Interface') as AggregatorV3Interface; + const [, currentRatioWeEth] = await rateProviderWeEth.latestRoundData({blockTag: blockToFetch}); + const weEthCapoPriceFeed = await deploymentManager.deploy( + 'weETH:priceFeed', + 'capo/contracts/ChainlinkCorrelatedAssetsPriceOracle.sol', + [ + timelock.address, + constantPriceFeed.address, + WEETH_STETH_PRICE_FEED_ADDRESS, + 'weETH / ETH CAPO Price Feed', + FEED_DECIMALS, + 3600, + { + snapshotRatio: currentRatioWeEth, + snapshotTimestamp: blockToFetchTimestamp, + maxYearlyRatioGrowthPercent: exp(0.0323, 4) + } + ], + true + ); + + return { + wstEthCapoPriceFeedAddress: wstEthCapoPriceFeed.address, + ezEthCapoPriceFeedAddress: ezEthCapoPriceFeed.address, + rsEthCapoPriceFeedAddress: rsEthCapoPriceFeed.address, + weEthCapoPriceFeedAddress: weEthCapoPriceFeed.address + }; + }, + + async enact(deploymentManager: DeploymentManager, govDeploymentManager, { + wstEthCapoPriceFeedAddress, + ezEthCapoPriceFeedAddress, + rsEthCapoPriceFeedAddress, + weEthCapoPriceFeedAddress + }) { + + newWstETHToETHPriceFeed = wstEthCapoPriceFeedAddress; + newEzETHToETHPriceFeed = ezEthCapoPriceFeedAddress; + newWrsEthToETHPriceFeed = rsEthCapoPriceFeedAddress; + newWeEthToETHPriceFeed = weEthCapoPriceFeedAddress; + + const trace = deploymentManager.tracer(); + + const { + configurator, + comet, + bridgeReceiver, + cometAdmin + } = await deploymentManager.getContracts(); + + const { + governor, + baseL1CrossDomainMessenger + } = await govDeploymentManager.getContracts(); + + const updateEzEthPriceFeedCalldata = await calldata( + configurator.populateTransaction.updateAssetPriceFeed( + comet.address, + EZETH_ADDRESS, + ezEthCapoPriceFeedAddress + ) + ); + + const updateWstEthPriceFeedCalldata = await calldata( + configurator.populateTransaction.updateAssetPriceFeed( + comet.address, + WSTETH_ADDRESS, + wstEthCapoPriceFeedAddress + ) + ); + + const updateRsEthPriceFeedCalldata = await calldata( + configurator.populateTransaction.updateAssetPriceFeed( + comet.address, + WRSETH_ADDRESS, + rsEthCapoPriceFeedAddress + ) + ); + + const updateWeEthPriceFeedCalldata = await calldata( + configurator.populateTransaction.updateAssetPriceFeed( + comet.address, + WEETH_ADDRESS, + weEthCapoPriceFeedAddress + ) + ); + + const deployAndUpgradeToCalldata = utils.defaultAbiCoder.encode( + ['address', 'address'], + [configurator.address, comet.address] + ); + + const l2ProposalData = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [ + configurator.address, + configurator.address, + configurator.address, + configurator.address, + cometAdmin.address + ], + [0, 0, 0, 0, 0], + [ + 'updateAssetPriceFeed(address,address,address)', + 'updateAssetPriceFeed(address,address,address)', + 'updateAssetPriceFeed(address,address,address)', + 'updateAssetPriceFeed(address,address,address)', + 'deployAndUpgradeTo(address,address)' + ], + [ + updateWstEthPriceFeedCalldata, + updateEzEthPriceFeedCalldata, + updateRsEthPriceFeedCalldata, + updateWeEthPriceFeedCalldata, + deployAndUpgradeToCalldata + ], + ] + ); + + [,, oldWstETHToETHPriceFeed] = await comet.getAssetInfoByAddress(WSTETH_ADDRESS); + [,, oldEzETHToETHPriceFeed] = await comet.getAssetInfoByAddress(EZETH_ADDRESS); + [,, oldWrsEthToETHPriceFeed] = await comet.getAssetInfoByAddress(WRSETH_ADDRESS); + [,, oldWeEthToETHPriceFeed] = await comet.getAssetInfoByAddress(WEETH_ADDRESS); + + const mainnetActions = [ + { + contract: baseL1CrossDomainMessenger, + signature: 'sendMessage(address,bytes,uint32)', + args: [ + bridgeReceiver.address, + l2ProposalData, + 3_000_000 + ] + }, + ]; + + const description = `# Update price feeds in cWETHv3 on Base with CAPO implementation. + +## Proposal summary + +This proposal updates existing price feeds for wstETH, ezETH, rsETH, and weETH on the WETH market on Base. + +### CAPO summary + +CAPO is a price oracle adapter designed to support assets that grow gradually relative to a base asset - such as liquid staking tokens that accumulate yield over time. It provides a mechanism to track this expected growth while protecting downstream protocol from sudden or manipulated price spikes. wstETH, ezETH, rsETH, and weETH price feeds are updated to their CAPO implementations. + +Further detailed information can be found on the corresponding [proposal pull request](https://github.com/compound-finance/comet/pull/1040) and [forum discussion for CAPO](https://www.comp.xyz/t/woof-correlated-assets-price-oracle-capo/6245). + +### CAPO audit + +CAPO has been audited by [OpenZeppelin](https://www.comp.xyz/t/capo-price-feed-audit/6631, as well as the LST / LRT implementation [here](https://www.comp.xyz/t/capo-lst-lrt-audit/7118). + +## Proposal actions + +The first action updates wstETH, ezETH, rsETH, and weETH price feeds to the CAPO implementation. This sends the encoded 'updateAssetPriceFeed' and 'deployAndUpgradeTo' calls across the bridge to the governance receiver on Base. +`; + const txn = await govDeploymentManager.retry(async () => + trace( + await governor.propose(...(await proposal(mainnetActions, description))) + ) + ); + + const event = txn.events.find( + (event: { event: string }) => event.event === 'ProposalCreated' + ); + const [proposalId] = event.args; + trace(`Created proposal ${proposalId}.`); + }, + + async enacted(deploymentManager: DeploymentManager): Promise { + return true; + }, + + async verify(deploymentManager: DeploymentManager) { + const { comet, configurator } = await deploymentManager.getContracts(); + + // 1. wstETH + const wstETHIndexInComet = await configurator.getAssetIndex(comet.address, WSTETH_ADDRESS); + const wstETHInCometInfo = await comet.getAssetInfoByAddress(WSTETH_ADDRESS); + const wstETHInConfiguratorInfoWETHComet = (await configurator.getConfiguration(comet.address)).assetConfigs[wstETHIndexInComet]; + + expect(wstETHInCometInfo.priceFeed).to.eq(newWstETHToETHPriceFeed); + expect(wstETHInConfiguratorInfoWETHComet.priceFeed).to.eq(newWstETHToETHPriceFeed); + expect(await comet.getPrice(newWstETHToETHPriceFeed)).to.be.closeTo(await comet.getPrice(oldWstETHToETHPriceFeed), 1e6); + + // 2. ezETH + const ezETHIndexInComet = await configurator.getAssetIndex(comet.address, EZETH_ADDRESS); + const ezETHInCometInfo = await comet.getAssetInfoByAddress(EZETH_ADDRESS); + const ezETHInConfiguratorInfoWETHComet = (await configurator.getConfiguration(comet.address)).assetConfigs[ezETHIndexInComet]; + + expect(ezETHInCometInfo.priceFeed).to.eq(newEzETHToETHPriceFeed); + expect(ezETHInConfiguratorInfoWETHComet.priceFeed).to.eq(newEzETHToETHPriceFeed); + expect(await comet.getPrice(newEzETHToETHPriceFeed)).to.equal(await comet.getPrice(oldEzETHToETHPriceFeed)); + + // 3. wrsETH + const wrsETHIndexInComet = await configurator.getAssetIndex(comet.address, WRSETH_ADDRESS); + const wrsETHInCometInfo = await comet.getAssetInfoByAddress(WRSETH_ADDRESS); + const wrsETHInConfiguratorInfoWETHComet = (await configurator.getConfiguration(comet.address)).assetConfigs[wrsETHIndexInComet]; + + expect(wrsETHInCometInfo.priceFeed).to.eq(newWrsEthToETHPriceFeed); + expect(wrsETHInConfiguratorInfoWETHComet.priceFeed).to.eq(newWrsEthToETHPriceFeed); + expect(await comet.getPrice(newWrsEthToETHPriceFeed)).to.equal(await comet.getPrice(oldWrsEthToETHPriceFeed)); + + // 4. weETH + const weETHIndexInComet = await configurator.getAssetIndex(comet.address, WEETH_ADDRESS); + const weETHInCometInfo = await comet.getAssetInfoByAddress(WEETH_ADDRESS); + const weETHInConfiguratorInfoWETHComet = (await configurator.getConfiguration(comet.address)).assetConfigs[weETHIndexInComet]; + + expect(weETHInCometInfo.priceFeed).to.eq(newWeEthToETHPriceFeed); + expect(weETHInConfiguratorInfoWETHComet.priceFeed).to.eq(newWeEthToETHPriceFeed); + expect(await comet.getPrice(newWeEthToETHPriceFeed)).to.equal(await comet.getPrice(oldWeEthToETHPriceFeed)); + }, +}); diff --git a/deployments/hardhat/dai/deploy.ts b/deployments/hardhat/dai/deploy.ts index 0d6bde034..a18938597 100644 --- a/deployments/hardhat/dai/deploy.ts +++ b/deployments/hardhat/dai/deploy.ts @@ -1,6 +1,6 @@ import { Deployed, DeploymentManager } from '../../../plugins/deployment_manager'; import { FaucetToken, SimplePriceFeed } from '../../../build/types'; -import { DeploySpec, cloneGov, deployComet, exp, sameAddress, wait } from '../../../src/deploy'; +import { DeploySpec, cloneGov, deployComet, exp, wait } from '../../../src/deploy'; async function makeToken( deploymentManager: DeploymentManager, @@ -25,11 +25,10 @@ async function makePriceFeed( // TODO: Support configurable assets as well? export default async function deploy(deploymentManager: DeploymentManager, deploySpec: DeploySpec): Promise { const trace = deploymentManager.tracer(); - const ethers = deploymentManager.hre.ethers; const signer = await deploymentManager.getSigner(); // Deploy governance contracts - const { fauceteer, governor, timelock } = await cloneGov(deploymentManager); + const { fauceteer } = await cloneGov(deploymentManager); const DAI = await makeToken(deploymentManager, 10000000, 'DAI', 18, 'DAI'); const GOLD = await makeToken(deploymentManager, 20000000, 'GOLD', 8, 'GOLD'); @@ -80,7 +79,7 @@ export default async function deploy(deploymentManager: DeploymentManager, deplo trace(`Attempting to mint as ${signer.address}...`); await Promise.all( - [[DAI, 1e8], [GOLD, 2e6], [SILVER, 1e7]].map(([asset, units]) => { + ([[DAI, 1e8], [GOLD, 2e6], [SILVER, 1e7]] as [FaucetToken, number][]).map(([asset, units]) => { return deploymentManager.idempotent( async () => (await asset.balanceOf(fauceteer.address)).eq(0), async () => { diff --git a/deployments/mainnet/usdc/relations.ts b/deployments/mainnet/usdc/relations.ts index 776d101e6..ef9403471 100644 --- a/deployments/mainnet/usdc/relations.ts +++ b/deployments/mainnet/usdc/relations.ts @@ -21,6 +21,13 @@ export default { } } }, + AdminUpgradableProxy: { + delegates: { + field: { + slot: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' + } + } + }, UUPSProxy: { artifact: 'contracts/ERC20.sol:ERC20', delegates: { diff --git a/deployments/mainnet/usdc/roots.json b/deployments/mainnet/usdc/roots.json index f01a53815..8cdf987c1 100644 --- a/deployments/mainnet/usdc/roots.json +++ b/deployments/mainnet/usdc/roots.json @@ -1,5 +1,4 @@ { - "l1TokenAdminRegistry": "0xb22764f98dD05c789929716D677382Df22C05Cb6", "comptrollerV2": "0x3d9819210a31b4961b30ef54be2aed79b9c9cd3b", "comet": "0xc3d688B66703497DAA19211EEdff47f25384cdc3", "configurator": "0x316f9708bB98af7dA9c68C1C3b5e79039cD336E3", @@ -8,8 +7,8 @@ "fxRoot": "0xfe5e5D361b2ad62c541bAb87C45a0B9B018389a2", "arbitrumInbox": "0x4Dbd4fc535Ac27206064B68FfCf827b0A60BAB3f", "arbitrumL1GatewayRouter": "0x72Ce9c846789fdB6fC1f34aC4AD25Dd9ef7031ef", - "CCTPTokenMessenger": "0xbd3fa81b58ba92a82136038b25adec7066af3155", - "CCTPMessageTransmitter": "0x0a992d191deec32afe36203ad87d7d289a738f81", + "CCTPTokenMessenger": "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d", + "CCTPMessageTransmitter": "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64", "baseL1CrossDomainMessenger": "0x866E82a600A1414e583f7F13623F1aC5d58b0Afa", "baseL1StandardBridge": "0x3154Cf16ccdb4C6d922629664174b904d80F2C35", "baseL1USDSBridge": "0xA5874756416Fa632257eEA380CAbd2E87cED352A", @@ -25,6 +24,7 @@ "lineaMessageService": "0xd19d4B5d358258f05D7B411E21A1460D11B0876F", "lineaL1TokenBridge": "0x051F1D88f0aF5763fB888eC4378b4D8B29ea3319", "lineaL1USDCBridge": "0x504A330327A089d8364C4ab3811Ee26976d388ce", + "l1TokenAdminRegistry": "0xb22764f98dD05c789929716D677382Df22C05Cb6", "roninl1CCIPOnRamp": "0xdC5b578ff3AFcC4A4a6E149892b9472390b50844", "roninl1NativeBridge": "0x64192819Ac13Ef72bF6b5AE239AC672B43a9AF08" } \ No newline at end of file diff --git a/deployments/mainnet/usdt/migrations/1735299664_upgrade_to_capo_price_feeds.ts b/deployments/mainnet/usdt/migrations/1735299664_upgrade_to_capo_price_feeds.ts index 399fe8401..e724eec36 100644 --- a/deployments/mainnet/usdt/migrations/1735299664_upgrade_to_capo_price_feeds.ts +++ b/deployments/mainnet/usdt/migrations/1735299664_upgrade_to_capo_price_feeds.ts @@ -1,15 +1,10 @@ import { expect } from 'chai'; import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; import { migration } from '../../../../plugins/deployment_manager/Migration'; -import { proposal } from '../../../../src/deploy'; -import { Numeric } from '../../../../test/helpers'; +import { proposal, exp } from '../../../../src/deploy'; import { IWstETH, IRateProvider, AggregatorV3Interface } from '../../../../build/types'; import { constants } from 'ethers'; -export function exp(i: number, d: Numeric = 0, r: Numeric = 6): bigint { - return (BigInt(Math.floor(i * 10 ** Number(r))) * 10n ** BigInt(d)) / 10n ** BigInt(r); -} - const WETH_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; const ETH_USD_SVR_PRICE_FEED = '0xc0053f3FBcCD593758258334Dfce24C2A9A673aD'; @@ -262,11 +257,11 @@ export default migration('1735299664_upgrade_to_capo_price_feeds', { This proposal updates existing price feeds for wstETH, sFRAX, weETH, WBTC, WETH, mETH, COMP, and LINK on the USDT market on Mainnet. -SVR summery +## SVR summary [RFP process](https://www.comp.xyz/t/oev-rfp-process-update-july-2025/6945) and community [vote](https://snapshot.box/#/s:comp-vote.eth/proposal/0x98a3873319cdb5a4c66b6f862752bdcfb40d443a5b9c2f9472188d7ed5f9f2e0) passed and decided to implement Chainlink's SVR solution for Mainnet markets, this proposal updates wstETH, WBTC, WETH, LINK, weETH, mETH, COMP price feeds to support SVR implementations. -CAPO summery +## CAPO summary CAPO is a price oracle adapter designed to support assets that grow gradually relative to a base asset - such as liquid staking tokens that accumulate yield over time. It provides a mechanism to track this expected growth while protecting downstream protocol from sudden or manipulated price spikes. wstETH, sFRAX, weETH, mETH price feeds are updated to their CAPO implementations. @@ -307,7 +302,7 @@ The ninth action deploys and upgrades Comet to a new version. }, async enacted(): Promise { - return false; + return true; }, async verify(deploymentManager: DeploymentManager) { diff --git a/deployments/relations.ts b/deployments/relations.ts index c7b823b32..b48345c0b 100644 --- a/deployments/relations.ts +++ b/deployments/relations.ts @@ -59,6 +59,12 @@ const relationConfigMap: RelationConfigMap = { if (address === '0xd09acb80c1e8f2291862c4978a008791c9167003') { return 'tETH'; } + if (address === '0x5a7facb970d094b6c7ff1df0ea68d99e6e73cbff') { + return 'weETH'; + } + if (address.toLowerCase() === '0x87eee96d50fb761ad85b1c982d28a042169d61b1') { + return 'wrsETH'; + } throw new Error(`Failed to get symbol for token ${token.address}: ${e.message}`); } @@ -85,6 +91,12 @@ const relationConfigMap: RelationConfigMap = { if (address === '0xd09acb80c1e8f2291862c4978a008791c9167003') { return 'tETH:priceFeed'; } + if (address === '0x5a7facb970d094b6c7ff1df0ea68d99e6e73cbff') { + return 'weETH:priceFeed'; + } + if (address === '0x87eee96d50fb761ad85b1c982d28a042169d61b1') { + return 'wrsETH:priceFeed'; + } throw new Error(`Failed to get symbol for token ${assets[i].address}: ${e.message}`); } diff --git a/deployments/ronin/weth/deploy.ts b/deployments/ronin/weth/deploy.ts index 06bebf97e..383b9b10a 100644 --- a/deployments/ronin/weth/deploy.ts +++ b/deployments/ronin/weth/deploy.ts @@ -73,6 +73,11 @@ async function deployContracts( 'ronin' ); + const l2CCIPOnRamp = await deploymentManager.existing( + 'l2CCIPOnRamp', + '0x02b60267bceeaFDC45005e0Fa0dd783eFeBc9F1b', + 'ronin' + ); // Deploy Local Timelock const localTimelock = await deploymentManager.deploy( @@ -195,6 +200,7 @@ async function deployContracts( bridgeReceiver, l2CCIPRouter, l2CCIPOffRamp, + l2CCIPOnRamp, roninl2NativeBridge, bulker, // COMP diff --git a/deployments/ronin/weth/roots.json b/deployments/ronin/weth/roots.json index 7756d849c..86fa59c10 100644 --- a/deployments/ronin/weth/roots.json +++ b/deployments/ronin/weth/roots.json @@ -6,6 +6,7 @@ "bridgeReceiver": "0x2c7EfA766338D33B9192dB1fB5D170Bdc03ef3F9", "l2CCIPRouter": "0x46527571D5D1B68eE7Eb60B18A32e6C60DcEAf99", "l2CCIPOffRamp": "0x320A10449556388503Fd71D74A16AB52e0BD1dEb", + "l2CCIPOnRamp": "0x02b60267bceeaFDC45005e0Fa0dd783eFeBc9F1b", "roninl2NativeBridge": "0x0cf8ff40a508bdbc39fbe1bb679dcba64e65c7df", "bulker": "0x840281FaD56DD88afba052B7F18Be2A65796Ecc6", "l2TokenAdminRegistry": "0x90e83d532A4aD13940139c8ACE0B93b0DdbD323a" diff --git a/deployments/ronin/wron/deploy.ts b/deployments/ronin/wron/deploy.ts index eec9022bb..1aae0a786 100644 --- a/deployments/ronin/wron/deploy.ts +++ b/deployments/ronin/wron/deploy.ts @@ -79,6 +79,12 @@ async function deployContracts( 'ronin' ); + const l2CCIPOnRamp = await deploymentManager.existing( + 'l2CCIPOnRamp', + '0x02b60267bceeaFDC45005e0Fa0dd783eFeBc9F1b', + 'ronin' + ); + // Deploy all Comet-related contracts const deployed = await deployComet(deploymentManager, deploySpec, {}, true); @@ -86,6 +92,7 @@ async function deployContracts( ...deployed, bridgeReceiver, l2CCIPRouter, + l2CCIPOnRamp, l2CCIPOffRamp, l2TokenAdminRegistry, bulker diff --git a/deployments/ronin/wron/roots.json b/deployments/ronin/wron/roots.json index 13f7b0b98..a616419a3 100644 --- a/deployments/ronin/wron/roots.json +++ b/deployments/ronin/wron/roots.json @@ -5,6 +5,7 @@ "cometFactory": "0x4DF9E0f8e94a7A8A9aEa6010CD9d341F8Ecfe4c6", "bridgeReceiver": "0x2c7EfA766338D33B9192dB1fB5D170Bdc03ef3F9", "l2CCIPRouter": "0x46527571D5D1B68eE7Eb60B18A32e6C60DcEAf99", + "l2CCIPOnRamp": "0x02b60267bceeaFDC45005e0Fa0dd783eFeBc9F1b", "l2CCIPOffRamp": "0x320A10449556388503Fd71D74A16AB52e0BD1dEb", "roninl2NativeBridge": "0x0cf8ff40a508bdbc39fbe1bb679dcba64e65c7df", "l2TokenAdminRegistry": "0x90e83d532A4aD13940139c8ACE0B93b0DdbD323a", diff --git a/forge/run-tests.sh b/forge/run-tests.sh index 607f5fc36..126ab55e0 100644 --- a/forge/run-tests.sh +++ b/forge/run-tests.sh @@ -10,7 +10,7 @@ node scripts/exportNetworkConfigs.js export $(cat .env.forge-temp | xargs) # 3. Run the Forge tests -forge test -vvv --via-ir --optimizer-runs 1 --no-match-path "./contracts/capo/*" +forge test -vvv --no-match-path "./contracts/capo/*" # 4. Delete the temporary environment file rm .env.forge-temp diff --git a/forge/script/marketupdates/helpers/GovernanceHelper.sol b/forge/script/marketupdates/helpers/GovernanceHelper.sol index eaaf77422..f56257cfb 100644 --- a/forge/script/marketupdates/helpers/GovernanceHelper.sol +++ b/forge/script/marketupdates/helpers/GovernanceHelper.sol @@ -210,7 +210,7 @@ library GovernanceHelper { } function voteOnProposal(Vm vm, uint256 proposalId, address proposalCreator) public { - address[12] memory voters = getTopDelegates(); + address[11] memory voters = getTopDelegates(); console.log("Voting on proposal with ID: ", proposalId); console.log("Proposal Creator: ", proposalCreator); @@ -225,12 +225,11 @@ library GovernanceHelper { } } - function getTopDelegates() public pure returns (address[12] memory) { + function getTopDelegates() public pure returns (address[11] memory) { return [ 0x070341aA5Ed571f0FB2c4a5641409B1A46b4961b, 0x0579A616689f7ed748dC07692A3F150D44b0CA09, - 0x9AA835Bc7b8cE13B9B0C9764A52FbF71AC62cCF1, - 0x7E959eAB54932f5cFd10239160a7fd6474171318, + 0x66cD62c6F8A4BB0Cd8720488BCBd1A6221B765F9, 0x2210dc066aacB03C9676C4F1b36084Af14cCd02E, 0x88F659b4B6D5614B991c6404b34f821e10390eC0, 0xb06DF4dD01a5c5782f360aDA9345C87E86ADAe3D, diff --git a/foundry.toml b/foundry.toml index 3ea87d9cc..e9d9fc01f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -15,6 +15,12 @@ optimizer = true optimizer_runs = 1 via_ir = true +[profile.default.optimizer_details] +yul = true + +[profile.default.optimizer_details.yulDetails] +optimizerSteps = 'dhfoDgvulfnTUtnIf [xa[r]scLM cCTUtTOntnfDIul Lcul Vcul [j] Tpeul xa[rul] xa[r]cL gvif CTUca[r]LsTOtfDnca[r]Iulc] jmul[jul] VcTOcul jmul' + remappings = [ "@forge-std/=forge/lib/forge-std/", "@comet-contracts/=contracts/", diff --git a/hardhat.config.ts b/hardhat.config.ts index 63d2caae8..7355e845f 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -118,10 +118,16 @@ interface NetworkConfig { gasPrice?: number | 'auto'; } +const EXTERNAL_CONTRACTS_COMPILE_LIST = [ + 'contracts/capo/contracts/test/MockERC20.sol' +]; + subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction(async (_, __, runSuper) => { const paths = await runSuper(); - + return paths.filter((p: string) => { + if (EXTERNAL_CONTRACTS_COMPILE_LIST.some((allowed) => p.includes(allowed))) return true; + return !( p.includes('contracts/capo/contracts/test/') || p.includes('contracts/capo/test/') || @@ -198,7 +204,7 @@ export const networkConfigs: NetworkConfig[] = [ { network: 'scroll', chainId: 534352, - url: 'https://rpc.scroll.io', + url: 'https://scroll.drpc.org', }, ]; @@ -342,6 +348,7 @@ const config: HardhatUserConfig = { hardforkHistory: { berlin: 1, london: 2, + shanghai: 3, } }; return acc; diff --git a/plugins/deployment_manager/DeploymentManager.ts b/plugins/deployment_manager/DeploymentManager.ts index 2bcfc2357..5ddf68d71 100644 --- a/plugins/deployment_manager/DeploymentManager.ts +++ b/plugins/deployment_manager/DeploymentManager.ts @@ -282,7 +282,7 @@ export class DeploymentManager { } } - stashRelayMessage(messanger: string, callData: string, signer: string) { + stashRelayMessage(messenger: string, callData: string, signer: string) { try { const cacheDir = path.resolve(__dirname, '../..', 'cache'); mkdirSync(cacheDir, { recursive: true }); @@ -301,7 +301,7 @@ export class DeploymentManager { } } - const newEntry = { messanger, callData, signer }; + const newEntry = { messenger, callData, signer }; if (!data.some(entry => JSON.stringify(entry) === JSON.stringify(newEntry))) { data.push(newEntry); writeFileSync(file, JSON.stringify(data, null, 2), 'utf8'); diff --git a/plugins/deployment_manager/Import.ts b/plugins/deployment_manager/Import.ts index 2431acc89..1c3fe593e 100644 --- a/plugins/deployment_manager/Import.ts +++ b/plugins/deployment_manager/Import.ts @@ -44,7 +44,7 @@ export async function importContract( console.warn(`Import failed for ${network}@${address} (${e.message}), retrying in ${retryDelay / 1000}s; ${retries} retries left`); await new Promise(ok => setTimeout(ok, retryDelay)); - return importContract(network, address, retries - 1, retryDelay * 2); + return importContract(network, address, retries - 1, retryDelay * 2 > 10000 ? 10000 : retryDelay * 2); } } @@ -61,7 +61,7 @@ export async function importContract( console.warn(`Import failed for ${network}@${address} (${e.message}), retrying in ${retryDelay / 1000}s; ${retries} retries left`); await new Promise(ok => setTimeout(ok, retryDelay)); - return importContract(network, address, retries - 1, retryDelay * 2); + return importContract(network, address, retries - 1, retryDelay * 2 > 10000 ? 10000 : retryDelay * 2); } } diff --git a/scenario/ConfiguratorScenario.ts b/scenario/ConfiguratorScenario.ts index d05bb9ebd..4e156a99e 100644 --- a/scenario/ConfiguratorScenario.ts +++ b/scenario/ConfiguratorScenario.ts @@ -1,78 +1,3124 @@ -import { scenario } from './context/CometContext'; -import { expectRevertCustom } from './utils'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { expect } from 'chai'; +import { BigNumber, ethers } from 'ethers'; +import { CometContext, scenario } from './context/CometContext'; +import { exp } from '../test/helpers'; +import { expectRevertCustom, supportsMarketAdminPermissionChecker } from './utils'; +import { MarketAdminPermissionChecker } from '../build/types'; -scenario('upgrade governor', {}, async ({ comet, configurator, timelock, actors }, context) => { - const { admin, albert } = actors; +const SECONDS_PER_YEAR = 31_536_000n; +// Based on contract's internal precision: FACTOR_SCALE=1e18 with 4 decimal places +const FACTOR_SCALE = 10n ** 18n; +const MIN_FACTOR_INCREMENT = FACTOR_SCALE / 10n ** 4n; - expect(await comet.governor()).to.equal(timelock.address); - expect((await configurator.getConfiguration(comet.address)).governor).to.equal(timelock.address); +type ArrayMethods = keyof Omit; - await context.setNextBaseFeeToZero(); - await configurator.connect(admin.signer).setGovernor(comet.address, albert.address, { gasPrice: 0 }); - await context.setNextBaseFeeToZero(); - await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); +type NamedKeys = { + [K in keyof T as K extends number | `${number}` | ArrayMethods ? never : K]: T[K]; +}; - expect(await comet.governor()).to.equal(albert.address); - expect((await configurator.getConfiguration(comet.address)).governor).to.be.equal(albert.address); -}); +type Normalize = T extends BigNumber + ? bigint + : T extends string | number | boolean + ? T + : [NamedKeys] extends [Record] + ? T extends (infer U)[] + ? Normalize[] + : T + : { [K in keyof NamedKeys]: Normalize[K]> }; -scenario('add assets', {}, async ({ comet, configurator, actors }, context) => { - const { admin } = actors; - let numAssets = await comet.numAssets(); - const collateralAssets = await Promise.all(Array(numAssets).fill(0).map((_, i) => comet.getAssetInfo(i))); - const contextAssets = - Object.values(collateralAssets) - .map((asset) => asset.asset); // grab asset address - expect(collateralAssets.map(a => a.asset)).to.have.members(contextAssets); - - // Add new asset and deploy + upgrade - const newAsset = await comet.getAssetInfo(0); - const newAssetDecimals = Math.log10(Number(newAsset.scale.toString())); - const newAssetConfig = { - asset: newAsset.asset, - priceFeed: newAsset.priceFeed, - decimals: newAssetDecimals.toString(), - borrowCollateralFactor: (0.9e18).toString(), - liquidateCollateralFactor: (1e18).toString(), - liquidationFactor: (0.95e18).toString(), - supplyCap: (1000000e8).toString(), +type NormalizedStruct = Normalize>; + +/** + * Hybrid array-objects with both numeric and named keys are stripped to plain + * objects with native bigint values, safe to destructure, compare, and serialize. + */ +function normalizeStructOutput(value: T): NormalizedStruct { + function normalize(val: any): any { + if (BigNumber.isBigNumber(val)) { + return val.toBigInt(); + } + if (val && typeof val === 'object') { + const namedKeys = Object.keys(val).filter((key) => isNaN(Number(key))); + if (namedKeys.length > 0) { + return Object.fromEntries(namedKeys.map((key) => [key, normalize(val[key])])); + } + if (Array.isArray(val)) { + return val.map(normalize); + } + } + return val; + } + + return normalize(value) as NormalizedStruct; +} + +async function hasActiveAsset(ctx: CometContext): Promise { + const configurator = await ctx.getConfigurator(); + const cometAddress = (await ctx.getComet()).address; + const assetConfigs = normalizeStructOutput(await configurator.getConfiguration(cometAddress)).assetConfigs; + + return assetConfigs.some((asset) => asset.borrowCollateralFactor > 0n && asset.supplyCap > 0n); +} + +/// Finds the first asset with non-zero configuration values +async function getActiveAsset(context: CometContext) { + const configurator = await context.getConfigurator(); + const cometAddress = (await context.getComet()).address; + const assetConfigs = normalizeStructOutput(await configurator.getConfiguration(cometAddress)).assetConfigs; + + const assetIndex = assetConfigs.findIndex((asset) => asset.borrowCollateralFactor > 0n && asset.supplyCap > 0n); + + return { + assetIndex, + assetConfig: assetConfigs[assetIndex] }; - await context.setNextBaseFeeToZero(); - await configurator.connect(admin.signer).addAsset(comet.address, newAssetConfig, { gasPrice: 0 }); - await context.setNextBaseFeeToZero(); - await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); - - // Verify new asset is added - numAssets = await comet.numAssets(); - const updatedCollateralAssets = await Promise.all(Array(numAssets).fill(0).map((_, i) => comet.getAssetInfo(i))); - const updatedContextAssets = - Object.values(updatedCollateralAssets) - .map((asset) => asset.asset); // grab asset address - expect(updatedCollateralAssets.length).to.equal(collateralAssets.length + 1); - expect(updatedCollateralAssets.map(a => a.asset)).to.have.members(updatedContextAssets); -}); +} + +function getMinSupplyCapIncrement(decimals: number): bigint { + return 10n ** BigInt(decimals); +} + +async function getMarketAdminSigner(context: CometContext): Promise { + const dm = context.world.deploymentManager; + const configurator = await context.getConfigurator(); + + const marketAdminPermissionChecker = (await dm.hre.ethers.getContractAt( + 'MarketAdminPermissionChecker', + await configurator.marketAdminPermissionChecker() + )) as MarketAdminPermissionChecker; + + return context.world.impersonateAddress(await marketAdminPermissionChecker.marketAdmin()); +} + +async function deployMarketAdminPermissionChecker(context: CometContext, force?: boolean): Promise { + const dm = context.world.deploymentManager; + const initialOwner = await ethers.Wallet.createRandom().getAddress(); + const marketAdmin = await ethers.Wallet.createRandom().getAddress(); + const marketAdminPauseGuardian = await ethers.Wallet.createRandom().getAddress(); + + const marketAdminPermissionChecker = await dm.deploy( + 'test:marketAdminPermissionChecker', + 'marketupdates/MarketAdminPermissionChecker.sol', + [initialOwner, marketAdmin, marketAdminPauseGuardian], + force + ); + + return marketAdminPermissionChecker.address; +} + +async function deployCometFactory(context: CometContext, force?: boolean): Promise { + const dm = context.world.deploymentManager; + const cometFactory = await dm.deploy('test:cometFactory', 'CometFactoryWithExtendedAssetList.sol', [], force); + + return cometFactory.address; +} + +async function deployPriceFeed(context: CometContext, alias: string, force?: boolean): Promise { + const dm = context.world.deploymentManager; + const PRICE_FEED_DECIMALS = 8; + const PRICE_FEED_ANSWER = 1 * 10 ** PRICE_FEED_DECIMALS; + + const priceFeed = await dm.deploy( + `test:${alias}PriceFeed`, + 'test/SimplePriceFeed.sol', + [PRICE_FEED_ANSWER, PRICE_FEED_DECIMALS], + force + ); + + return priceFeed.address; +} + +async function deployTimelock(context: CometContext, force?: boolean): Promise { + const dm = context.world.deploymentManager; + const admin = context.actors.admin; + const timelock = await dm.deploy('test:timelock', 'test/SimpleTimelock.sol', [admin.address], force); + + return timelock.address; +} + +async function deployMockERC20(context: CometContext, alias: string, force?: boolean): Promise { + const dm = context.world.deploymentManager; + + const mockERC20 = await dm.deploy( + `mockERC20:${alias}`, + 'capo/contracts/test/MockERC20.sol', + ['Mock Token', 'MOCK', 18], + force + ); + + return mockERC20.address; +} + +async function deployCometExt(context: CometContext, force?: boolean): Promise { + const dm = context.world.deploymentManager; + const assetListFactory = await dm.deploy('test:assetListFactory', 'AssetListFactory.sol', []); + + const extConfiguration = { + name32: ethers.utils.formatBytes32String('MOCK'), + symbol32: ethers.utils.formatBytes32String('cMOCKv3') + }; + + const cometExt = await dm.deploy( + 'test:comet:implementation:implementation', + 'CometExtAssetList.sol', + [extConfiguration, assetListFactory.address], + force + ); + + return cometExt.address; +} + +async function deployComet(context: CometContext): Promise { + const dm = context.world.deploymentManager; + const { admin, pauseGuardian } = context.actors; + + const configuration = { + governor: admin.address, + pauseGuardian: pauseGuardian.address, + baseToken: await deployMockERC20(context, 'baseToken'), + baseTokenPriceFeed: await deployPriceFeed(context, 'baseToken'), + extensionDelegate: await deployCometExt(context), + supplyKink: exp(0.9, 18), // 900000000000000000n + supplyPerYearInterestRateSlopeLow: exp(0.036, 18), // 36000000000000000n + supplyPerYearInterestRateSlopeHigh: exp(3.196, 18), // 3196000000000000000n + supplyPerYearInterestRateBase: 0n, + borrowKink: exp(0.9, 18), // 900000000000000000n + borrowPerYearInterestRateSlopeLow: exp(0.027778, 18), // 27778000000000000n + borrowPerYearInterestRateSlopeHigh: exp(3.6, 18), // 3600000000000000000n + borrowPerYearInterestRateBase: exp(0.015, 18), // 15000000000000000n + storeFrontPriceFactor: exp(0.6, 18), // 600000000000000000n + trackingIndexScale: exp(0.001, 18), // 1000000000000000n + baseTrackingSupplySpeed: 0n, + baseTrackingBorrowSpeed: 0n, + baseMinForRewards: exp(1, 9), // 1000000000n + baseBorrowMin: exp(1, 5), // 100000n + targetReserves: exp(2, 13), //20000000000000n + assetConfigs: [ + { + asset: await deployMockERC20(context, 'asset'), + priceFeed: await deployPriceFeed(context, 'asset'), + decimals: 18, + borrowCollateralFactor: exp(0.65, 18), // 650000000000000000n + liquidateCollateralFactor: exp(0.7, 18), // 700000000000000000n + liquidationFactor: exp(0.8, 18), // 800000000000000000n + supplyCap: exp(1.4, 24) // 1400000000000000000000000n + } + ] + }; + + const cometAdmin = await context.getCometAdmin(); + const tmpCometImpl = await dm.deploy('test:comet:implementation', 'CometWithExtendedAssetList.sol', [configuration]); + + const cometProxy = await dm.deploy('test:comet', 'vendor/proxy/transparent/TransparentUpgradeableProxy.sol', [ + tmpCometImpl.address, + cometAdmin.address, + [] + ]); + + return cometProxy.address; +} + +/* +|======================================== +| Governor-Only Functions +|======================================== +*/ +scenario( + 'Configurator#transferGovernor updates configurator governor if called by governor', + {}, + async ({ configurator, actors }, context) => { + const { admin } = actors; + + const newGovernor = await deployTimelock(context); + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).transferGovernor(newGovernor, { gasPrice: 0 }); + + expect(await configurator.governor()).to.be.equal(newGovernor); + } +); + +scenario( + 'Configurator#transferGovernor new governor can call governor-only methods', + {}, + async ({ configurator, actors }, context) => { + const { admin } = actors; + + const newGovernor = await deployTimelock(context); + const newGovernorSigner = await context.world.impersonateAddress(newGovernor); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).transferGovernor(newGovernor, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await configurator.connect(newGovernorSigner).transferGovernor(admin.address, { gasPrice: 0 }); + + expect(await configurator.governor()).to.be.equal(admin.address); + } +); + +scenario( + 'Configurator#transferGovernor reverts if called by non-governor', + {}, + async ({ configurator, actors }, context) => { + const { albert } = actors; + const newGovernor = await deployTimelock(context); + + await expectRevertCustom(configurator.connect(albert.signer).transferGovernor(newGovernor), 'Unauthorized()'); + } +); + +scenario( + 'Configurator#setFactory updates factory if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + await context.setNextBaseFeeToZero(); + const newFactory = await deployCometFactory(context); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setFactory(comet.address, newFactory, { gasPrice: 0 }); + + expect(await configurator.factory(comet.address)).to.be.equal(newFactory); + } +); scenario( - 'reverts if configurator is not called by admin', + 'Configurator#setFactory can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const firstNewFactory = await deployCometFactory(context); + const secondNewFactory = await deployCometFactory(context, true); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setFactory(comet.address, firstNewFactory, { gasPrice: 0 }); + + expect(await configurator.factory(comet.address)).to.be.equal(firstNewFactory); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setFactory(comet.address, secondNewFactory, { gasPrice: 0 }); + + expect(await configurator.factory(comet.address)).to.be.equal(secondNewFactory); + } +); + +scenario( + 'Configurator#setFactory reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + const newFactory = await deployCometFactory(context); + + await expectRevertCustom( + configurator.connect(albert.signer).setFactory(comet.address, newFactory), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setConfiguration updates existing configuration if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const existingConfiguration = normalizeStructOutput(await configurator.getConfiguration(comet.address)); + + const updatedConfiguration = { + ...existingConfiguration, + baseBorrowMin: existingConfiguration.baseBorrowMin + 1n + }; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setConfiguration(comet.address, updatedConfiguration, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address))).to.be.deep.equal( + updatedConfiguration + ); + } +); + +scenario( + 'Configurator#setConfiguration initializes new comet proxy configuration', + {}, + async ({ configurator, actors }, context) => { + const { admin } = actors; + const newCometProxy = await deployComet(context); + const configuration = normalizeStructOutput(await configurator.getConfiguration(newCometProxy)); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setConfiguration(newCometProxy, configuration, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(newCometProxy))).to.be.deep.equal(configuration); + } +); + +scenario( + 'Configurator#setConfiguration reverts if called by non-governor', {}, async ({ comet, configurator, actors }) => { const { albert } = actors; + + const existingConfiguration = normalizeStructOutput(await configurator.getConfiguration(comet.address)); + + const updatedConfiguration = { + ...existingConfiguration, + baseBorrowMin: existingConfiguration.baseBorrowMin + 1n + }; await expectRevertCustom( - configurator.connect(albert.signer).setGovernor(comet.address, albert.address), + configurator.connect(albert.signer).setConfiguration(comet.address, updatedConfiguration), 'Unauthorized()' ); - }); + } +); -scenario.skip('reverts if proxy is not upgraded by ProxyAdmin', {}, async () => { - // XXX -}); +scenario( + 'Configurator#setConfiguration reverts if base token is changed for existing configuration', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const existingConfiguration = normalizeStructOutput(await configurator.getConfiguration(comet.address)); + const updatedConfiguration = { + ...existingConfiguration, + baseToken: await deployMockERC20(context, 'baseToken') + }; -scenario.skip('fallbacks to implementation if called by non-admin', {}, async () => { - // XXX -}); + await context.setNextBaseFeeToZero(); + await expectRevertCustom( + configurator.connect(admin.signer).setConfiguration(comet.address, updatedConfiguration, { gasPrice: 0 }), + 'ConfigurationAlreadyExists()' + ); + } +); -scenario.skip('transfer admin of configurator', {}, async () => { - // XXX -}); +scenario( + 'Configurator#setConfiguration reverts if tracking index scale is changed for existing configuration', + {}, + async ({ comet, configurator, actors }) => { + const { admin } = actors; + const existingConfiguration = normalizeStructOutput(await configurator.getConfiguration(comet.address)); + + const updatedConfiguration = { + ...existingConfiguration, + trackingIndexScale: existingConfiguration.trackingIndexScale + 1n + }; + + await expectRevertCustom( + configurator.connect(admin.signer).setConfiguration(comet.address, updatedConfiguration), + 'ConfigurationAlreadyExists()' + ); + } +); + +scenario( + 'Configurator#setGovernor updates governor in configuration if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const newGovernor = await deployTimelock(context); + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setGovernor(comet.address, newGovernor, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).governor).to.be.equal(newGovernor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect(await comet.governor()).to.be.equal(newGovernor); + } +); + +scenario( + 'Configurator#setGovernor can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const firstNewGovernor = await deployTimelock(context); + const secondNewGovernor = await deployTimelock(context, true); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setGovernor(comet.address, firstNewGovernor, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).governor).to.be.equal(firstNewGovernor); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setGovernor(comet.address, secondNewGovernor, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).governor).to.be.equal(secondNewGovernor); + } +); + +scenario( + 'Configurator#setGovernor reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + const newGovernor = await deployTimelock(context); + + await expectRevertCustom( + configurator.connect(albert.signer).setGovernor(comet.address, newGovernor), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setPauseGuardian updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const newPauseGuardian = await ethers.Wallet.createRandom().getAddress(); + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setPauseGuardian(comet.address, newPauseGuardian, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).pauseGuardian).to.be.equal(newPauseGuardian); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect(await comet.pauseGuardian()).to.be.equal(newPauseGuardian); + } +); + +scenario( + 'Configurator#setPauseGuardian can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const firstNewPauseGuardian = await ethers.Wallet.createRandom().getAddress(); + const secondNewPauseGuardian = await ethers.Wallet.createRandom().getAddress(); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setPauseGuardian(comet.address, firstNewPauseGuardian, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).pauseGuardian).to.be.equal(firstNewPauseGuardian); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setPauseGuardian(comet.address, secondNewPauseGuardian, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).pauseGuardian).to.be.equal(secondNewPauseGuardian); + } +); + +scenario( + 'Configurator#setPauseGuardian reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const newPauseGuardian = await ethers.Wallet.createRandom().getAddress(); + + await expectRevertCustom( + configurator.connect(albert.signer).setPauseGuardian(comet.address, newPauseGuardian), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setMarketAdminPermissionChecker updates value if called by governor', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ configurator, actors }, context) => { + const { admin } = actors; + + const newMarketAdminPermissionChecker = await deployMarketAdminPermissionChecker(context); + await configurator.connect(admin.signer).setMarketAdminPermissionChecker(newMarketAdminPermissionChecker, { + gasPrice: 0 + }); + + expect(await configurator.marketAdminPermissionChecker()).to.be.equal(newMarketAdminPermissionChecker); + } +); + +scenario( + 'Configurator#setMarketAdminPermissionChecker can be overwritten multiple times', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ configurator, actors }, context) => { + const { admin } = actors; + + const firstNewMarketAdminPermissionChecker = await deployMarketAdminPermissionChecker(context); + const secondNewMarketAdminPermissionChecker = await deployMarketAdminPermissionChecker(context, true); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setMarketAdminPermissionChecker(firstNewMarketAdminPermissionChecker, { + gasPrice: 0 + }); + + expect(await configurator.marketAdminPermissionChecker()).to.be.equal(firstNewMarketAdminPermissionChecker); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setMarketAdminPermissionChecker(secondNewMarketAdminPermissionChecker, { + gasPrice: 0 + }); + + expect(await configurator.marketAdminPermissionChecker()).to.be.equal(secondNewMarketAdminPermissionChecker); + } +); + +scenario( + 'Configurator#setMarketAdminPermissionChecker reverts if called by non-governor', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ configurator, actors }, context) => { + const { albert } = actors; + + const newMarketAdminPermissionChecker = await deployMarketAdminPermissionChecker(context); + + await expectRevertCustom( + configurator.connect(albert.signer).setMarketAdminPermissionChecker(newMarketAdminPermissionChecker), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBaseTokenPriceFeed updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const newPriceFeed = await deployPriceFeed(context, 'baseToken'); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseTokenPriceFeed(comet.address, newPriceFeed, { + gasPrice: 0 + }); + + expect((await configurator.getConfiguration(comet.address)).baseTokenPriceFeed).to.be.equal(newPriceFeed); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect(await comet.baseTokenPriceFeed()).to.be.equal(newPriceFeed); + } +); + +scenario( + 'Configurator#setBaseTokenPriceFeed can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const firstNewPriceFeed = await deployPriceFeed(context, 'baseToken'); + const secondNewPriceFeed = await deployPriceFeed(context, 'baseToken', true); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseTokenPriceFeed(comet.address, firstNewPriceFeed, { + gasPrice: 0 + }); + + expect((await configurator.getConfiguration(comet.address)).baseTokenPriceFeed).to.be.equal(firstNewPriceFeed); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseTokenPriceFeed(comet.address, secondNewPriceFeed, { + gasPrice: 0 + }); + + expect((await configurator.getConfiguration(comet.address)).baseTokenPriceFeed).to.be.equal(secondNewPriceFeed); + } +); + +scenario( + 'Configurator#setBaseTokenPriceFeed reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + + const newPriceFeed = await deployPriceFeed(context, 'baseToken'); + + await expectRevertCustom( + configurator.connect(albert.signer).setBaseTokenPriceFeed(comet.address, newPriceFeed), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setExtensionDelegate updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const newExtensionDelegate = await deployCometExt(context); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setExtensionDelegate(comet.address, newExtensionDelegate, { + gasPrice: 0 + }); + + expect((await configurator.getConfiguration(comet.address)).extensionDelegate).to.be.equal(newExtensionDelegate); + } +); + +scenario( + 'Configurator#setExtensionDelegate can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const firstNewExtensionDelegate = await deployCometExt(context); + const secondNewExtensionDelegate = await deployCometExt(context, true); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setExtensionDelegate(comet.address, firstNewExtensionDelegate, { + gasPrice: 0 + }); + + expect((await configurator.getConfiguration(comet.address)).extensionDelegate).to.be.equal( + firstNewExtensionDelegate + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setExtensionDelegate(comet.address, secondNewExtensionDelegate, { + gasPrice: 0 + }); + + expect((await configurator.getConfiguration(comet.address)).extensionDelegate).to.be.equal( + secondNewExtensionDelegate + ); + } +); + +scenario( + 'Configurator#setExtensionDelegate reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + + const newExtensionDelegate = await deployCometExt(context); + + await expectRevertCustom( + configurator.connect(albert.signer).setExtensionDelegate(comet.address, newExtensionDelegate), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setStoreFrontPriceFactor updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldStoreFrontPriceFactor = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).storeFrontPriceFactor; + + const newStoreFrontPriceFactor = oldStoreFrontPriceFactor + 1n; + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setStoreFrontPriceFactor(comet.address, newStoreFrontPriceFactor, { + gasPrice: 0 + }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).storeFrontPriceFactor).to.be.equal( + newStoreFrontPriceFactor + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect(await comet.storeFrontPriceFactor()).to.be.equal(newStoreFrontPriceFactor); + } +); +scenario( + 'Configurator#setStoreFrontPriceFactor can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const initialStoreFrontPriceFactor = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).storeFrontPriceFactor; + + const firstStoreFrontPriceFactor = initialStoreFrontPriceFactor + 1n; + const secondStoreFrontPriceFactor = firstStoreFrontPriceFactor + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setStoreFrontPriceFactor(comet.address, firstStoreFrontPriceFactor, { + gasPrice: 0 + }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).storeFrontPriceFactor).to.be.equal( + firstStoreFrontPriceFactor + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setStoreFrontPriceFactor(comet.address, secondStoreFrontPriceFactor, { + gasPrice: 0 + }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).storeFrontPriceFactor).to.be.equal( + secondStoreFrontPriceFactor + ); + } +); + +scenario( + 'Configurator#setStoreFrontPriceFactor reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldStoreFrontPriceFactor = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).storeFrontPriceFactor; + + const newStoreFrontPriceFactor = oldStoreFrontPriceFactor + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setStoreFrontPriceFactor(comet.address, newStoreFrontPriceFactor), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBaseMinForRewards updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const oldBaseMinForRewards = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseMinForRewards; + + const newBaseMinForRewards = oldBaseMinForRewards + 1n; + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseMinForRewards(comet.address, newBaseMinForRewards, { + gasPrice: 0 + }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseMinForRewards).to.be.equal( + newBaseMinForRewards + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect(await comet.baseMinForRewards()).to.be.equal(newBaseMinForRewards); + } +); + +scenario( + 'Configurator#setBaseMinForRewards can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const initialBaseMinForRewards = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseMinForRewards; + + const firstBaseMinForRewards = initialBaseMinForRewards + 1n; + const secondBaseMinForRewards = firstBaseMinForRewards + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseMinForRewards(comet.address, firstBaseMinForRewards, { + gasPrice: 0 + }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseMinForRewards).to.be.equal( + firstBaseMinForRewards + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseMinForRewards(comet.address, secondBaseMinForRewards, { + gasPrice: 0 + }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseMinForRewards).to.be.equal( + secondBaseMinForRewards + ); + } +); + +scenario( + 'Configurator#setBaseMinForRewards reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBaseMinForRewards = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseMinForRewards; + + const newBaseMinForRewards = oldBaseMinForRewards + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setBaseMinForRewards(comet.address, newBaseMinForRewards), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setTargetReserves updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const oldTargetReserves = normalizeStructOutput(await configurator.getConfiguration(comet.address)).targetReserves; + + const newTargetReserves = oldTargetReserves + 1n; + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setTargetReserves(comet.address, newTargetReserves, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).targetReserves).to.be.equal( + newTargetReserves + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect(await comet.targetReserves()).to.be.equal(newTargetReserves); + } +); + +scenario( + 'Configurator#setTargetReserves can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const initialTargetReserves = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).targetReserves; + + const firstTargetReserves = initialTargetReserves + 1n; + const secondTargetReserves = firstTargetReserves + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setTargetReserves(comet.address, firstTargetReserves, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).targetReserves).to.be.equal( + firstTargetReserves + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setTargetReserves(comet.address, secondTargetReserves, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).targetReserves).to.be.equal( + secondTargetReserves + ); + } +); + +scenario( + 'Configurator#setTargetReserves reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldTargetReserves = normalizeStructOutput(await configurator.getConfiguration(comet.address)).targetReserves; + const newTargetReserves = oldTargetReserves + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setTargetReserves(comet.address, newTargetReserves), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#addAsset succeeds if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const numAssetsBefore = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs + .length; + + const newAssetConfig = { + asset: await deployMockERC20(context, 'asset'), + priceFeed: await deployPriceFeed(context, 'asset'), + decimals: 18, + borrowCollateralFactor: exp(0.8, 18), + liquidateCollateralFactor: exp(0.85, 18), + liquidationFactor: exp(0.9, 18), + supplyCap: exp(5e6, 18) + }; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).addAsset(comet.address, newAssetConfig, { gasPrice: 0 }); + const assetConfigsAfter = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs; + + expect(assetConfigsAfter.length).to.be.equal(numAssetsBefore + 1); + expect(assetConfigsAfter.at(-1)).to.be.deep.equal(newAssetConfig); + } +); + +scenario('Configurator#addAsset can add multiple assets', {}, async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const numAssetsBefore = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.length; + + const firstNewAssetConfig = { + asset: await deployMockERC20(context, 'asset'), + priceFeed: await deployPriceFeed(context, 'asset'), + decimals: 18, + borrowCollateralFactor: exp(0.8, 18), + liquidateCollateralFactor: exp(0.85, 18), + liquidationFactor: exp(0.9, 18), + supplyCap: exp(5e6, 18) + }; + + const secondNewAssetConfig = { + asset: await deployMockERC20(context, 'asset', true), + priceFeed: await deployPriceFeed(context, 'asset', true), + decimals: 6, + borrowCollateralFactor: exp(0.8, 18), + liquidateCollateralFactor: exp(0.85, 18), + liquidationFactor: exp(0.9, 18), + supplyCap: exp(5e6, 6) + }; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).addAsset(comet.address, firstNewAssetConfig, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).addAsset(comet.address, secondNewAssetConfig, { gasPrice: 0 }); + const assetConfigsAfter = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs; + + expect(assetConfigsAfter.length).to.be.equal(numAssetsBefore + 2); + expect(assetConfigsAfter.at(-2)).to.be.deep.equal(firstNewAssetConfig); + expect(assetConfigsAfter.at(-1)).to.be.deep.equal(secondNewAssetConfig); +}); + +scenario( + 'Configurator#addAsset reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + + await expectRevertCustom( + configurator.connect(albert.signer).addAsset(comet.address, { + asset: await deployMockERC20(context, 'asset'), + priceFeed: await deployPriceFeed(context, 'asset'), + decimals: 18, + borrowCollateralFactor: exp(0.8, 18), + liquidateCollateralFactor: exp(0.85, 18), + liquidationFactor: exp(0.9, 18), + supplyCap: exp(5e6, 18) + }), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAsset succeeds if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfigsBefore = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs; + const existingAssetConfig = assetConfigsBefore.at(assetIndex); + + const updatedAssetConfig = { + ...existingAssetConfig, + borrowCollateralFactor: existingAssetConfig.borrowCollateralFactor + MIN_FACTOR_INCREMENT, + liquidateCollateralFactor: existingAssetConfig.liquidateCollateralFactor + MIN_FACTOR_INCREMENT + }; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAsset(comet.address, updatedAssetConfig, { gasPrice: 0 }); + const assetConfigsAfter = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs; + + expect(assetConfigsAfter.length).to.be.equal(assetConfigsBefore.length); + expect(assetConfigsAfter.at(assetIndex)).to.be.deep.equal(updatedAssetConfig); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const updatedAssetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(existingAssetConfig.asset)); + + expect(updatedAssetInfo.borrowCollateralFactor).to.be.equal(updatedAssetConfig.borrowCollateralFactor); + expect(updatedAssetInfo.liquidateCollateralFactor).to.be.equal(updatedAssetConfig.liquidateCollateralFactor); + } +); + +scenario( + 'Configurator#updateAsset can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + + const firstUpdatedAssetConfig = { + ...assetConfig, + liquidateCollateralFactor: assetConfig.liquidateCollateralFactor + MIN_FACTOR_INCREMENT + }; + + const secondUpdatedAssetConfig = { + ...firstUpdatedAssetConfig, + borrowCollateralFactor: firstUpdatedAssetConfig.borrowCollateralFactor + MIN_FACTOR_INCREMENT + }; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAsset(comet.address, firstUpdatedAssetConfig, { gasPrice: 0 }); + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + ).to.be.deep.equal(firstUpdatedAssetConfig); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAsset(comet.address, secondUpdatedAssetConfig, { gasPrice: 0 }); + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + ).to.be.deep.equal(secondUpdatedAssetConfig); + } +); + +scenario('Configurator#updateAsset reverts if called by non-governor', {}, async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const existingAssetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + -1 + ); + + const updatedAssetConfig = { + ...existingAssetConfig, + supplyCap: existingAssetConfig.supplyCap + getMinSupplyCapIncrement(existingAssetConfig.decimals) + }; + + await expectRevertCustom( + configurator.connect(albert.signer).updateAsset(comet.address, updatedAssetConfig), + 'Unauthorized()' + ); +}); + +scenario('Configurator#updateAsset reverts if asset does not exist', {}, async ({ comet, configurator, actors }) => { + const { admin } = actors; + + const existingAssetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + -1 + ); + + const updatedAssetConfig = { + ...existingAssetConfig, + asset: await ethers.Wallet.createRandom().getAddress() + }; + + await expectRevertCustom( + configurator.connect(admin.signer).updateAsset(comet.address, updatedAssetConfig), + 'AssetDoesNotExist()' + ); +}); + +scenario( + 'Configurator#updateAssetPriceFeed succeeds if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + // use the last asset in the existing configuration to ensure the asset exists + const assetIndex = -1; + const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).asset; + const newPriceFeed = await deployPriceFeed(context, 'asset'); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetPriceFeed(comet.address, existingAsset, newPriceFeed, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).priceFeed).to.be.equal( + newPriceFeed + ); + } +); + +scenario( + 'Configurator#updateAssetPriceFeed can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + // use the last asset in the existing configuration to ensure the asset exists + const assetIndex = -1; + const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).asset; + + const firstNewPriceFeed = await deployPriceFeed(context, 'asset'); + const secondNewPriceFeed = await deployPriceFeed(context, 'asset', true); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetPriceFeed(comet.address, existingAsset, firstNewPriceFeed, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).priceFeed).to.be.equal( + firstNewPriceFeed + ); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetPriceFeed(comet.address, existingAsset, secondNewPriceFeed, { gasPrice: 0 }); + + expect((await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).priceFeed).to.be.equal( + secondNewPriceFeed + ); + } +); + +scenario( + 'Configurator#updateAssetPriceFeed reverts if called by non-governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + + const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(-1).asset; + const newPriceFeed = await deployPriceFeed(context, 'asset'); + + await expectRevertCustom( + configurator.connect(albert.signer).updateAssetPriceFeed(comet.address, existingAsset, newPriceFeed), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAssetPriceFeed reverts if asset does not exist', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const nonExistingAsset = await ethers.Wallet.createRandom().getAddress(); + const newPriceFeed = await deployPriceFeed(context, 'asset'); + + await expectRevertCustom( + configurator.connect(admin.signer).updateAssetPriceFeed(comet.address, nonExistingAsset, newPriceFeed), + 'AssetDoesNotExist()' + ); + } +); + +/* +|======================================== +| Governor & Market Admin-Only Functions +|======================================== +*/ + +scenario( + 'Configurator#setSupplyKink updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink; + const newSupplyKink = oldSupplyKink + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setSupplyKink(comet.address, newSupplyKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink).to.be.equal( + newSupplyKink + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyKink()).toBigInt()).to.be.equal(newSupplyKink); + } +); + +scenario( + 'Configurator#setSupplyKink can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink; + const firstNewSupplyKink = oldSupplyKink + 1n; + const secondNewSupplyKink = firstNewSupplyKink + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setSupplyKink(comet.address, firstNewSupplyKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink).to.be.equal( + firstNewSupplyKink + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setSupplyKink(comet.address, secondNewSupplyKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink).to.be.equal( + secondNewSupplyKink + ); + } +); + +scenario( + 'Configurator#setSupplyKink updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldSupplyKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink; + const newSupplyKink = oldSupplyKink + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(marketAdminSigner).setSupplyKink(comet.address, newSupplyKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink).to.be.equal( + newSupplyKink + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyKink()).toBigInt()).to.be.equal(newSupplyKink); + } +); + +scenario( + 'Configurator#setSupplyKink reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldSupplyKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyKink; + const newSupplyKink = oldSupplyKink + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setSupplyKink(comet.address, newSupplyKink), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeLow updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeLow; + + const newSupplyPerYearInterestRateSlopeLow = oldSupplyPerYearInterestRateSlopeLow + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateSlopeLow(comet.address, newSupplyPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeLow + ).to.be.equal(newSupplyPerYearInterestRateSlopeLow); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyPerSecondInterestRateSlopeLow()).toBigInt()).to.be.equal( + newSupplyPerYearInterestRateSlopeLow / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeLow can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeLow; + + const firstNewSupplyPerYearInterestRateSlopeLow = oldSupplyPerYearInterestRateSlopeLow + 1n; + const secondNewSupplyPerYearInterestRateSlopeLow = firstNewSupplyPerYearInterestRateSlopeLow + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateSlopeLow(comet.address, firstNewSupplyPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeLow + ).to.be.equal(firstNewSupplyPerYearInterestRateSlopeLow); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateSlopeLow(comet.address, secondNewSupplyPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeLow + ).to.be.equal(secondNewSupplyPerYearInterestRateSlopeLow); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeLow updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldSupplyPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeLow; + + const newSupplyPerYearInterestRateSlopeLow = oldSupplyPerYearInterestRateSlopeLow + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setSupplyPerYearInterestRateSlopeLow(comet.address, newSupplyPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeLow + ).to.be.equal(newSupplyPerYearInterestRateSlopeLow); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyPerSecondInterestRateSlopeLow()).toBigInt()).to.be.equal( + newSupplyPerYearInterestRateSlopeLow / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeLow reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldSupplyPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeLow; + + const newSupplyPerYearInterestRateSlopeLow = oldSupplyPerYearInterestRateSlopeLow + 1n; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .setSupplyPerYearInterestRateSlopeLow(comet.address, newSupplyPerYearInterestRateSlopeLow), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeHigh updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeHigh; + + const newSupplyPerYearInterestRateSlopeHigh = oldSupplyPerYearInterestRateSlopeHigh + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateSlopeHigh(comet.address, newSupplyPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeHigh + ).to.be.equal(newSupplyPerYearInterestRateSlopeHigh); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyPerSecondInterestRateSlopeHigh()).toBigInt()).to.be.equal( + newSupplyPerYearInterestRateSlopeHigh / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeHigh can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeHigh; + + const firstNewSupplyPerYearInterestRateSlopeHigh = oldSupplyPerYearInterestRateSlopeHigh + 1n; + const secondNewSupplyPerYearInterestRateSlopeHigh = firstNewSupplyPerYearInterestRateSlopeHigh + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateSlopeHigh(comet.address, firstNewSupplyPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeHigh + ).to.be.equal(firstNewSupplyPerYearInterestRateSlopeHigh); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateSlopeHigh(comet.address, secondNewSupplyPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeHigh + ).to.be.equal(secondNewSupplyPerYearInterestRateSlopeHigh); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeHigh updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldSupplyPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeHigh; + + const newSupplyPerYearInterestRateSlopeHigh = oldSupplyPerYearInterestRateSlopeHigh + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setSupplyPerYearInterestRateSlopeHigh(comet.address, newSupplyPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateSlopeHigh + ).to.be.equal(newSupplyPerYearInterestRateSlopeHigh); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyPerSecondInterestRateSlopeHigh()).toBigInt()).to.be.equal( + newSupplyPerYearInterestRateSlopeHigh / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateSlopeHigh reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldSupplyPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateSlopeHigh; + + const newSupplyPerYearInterestRateSlopeHigh = oldSupplyPerYearInterestRateSlopeHigh + 1n; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .setSupplyPerYearInterestRateSlopeHigh(comet.address, newSupplyPerYearInterestRateSlopeHigh), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateBase updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateBase; + + const newSupplyPerYearInterestRateBase = oldSupplyPerYearInterestRateBase + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateBase(comet.address, newSupplyPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateBase + ).to.be.equal(newSupplyPerYearInterestRateBase); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyPerSecondInterestRateBase()).toBigInt()).to.be.equal( + newSupplyPerYearInterestRateBase / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateBase can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldSupplyPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateBase; + + const firstNewSupplyPerYearInterestRateBase = oldSupplyPerYearInterestRateBase + 1n; + const secondNewSupplyPerYearInterestRateBase = firstNewSupplyPerYearInterestRateBase + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateBase(comet.address, firstNewSupplyPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateBase + ).to.be.equal(firstNewSupplyPerYearInterestRateBase); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setSupplyPerYearInterestRateBase(comet.address, secondNewSupplyPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateBase + ).to.be.equal(secondNewSupplyPerYearInterestRateBase); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateBase updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldSupplyPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateBase; + + const newSupplyPerYearInterestRateBase = oldSupplyPerYearInterestRateBase + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setSupplyPerYearInterestRateBase(comet.address, newSupplyPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).supplyPerYearInterestRateBase + ).to.be.equal(newSupplyPerYearInterestRateBase); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.supplyPerSecondInterestRateBase()).toBigInt()).to.be.equal( + newSupplyPerYearInterestRateBase / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setSupplyPerYearInterestRateBase reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldSupplyPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).supplyPerYearInterestRateBase; + + const newSupplyPerYearInterestRateBase = oldSupplyPerYearInterestRateBase + 1n; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .setSupplyPerYearInterestRateBase(comet.address, newSupplyPerYearInterestRateBase), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBorrowKink updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink; + const newBorrowKink = oldBorrowKink + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBorrowKink(comet.address, newBorrowKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink).to.be.equal( + newBorrowKink + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowKink()).toBigInt()).to.be.equal(newBorrowKink); + } +); + +scenario( + 'Configurator#setBorrowKink can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink; + const firstNewBorrowKink = oldBorrowKink + 1n; + const secondNewBorrowKink = firstNewBorrowKink + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBorrowKink(comet.address, firstNewBorrowKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink).to.be.equal( + firstNewBorrowKink + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBorrowKink(comet.address, secondNewBorrowKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink).to.be.equal( + secondNewBorrowKink + ); + } +); + +scenario( + 'Configurator#setBorrowKink updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const oldBorrowKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink; + const newBorrowKink = oldBorrowKink + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(marketAdminSigner).setBorrowKink(comet.address, newBorrowKink, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink).to.be.equal( + newBorrowKink + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowKink()).toBigInt()).to.be.equal(newBorrowKink); + } +); + +scenario( + 'Configurator#setBorrowKink reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBorrowKink = normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowKink; + const newBorrowKink = oldBorrowKink + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setBorrowKink(comet.address, newBorrowKink), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeLow updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeLow; + + const newBorrowPerYearInterestRateSlopeLow = oldBorrowPerYearInterestRateSlopeLow + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateSlopeLow(comet.address, newBorrowPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeLow + ).to.be.equal(newBorrowPerYearInterestRateSlopeLow); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowPerSecondInterestRateSlopeLow()).toBigInt()).to.be.equal( + newBorrowPerYearInterestRateSlopeLow / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeLow can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeLow; + + const firstNewBorrowPerYearInterestRateSlopeLow = oldBorrowPerYearInterestRateSlopeLow + 1n; + const secondNewBorrowPerYearInterestRateSlopeLow = firstNewBorrowPerYearInterestRateSlopeLow + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateSlopeLow(comet.address, firstNewBorrowPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeLow + ).to.be.equal(firstNewBorrowPerYearInterestRateSlopeLow); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateSlopeLow(comet.address, secondNewBorrowPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeLow + ).to.be.equal(secondNewBorrowPerYearInterestRateSlopeLow); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeLow updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldBorrowPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeLow; + + const newBorrowPerYearInterestRateSlopeLow = oldBorrowPerYearInterestRateSlopeLow + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setBorrowPerYearInterestRateSlopeLow(comet.address, newBorrowPerYearInterestRateSlopeLow, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeLow + ).to.be.equal(newBorrowPerYearInterestRateSlopeLow); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowPerSecondInterestRateSlopeLow()).toBigInt()).to.be.equal( + newBorrowPerYearInterestRateSlopeLow / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeLow reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBorrowPerYearInterestRateSlopeLow = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeLow; + + const newBorrowPerYearInterestRateSlopeLow = oldBorrowPerYearInterestRateSlopeLow + 1n; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .setBorrowPerYearInterestRateSlopeLow(comet.address, newBorrowPerYearInterestRateSlopeLow), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeHigh updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeHigh; + + const newBorrowPerYearInterestRateSlopeHigh = oldBorrowPerYearInterestRateSlopeHigh + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateSlopeHigh(comet.address, newBorrowPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeHigh + ).to.be.equal(newBorrowPerYearInterestRateSlopeHigh); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowPerSecondInterestRateSlopeHigh()).toBigInt()).to.be.equal( + newBorrowPerYearInterestRateSlopeHigh / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeHigh can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeHigh; + + const firstNewBorrowPerYearInterestRateSlopeHigh = oldBorrowPerYearInterestRateSlopeHigh + 1n; + const secondNewBorrowPerYearInterestRateSlopeHigh = oldBorrowPerYearInterestRateSlopeHigh + 2n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateSlopeHigh(comet.address, firstNewBorrowPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeHigh + ).to.be.equal(firstNewBorrowPerYearInterestRateSlopeHigh); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateSlopeHigh(comet.address, secondNewBorrowPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeHigh + ).to.be.equal(secondNewBorrowPerYearInterestRateSlopeHigh); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeHigh updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldBorrowPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeHigh; + + const newBorrowPerYearInterestRateSlopeHigh = oldBorrowPerYearInterestRateSlopeHigh + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setBorrowPerYearInterestRateSlopeHigh(comet.address, newBorrowPerYearInterestRateSlopeHigh, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateSlopeHigh + ).to.be.equal(newBorrowPerYearInterestRateSlopeHigh); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowPerSecondInterestRateSlopeHigh()).toBigInt()).to.be.equal( + newBorrowPerYearInterestRateSlopeHigh / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateSlopeHigh reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBorrowPerYearInterestRateSlopeHigh = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateSlopeHigh; + + const newBorrowPerYearInterestRateSlopeHigh = oldBorrowPerYearInterestRateSlopeHigh + 1n; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .setBorrowPerYearInterestRateSlopeHigh(comet.address, newBorrowPerYearInterestRateSlopeHigh), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateBase updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateBase; + + const newBorrowPerYearInterestRateBase = oldBorrowPerYearInterestRateBase + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateBase(comet.address, newBorrowPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateBase + ).to.be.equal(newBorrowPerYearInterestRateBase); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowPerSecondInterestRateBase()).toBigInt()).to.be.equal( + newBorrowPerYearInterestRateBase / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateBase can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBorrowPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateBase; + + const firstNewBorrowPerYearInterestRateBase = oldBorrowPerYearInterestRateBase + 1n; + const secondNewBorrowPerYearInterestRateBase = firstNewBorrowPerYearInterestRateBase + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateBase(comet.address, firstNewBorrowPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateBase + ).to.be.equal(firstNewBorrowPerYearInterestRateBase); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBorrowPerYearInterestRateBase(comet.address, secondNewBorrowPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateBase + ).to.be.equal(secondNewBorrowPerYearInterestRateBase); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateBase updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldBorrowPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateBase; + + const newBorrowPerYearInterestRateBase = oldBorrowPerYearInterestRateBase + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setBorrowPerYearInterestRateBase(comet.address, newBorrowPerYearInterestRateBase, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).borrowPerYearInterestRateBase + ).to.be.equal(newBorrowPerYearInterestRateBase); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.borrowPerSecondInterestRateBase()).toBigInt()).to.be.equal( + newBorrowPerYearInterestRateBase / SECONDS_PER_YEAR + ); + } +); + +scenario( + 'Configurator#setBorrowPerYearInterestRateBase reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBorrowPerYearInterestRateBase = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).borrowPerYearInterestRateBase; + + const newBorrowPerYearInterestRateBase = oldBorrowPerYearInterestRateBase + 1n; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .setBorrowPerYearInterestRateBase(comet.address, newBorrowPerYearInterestRateBase), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBaseTrackingSupplySpeed updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBaseTrackingSupplySpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingSupplySpeed; + + const newBaseTrackingSupplySpeed = oldBaseTrackingSupplySpeed + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseTrackingSupplySpeed(comet.address, newBaseTrackingSupplySpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingSupplySpeed + ).to.be.equal(newBaseTrackingSupplySpeed); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.baseTrackingSupplySpeed()).toBigInt()).to.be.equal(newBaseTrackingSupplySpeed); + } +); + +scenario( + 'Configurator#setBaseTrackingSupplySpeed can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBaseTrackingSupplySpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingSupplySpeed; + + const firstNewBaseTrackingSupplySpeed = oldBaseTrackingSupplySpeed + 1n; + const secondNewBaseTrackingSupplySpeed = firstNewBaseTrackingSupplySpeed + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBaseTrackingSupplySpeed(comet.address, firstNewBaseTrackingSupplySpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingSupplySpeed + ).to.be.equal(firstNewBaseTrackingSupplySpeed); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBaseTrackingSupplySpeed(comet.address, secondNewBaseTrackingSupplySpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingSupplySpeed + ).to.be.equal(secondNewBaseTrackingSupplySpeed); + } +); + +scenario( + 'Configurator#setBaseTrackingSupplySpeed updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldBaseTrackingSupplySpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingSupplySpeed; + + const newBaseTrackingSupplySpeed = oldBaseTrackingSupplySpeed + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setBaseTrackingSupplySpeed(comet.address, newBaseTrackingSupplySpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingSupplySpeed + ).to.be.equal(newBaseTrackingSupplySpeed); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.baseTrackingSupplySpeed()).toBigInt()).to.be.equal(newBaseTrackingSupplySpeed); + } +); + +scenario( + 'Configurator#setBaseTrackingSupplySpeed reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBaseTrackingSupplySpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingSupplySpeed; + + const newBaseTrackingSupplySpeed = oldBaseTrackingSupplySpeed + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setBaseTrackingSupplySpeed(comet.address, newBaseTrackingSupplySpeed), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBaseTrackingBorrowSpeed updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBaseTrackingBorrowSpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingBorrowSpeed; + + const newBaseTrackingBorrowSpeed = oldBaseTrackingBorrowSpeed + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseTrackingBorrowSpeed(comet.address, newBaseTrackingBorrowSpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingBorrowSpeed + ).to.be.equal(newBaseTrackingBorrowSpeed); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.baseTrackingBorrowSpeed()).toBigInt()).to.be.equal(newBaseTrackingBorrowSpeed); + } +); + +scenario( + 'Configurator#setBaseTrackingBorrowSpeed can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBaseTrackingBorrowSpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingBorrowSpeed; + + const firstNewBaseTrackingBorrowSpeed = oldBaseTrackingBorrowSpeed + 1n; + const secondNewBaseTrackingBorrowSpeed = firstNewBaseTrackingBorrowSpeed + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBaseTrackingBorrowSpeed(comet.address, firstNewBaseTrackingBorrowSpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingBorrowSpeed + ).to.be.equal(firstNewBaseTrackingBorrowSpeed); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .setBaseTrackingBorrowSpeed(comet.address, secondNewBaseTrackingBorrowSpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingBorrowSpeed + ).to.be.equal(secondNewBaseTrackingBorrowSpeed); + } +); + +scenario( + 'Configurator#setBaseTrackingBorrowSpeed updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + + const oldBaseTrackingBorrowSpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingBorrowSpeed; + + const newBaseTrackingBorrowSpeed = oldBaseTrackingBorrowSpeed + 1n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .setBaseTrackingBorrowSpeed(comet.address, newBaseTrackingBorrowSpeed, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseTrackingBorrowSpeed + ).to.be.equal(newBaseTrackingBorrowSpeed); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.baseTrackingBorrowSpeed()).toBigInt()).to.be.equal(newBaseTrackingBorrowSpeed); + } +); + +scenario( + 'Configurator#setBaseTrackingBorrowSpeed reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBaseTrackingBorrowSpeed = normalizeStructOutput( + await configurator.getConfiguration(comet.address) + ).baseTrackingBorrowSpeed; + + const newBaseTrackingBorrowSpeed = oldBaseTrackingBorrowSpeed + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setBaseTrackingBorrowSpeed(comet.address, newBaseTrackingBorrowSpeed), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#setBaseBorrowMin updates value if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBaseBorrowMin = normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin; + const newBaseBorrowMin = oldBaseBorrowMin + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseBorrowMin(comet.address, newBaseBorrowMin, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin).to.be.equal( + newBaseBorrowMin + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.baseBorrowMin()).toBigInt()).to.be.equal(newBaseBorrowMin); + } +); + +scenario( + 'Configurator#setBaseBorrowMin can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const oldBaseBorrowMin = normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin; + const firstNewBaseBorrowMin = oldBaseBorrowMin + 1n; + const secondNewBaseBorrowMin = firstNewBaseBorrowMin + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseBorrowMin(comet.address, firstNewBaseBorrowMin, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin).to.be.equal( + firstNewBaseBorrowMin + ); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).setBaseBorrowMin(comet.address, secondNewBaseBorrowMin, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin).to.be.equal( + secondNewBaseBorrowMin + ); + } +); + +scenario( + 'Configurator#setBaseBorrowMin updates value if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const oldBaseBorrowMin = normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin; + const newBaseBorrowMin = oldBaseBorrowMin + 1n; + + await context.setNextBaseFeeToZero(); + await configurator.connect(marketAdminSigner).setBaseBorrowMin(comet.address, newBaseBorrowMin, { gasPrice: 0 }); + + expect(normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin).to.be.equal( + newBaseBorrowMin + ); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + expect((await comet.baseBorrowMin()).toBigInt()).to.be.equal(newBaseBorrowMin); + } +); + +scenario( + 'Configurator#setBaseBorrowMin reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const oldBaseBorrowMin = normalizeStructOutput(await configurator.getConfiguration(comet.address)).baseBorrowMin; + const newBaseBorrowMin = oldBaseBorrowMin + 1n; + + await expectRevertCustom( + configurator.connect(albert.signer).setBaseBorrowMin(comet.address, newBaseBorrowMin), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor succeeds if called by governor', + { + filter: async (ctx: CometContext) => await hasActiveAsset(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const { assetIndex, assetConfig } = await getActiveAsset(context); + const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; + const newAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, newAssetBorrowCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .borrowCollateralFactor + ).to.be.equal(newAssetBorrowCollateralFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.borrowCollateralFactor).to.be.equal(newAssetBorrowCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; + const firstNewAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; + const secondNewAssetBorrowCollateralFactor = firstNewAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, firstNewAssetBorrowCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .borrowCollateralFactor + ).to.be.equal(firstNewAssetBorrowCollateralFactor); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, secondNewAssetBorrowCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .borrowCollateralFactor + ).to.be.equal(secondNewAssetBorrowCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor disables asset if called by governor', + { + filter: async (ctx: CometContext) => await hasActiveAsset(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const { assetIndex, assetConfig } = await getActiveAsset(context); + const newAssetBorrowCollateralFactor = 0n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, newAssetBorrowCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .borrowCollateralFactor + ).to.be.equal(newAssetBorrowCollateralFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.borrowCollateralFactor).to.be.equal(newAssetBorrowCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor succeeds if called by market-admin', + { + filter: async (ctx: CometContext) => + (await supportsMarketAdminPermissionChecker(ctx)) && (await hasActiveAsset(ctx)) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const { assetIndex, assetConfig } = await getActiveAsset(context); + const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; + const newAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, newAssetBorrowCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .borrowCollateralFactor + ).to.be.equal(newAssetBorrowCollateralFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.borrowCollateralFactor).to.be.equal(newAssetBorrowCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor disables asset if called by market-admin', + { + filter: async (ctx: CometContext) => + (await supportsMarketAdminPermissionChecker(ctx)) && (await hasActiveAsset(ctx)) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const { assetIndex, assetConfig } = await getActiveAsset(context); + const newAssetBorrowCollateralFactor = 0n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, newAssetBorrowCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .borrowCollateralFactor + ).to.be.equal(newAssetBorrowCollateralFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.borrowCollateralFactor).to.be.equal(newAssetBorrowCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }, context) => { + const { albert } = actors; + + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(-1); + const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; + const newAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .updateAssetBorrowCollateralFactor(comet.address, assetConfig.asset, newAssetBorrowCollateralFactor), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAssetBorrowCollateralFactor reverts if asset does not exist', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + // use the existing config to get a valid factor value + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(-1); + const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; + const newAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; + + const nonExistingAsset = await ethers.Wallet.createRandom().getAddress(); + + await expectRevertCustom( + configurator + .connect(admin.signer) + .updateAssetBorrowCollateralFactor(comet.address, nonExistingAsset, newAssetBorrowCollateralFactor), + 'AssetDoesNotExist()' + ); + } +); + +scenario( + 'Configurator#updateAssetLiquidateCollateralFactor succeeds if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetLiquidateCollateralFactor = assetConfig.liquidateCollateralFactor; + const newAssetLiquidateCollateralFactor = oldAssetLiquidateCollateralFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetLiquidateCollateralFactor(comet.address, assetConfig.asset, newAssetLiquidateCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidateCollateralFactor + ).to.be.equal(newAssetLiquidateCollateralFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.liquidateCollateralFactor).to.be.equal(newAssetLiquidateCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetLiquidateCollateralFactor can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetLiquidateCollateralFactor = assetConfig.liquidateCollateralFactor; + const firstNewAssetLiquidateCollateralFactor = oldAssetLiquidateCollateralFactor + MIN_FACTOR_INCREMENT; + const secondNewAssetLiquidateCollateralFactor = firstNewAssetLiquidateCollateralFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetLiquidateCollateralFactor(comet.address, assetConfig.asset, firstNewAssetLiquidateCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidateCollateralFactor + ).to.be.equal(firstNewAssetLiquidateCollateralFactor); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetLiquidateCollateralFactor(comet.address, assetConfig.asset, secondNewAssetLiquidateCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidateCollateralFactor + ).to.be.equal(secondNewAssetLiquidateCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetLiquidateCollateralFactor succeeds if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetLiquidateCollateralFactor = assetConfig.liquidateCollateralFactor; + const newAssetLiquidateCollateralFactor = oldAssetLiquidateCollateralFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .updateAssetLiquidateCollateralFactor(comet.address, assetConfig.asset, newAssetLiquidateCollateralFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidateCollateralFactor + ).to.be.equal(newAssetLiquidateCollateralFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.liquidateCollateralFactor).to.be.equal(newAssetLiquidateCollateralFactor); + } +); + +scenario( + 'Configurator#updateAssetLiquidateCollateralFactor reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const assetConfigs = (await configurator.getConfiguration(comet.address)).assetConfigs; + + await expectRevertCustom( + configurator + .connect(albert.signer) + .updateAssetLiquidateCollateralFactor(comet.address, assetConfigs.at(-1).asset, 1n), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAssetLiquidateCollateralFactor reverts if asset does not exist', + {}, + async ({ comet, configurator, actors }) => { + const { admin } = actors; + + const nonExistingAsset = await ethers.Wallet.createRandom().getAddress(); + + await expectRevertCustom( + configurator.connect(admin.signer).updateAssetLiquidateCollateralFactor(comet.address, nonExistingAsset, 1n), + 'AssetDoesNotExist()' + ); + } +); + +scenario( + 'Configurator#updateAssetLiquidationFactor succeeds if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetLiquidationFactor = assetConfig.liquidationFactor; + const newAssetLiquidationFactor = oldAssetLiquidationFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetLiquidationFactor(comet.address, assetConfig.asset, newAssetLiquidationFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidationFactor + ).to.be.equal(newAssetLiquidationFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.liquidationFactor).to.be.equal(newAssetLiquidationFactor); + } +); + +scenario( + 'Configurator#updateAssetLiquidationFactor can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetLiquidationFactor = assetConfig.liquidationFactor; + const firstNewAssetLiquidationFactor = oldAssetLiquidationFactor + MIN_FACTOR_INCREMENT; + const secondNewAssetLiquidationFactor = firstNewAssetLiquidationFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetLiquidationFactor(comet.address, assetConfig.asset, firstNewAssetLiquidationFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidationFactor + ).to.be.equal(firstNewAssetLiquidationFactor); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetLiquidationFactor(comet.address, assetConfig.asset, secondNewAssetLiquidationFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidationFactor + ).to.be.equal(secondNewAssetLiquidationFactor); + } +); + +scenario( + 'Configurator#updateAssetLiquidationFactor succeeds if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetLiquidationFactor = assetConfig.liquidationFactor; + const newAssetLiquidationFactor = oldAssetLiquidationFactor + MIN_FACTOR_INCREMENT; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .updateAssetLiquidationFactor(comet.address, assetConfig.asset, newAssetLiquidationFactor, { + gasPrice: 0 + }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex) + .liquidationFactor + ).to.be.equal(newAssetLiquidationFactor); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.liquidationFactor).to.be.equal(newAssetLiquidationFactor); + } +); + +scenario( + 'Configurator#updateAssetLiquidationFactor reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + + const assetConfigs = (await configurator.getConfiguration(comet.address)).assetConfigs; + + await expectRevertCustom( + configurator.connect(albert.signer).updateAssetLiquidationFactor(comet.address, assetConfigs.at(-1).asset, 1n), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAssetLiquidationFactor reverts if asset does not exist', + {}, + async ({ comet, configurator, actors }) => { + const { admin } = actors; + + const nonExistingAsset = await ethers.Wallet.createRandom().getAddress(); + + await expectRevertCustom( + configurator.connect(admin.signer).updateAssetLiquidationFactor(comet.address, nonExistingAsset, 1n), + 'AssetDoesNotExist()' + ); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap succeeds if called by governor', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetSupplyCap = assetConfig.supplyCap; + const newAssetSupplyCap = oldAssetSupplyCap + getMinSupplyCapIncrement(assetConfig.decimals); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetSupplyCap(comet.address, assetConfig.asset, newAssetSupplyCap, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).supplyCap + ).to.be.equal(newAssetSupplyCap); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.supplyCap).to.be.equal(newAssetSupplyCap); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap can be overwritten multiple times', + {}, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetSupplyCap = assetConfig.supplyCap; + const firstNewAssetSupplyCap = oldAssetSupplyCap + getMinSupplyCapIncrement(assetConfig.decimals); + const secondNewAssetSupplyCap = firstNewAssetSupplyCap + getMinSupplyCapIncrement(assetConfig.decimals); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetSupplyCap(comet.address, assetConfig.asset, firstNewAssetSupplyCap, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).supplyCap + ).to.be.equal(firstNewAssetSupplyCap); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetSupplyCap(comet.address, assetConfig.asset, secondNewAssetSupplyCap, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).supplyCap + ).to.be.equal(secondNewAssetSupplyCap); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap disables asset if called by governor', + { + filter: async (ctx: CometContext) => await hasActiveAsset(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const { assetIndex, assetConfig } = await getActiveAsset(context); + const newAssetSupplyCap = 0n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(admin.signer) + .updateAssetSupplyCap(comet.address, assetConfig.asset, newAssetSupplyCap, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).supplyCap + ).to.be.equal(newAssetSupplyCap); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.supplyCap).to.be.equal(newAssetSupplyCap); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap succeeds if called by market-admin', + { + filter: async (ctx: CometContext) => await supportsMarketAdminPermissionChecker(ctx) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const assetIndex = -1; + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( + assetIndex + ); + const oldAssetSupplyCap = assetConfig.supplyCap; + const newAssetSupplyCap = oldAssetSupplyCap + getMinSupplyCapIncrement(assetConfig.decimals); + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .updateAssetSupplyCap(comet.address, assetConfig.asset, newAssetSupplyCap, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).supplyCap + ).to.be.equal(newAssetSupplyCap); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.supplyCap).to.be.equal(newAssetSupplyCap); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap disables asset if called by market-admin', + { + filter: async (ctx: CometContext) => + (await supportsMarketAdminPermissionChecker(ctx)) && (await hasActiveAsset(ctx)) + }, + async ({ comet, configurator, actors }, context) => { + const { admin } = actors; + + const marketAdminSigner = await getMarketAdminSigner(context); + const { assetIndex, assetConfig } = await getActiveAsset(context); + const newAssetSupplyCap = 0n; + + await context.setNextBaseFeeToZero(); + await configurator + .connect(marketAdminSigner) + .updateAssetSupplyCap(comet.address, assetConfig.asset, newAssetSupplyCap, { gasPrice: 0 }); + + expect( + normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).supplyCap + ).to.be.equal(newAssetSupplyCap); + + await context.setNextBaseFeeToZero(); + await admin.deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); + + expect(assetInfo.supplyCap).to.be.equal(newAssetSupplyCap); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap reverts if called by unauthorized caller', + {}, + async ({ comet, configurator, actors }) => { + const { albert } = actors; + const assetConfigs = (await configurator.getConfiguration(comet.address)).assetConfigs; + + await expectRevertCustom( + configurator.connect(albert.signer).updateAssetSupplyCap(comet.address, assetConfigs.at(-1).asset, 1n), + 'Unauthorized()' + ); + } +); + +scenario( + 'Configurator#updateAssetSupplyCap reverts if asset does not exist', + {}, + async ({ comet, configurator, actors }) => { + const { admin } = actors; + const nonExistingAsset = await ethers.Wallet.createRandom().getAddress(); + + await expectRevertCustom( + configurator.connect(admin.signer).updateAssetSupplyCap(comet.address, nonExistingAsset, 1n), + 'AssetDoesNotExist()' + ); + } +); diff --git a/scenario/InterestRateScenario.ts b/scenario/InterestRateScenario.ts index 72256e908..5dbcd7c71 100644 --- a/scenario/InterestRateScenario.ts +++ b/scenario/InterestRateScenario.ts @@ -1,17 +1,45 @@ -import { scenario } from './context/CometContext'; +import { CometContext, scenario } from './context/CometContext'; import { expect } from 'chai'; -import { annualize, defactor, exp } from '../test/helpers'; +import { annualize, defactor, exp, factorScale } from '../test/helpers'; import { BigNumber } from 'ethers'; import { FuzzType } from './constraints/Fuzzing'; +import { expectRevertCustom, supportUtilizationLimit, isFreshMarket } from './utils'; +import { getConfigForScenario } from './utils/scenarioHelper'; -function calculateInterestRate( +function calculateInterestRateSupply( utilization: BigNumber, kink: BigNumber, interestRateBase: BigNumber, interestRateSlopeLow: BigNumber, interestRateSlopeHigh: BigNumber, - factorScale = BigNumber.from(exp(1, 18)) + totalSupplyBase: BigNumber +): BigNumber { + const factorScale = BigNumber.from(exp(1, 18)); + + if (totalSupplyBase.isZero()) return BigNumber.from(0); + + if (utilization.lte(kink)) { + const interestRateWithoutBase = interestRateSlopeLow.mul(utilization).div(factorScale); + return interestRateBase.add(interestRateWithoutBase); + } else { + const rateSlopeLow = interestRateSlopeLow.mul(kink).div(factorScale); + const rateSlopeHigh = interestRateSlopeHigh.mul(utilization.sub(kink)).div(factorScale); + return interestRateBase.add(rateSlopeLow).add(rateSlopeHigh); + } +} + +function calculateInterestRateBorrow( + utilization: BigNumber, + kink: BigNumber, + interestRateBase: BigNumber, + interestRateSlopeLow: BigNumber, + interestRateSlopeHigh: BigNumber, + totalBorrowBase?: BigNumber, ): BigNumber { + const factorScale = BigNumber.from(exp(1, 18)); + + if(totalBorrowBase.isZero()) return BigNumber.from(0); + if (utilization.lte(kink)) { const interestRateWithoutBase = interestRateSlopeLow.mul(utilization).div(factorScale); return interestRateBase.add(interestRateWithoutBase); @@ -56,22 +84,26 @@ scenario( const expectedUtilization = calculateUtilization(totalSupplyBase, totalBorrowBase, baseSupplyIndex, baseBorrowIndex); expect(defactor(actualUtilization)).to.be.approximately(defactor(expectedUtilization), 0.00001); + totalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; expect(await comet.getSupplyRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateSupply( actualUtilization, supplyKink, supplyPerSecondInterestRateBase, supplyPerSecondInterestRateSlopeLow, - supplyPerSecondInterestRateSlopeHigh + supplyPerSecondInterestRateSlopeHigh, + totalSupplyBase ) ); + totalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; expect(await comet.getBorrowRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateBorrow( actualUtilization, borrowKink, borrowPerSecondInterestRateBase, borrowPerSecondInterestRateSlopeLow, - borrowPerSecondInterestRateSlopeHigh + borrowPerSecondInterestRateSlopeHigh, + totalBorrowBase ) ); } @@ -148,22 +180,26 @@ scenario( const expectedUtilization = calculateUtilization(totalSupplyBase, totalBorrowBase, baseSupplyIndex, baseBorrowIndex); expect(defactor(actualUtilization)).to.be.approximately(defactor(expectedUtilization), 0.00001); + totalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; expect(await comet.getSupplyRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateSupply( actualUtilization, supplyKink, supplyPerSecondInterestRateBase, supplyPerSecondInterestRateSlopeLow, - supplyPerSecondInterestRateSlopeHigh + supplyPerSecondInterestRateSlopeHigh, + totalSupplyBase ) ); + totalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; expect(await comet.getBorrowRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateBorrow( actualUtilization, borrowKink, borrowPerSecondInterestRateBase, borrowPerSecondInterestRateSlopeLow, - borrowPerSecondInterestRateSlopeHigh + borrowPerSecondInterestRateSlopeHigh, + totalBorrowBase, ) ); } @@ -194,3 +230,375 @@ scenario.skip( } } ); + +scenario( + 'Comet#interestRate reverts for pushing utilization above 200%', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + }, + async ({ comet }, context: CometContext) => { + const { albert, betty } = context.actors; + const { asset, scale, borrowCollateralFactor, priceFeed } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + + // Get constants + const baseScale = (await comet.baseScale()).toBigInt(); + const collateralScale = scale.toBigInt(); + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const collateralPrice = (await comet.getPrice(priceFeed)).toBigInt(); + + // Step 1: Set up a known supply state + // Supply a fixed amount of base tokens to establish a baseline + const baseSupplyAmount = 10n * baseScale; // 10 base tokens + await context.sourceTokens(baseSupplyAmount, baseToken.address, betty.address); + await baseToken.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken.address, amount: baseSupplyAmount }); + + // Get current state after supply + let currentTotalSupply = (await comet.totalSupply()).toBigInt(); + + // Step 2: Calculate borrow amount to exceed 200% utilization + // We want to borrow enough so that: (currentTotalBorrow + borrowAmount) / currentTotalSupply > 2 + // Simplest approach: borrow 3x the current supply (which gives 300% utilization if no existing borrow) + // This ensures we definitely exceed 200% even with existing borrows + let targetBorrowAmount = 3n * currentTotalSupply; + + // Ensure we have enough base tokens available to borrow + // We need: supply + reserves >= borrowAmount + // If not, we need to supply more. If we supply more, utilization goes down, + // so we need to borrow even more. Let's supply enough to cover the borrow. + const currentReserves = (await comet.getReserves()).toBigInt(); + const availableToBorrow = currentTotalSupply + (currentReserves > 0n ? currentReserves : 0n); + + if (targetBorrowAmount > availableToBorrow) { + // Supply enough to cover the borrow + // We need: newSupply >= targetBorrowAmount + // So: additionalSupply = targetBorrowAmount - currentTotalSupply (assuming no reserves) + const additionalSupply = targetBorrowAmount - currentTotalSupply + baseScale; + await context.sourceTokens(additionalSupply, baseToken.address, betty.address); + await baseToken.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken.address, amount: additionalSupply }); + + // Recalculate: now we have more supply, so we need to borrow even more to exceed 200% + currentTotalSupply = (await comet.totalSupply()).toBigInt(); + targetBorrowAmount = 3n * currentTotalSupply; + } + + // Step 4: Calculate collateral needed for the borrow + // We need enough collateral to support the borrow based on borrowCollateralFactor + const collateralWeiPerUnitBase = (collateralScale * basePrice) / collateralPrice; + let collateralNeeded = (collateralWeiPerUnitBase * targetBorrowAmount) / baseScale; + collateralNeeded = (collateralNeeded * factorScale) / borrowCollateralFactor.toBigInt(); // adjust for borrowCollateralFactor + collateralNeeded = (collateralNeeded * 11n) / 10n; // add 10% fudge factor for safety + + // Step 5: Source collateral tokens for albert and have him supply + await context.sourceTokens(collateralNeeded, collateralAsset.address, albert.address); + await collateralAsset.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset.address, amount: collateralNeeded }); + + // Step 6: Try to borrow base asset, which should revert with ExceedsSupportedUtilization + // The borrow should push utilization above 200% + await expectRevertCustom( + albert.withdrawAsset({ asset: baseTokenAddress, amount: targetBorrowAmount }), + 'ExceedsSupportedUtilization()' + ); + } +); + +/** + * @notice Verifies that supply index remains unchanged when market has no supplies + * @dev `if (totalSupplyBase == 0) return 0;` + * When there are no lenders in the market, supply rate should be 0 and + * baseSupplyIndex should not accrue even after time passes. + * This prevents phantom interest accrual on an empty market. + */ +scenario( + 'Comet#interestRate > supply index does not change when there are no supplies', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + + // Get initial state + const initialTotals = await comet.totalsBasic(); + const initialSupplyIndex = initialTotals.baseSupplyIndex; + + // Verify there are no supplies (totalSupplyBase == 0) + expect(initialTotals.totalSupplyBase.toBigInt()).to.equal(0n); + + // Verify supply rate is 0 when there are no supplies + const supplyRate = await comet.getSupplyRate(0); + expect(supplyRate.toBigInt()).to.equal(0n); + + // Skip some time (1 hour) + await ethers.provider.send('evm_increaseTime', [3600]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue by calling accrueAccount + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get state after time skip + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + + // Verify baseSupplyIndex has not changed + expect(finalSupplyIndex.toBigInt()).to.equal(initialSupplyIndex.toBigInt()); + + // Verify lastAccrualTime was updated (accrual happened but index didn't change) + expect(finalTotals.lastAccrualTime).to.be.greaterThan(initialTotals.lastAccrualTime); + } +); + +/** + * @notice Verifies that supply index does not grow when there are supplies but no reserves + * @dev When lenders supply to the market but there are no reserves (or reserves are exhausted), + * the baseSupplyIndex should not increase because there are no funds to pay interest from. + * This prevents lenders from accruing interest that cannot be withdrawn (illiquidity protection). + */ +scenario( + 'Comet#interestRate > supply index does not grow without reserves even with supplies', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + const { albert } = context.actors; + + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + const totalsBeforeSupply = await comet.totalsBasic(); + + // Supply some base tokens to the market + const supplyAmount = BigInt(getConfigForScenario(context).supplyBase) * baseScale; + await context.sourceTokens(supplyAmount, baseToken.address, albert.address); + await baseToken.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Verify supply was successful + const totalsAfterSupply = await comet.totalsBasic(); + expect(totalsAfterSupply.totalSupplyBase.toBigInt()).to.equal(totalsBeforeSupply.totalSupplyBase.toBigInt() + supplyAmount); + + // Get supply index before time skip + const prevSupplyIndex = totalsAfterSupply.baseSupplyIndex; + + // Skip some time (1 hour) + await ethers.provider.send('evm_increaseTime', [3600]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get state after time skip + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + + // Verify baseSupplyIndex has not changed because there are no reserves to fund the interest + expect(finalSupplyIndex.toBigInt()).to.equal(prevSupplyIndex.toBigInt()); + + // Verify utilization is 0 (no borrows) + expect((await comet.getUtilization()).toBigInt()).to.equal(0n); + } +); + +/** + * @notice Verifies that supply index grows when there are both supplies and reserves + * @dev When lenders supply to the market AND there are reserves available, + * the baseSupplyIndex should increase according to the base supply rate. + * Reserves fund the interest payments to lenders when there are no borrowers. + */ +scenario( + 'Comet#interestRate > supply index grows with reserves and supplies', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + const { albert } = context.actors; + + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + + // Supply some base tokens to the market + const supplyAmount = BigInt(getConfigForScenario(context).supplyBase) * baseScale; + await context.sourceTokens(supplyAmount, baseToken.address, albert.address); + await baseToken.approve(albert, comet.address); + await albert.supplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Add reserves to the market (send tokens directly to comet without supplying) + const reservesAmount = BigInt(getConfigForScenario(context).reservesBase) * baseScale; + await context.sourceTokens(reservesAmount, baseToken.address, comet.address); + + // Verify reserves are positive + const reserves = await comet.getReserves(); + expect(reserves.toBigInt()).to.be.greaterThan(0n); + + // Get state before time skip + const totalsBeforeAccrue = await comet.totalsBasic(); + const prevSupplyIndex = totalsBeforeAccrue.baseSupplyIndex; + const prevLastAccrualTime = totalsBeforeAccrue.lastAccrualTime; + + // Verify supply rate is positive (base rate applies since utilization is 0 but reserves exist) + const supplyRate = await comet.getSupplyRate(0); + expect(supplyRate.toBigInt()).to.be.greaterThan(0n); + + // Skip some time (1 hour) + await ethers.provider.send('evm_increaseTime', [3600]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get state after time skip + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + const timeElapsed = finalTotals.lastAccrualTime - prevLastAccrualTime; + + // Calculate expected supply index growth + // accruedIndex = prevIndex + prevIndex * supplyRate * timeElapsed / 1e18 + const expectedAccruedIndex = prevSupplyIndex.add( + prevSupplyIndex.mul(supplyRate).mul(timeElapsed).div(exp(1, 18)) + ); + + // Verify baseSupplyIndex has grown + expect(finalSupplyIndex).to.be.greaterThan(prevSupplyIndex); + expect(finalSupplyIndex).to.equal(expectedAccruedIndex); + + // Verify utilization is still 0 (no borrows) + expect((await comet.getUtilization()).toBigInt()).to.equal(0n); + } +); + +/** + * @notice Verifies that supply interest accrual is capped by available reserves when there are no borrows + * @dev In a new market with lenders but no borrowers, lenders earn the base supply rate funded from reserves. + * Without this safeguard, totalSupply() could exceed the actual token balance, causing illiquidity. + * Once reserves are exhausted (totalSupply >= balance), the supply index stops growing + * to ensure lenders can always withdraw their entitled amounts. + */ +scenario( + 'Comet#interestRate > supply interest does not exceed reserves without borrows', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + const { albert, betty } = context.actors; + + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + + // Supply base tokens to the market + const supplyAmount = BigInt(getConfigForScenario(context).supplyBase) * baseScale; + await context.sourceTokens(supplyAmount, baseToken.address, albert.address); + await baseToken.approve(albert, comet.address); + await albert.supplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Another user also supplies + await context.sourceTokens(supplyAmount, baseToken.address, betty.address); + await baseToken.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Add reserves to the market + const initialReserves = BigInt(getConfigForScenario(context).reservesBase) * baseScale; + await context.sourceTokens(initialReserves, baseToken.address, comet.address); + + // Get supply rate (base rate since utilization is 0) + const supplyPerSecondInterestRateBase = await comet.supplyPerSecondInterestRateBase(); + + // Calculate time needed for reserves to be consumed by interest + // Interest accrued = principal * rate * time + // When totalSupply() reaches balance, interest stops accruing + // We need to find time such that: initialSupply * (1 + rate*time) >= balance + // Simplification: time = reserves / (supply * rate) + const totalSupplyBase = (await comet.totalsBasic()).totalSupplyBase.toBigInt(); + const expectedTimeToExhaustReserves = (initialReserves * BigInt(exp(1, 18))) / + (totalSupplyBase * supplyPerSecondInterestRateBase.toBigInt()); + + // Skip time significantly past when reserves should be exhausted + const timeToSkip = Number(expectedTimeToExhaustReserves) + 3600; // Add 1 hour buffer + await ethers.provider.send('evm_increaseTime', [timeToSkip]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue + await comet.accrueAccount(ethers.constants.AddressZero); + + // After reserves are exhausted, totalSupply() should approximately equal the base token balance + const totalSupply = await comet.totalSupply(); + const cometBalance = await baseToken.balanceOf(comet.address); + + // totalSupply should be approximately equal to or less than balance (within rounding) + expect(totalSupply.toBigInt()).to.be.approximately(cometBalance, 10000000); + + // Get the supply index after reserves exhaustion + const totalsAfterExhaustion = await comet.totalsBasic(); + const indexAfterExhaustion = totalsAfterExhaustion.baseSupplyIndex; + + const baseBalance = await baseToken.balanceOf(comet.address); + const baseIndexScale = (await comet.baseIndexScale()).toBigInt(); + expect(indexAfterExhaustion).to.equal(baseBalance * baseIndexScale / totalSupplyBase); + + // Skip more time + await ethers.provider.send('evm_increaseTime', [3600]); // 1 more hour + await ethers.provider.send('evm_mine', []); + + // Trigger accrue again + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get final state + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + + // Supply index should NOT have grown further (reserves exhausted) + expect(finalSupplyIndex.toBigInt()).to.equal(indexAfterExhaustion.toBigInt()); + + // Supply rate should now be the base rate + const supplyRateNow = await comet.getSupplyRate(0); + expect(supplyRateNow.toBigInt()).to.equal((await comet.supplyPerSecondInterestRateBase()).toBigInt()); + } +); diff --git a/scenario/LiquidationScenario.ts b/scenario/LiquidationScenario.ts index e13612569..ef59a4baa 100644 --- a/scenario/LiquidationScenario.ts +++ b/scenario/LiquidationScenario.ts @@ -1,6 +1,6 @@ -import { scenario } from './context/CometContext'; +import { CometContext, scenario } from './context/CometContext'; import { event, expect } from '../test/helpers'; -import { expectRevertCustom, timeUntilUnderwater } from './utils'; +import { MAX_ASSETS, expectRevertCustom, isValidAssetIndex, timeUntilUnderwater, isTriviallySourceable, usesAssetList, isAssetDelisted, supportsExtendedPause } from './utils'; import { matchesDeployment } from './utils'; import { getConfigForScenario } from './utils/scenarioHelper'; @@ -210,7 +210,10 @@ scenario( scenario( 'Comet#liquidation > user can end up with a minted supply', { - filter: async (ctx) => !matchesDeployment(ctx, [{ network: 'base', deployment: 'usds' }]), + filter: async (ctx) => !matchesDeployment(ctx, [ + { network: 'base', deployment: 'usds' }, + { network: 'ronin' }, + ]), tokenBalances: async (ctx) => ( { $comet: { @@ -297,4 +300,388 @@ scenario.skip( } }); } -); \ No newline at end of file +); + +/** + * @title Liquidation Scenario - isLiquidatable with liquidateCollateralFactor = 0 + * @notice Test suite for isLiquidatable behavior when liquidateCollateralFactor is set to 0 + * + * @dev This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that when a price feed becomes unavailable, the protocol cannot calculate the USD value + * of collateral (e.g., during absorption when trying to getPrice() for a delisted asset). + * + * @dev The solution was to set the asset's liquidateCollateralFactor to 0 for delisted collateral. For isLiquidatable, + * when liquidateCollateralFactor = 0, the contract skips that asset in the liquidity calculation, effectively + * excluding it from contributing to the user's collateralization. This prevents the protocol from calling + * getPrice() on unavailable price feeds. + * + * @dev This scenario tests isLiquidatable behavior in two phases: + * 1. Normal operation: Verifies that positions with positive liquidateCF are properly collateralized and not liquidatable + * 2. Delisted asset: Sets liquidateCF to 0 and verifies that the collateral is excluded from liquidity calculations, + * causing positions to become liquidatable when their only collateral asset is delisted + * + * @dev The scenario runs for all valid assets (up to MAX_ASSETS) and only on Comet deployments that use + * the extended asset list feature (CometExtAssetList), as the liquidateCollateralFactor = 0 behavior is specific + * to that implementation. The test filters deployments using the usesAssetList() utility function to ensure + * compatibility, and excludes assets that are already delisted. + */ +for (let i = 0; i < MAX_ASSETS; i++) { + scenario( + `Comet#liquidation > skips liquidation value of asset ${i} with liquidateCF=0`, + { + filter: async (ctx: CometContext) => await isValidAssetIndex(ctx, i) && await isTriviallySourceable(ctx, i, getConfigForScenario(ctx, i).supplyCollateral) && await usesAssetList(ctx) && !(await isAssetDelisted(ctx, i)) && await supportsExtendedPause(ctx), + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: '== 0' }, + $comet: { $base: getConfigForScenario(ctx, i).withdrawBase }, + } + ), + }, + async ({ comet, configurator, proxyAdmin, actors }, context) => { + const { albert, admin } = actors; + const { asset, borrowCollateralFactor, priceFeed, scale } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const collateralScale = scale.toBigInt(); + + // Get price feeds and scales + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const collateralPrice = (await comet.getPrice(priceFeed)).toBigInt(); + const baseScale = (await comet.baseScale()).toBigInt(); + const factorScale = (await comet.factorScale()).toBigInt(); + + // Target borrow amount (in base units, not wei) + const targetBorrowBase = BigInt(getConfigForScenario(context, i).withdrawBase); + const targetBorrowBaseWei = targetBorrowBase * baseScale; + + // Calculate required collateral amount + // Formula from CometBalanceConstraint.ts: + const collateralWeiPerUnitBase = (collateralScale * basePrice) / collateralPrice; + let collateralNeeded = (collateralWeiPerUnitBase * targetBorrowBaseWei) / baseScale; + collateralNeeded = (collateralNeeded * factorScale) / borrowCollateralFactor.toBigInt(); + collateralNeeded = (collateralNeeded * 11n) / 10n; // add fudge factor to ensure collateralization + + // Set up balances dynamically + // 1. Source collateral tokens for albert + await context.sourceTokens(collateralNeeded, collateralAsset, albert); + + // 2. Approve and supply collateral + await collateralAsset.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset.address, amount: collateralNeeded }); + + // 3. Borrow base (this will make albert have negative base balance) + const baseTokenAddress = await comet.baseToken(); + await albert.withdrawAsset({ asset: baseTokenAddress, amount: targetBorrowBaseWei }); + + // Verify initial state: position should be collateralized and not liquidatable + expect(await comet.isLiquidatable(albert.address)).to.be.false; + + // Set liquidateCF to 0 (CometWithExtendedAssetList allows this even if borrowCF > 0) + // LiquidateCF can be set to 0, when borrowCF is zero, thus we need to set borrowCF to 0 first + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAssetBorrowCollateralFactor(comet.address, asset, 0n, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAssetLiquidateCollateralFactor(comet.address, asset, 0n, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await proxyAdmin.connect(admin.signer).deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + // Verify liquidateCF is 0 + const assetInfo = await comet.getAssetInfoByAddress(asset); + expect(assetInfo.liquidateCollateralFactor).to.equal(0); + + // After zeroing the only supplied asset's liquidateCF, position should be liquidatable + expect(await comet.isLiquidatable(albert.address)).to.equal(true); + } + ); +} + +/** + * @title Liquidation Scenario - Absorption with liquidationFactor = 0 + * @notice Test suite for absorption behavior when liquidationFactor is set to 0 + * + * @dev This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that during absorption, the protocol would not be able to calculate the USD value + * of collateral seized when trying to getPrice() for a delisted asset. + * + * @dev The solution was to set the asset's liquidationFactor to 0 for delisted collateral. For absorption, + * when liquidationFactor = 0, the protocol skips seizing that collateral during absorption, but still + * proceeds with debt absorption. This allows the protocol to continue functioning even when a price feed + * becomes unavailable, by setting the asset's liquidation factor to 0 to prevent attempts to calculate its USD value. + * + * @dev This scenario tests absorption behavior in two phases: + * 1. Normal operation: Verifies that when collateral has a non-zero liquidation factor, the protocol can + * successfully liquidate/seize the collateral during absorption, calculate its USD value, and update all state correctly + * 2. Delisted asset: Sets liquidationFactor to 0 and verifies that the protocol skips seizing that collateral + * during absorption, but still proceeds with debt absorption + * + * @dev The scenario runs for all valid assets (up to MAX_ASSETS) and only on Comet deployments that use + * the extended asset list feature (CometExtAssetList), as the liquidationFactor = 0 behavior is specific + * to that implementation. The test filters deployments using the usesAssetList() utility function to ensure + * compatibility, and excludes assets that are already delisted. + */ +for (let i = 0; i < MAX_ASSETS; i++) { + scenario( + `Comet#liquidation > skips absorption of asset ${i} with liquidation factor = 0`, + { + filter: async (ctx) => + await isValidAssetIndex(ctx, i) && await isTriviallySourceable(ctx, i, getConfigForScenario(ctx, i).supplyCollateral) && await usesAssetList(ctx) && !(await isAssetDelisted(ctx, i)) && await supportsExtendedPause(ctx), + tokenBalances: async (ctx) => ({ + albert: { $base: '== 0' }, + $comet: { + $base: getConfigForScenario(ctx).withdrawBase + } + }), + }, + async ({ comet, configurator, proxyAdmin, actors }, context, world) => { + const { albert, betty, admin } = actors; + const { asset, borrowCollateralFactor, priceFeed, scale } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const collateralScale = scale.toBigInt(); + const baseToken = await comet.baseToken(); + const baseScale = (await comet.baseScale()).toBigInt(); + + // Get price feeds and scales + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const collateralPrice = (await comet.getPrice(priceFeed)).toBigInt(); + const factorScale = (await comet.factorScale()).toBigInt(); + + // Target borrow amount (in base units, not wei) + const targetBorrowBase = BigInt(getConfigForScenario(context, i).withdrawBase); + const targetBorrowBaseWei = targetBorrowBase * baseScale; + + // Calculate required collateral amount + // Formula from CometBalanceConstraint.ts: + const collateralWeiPerUnitBase = (collateralScale * basePrice) / collateralPrice; + let collateralNeeded = (collateralWeiPerUnitBase * targetBorrowBaseWei) / baseScale; + collateralNeeded = (collateralNeeded * factorScale) / borrowCollateralFactor.toBigInt(); + collateralNeeded = (collateralNeeded * 11n) / 10n; // add fudge factor to ensure collateralization + + // Set up balances dynamically + // 1. Source collateral tokens for albert + await context.sourceTokens(collateralNeeded, collateralAsset, albert); + + // 2. Approve and supply collateral + await collateralAsset.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset.address, amount: collateralNeeded }); + + // 3. Borrow base (this will make albert have negative base balance) + await albert.withdrawAsset({ asset: baseToken, amount: targetBorrowBaseWei }); + + // Set up betty's base token supply for forcing accrue + // Betty needs base tokens supplied to Comet to be able to withdraw them + const bettyBaseAmount = BigInt(getConfigForScenario(context).withdrawBase) * baseScale; + const baseAsset = context.getAssetByAddress(baseToken); + await context.sourceTokens(bettyBaseAmount, baseAsset, betty); + await baseAsset.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken, amount: bettyBaseAmount }); + + // Ensure account is liquidatable by waiting for time to pass and accruing interest + const timeBeforeLiquidation = await timeUntilUnderwater({ + comet, + actor: albert, + fudgeFactor: 6000n * 6000n // 1 hour past when position is underwater + }); + + while(!(await comet.isLiquidatable(albert.address))) { + await comet.accrueAccount(albert.address); + await world.increaseTime(timeBeforeLiquidation); + } + + // Force accrue to ensure state is up to date + await betty.withdrawAsset({ asset: baseToken, amount: BigInt(getConfigForScenario(context).withdrawBase) / 100n * baseScale }); + + // Verify account is liquidatable + expect(await comet.isLiquidatable(albert.address)).to.be.true; + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAssetLiquidationFactor(comet.address, asset, 0n, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await proxyAdmin.connect(admin.signer).deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + // Verify liquidationFactor is 0 + expect((await comet.getAssetInfoByAddress(asset)).liquidationFactor).to.equal(0); + + expect(await comet.isLiquidatable(albert.address)).to.be.true; + + // Save balances before absorb + const userCollateralBefore = (await comet.userCollateral(albert.address, asset)).balance; + const totalsBefore = (await comet.totalsCollateral(asset)).totalSupplyAsset; + + await betty.absorb({ absorber: betty.address, accounts: [albert.address] }); + + expect((await comet.userCollateral(albert.address, asset)).balance).to.equal(userCollateralBefore); + expect((await comet.totalsCollateral(asset)).totalSupplyAsset).to.equal(totalsBefore); + } + ); +} + +/** + * @title Liquidation Scenario - Two collaterals, one with liquidationFactor = 0 + * @notice Tests that absorption correctly skips a non-liquidatable collateral while seizing the other + * + * @dev This scenario verifies the selective seizure behavior during absorption when an account + * holds two different collateral assets (asset0 and asset1) and one of them has its + * liquidationFactor set to 0 (simulating a de-listed asset whose price feed may be unavailable). + * + * @dev The test proceeds through the following phases: + * 1. Setup: Supply two collateral assets (asset0 and asset1) and borrow base tokens + * 2. Wait until the position becomes liquidatable through interest accrual + * 3. De-list asset0 by setting its liquidationFactor to 0 via governance (configurator + upgrade) + * 4. Absorb (liquidate) the account + * 5. Verify that: + * - Asset0 (liquidationFactor = 0) remains on the user's balance — it was NOT seized + * - Asset1 (normal liquidationFactor) was fully seized — its balance is now 0 + * + * @dev This proves that absorbInternal correctly skips non-liquidatable collateral (avoiding + * a getPrice() call on a potentially broken oracle) while still proceeding with seizure of + * all other liquidatable assets. The account's debt is absorbed regardless. + */ +scenario( + 'Comet#liquidation > two collaterals: asset0 (liqFactor=0) retained, asset1 absorbed', + { + filter: async (ctx) => + await isValidAssetIndex(ctx, 0) && + await isValidAssetIndex(ctx, 1) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx, 0).supplyCollateral) && + await isTriviallySourceable(ctx, 1, getConfigForScenario(ctx, 1).supplyCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + !(await isAssetDelisted(ctx, 1)) && + await supportsExtendedPause(ctx), + tokenBalances: async (ctx) => ({ + albert: { $base: '== 0' }, + $comet: { + $base: getConfigForScenario(ctx).withdrawBase + } + }), + }, + async ({ comet, configurator, proxyAdmin, actors }, context, world) => { + const { albert, betty, admin } = actors; + const baseToken = await comet.baseToken(); + const baseScale = (await comet.baseScale()).toBigInt(); + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const factorScale = (await comet.factorScale()).toBigInt(); + + // ── Step 1: Supply two different collateral assets ── + // Asset0 — this one will later be de-listed (liquidationFactor set to 0) + const assetInfo0 = await comet.getAssetInfo(0); + const collateralAsset0 = context.getAssetByAddress(assetInfo0.asset); + const collateralPrice0 = (await comet.getPrice(assetInfo0.priceFeed)).toBigInt(); + + // Asset1 — this one keeps normal parameters and should be seized during absorption + const assetInfo1 = await comet.getAssetInfo(1); + const collateralAsset1 = context.getAssetByAddress(assetInfo1.asset); + const collateralPrice1 = (await comet.getPrice(assetInfo1.priceFeed)).toBigInt(); + + // Calculate how much of each collateral to supply so that combined they cover the borrow. + // We split the borrow coverage roughly 50/50 between the two assets. + const targetBorrowBase = BigInt(getConfigForScenario(context).withdrawBase); + const targetBorrowBaseWei = targetBorrowBase * baseScale; + const halfBorrowWei = targetBorrowBaseWei / 2n; + + // Collateral needed for asset0 (covers ~half the borrow) + const collateralWeiPerUnitBase0 = (assetInfo0.scale.toBigInt() * basePrice) / collateralPrice0; + let collateralNeeded0 = (collateralWeiPerUnitBase0 * halfBorrowWei) / baseScale; + collateralNeeded0 = (collateralNeeded0 * factorScale) / assetInfo0.borrowCollateralFactor.toBigInt(); + collateralNeeded0 = (collateralNeeded0 * 12n) / 10n; // 20% buffer + + // Collateral needed for asset1 (covers ~half the borrow) + const collateralWeiPerUnitBase1 = (assetInfo1.scale.toBigInt() * basePrice) / collateralPrice1; + let collateralNeeded1 = (collateralWeiPerUnitBase1 * halfBorrowWei) / baseScale; + collateralNeeded1 = (collateralNeeded1 * factorScale) / assetInfo1.borrowCollateralFactor.toBigInt(); + collateralNeeded1 = (collateralNeeded1 * 12n) / 10n; // 20% buffer + + // Source, approve, and supply collateral asset0 + await context.sourceTokens(collateralNeeded0, collateralAsset0, albert); + await collateralAsset0.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset0.address, amount: collateralNeeded0 }); + + // Source, approve, and supply collateral asset1 + await context.sourceTokens(collateralNeeded1, collateralAsset1, albert); + await collateralAsset1.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset1.address, amount: collateralNeeded1 }); + + // ── Step 2: Borrow base tokens ── + // This creates a negative base balance, making the account a borrower + await albert.withdrawAsset({ asset: baseToken, amount: targetBorrowBaseWei }); + + // Verify initial state: position should be collateralized and not liquidatable + expect(await comet.isBorrowCollateralized(albert.address)).to.be.true; + expect(await comet.isLiquidatable(albert.address)).to.be.false; + + // Set up betty with base tokens so she can force accrue later + const bettyBaseAmount = BigInt(getConfigForScenario(context).withdrawBase) * baseScale; + const baseAsset = context.getAssetByAddress(baseToken); + await context.sourceTokens(bettyBaseAmount, baseAsset, betty); + await baseAsset.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken, amount: bettyBaseAmount }); + + // ── Step 3: Wait until the position becomes liquidatable via interest accrual ── + const timeBeforeLiquidation = await timeUntilUnderwater({ + comet, + actor: albert, + fudgeFactor: 6000n * 6000n // ~1 hour past underwater + }); + + while (!(await comet.isLiquidatable(albert.address))) { + await comet.accrueAccount(albert.address); + await world.increaseTime(timeBeforeLiquidation); + } + + // Force accrue to ensure state is up to date + await betty.withdrawAsset({ asset: baseToken, amount: BigInt(getConfigForScenario(context).withdrawBase) / 100n * baseScale }); + + expect(await comet.isLiquidatable(albert.address)).to.be.true; + + // ── Step 4: De-list asset0 by setting its liquidationFactor to 0 ── + // This simulates a governance action to de-list an asset whose price feed + // has become unavailable. After this, absorbInternal should skip asset0 + // entirely — not seize it, not call getPrice() on it. + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAssetLiquidationFactor( + comet.address, assetInfo0.asset, 0n, { gasPrice: 0 } + ); + await context.setNextBaseFeeToZero(); + await proxyAdmin.connect(admin.signer).deployAndUpgradeTo( + configurator.address, comet.address, { gasPrice: 0 } + ); + + // Verify liquidationFactor for asset0 is now 0 + const updatedAssetInfo0 = await comet.getAssetInfoByAddress(assetInfo0.asset); + expect(updatedAssetInfo0.liquidationFactor).to.equal(0); + + // Account should still be liquidatable (asset1 alone may not cover the debt, + // and asset0 no longer contributes to the liquidation threshold) + expect(await comet.isLiquidatable(albert.address)).to.be.true; + + // Record balances before absorption (we expect asset0 unchanged, asset1 fully seized) + const collateralBalance0_before = (await comet.userCollateral(albert.address, assetInfo0.asset)).balance; + const totalSupply0_before = (await comet.totalsCollateral(assetInfo0.asset)).totalSupplyAsset; + const collateralBalance1_before = (await comet.userCollateral(albert.address, assetInfo1.asset)).balance; + const totalSupply1_before = (await comet.totalsCollateral(assetInfo1.asset)).totalSupplyAsset; + + // ── Step 5: Absorb (liquidate) the account ── + await betty.absorb({ absorber: betty.address, accounts: [albert.address] }); + + // ── Step 6: Verify selective seizure ── + // Asset0 (liquidationFactor = 0): NOT seized — balance and totals unchanged. + // The collateral remains with the user because the protocol intentionally + // skips non-liquidatable assets during absorption. + expect((await comet.userCollateral(albert.address, assetInfo0.asset)).balance) + .to.equal(collateralBalance0_before); + expect((await comet.totalsCollateral(assetInfo0.asset)).totalSupplyAsset) + .to.equal(totalSupply0_before); + + // Asset1 (normal liquidationFactor): fully seized — balance is now 0 + // and totals decreased by the seized amount. This asset participated in + // the liquidation normally. + expect((await comet.userCollateral(albert.address, assetInfo1.asset)).balance) + .to.equal(0); + expect((await comet.totalsCollateral(assetInfo1.asset)).totalSupplyAsset) + .to.equal(totalSupply1_before.sub(collateralBalance1_before)); + + // Debt was absorbed: albert's base balance should be >= 0 + const baseBalance = await albert.getCometBaseBalance(); + expect(Number(baseBalance)).to.be.greaterThanOrEqual(0); + } +); + diff --git a/scenario/QuoteCollateralScenario.ts b/scenario/QuoteCollateralScenario.ts new file mode 100644 index 000000000..1ec3d1f16 --- /dev/null +++ b/scenario/QuoteCollateralScenario.ts @@ -0,0 +1,75 @@ +import { expect } from 'chai'; +import { CometContext, scenario } from './context/CometContext'; +import { MAX_ASSETS, isAssetDelisted, isValidAssetIndex, usesAssetList, supportsExtendedPause } from './utils'; + +/** + * @title Quote Collateral Scenario + * @notice Test suite for quoteCollateral behavior with and without liquidation discounts + * + * @dev This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that when a price feed becomes unavailable, the protocol cannot calculate the USD value + * of collateral (e.g., during absorption when trying to getPrice() for a delisted asset). + * + * @dev The solution was to set the asset's liquidationFactor to 0 for delisted collateral. For quoteCollateral, + * when liquidationFactor = 0, the store front discount becomes 0, and quoteCollateral quotes at market price + * without any discount (see quoteCollateral() in CometWithExtendedAssetList.sol) + * + * @dev This scenario tests quoteCollateral behavior in two phases: + * 1. Normal operation: Verifies that quoteCollateral applies the correct discount when liquidationFactor > 0 + * 2. Delisted asset: Sets liquidationFactor to 0 and verifies that quoteCollateral quotes at market price + * without discount, handling the transition correctly + * + * @dev The scenario runs for all valid assets (up to MAX_ASSETS) and only on Comet deployments that use + * the extended asset list feature (CometExtAssetList), as the quoteCollateral behavior with liquidationFactor = 0 + * is specific to that implementation. The test filters deployments using the usesAssetList() utility function + * to ensure compatibility, and excludes assets that are already delisted. + */ +for (let i = 0; i < MAX_ASSETS; i++) { + scenario( + `Comet#quoteCollateral > quotes with discount for asset ${i}`, + { + filter: async (ctx: CometContext) => await isValidAssetIndex(ctx, i) && await usesAssetList(ctx) && !(await isAssetDelisted(ctx, i)) && await supportsExtendedPause(ctx) + }, + async ({ comet, configurator, proxyAdmin, actors }, context) => { + const { admin } = actors; + const { asset } = await comet.getAssetInfo(i); + + // Get baseScale first to calculate proper QUOTE_AMOUNT + const baseScale = (await comet.baseScale()).toBigInt(); + // QUOTE_AMOUNT should be in base token units (e.g., 10000 * baseScale for 10000 base tokens) + const QUOTE_AMOUNT = BigInt(10000) * baseScale; + + // Get initial asset info and prices + let assetInfo = await comet.getAssetInfoByAddress(asset); + const assetPrice = (await comet.getPrice(assetInfo.priceFeed)).toBigInt(); + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const factorScale = (await comet.factorScale()).toBigInt(); + const assetScale = assetInfo.scale.toBigInt(); + const liquidationFactor = assetInfo.liquidationFactor.toBigInt(); + const storeFrontPriceFactor = (await comet.storeFrontPriceFactor()).toBigInt(); + + // First quote with discount + const quoteAmount = (await comet.quoteCollateral(asset, QUOTE_AMOUNT)).toBigInt(); + const discountFactor = storeFrontPriceFactor * (factorScale - liquidationFactor) / factorScale; + const assetPriceDiscounted = assetPrice * (factorScale - discountFactor) / factorScale; + const expectedQuoteWithDiscount = (basePrice * QUOTE_AMOUNT * assetScale) / assetPriceDiscounted / baseScale; + expect(quoteAmount).to.equal(expectedQuoteWithDiscount); + + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAssetLiquidationFactor(comet.address, asset, 0n, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await proxyAdmin.connect(admin.signer).deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + assetInfo = await comet.getAssetInfoByAddress(asset); + expect(assetInfo.liquidationFactor).to.equal(0); + + // Second quote without discount + const quoteAmountWithoutDiscount = (await comet.quoteCollateral(asset, QUOTE_AMOUNT)).toBigInt(); + // When liquidationFactor = 0, no discount is applied, so use assetPrice directly + const expectedQuoteWithoutDiscount = (basePrice * QUOTE_AMOUNT * assetInfo.scale.toBigInt()) / assetPrice / baseScale; + // Verify quote calculation + expect(quoteAmountWithoutDiscount).to.be.closeTo(expectedQuoteWithoutDiscount, BigInt(1e18)); + } + ); +} + diff --git a/scenario/SupplyScenario.ts b/scenario/SupplyScenario.ts index be3be8537..843af42ca 100644 --- a/scenario/SupplyScenario.ts +++ b/scenario/SupplyScenario.ts @@ -1,11 +1,12 @@ import { CometContext, scenario } from './context/CometContext'; import { expect } from 'chai'; -import { expectApproximately, expectBase, expectRevertCustom, expectRevertMatches, getExpectedBaseBalance, getInterest, isTriviallySourceable, isValidAssetIndex, MAX_ASSETS, UINT256_MAX } from './utils'; +import { expectApproximately, expectBase, expectRevertCustom, expectRevertMatches, getExpectedBaseBalance, getInterest, isTriviallySourceable, isValidAssetIndex, MAX_ASSETS, UINT256_MAX, fundAccount, usesAssetList, isAssetDelisted, supportsExtendedPause } from './utils'; import { ContractReceipt } from 'ethers'; import { matchesDeployment } from './utils'; import { exp } from '../test/helpers'; import { ethers } from 'hardhat'; import { getConfigForScenario } from './utils/scenarioHelper'; +import { log } from 'console'; // XXX introduce a SupplyCapConstraint to separately test the happy path and revert path instead // of testing them conditionally @@ -406,12 +407,20 @@ scenario( scenario( 'Comet#supplyFrom > repay borrow', { - tokenBalances: { - albert: { $base: 1010 } - }, - cometBalances: { - betty: { $base: '<= -1000' } // in units of asset, not wei - }, + tokenBalances: async (ctx) => ( + { + albert: { + $base: getConfigForScenario(ctx).supplyBase + (0.01 * getConfigForScenario(ctx).supplyBase) + } + } + ), + cometBalances: async (ctx) => ( + { + betty: { + $base: `<= -${getConfigForScenario(ctx).supplyBase}` + } + } + ), }, async ({ comet, actors }, context) => { const { albert, betty } = actors; @@ -699,4 +708,699 @@ scenario( } ); -// XXX enforce supply cap \ No newline at end of file +scenario( + 'Comet#supply reverts when base supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause base supply + await cometExt.connect(pauseGuardian.signer).pauseBaseSupply(true); + + await baseAsset.approve(albert, comet.address); + await expectRevertCustom( + albert.supplyAsset({ + asset: baseAsset.address, + amount: BigInt(getConfigForScenario(context).transferBase) * scale, + }), + 'BaseSupplyPaused()' + ); + } +); + +scenario( + 'Comet#supply reverts when collateral supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).supplyCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).supplyCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral supply + await cometExt.connect(pauseGuardian.signer).pauseCollateralSupply(true); + + await collateralAsset.approve(albert, comet.address); + await expectRevertCustom( + albert.supplyAsset({ + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).supplyCollateral) * scale, + }), + 'CollateralSupplyPaused()' + ); + } +); + +scenario( + 'Comet#supplyTo reverts when base supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause base supply + await cometExt.connect(pauseGuardian.signer).pauseBaseSupply(true); + + await baseAsset.approve(albert, comet.address); + await expectRevertCustom( + comet.connect(albert.signer).supplyTo(betty.address, baseAsset.address, BigInt(getConfigForScenario(context).transferBase) * scale), + 'BaseSupplyPaused()' + ); + } +); + +scenario( + 'Comet#supplyTo reverts when collateral supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).supplyCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).supplyCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral supply + await cometExt.connect(pauseGuardian.signer).pauseCollateralSupply(true); + + await collateralAsset.approve(albert, comet.address); + await expectRevertCustom( + comet.connect(albert.signer).supplyTo(betty.address, collateralAsset.address, BigInt(getConfigForScenario(context).supplyCollateral) * scale), + 'CollateralSupplyPaused()' + ); + } +); + +scenario( + 'Comet#supplyTo reverts when specific collateral asset supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).supplyCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).supplyCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause specific collateral asset supply + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(0, true); + + await collateralAsset.approve(albert, comet.address); + await expectRevertCustom( + comet.connect(albert.signer).supplyTo(betty.address, collateralAsset.address, BigInt(getConfigForScenario(context).supplyCollateral) * scale), + 'CollateralAssetSupplyPaused(0)' + ); + } +); + +scenario( + 'Comet#supplyFrom reverts when base supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, charles, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + + await baseAsset.approve(albert, comet.address); + await albert.allow(charles, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause base supply + await cometExt.connect(pauseGuardian.signer).pauseBaseSupply(true); + + await expectRevertCustom( + charles.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: baseAsset.address, + amount: BigInt(getConfigForScenario(context).transferBase) * scale, + }), + 'BaseSupplyPaused()' + ); + } +); + +scenario( + 'Comet#supplyFrom reverts when collateral supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).supplyCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).supplyCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, charles, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + await collateralAsset.approve(albert, comet.address); + await albert.allow(charles, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral supply + await cometExt.connect(pauseGuardian.signer).pauseCollateralSupply(true); + + await expectRevertCustom( + charles.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).supplyCollateral) * scale, + }), + 'CollateralSupplyPaused()' + ); + } +); + +scenario( + 'Comet#supplyFrom reverts when specific collateral asset supply is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).supplyCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).supplyCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, charles, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + await collateralAsset.approve(albert, comet.address); + await albert.allow(charles, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause specific collateral asset supply + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(0, true); + + await expectRevertCustom( + charles.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).supplyCollateral) * scale, + }), + 'CollateralAssetSupplyPaused(0)' + ); + } +); + +scenario( + 'Comet#supply reverts when collateral asset supply is paused and allows to supply when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).supplyCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const supplyCollateral = BigInt(getConfigForScenario(context).supplyCollateral) * scale; + + log(`Supplying reverts when collateral asset ${i} supply is paused`); + + // Source collateral asset + await context.sourceTokens(supplyCollateral, collateralAsset.address, albert.address); + + // Pause specific collateral asset supply at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, true); + + await collateralAsset.approve(albert, comet.address); + await expectRevertCustom( + albert.supplyAsset({ + asset: collateralAsset.address, + amount: supplyCollateral, + }), + `CollateralAssetSupplyPaused(${i})` + ); + + log(`Supplying is allowed when collateral asset ${i} supply is unpaused`); + + // Unpause specific collateral asset supply at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, false); + + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: supplyCollateral, + }); + + expect(await comet.collateralBalanceOf( + albert.address, + collateralAsset.address + )).to.be.equal(supplyCollateral); + } + } +); + +scenario( + 'Comet#supplyTo reverts when collateral asset supply is paused and allows to supply when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).supplyCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const supplyCollateral = BigInt(getConfigForScenario(context).supplyCollateral) * scale; + + log(`Supplying reverts when collateral asset ${i} supply is paused`); + + // Source collateral asset + await context.sourceTokens(supplyCollateral, collateralAsset.address, albert.address); + + // Pause specific collateral asset supply at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, true); + + await collateralAsset.approve(albert, comet.address); + await expectRevertCustom( + albert.supplyAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: supplyCollateral, + }), + `CollateralAssetSupplyPaused(${i})` + ); + + log(`Supplying is allowed when collateral asset ${i} supply is unpaused`); + + // Unpause specific collateral asset supply at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, false); + + await albert.safeSupplyAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: supplyCollateral, + }); + + expect(await comet.collateralBalanceOf( + betty.address, + collateralAsset.address + )).to.be.equal(supplyCollateral); + } + } +); + +scenario( + 'Comet#supplyFrom reverts when collateral asset supply is paused and allows to supply when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).supplyCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const supplyCollateral = BigInt(getConfigForScenario(context).supplyCollateral) * scale; + + log(`Supplying reverts when collateral asset ${i} supply is paused`); + + // Source collateral asset + await context.sourceTokens(supplyCollateral, collateralAsset.address, albert.address); + + // Pause specific collateral asset supply at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, true); + + await collateralAsset.approve(albert, comet.address); + await albert.allow(betty, true); + + await expectRevertCustom( + betty.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: supplyCollateral, + }), + `CollateralAssetSupplyPaused(${i})` + ); + + log(`Supplying is allowed when collateral asset ${i} supply is unpaused`); + + // Unpause specific collateral asset supply at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, false); + + await betty.safeSupplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: supplyCollateral, + }); + + expect(await comet.collateralBalanceOf( + betty.address, + collateralAsset.address + )).to.be.equal(supplyCollateral); + } + } +); + + +/*////////////////////////////////////////////////////////////// + DEACTIVATE/ACTIVATE COLLATERALS +//////////////////////////////////////////////////////////////*/ + +scenario('Comet#supply reverts when collateral asset is deactivated and allows to supply when activated', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, async ({ comet, actors, cometExt }, context, world) => { + const { pauseGuardian, albert } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).supplyCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBigNumber } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBigNumber.toBigInt(); + const supplyAmount = BigInt(getConfigForScenario(context).supplyCollateral) * scale; + + log(`Supply reverts when collateral asset ${i} is deactivated`); + + // Source collateral asset + await context.sourceTokens(supplyAmount, collateralAsset.address, albert.address); + + // Approve the asset for supply + await collateralAsset.approve(albert, comet.address); + + // Deactivate collateral asset + await cometExt.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expectRevertCustom( + albert.safeSupplyAsset({ + asset: asset, + amount: supplyAmount, + }), + `CollateralAssetSupplyPaused(${i})` + ); + + log(`Supply is allowed when collateral asset ${i} is activated`); + + // Activate collateral asset + await cometExt.connect(pauseGuardian.signer).activateCollateral(i); + + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: supplyAmount, + }); + + expect(await comet.collateralBalanceOf( + albert.address, + collateralAsset.address + )).to.be.equal(supplyAmount); + } + } +); + +scenario( + 'Comet#supplyTo reverts when collateral asset is deactivated and allows to supply when activated', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { pauseGuardian, albert, betty } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).supplyCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBigNumber } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBigNumber.toBigInt(); + const supplyAmount = BigInt(getConfigForScenario(context).supplyCollateral) * scale; + + log(`SupplyTo reverts when collateral asset ${i} is deactivated`); + + // Source collateral asset + await context.sourceTokens(supplyAmount, collateralAsset.address, albert.address); + + // Approve the asset for supply + await collateralAsset.approve(albert, comet.address); + + // Deactivate collateral asset + await cometExt.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expectRevertCustom( + albert.safeSupplyAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: supplyAmount, + }), + `CollateralAssetSupplyPaused(${i})` + ); + + log(`SupplyTo is allowed when collateral asset ${i} is activated`); + + // Activate collateral asset + await cometExt.connect(pauseGuardian.signer).activateCollateral(i); + + await albert.safeSupplyAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: supplyAmount, + }); + + expect(await comet.collateralBalanceOf( + betty.address, + collateralAsset.address + )).to.be.equal(supplyAmount); + } + } +); + +scenario( + 'Comet#supplyFrom reverts when collateral asset is deactivated and allows to supply when activated', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { pauseGuardian, albert, betty } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Allow betty to act on behalf of albert + await albert.allow(betty, true); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).supplyCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBigNumber } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBigNumber.toBigInt(); + const supplyAmount = BigInt(getConfigForScenario(context).supplyCollateral) * scale; + + log(`SupplyFrom reverts when collateral asset ${i} is deactivated`); + + // Source collateral asset + await context.sourceTokens(supplyAmount, collateralAsset.address, albert.address); + + // Approve the asset for supply + await collateralAsset.approve(albert, comet.address); + + // Deactivate collateral asset + await cometExt.connect(pauseGuardian.signer).deactivateCollateral(i); + + + + await expectRevertCustom( + betty.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: supplyAmount, + }), + `CollateralAssetSupplyPaused(${i})` + ); + + log(`SupplyFrom is allowed when collateral asset ${i} is activated`); + + // Activate collateral asset + await cometExt.connect(pauseGuardian.signer).activateCollateral(i); + + await betty.safeSupplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: supplyAmount, + }); + + expect(await comet.collateralBalanceOf( + betty.address, + collateralAsset.address + )).to.be.equal(supplyAmount); + } + } +); diff --git a/scenario/TransferScenario.ts b/scenario/TransferScenario.ts index e5c94310b..8fd0eb12a 100644 --- a/scenario/TransferScenario.ts +++ b/scenario/TransferScenario.ts @@ -1,8 +1,9 @@ import { CometContext, scenario } from './context/CometContext'; import { expect } from 'chai'; -import { expectApproximately, expectBase, expectRevertCustom, getInterest, hasMinBorrowGreaterThanOne, isTriviallySourceable, isValidAssetIndex, MAX_ASSETS } from './utils'; +import { expectApproximately, expectBase, expectRevertCustom, getInterest, hasMinBorrowGreaterThanOne, isTriviallySourceable, isValidAssetIndex, MAX_ASSETS, fundAccount, usesAssetList, isAssetDelisted, supportsExtendedPause } from './utils'; import { ContractReceipt } from 'ethers'; import { getConfigForScenario } from './utils/scenarioHelper'; +import { log } from 'console'; async function testTransferCollateral(context: CometContext, assetNum: number): Promise { const comet = await context.getComet(); @@ -180,32 +181,35 @@ scenario( scenario( 'Comet#transferFrom > withdraw to repay', { - cometBalances: { - albert: { $base: 1000, $asset0: 50 }, // in units of asset, not wei - betty: { $base: -1000 }, - charles: { $base: 1000 }, // to give the protocol enough base for others to borrow from - }, + cometBalances: async (ctx) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase, $asset0: getConfigForScenario(ctx).transferAsset2 }, // in units of asset, not wei + betty: { $base: -getConfigForScenario(ctx).transferBase }, + charles: { $base: getConfigForScenario(ctx).transferBase }, // to give the protocol enough base for others to borrow from + } + ), }, async ({ comet, actors }, context) => { const { albert, betty } = actors; const baseAssetAddress = await comet.baseToken(); const baseAsset = context.getAssetByAddress(baseAssetAddress); const scale = (await comet.baseScale()).toBigInt(); + const amountTransferred = BigInt(getConfigForScenario(context).transferBase) * scale; const utilization = await comet.getUtilization(); const borrowRate = (await comet.getBorrowRate(utilization)).toBigInt(); // XXX 70 seconds?! - expectApproximately(await albert.getCometBaseBalance(), 1000n * scale, getInterest(1000n * scale, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); - expectApproximately(await betty.getCometBaseBalance(), -1000n * scale, getInterest(1000n * scale, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await albert.getCometBaseBalance(), amountTransferred, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await betty.getCometBaseBalance(), -amountTransferred, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); await albert.allow(betty, true); // Betty withdraws from Albert to repay her own borrows - const toTransfer = 999n * scale; // XXX cannot withdraw 1000 (to ~0) + const toTransfer = amountTransferred - scale; // XXX cannot withdraw 1000 (to ~0) const txn = await betty.transferAssetFrom({ src: albert.address, dst: betty.address, asset: baseAsset.address, amount: toTransfer }); - expectApproximately(await albert.getCometBaseBalance(), scale, getInterest(1000n * scale, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); - expectApproximately(await betty.getCometBaseBalance(), -scale, getInterest(1000n * scale, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await albert.getCometBaseBalance(), scale, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await betty.getCometBaseBalance(), -scale, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); return txn; // return txn to measure gas } @@ -214,26 +218,29 @@ scenario( scenario( 'Comet#transfer base reverts if undercollateralized', { - cometBalances: { - albert: { $base: 1000, $asset0: 0.000001 }, // in units of asset, not wei - betty: { $base: -1000 }, - charles: { $base: 1000 }, // to give the protocol enough base for others to borrow from - }, + cometBalances: async (ctx) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase, $asset0: 0.000001 }, // in units of asset, not wei + betty: { $base: -getConfigForScenario(ctx).transferBase }, + charles: { $base: getConfigForScenario(ctx).transferBase }, // to give the protocol enough base for others to borrow from + } + ), }, async ({ comet, actors }, context) => { const { albert, betty } = actors; const baseAssetAddress = await comet.baseToken(); const baseAsset = context.getAssetByAddress(baseAssetAddress); const scale = (await comet.baseScale()).toBigInt(); + const amountTransferred = BigInt(getConfigForScenario(context).transferBase) * scale; const utilization = await comet.getUtilization(); const borrowRate = (await comet.getBorrowRate(utilization)).toBigInt(); // XXX 100 seconds?! - expectApproximately(await albert.getCometBaseBalance(), 1000n * scale, getInterest(1000n * scale, borrowRate, 100n) + 2n); - expectApproximately(await betty.getCometBaseBalance(), -1000n * scale, getInterest(1000n * scale, borrowRate, 100n) + 2n); + expectApproximately(await albert.getCometBaseBalance(), amountTransferred, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await betty.getCometBaseBalance(), -amountTransferred, getInterest(amountTransferred, borrowRate, 100n) + 2n); // Albert with positive balance transfers to Betty with negative balance - const toTransfer = 2001n * scale; // XXX min borrow... + const toTransfer = 2n*amountTransferred + scale; // XXX min borrow... await expectRevertCustom( albert.transferAsset({ dst: betty.address, @@ -248,28 +255,31 @@ scenario( scenario( 'Comet#transferFrom base reverts if undercollateralized', { - cometBalances: { - albert: { $base: 1000, $asset0: 0.000001 }, // in units of asset, not wei - betty: { $base: -1000 }, - charles: { $base: 1000 }, // to give the protocol enough base for others to borrow from - }, + cometBalances: async (ctx) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase, $asset0: 0.000001 }, // in units of asset, not wei + betty: { $base: -getConfigForScenario(ctx).transferBase }, + charles: { $base: getConfigForScenario(ctx).transferBase }, // to give the protocol enough base for others to borrow from + } + ), }, async ({ comet, actors }, context) => { const { albert, betty } = actors; const baseAssetAddress = await comet.baseToken(); const baseAsset = context.getAssetByAddress(baseAssetAddress); const scale = (await comet.baseScale()).toBigInt(); + const amountTransferred = BigInt(getConfigForScenario(context).transferBase) * scale; const utilization = await comet.getUtilization(); const borrowRate = (await comet.getBorrowRate(utilization)).toBigInt(); // XXX 70 seconds?! - expectApproximately(await albert.getCometBaseBalance(), 1000n * scale, getInterest(1000n * scale, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); - expectApproximately(await betty.getCometBaseBalance(), -1000n * scale, getInterest(1000n * scale, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await albert.getCometBaseBalance(), amountTransferred, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); + expectApproximately(await betty.getCometBaseBalance(), -amountTransferred, getInterest(amountTransferred, borrowRate, BigInt(getConfigForScenario(context).interestSeconds)) + 2n); await albert.allow(betty, true); // Albert with positive balance transfers to Betty with negative balance - const toTransfer = 2001n * scale; // XXX min borrow... + const toTransfer = 2n*amountTransferred + scale; // XXX min borrow... await expectRevertCustom( betty.transferAssetFrom({ src: albert.address, @@ -527,4 +537,626 @@ scenario( 'BorrowTooSmall()' ); } +); + +scenario( + 'Comet#transfer reverts when collateral transfer is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).transferCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral transfer + await cometExt.connect(pauseGuardian.signer).pauseCollateralTransfer(true); + + await expectRevertCustom( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).transferCollateral) * scale + }), + 'CollateralTransferPaused()' + ); + } +); + +scenario( + 'Comet#transferFrom reverts when collateral transfer is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).transferCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, charles, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + await albert.allow(betty, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral transfer + await cometExt.connect(pauseGuardian.signer).pauseCollateralTransfer(true); + + await expectRevertCustom( + betty.transferAssetFrom({ + src: albert.address, + dst: charles.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).transferCollateral) * scale + }), + 'CollateralTransferPaused()' + ); + } +); + +scenario( + 'Comet#transfer reverts when borrowers transfer is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: '== 0' }, + betty: { $base: getConfigForScenario(ctx).transferBase } + } + ), + cometBalances: async (ctx: CometContext) => ( + { + albert: { $base: -getConfigForScenario(ctx).transferBase, $asset0: getConfigForScenario(ctx).transferAsset }, + charles: { $base: getConfigForScenario(ctx).transferBase } // to give the protocol enough base for others to borrow from + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause borrowers transfer + await cometExt.connect(pauseGuardian.signer).pauseBorrowersTransfer(true); + + await expectRevertCustom( + albert.transferAsset({ + dst: betty.address, + asset: baseAsset.address, + amount: BigInt(getConfigForScenario(context).transferBase) * scale + }), + 'BorrowersTransferPaused()' + ); + } +); + +scenario( + 'Comet#transferFrom reverts when borrowers transfer is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: '== 0' }, + $comet: { $base: getConfigForScenario(ctx).transferBase } + } + ), + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).transferAsset } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + + await albert.allow(betty, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause borrowers transfer + await cometExt.connect(pauseGuardian.signer).pauseBorrowersTransfer(true); + + await expectRevertCustom( + betty.transferAssetFrom({ + src: albert.address, + dst: betty.address, + asset: baseAsset.address, + amount: BigInt(getConfigForScenario(context).transferBase) * scale + }), + 'BorrowersTransferPaused()' + ); + } +); + +scenario( + 'Comet#transfer reverts when lenders transfer is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const baseSupplied = (await comet.balanceOf(albert.address)).toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause lenders transfer + await cometExt.connect(pauseGuardian.signer).pauseLendersTransfer(true); + + await expectRevertCustom( + albert.transferAsset({ + dst: betty.address, + asset: baseAsset.address, + amount: baseSupplied + }), + 'LendersTransferPaused()' + ); + } +); + +scenario( + 'Comet#transferFrom reverts when lenders transfer is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).transferBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const baseSupplied = (await comet.balanceOf(albert.address)).toBigInt(); + + + await albert.allow(betty, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause lenders transfer + await cometExt.connect(pauseGuardian.signer).pauseLendersTransfer(true); + + await expectRevertCustom( + betty.transferAssetFrom({ + src: albert.address, + dst: betty.address, + asset: baseAsset.address, + amount: baseSupplied + }), + 'LendersTransferPaused()' + ); + } +); + +scenario( + 'Comet#transfer reverts when specific collateral asset is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).transferCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { + $asset0: getConfigForScenario(ctx).transferCollateral + } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause only asset0 transfer + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(0, true); + + // Asset0 transfer should revert + await expectRevertCustom( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).transferCollateral) * scale + }), + 'CollateralAssetTransferPaused(0)' + ); + } +); + + +scenario( + 'Comet#transfer reverts when collateral asset transfer is paused and allows to transfer when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).transferCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const transferCollateral = BigInt(getConfigForScenario(context).transferCollateral) * scale; + + log(`Transferring reverts when collateral asset ${i} transfer is paused`); + + // Source collateral asset + await context.sourceTokens(transferCollateral, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral asset + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: transferCollateral, + }); + + // Pause specific collateral asset transfer at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(i, true); + + await expectRevertCustom( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: transferCollateral, + }), + `CollateralAssetTransferPaused(${i})` + ); + + log(`Transferring is allowed when collateral asset ${i} transfer is unpaused`); + + // Unpause specific collateral asset transfer at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(i, false); + + // Save balances + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceBefore = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + + // Transfer asset from albert to betty + await albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: transferCollateral, + }); + + // Get balances after transfer + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceAfter = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + + // Assert balances after transfer + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - transferCollateral); + expect(bettyBalanceAfter).to.be.equal(bettyBalanceBefore.toBigInt() + transferCollateral); + } + } +); + +scenario( + 'Comet#transferFrom reverts when collateral asset transfer is paused and allows to transfer when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).transferCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const transferCollateral = BigInt(getConfigForScenario(context).transferCollateral) * scale; + + log(`Transferring reverts when collateral asset ${i} transfer is paused`); + + // Fund pause guardian account for gas fees + await context.sourceTokens(transferCollateral, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral asset + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: transferCollateral, + }); + + // Pause specific collateral asset transfer at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(i, true); + + // Allow betty to transfer asset from albert + await albert.allow(betty, true); + + await expectRevertCustom( + betty.transferAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: transferCollateral, + }), + `CollateralAssetTransferPaused(${i})` + ); + + log(`Transferring is allowed when collateral asset ${i} transfer is unpaused`); + + // Unpause specific collateral asset transfer at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(i, false); + + // Save balances + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceBefore = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + + // Transfer asset from albert to betty + await betty.transferAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).transferCollateral) * scale, + }); + + // Get balances after transfer + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceAfter = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + + // Assert balances after transfer + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - transferCollateral); + expect(bettyBalanceAfter).to.be.equal(bettyBalanceBefore.toBigInt() + transferCollateral); + } + } +); + +/*////////////////////////////////////////////////////////////// + DEACTIVATE/ACTIVATE COLLATERALS +//////////////////////////////////////////////////////////////*/ + +scenario( + 'Comet#transferFrom reverts when collateral asset is deactivated and allows to transfer when activated', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, charles, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Allow betty to act on behalf of albert + await albert.allow(betty, true); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).transferCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const transferAmount = BigInt(getConfigForScenario(context).transferCollateral) * scale; + + log(`TransferFrom reverts when collateral asset ${i} is deactivated`); + + // Source collateral asset + await context.sourceTokens(transferAmount, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: transferAmount, + }); + + // Deactivate collateral asset + await cometExt.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expectRevertCustom( + betty.transferAssetFrom({ + src: albert.address, + dst: charles.address, + asset: collateralAsset.address, + amount: transferAmount, + }), + `CollateralAssetTransferPaused(${i})` + ); + + // Activate collateral asset + await cometExt.connect(pauseGuardian.signer).activateCollateral(i); + + log(`TransferFrom is allowed when collateral asset ${i} is activated`); + + // Save balances + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const charlesBalanceBefore = await comet.collateralBalanceOf(charles.address, collateralAsset.address); + + await betty.transferAssetFrom({ + src: albert.address, + dst: charles.address, + asset: collateralAsset.address, + amount: transferAmount, + }); + + // Get balances after transfer + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const charlesBalanceAfter = await comet.collateralBalanceOf(charles.address, collateralAsset.address); + + // Assert balances after transfer + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - transferAmount); + expect(charlesBalanceAfter).to.be.equal(charlesBalanceBefore.toBigInt() + transferAmount); + } + } +); + +scenario( + 'Comet#transfer reverts when collateral asset is deactivated and allows to transfer when activated', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).transferCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const transferAmount = BigInt(getConfigForScenario(context).transferCollateral) * scale; + + log(`Transfer reverts when collateral asset ${i} is deactivated`); + + // Source collateral asset + await context.sourceTokens(transferAmount, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: transferAmount, + }); + + // Deactivate collateral asset + await cometExt.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expectRevertCustom( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: transferAmount, + }), + `CollateralAssetTransferPaused(${i})` + ); + + // Activate collateral asset + await cometExt.connect(pauseGuardian.signer).activateCollateral(i); + + log(`Transfer is allowed when collateral asset ${i} is activated`); + + // Save balances + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceBefore = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + + await albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: transferAmount, + }); + + // Get balances after transfer + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceAfter = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + + // Assert balances after transfer + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - transferAmount); + expect(bettyBalanceAfter).to.be.equal(bettyBalanceBefore.toBigInt() + transferAmount); + } + } ); \ No newline at end of file diff --git a/scenario/WithdrawScenario.ts b/scenario/WithdrawScenario.ts index d04b2d02e..ec3a9086f 100644 --- a/scenario/WithdrawScenario.ts +++ b/scenario/WithdrawScenario.ts @@ -1,8 +1,9 @@ import { CometContext, scenario } from './context/CometContext'; import { expect } from 'chai'; -import { expectApproximately, expectRevertCustom, hasMinBorrowGreaterThanOne, isTriviallySourceable, isValidAssetIndex, MAX_ASSETS } from './utils'; +import { expectApproximately, expectRevertCustom, hasMinBorrowGreaterThanOne, isTriviallySourceable, isValidAssetIndex, MAX_ASSETS, fundAccount, usesAssetList, isAssetDelisted, supportsExtendedPause } from './utils'; import { ContractReceipt } from 'ethers'; import { getConfigForScenario } from './utils/scenarioHelper'; +import { log } from 'console'; async function testWithdrawCollateral(context: CometContext, assetNum: number): Promise { const comet = await context.getComet(); @@ -280,6 +281,305 @@ scenario( } ); +scenario( + 'Comet#withdraw reverts when collateral withdraw is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).withdrawCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral withdraw + await cometExt.connect(pauseGuardian.signer).pauseCollateralWithdraw(true); + + await expectRevertCustom( + albert.withdrawAsset({ + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).withdrawCollateral) * scale + }), + 'CollateralWithdrawPaused()' + ); + } +); + +scenario( + 'Comet#withdrawFrom reverts when collateral withdraw is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).withdrawCollateral } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + await albert.allow(betty, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause collateral withdraw + await cometExt.connect(pauseGuardian.signer).pauseCollateralWithdraw(true); + + await expectRevertCustom( + betty.withdrawAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).withdrawCollateral) * scale + }), + 'CollateralWithdrawPaused()' + ); + } +); + +scenario( + 'Comet#withdraw reverts when borrowers withdraw is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: '== 0' }, + $comet: { $base: getConfigForScenario(ctx).withdrawBase } + } + ), + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).withdrawAsset } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause borrowers withdraw + await cometExt.connect(pauseGuardian.signer).pauseBorrowersWithdraw(true); + + await expectRevertCustom( + albert.withdrawAsset({ + asset: baseAsset.address, + amount: BigInt(getConfigForScenario(context).withdrawBase) * scale + }), + 'BorrowersWithdrawPaused()' + ); + } +); + +scenario( + 'Comet#withdrawFrom reverts when borrowers withdraw is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: '== 0' }, + $comet: { $base: getConfigForScenario(ctx).withdrawBase } + } + ), + cometBalances: async (ctx: CometContext) => ( + { + albert: { $asset0: getConfigForScenario(ctx).withdrawAsset } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + + await albert.allow(betty, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause borrowers withdraw + await cometExt.connect(pauseGuardian.signer).pauseBorrowersWithdraw(true); + + await expectRevertCustom( + betty.withdrawAssetFrom({ + src: albert.address, + dst: betty.address, + asset: baseAsset.address, + amount: BigInt(getConfigForScenario(context).withdrawBase) * scale + }), + 'BorrowersWithdrawPaused()' + ); + } +); + +scenario( + 'Comet#withdraw reverts when lenders withdraw is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).withdrawBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const baseSupplied = (await comet.balanceOf(albert.address)).toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause lenders withdraw + await cometExt.connect(pauseGuardian.signer).pauseLendersWithdraw(true); + + await expectRevertCustom( + albert.withdrawAsset({ + asset: baseAsset.address, + amount: baseSupplied + }), + 'LendersWithdrawPaused()' + ); + } +); + +scenario( + 'Comet#withdrawFrom reverts when lenders withdraw is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawBase) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { $base: getConfigForScenario(ctx).withdrawBase } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const baseSupplied = (await comet.balanceOf(albert.address)).toBigInt(); + + + await albert.allow(betty, true); + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause lenders withdraw + await cometExt.connect(pauseGuardian.signer).pauseLendersWithdraw(true); + + await expectRevertCustom( + betty.withdrawAssetFrom({ + src: albert.address, + dst: betty.address, + asset: baseAsset.address, + amount: baseSupplied + }), + 'LendersWithdrawPaused()' + ); + } +); + +scenario( + 'Comet#withdraw reverts when specific collateral asset is paused', + { + filter: async (ctx: CometContext) => { + return await isValidAssetIndex(ctx, 0) && + await isTriviallySourceable(ctx, 0, getConfigForScenario(ctx).withdrawCollateral) && + await usesAssetList(ctx) && + !(await isAssetDelisted(ctx, 0)) && + await supportsExtendedPause(ctx); + }, + cometBalances: async (ctx: CometContext) => ( + { + albert: { + $asset0: getConfigForScenario(ctx).withdrawCollateral + } + } + ), + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + const { asset, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Pause only asset0 withdraw + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(0, true); + + // Asset0 withdraw should revert + await expectRevertCustom( + albert.withdrawAsset({ + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).withdrawCollateral) * scale + }), + 'CollateralAssetWithdrawPaused(0)' + ); + } +); + scenario( 'Comet#withdraw base reverts if position is undercollateralized', { @@ -370,4 +670,339 @@ scenario.skip( async () => { // XXX fix for development base, where Faucet token doesn't give the same revert message } -); \ No newline at end of file +); + +/** + * @title Withdraw Scenario - isBorrowCollateralized with borrowCollateralFactor = 0 + * @notice Test suite for isBorrowCollateralized behavior when borrowCollateralFactor is set to 0 + * + * @dev This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that when a price feed becomes unavailable, the protocol cannot calculate the USD value + * of collateral (e.g., during absorption when trying to getPrice() for a delisted asset). + * + * @dev The solution was to set the asset's borrowCollateralFactor to 0 for delisted collateral. For isBorrowCollateralized, + * when borrowCollateralFactor = 0, the contract skips that asset in the liquidity calculation (see CometWithExtendedAssetList.sol + * lines 402-405), effectively excluding it from contributing to the user's collateralization. This prevents the protocol + * from calling getPrice() on unavailable price feeds. + * + * @dev This scenario tests isBorrowCollateralized behavior in two phases: + * 1. Normal operation: Verifies that positions with positive borrowCF are properly collateralized and can borrow + * 2. Delisted asset: Sets borrowCF to 0 and verifies that the collateral is excluded from liquidity calculations, + * causing positions to become undercollateralized and preventing further borrowing when their only collateral asset is delisted + * + * @dev Unlike isLiquidatable which uses liquidateCollateralFactor, this function determines whether a user can initiate + * new borrows, making it critical for preventing new positions from being opened with unpriceable collateral. + * + * @dev The scenario runs for all valid assets (up to MAX_ASSETS) and only on Comet deployments that use + * the extended asset list feature (CometExtAssetList), as the borrowCollateralFactor = 0 behavior is specific + * to that implementation. The base Comet contract does not have this check and will attempt to call getPrice() + * even when borrowCF=0, which would cause a revert if the price feed is unavailable. The test filters deployments + * using the usesAssetList() utility function to ensure compatibility, and excludes assets that are already delisted. + */ +for (let i = 0; i < MAX_ASSETS; i++) { + scenario( + `Comet#isBorrowCollateralized > skips liquidity of asset ${i} with borrowCF=0`, + { + filter: async (ctx) => await isValidAssetIndex(ctx, i) && await isTriviallySourceable(ctx, i, getConfigForScenario(ctx, i).supplyCollateral) && await usesAssetList(ctx) && !(await isAssetDelisted(ctx, i)) && await supportsExtendedPause(ctx), + tokenBalances: async (ctx: CometContext) => ( + { + albert: { $base: '== 0' }, + $comet: { $base: getConfigForScenario(ctx, i).withdrawBase }, + } + ), + }, + async ({ comet, configurator, proxyAdmin, actors }, context) => { + const { albert, admin } = actors; + const { asset, borrowCollateralFactor, priceFeed, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const collateralScale = scaleBN.toBigInt(); + + // Get price feeds and scales + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const collateralPrice = (await comet.getPrice(priceFeed)).toBigInt(); + const baseScale = (await comet.baseScale()).toBigInt(); + const factorScale = (await comet.factorScale()).toBigInt(); + + // Target borrow amount (in base units, not wei) + const targetBorrowBase = BigInt(getConfigForScenario(context, i).withdrawBase); + const targetBorrowBaseWei = targetBorrowBase * baseScale; + + // Calculate required collateral amount + // Formula from CometBalanceConstraint.ts: + const collateralWeiPerUnitBase = (collateralScale * basePrice) / collateralPrice; + let collateralNeeded = (collateralWeiPerUnitBase * targetBorrowBaseWei) / baseScale; + collateralNeeded = (collateralNeeded * factorScale) / borrowCollateralFactor.toBigInt(); + collateralNeeded = (collateralNeeded * 11n) / 10n; // add fudge factor to ensure collateralization + + // Set up balances dynamically + // 1. Source collateral tokens for albert + await context.sourceTokens(collateralNeeded, collateralAsset, albert); + + // 2. Approve and supply collateral + await collateralAsset.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset.address, amount: collateralNeeded }); + + // 3. Borrow base (this will make albert have negative base balance) + const baseTokenAddress = await comet.baseToken(); + await albert.withdrawAsset({ asset: baseTokenAddress, amount: targetBorrowBaseWei }); + + // Verify initial state: position should be collateralized + expect(await comet.isBorrowCollateralized(albert.address)).to.be.true; + + // Zero borrowCF for target asset via governance + await context.setNextBaseFeeToZero(); + await configurator.connect(admin.signer).updateAssetBorrowCollateralFactor(comet.address, asset, 0n, { gasPrice: 0 }); + await context.setNextBaseFeeToZero(); + await proxyAdmin.connect(admin.signer).deployAndUpgradeTo(configurator.address, comet.address, { gasPrice: 0 }); + + // Verify borrowCF is 0 + const assetInfo = await comet.getAssetInfoByAddress(asset); + expect(assetInfo.borrowCollateralFactor).to.equal(0); + + // After zeroing the only supplied asset's borrowCF, position should be undercollateralized + expect(await comet.isBorrowCollateralized(albert.address)).to.equal(false); + } + ); +} + +scenario( + 'Comet#withdraw reverts when collateral asset withdraw is paused and allows to withdraw when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).withdrawCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const withdrawCollateral = BigInt(getConfigForScenario(context).withdrawCollateral) * scale; + + log(`Withdrawing reverts when collateral asset ${i} withdraw is paused`); + + // Source collateral asset + await context.sourceTokens(withdrawCollateral, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral asset + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: withdrawCollateral, + }); + + // Pause specific collateral asset withdraw at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, true); + + await expectRevertCustom( + albert.withdrawAsset({ + asset: collateralAsset.address, + amount: withdrawCollateral, + }), + `CollateralAssetWithdrawPaused(${i})` + ); + + log(`Withdrawing is allowed when collateral asset ${i} withdraw is unpaused`); + + // Unpause specific collateral asset withdraw at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, false); + + // Save balance + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + + // Withdraw asset from albert + await albert.withdrawAsset({ + asset: collateralAsset.address, + amount: withdrawCollateral, + }); + + // Get balance after withdraw + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + + // Assert balance after withdraw + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - withdrawCollateral); + } + } +); + +scenario( + 'Comet#withdrawFrom reverts when collateral asset withdraw is paused and allows to withdraw when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + // Allow betty to withdraw asset from albert + await albert.allow(betty, true); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).withdrawCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const withdrawCollateral = BigInt(getConfigForScenario(context).withdrawCollateral) * scale; + + log(`Withdrawing reverts when collateral asset ${i} withdraw is paused`); + + // Source collateral asset + await context.sourceTokens(withdrawCollateral, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral asset + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: withdrawCollateral, + }); + + // Pause specific collateral asset withdraw at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, true); + + await expectRevertCustom( + betty.withdrawAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: withdrawCollateral, + }), + `CollateralAssetWithdrawPaused(${i})` + ); + + log(`Withdrawing is allowed when collateral asset ${i} withdraw is unpaused`); + + // Unpause specific collateral asset withdraw at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, false); + + // Save balances + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceBefore = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + const albertTokenBalanceBefore = await collateralAsset.balanceOf(albert.address); + const bettyTokenBalanceBefore = await collateralAsset.balanceOf(betty.address); + + // Withdraw asset from albert to betty + await betty.withdrawAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: withdrawCollateral, + }); + + // Get balances after withdraw + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceAfter = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + const albertTokenBalanceAfter = await collateralAsset.balanceOf(albert.address); + const bettyTokenBalanceAfter = await collateralAsset.balanceOf(betty.address); + + // Assert balances after withdraw + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - withdrawCollateral); + expect(bettyBalanceAfter).to.be.equal(bettyBalanceBefore); + + expect(albertTokenBalanceBefore).to.be.equal(albertTokenBalanceAfter); + expect(bettyTokenBalanceAfter).to.be.equal(bettyTokenBalanceBefore + withdrawCollateral); + } + } +); + +scenario( + 'Comet#withdrawTo reverts when collateral asset withdraw is paused and allows to withdraw when unpaused', + { + filter: async (ctx: CometContext) => { + return await usesAssetList(ctx) && await supportsExtendedPause(ctx); + }, + }, + async ({ comet, actors, cometExt }, context, world) => { + const { albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, pauseGuardian); + + for (let i = 0; i < MAX_ASSETS; i++) { + if (!await isValidAssetIndex(context, i)) continue; + if (!await isTriviallySourceable(context, i, getConfigForScenario(context).withdrawCollateral)) continue; + if (await isAssetDelisted(context, i)) continue; + + const { asset, scale: scaleBN } = await comet.getAssetInfo(i); + const collateralAsset = context.getAssetByAddress(asset); + const scale = scaleBN.toBigInt(); + const withdrawCollateral = BigInt(getConfigForScenario(context).withdrawCollateral) * scale; + + log(`Withdrawing reverts when collateral asset ${i} withdraw is paused`); + + // Source collateral asset + await context.sourceTokens(withdrawCollateral, collateralAsset.address, albert.address); + + // Approve collateral asset + await collateralAsset.approve(albert, comet.address); + + // Supply collateral asset + await albert.safeSupplyAsset({ + asset: collateralAsset.address, + amount: withdrawCollateral, + }); + + // Pause specific collateral asset withdraw at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, true); + + await expectRevertCustom( + albert.withdrawAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: withdrawCollateral, + }), + `CollateralAssetWithdrawPaused(${i})` + ); + + log(`Withdrawing is allowed when collateral asset ${i} withdraw is unpaused`); + + // Unpause specific collateral asset withdraw at index i + await cometExt.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, false); + + // Save balance + const albertBalanceBefore = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceBefore = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + const albertTokenBalanceBefore = await collateralAsset.balanceOf(albert.address); + const bettyTokenBalanceBefore = await collateralAsset.balanceOf(betty.address); + + // Withdraw asset to betty + await albert.withdrawAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: withdrawCollateral, + }); + + // Get balances after withdraw + const albertBalanceAfter = await comet.collateralBalanceOf(albert.address, collateralAsset.address); + const bettyBalanceAfter = await comet.collateralBalanceOf(betty.address, collateralAsset.address); + const albertTokenBalanceAfter = await collateralAsset.balanceOf(albert.address); + const bettyTokenBalanceAfter = await collateralAsset.balanceOf(betty.address); + + // Assert balances after withdraw + expect(albertBalanceAfter).to.be.equal(albertBalanceBefore.toBigInt() - withdrawCollateral); + expect(bettyBalanceAfter).to.be.equal(bettyBalanceBefore); + + expect(albertTokenBalanceBefore).to.be.equal(albertTokenBalanceAfter); + expect(bettyTokenBalanceAfter).to.be.equal(bettyTokenBalanceBefore + withdrawCollateral); + } + } +); diff --git a/scenario/constraints/ProposalConstraint.ts b/scenario/constraints/ProposalConstraint.ts index df76002d9..f1391c945 100644 --- a/scenario/constraints/ProposalConstraint.ts +++ b/scenario/constraints/ProposalConstraint.ts @@ -78,9 +78,9 @@ export class ProposalConstraint implements StaticConstra ); } - // temporary hack to skip proposal 510 - if (proposal.id.eq(510)) { - console.log('Skipping proposal 510'); + // temporary hack to skip proposals + if (proposal.id.eq(510) || proposal.id.eq(567) || proposal.id.eq(565) || proposal.id.eq(566)) { + console.log(`Skipping proposal ${proposal.id}`); continue; } diff --git a/scenario/context/CometActor.ts b/scenario/context/CometActor.ts index 772ac8c95..f1e802caf 100644 --- a/scenario/context/CometActor.ts +++ b/scenario/context/CometActor.ts @@ -90,11 +90,28 @@ export default class CometActor { return await (await comet.connect(this.signer).supply(asset, amount)).wait(); } + async supplyAssetTo({ dst, asset, amount }): Promise { + const comet = await this.context.getComet(); + return await (await comet.connect(this.signer).supplyTo(dst, asset, amount)).wait(); + } + + async safeSupplyAssetTo({ dst, asset, amount }): Promise { + const comet = await this.context.getComet(); + await this.context.bumpSupplyCaps({ [asset]: amount }); + return await (await comet.connect(this.signer).supplyTo(dst, asset, amount)).wait(); + } + async supplyAssetFrom({ src, dst, asset, amount }): Promise { const comet = await this.context.getComet(); return await (await comet.connect(this.signer).supplyFrom(src, dst, asset, amount)).wait(); } + async safeSupplyAssetFrom({ src, dst, asset, amount }): Promise { + const comet = await this.context.getComet(); + await this.context.bumpSupplyCaps({ [asset]: amount }); + return await (await comet.connect(this.signer).supplyFrom(src, dst, asset, amount)).wait(); + } + async transferAsset({ dst, asset, amount }): Promise { const comet = await this.context.getComet(); return await (await comet.connect(this.signer).transferAsset(dst, asset, amount)).wait(); @@ -115,6 +132,11 @@ export default class CometActor { return await (await comet.connect(this.signer).withdrawFrom(src, dst, asset, amount)).wait(); } + async withdrawAssetTo({ dst, asset, amount }): Promise { + const comet = await this.context.getComet(); + return await (await comet.connect(this.signer).withdrawTo(dst, asset, amount)).wait(); + } + async absorb({ absorber, accounts }): Promise { const comet = await this.context.getComet(); return await (await comet.connect(this.signer).absorb(absorber, accounts)).wait(); diff --git a/scenario/context/CometContext.ts b/scenario/context/CometContext.ts index 107f53723..d38a7a533 100644 --- a/scenario/context/CometContext.ts +++ b/scenario/context/CometContext.ts @@ -29,6 +29,7 @@ import { BaseBulker, BaseBridgeReceiver, ERC20, + CometExtAssetList, } from '../../build/types'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { sourceTokens } from '../../plugins/scenario/utils/TokenSourcer'; @@ -59,6 +60,7 @@ export interface CometProperties { rewards: CometRewards; bulker: BaseBulker; bridgeReceiver: BaseBridgeReceiver; + cometExt?: CometExtAssetList; } export class CometContext { @@ -401,17 +403,21 @@ async function getInitialContext(world: World): Promise { } async function getContextProperties(context: CometContext): Promise { + const comet = await context.getComet(); + const cometExt = await context.world.deploymentManager.hre.ethers.getContractAt('CometExtAssetList', comet.address) as CometExtAssetList; + return { actors: context.actors, assets: context.assets, - comet: await context.getComet(), + comet, configurator: await context.getConfigurator(), proxyAdmin: await context.getCometAdmin(), timelock: await context.getTimelock(), governor: await context.getGovernor(), rewards: await context.getRewards(), bulker: await context.getBulker(), - bridgeReceiver: await context.getBridgeReceiver() + bridgeReceiver: await context.getBridgeReceiver(), + cometExt }; } diff --git a/scenario/utils/hreUtils.ts b/scenario/utils/hreUtils.ts index 9c81f6934..805a35953 100644 --- a/scenario/utils/hreUtils.ts +++ b/scenario/utils/hreUtils.ts @@ -1,4 +1,6 @@ import { DeploymentManager } from '../../plugins/deployment_manager'; +import { World } from '../../plugins/scenario'; +import CometActor from '../context/CometActor'; export async function setNextBaseFeeToZero(dm: DeploymentManager) { await dm.hre.network.provider.send('hardhat_setNextBlockBaseFeePerGas', ['0x0']); @@ -12,4 +14,11 @@ export async function mineBlocks(dm: DeploymentManager, blocks: number) { export async function setNextBlockTimestamp(dm: DeploymentManager, timestamp: number) { await dm.hre.ethers.provider.send('evm_setNextBlockTimestamp', [timestamp]); +} + +export async function fundAccount(world: World, account: CometActor) { + await world.deploymentManager.hre.network.provider.send('hardhat_setBalance', [ + account.address, + world.deploymentManager.hre.ethers.utils.hexStripZeros(world.deploymentManager.hre.ethers.utils.parseEther('100').toHexString()), + ]); } \ No newline at end of file diff --git a/scenario/utils/index.ts b/scenario/utils/index.ts index 410d7c318..b3b2f4263 100644 --- a/scenario/utils/index.ts +++ b/scenario/utils/index.ts @@ -32,10 +32,11 @@ import CometActor from './../context/CometActor'; import { isBridgeProposal } from './isBridgeProposal'; import { Interface } from 'ethers/lib/utils'; import axios from 'axios'; -export { mineBlocks, setNextBaseFeeToZero, setNextBlockTimestamp }; import { readFileSync } from 'fs'; import path from 'path'; +export * from './hreUtils'; + export const MAX_ASSETS = 24; export const UINT256_MAX = 2n ** 256n - 1n; @@ -356,6 +357,15 @@ export async function isValidAssetIndex( return assetNum < (await comet.numAssets()); } +export async function isAssetDelisted( + ctx: CometContext, + assetNum: number +): Promise { + const comet = await ctx.getComet(); + const assetInfo = await comet.getAssetInfo(assetNum); + return assetInfo.borrowCollateralFactor.toBigInt() === 0n; +} + export async function isTriviallySourceable( ctx: CometContext, assetNum: number, @@ -426,10 +436,58 @@ export async function isRewardSupported(ctx: CometContext): Promise { return true; } +export async function usesAssetList(ctx: CometContext): Promise { + const comet = await ctx.getComet(); + return await comet.maxAssets() === MAX_ASSETS; +} + export function isBridgedDeployment(ctx: CometContext): boolean { return ctx.world.auxiliaryDeploymentManager !== undefined; } +export async function supportUtilizationLimit(ctx: CometContext): Promise { + try { + const comet = await ctx.getComet(); + const ethers = ctx.world.deploymentManager.hre.ethers; + + const iface = new ethers.utils.Interface([ + 'function MAX_SUPPORTED_UTILIZATION() external view returns (uint)', + ]); + const functionSelector = iface.getSighash('MAX_SUPPORTED_UTILIZATION'); + + // Try to call the function using a low-level static call + // If the function doesn't exist, this will revert + const result = await ethers.provider.call({ + to: comet.address, + data: functionSelector + }); + + // If the call succeeds (doesn't revert), the function exists + // Decode the result to verify it's a valid bool response + if (result && result !== '0x') { + return true; + } + return false; + } catch (error) { + return false; + } +} + +/** + * @notice Checks if the market is fresh (no supplies and no borrows) + * @dev A fresh market has totalSupplyBase == 0 and totalBorrowBase == 0 + * This is used to filter scenarios that should only run on new/empty markets + */ +export async function isFreshMarket(ctx: CometContext): Promise { + try { + const comet = await ctx.getComet(); + const totals = await comet.totalsBasic(); + return totals.totalSupplyBase.isZero() && totals.totalBorrowBase.isZero(); + } catch (error) { + return false; + } +} + export async function fetchLogs( contract: Contract, filter: EventFilter, @@ -488,172 +546,213 @@ async function redeployRenzoOracle(dm: DeploymentManager) { } } -const tokens = new Map([ - ['WETH', '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'], - ['LINK', '0x514910771AF9Ca656af840dff83E8264EcF986CA'], +const tokens = [ + ['mainnet', 'WETH', '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'], + ['mainnet', 'LINK', '0x514910771AF9Ca656af840dff83E8264EcF986CA'], + ['mainnet', 'GHO', '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f'], + ['ronin', 'WETH', '0xc99a6a985ed2cac1ef41640596c5a5f9f4e19ef5'], + ['ronin', 'WRON', '0xe514d9deb7966c8be0ca922de8a064264ea6bcd4'], + ['ronin', 'LINK', '0x3902228d6a3d2dc44731fd9d45fee6a61c722d0b'], +]; + +const dest = new Map([ + ['ronin', '6916147374840168594'], + ['mainnet', '5009297550715157269'], ]); -const dest = new Map([['ronin', '6916147374840168594']]); - -async function updateCCIPStats(dm: DeploymentManager) { - if (dm.network === 'mainnet') { - const commitStore = '0x2aa101bf99caef7fc1355d4c493a1fe187a007ce'; +export async function updateCCIPStats( + dm: DeploymentManager, + tenderlyLogs?: any[] +) { + const config = [ + { + network: 'mainnet', + commitStore: '0x2aa101bf99caef7fc1355d4c493a1fe187a007ce', + priceRegistry: '0x8c9b2Efb7c64C394119270bfecE7f54763b958Ad' + }, + { + network: 'ronin', + commitStore: '0x28c66d9693b2634b2f3b170f6d9584eec2f72ff0', + priceRegistry: '0xefCEa3CFA330adcDdeCe99219C57fd45cd166ac1' + } + ]; + const { commitStore, priceRegistry } = config.find(c => c.network === dm.network) || {}; + if (!commitStore || !priceRegistry) { + console.log(`No CCIP config for network ${dm.network}, skipping CCIP stats update.`); + return; + } + const abi = [ + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'sourceToken', + type: 'address', + }, + { + internalType: 'uint224', + name: 'usdPerToken', + type: 'uint224', + }, + ], + internalType: 'struct TokenPriceUpdate[]', + name: 'tokenPriceUpdates', + type: 'tuple[]', + }, + { + components: [ + { + internalType: 'uint64', + name: 'destChainSelector', + type: 'uint64', + }, + { + internalType: 'uint224', + name: 'usdPerUnitGas', + type: 'uint224', + }, + ], + internalType: 'struct GasPriceUpdate[]', + name: 'gasPriceUpdates', + type: 'tuple[]', + }, + ], + internalType: 'struct PriceUpdates', + name: 'priceUpdates', + type: 'tuple', + }, + ], + name: 'updatePrices', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint64', + name: 'destChainSelector', + type: 'uint64', + }, + ], + name: 'getDestinationChainGasPrice', + outputs: [ + { + components: [ + { + internalType: 'uint224', + name: 'value', + type: 'uint224', + }, + { + internalType: 'uint32', + name: 'timestamp', + type: 'uint32', + }, + ], + internalType: 'struct TimestampedPackedUint224', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + ], + name: 'getTokenPrice', + outputs: [ + { + components: [ + { + internalType: 'uint224', + name: 'value', + type: 'uint224', + }, + { + internalType: 'uint32', + name: 'timestamp', + type: 'uint32', + }, + ], + internalType: 'struct TimestampedPackedUint224', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + ]; - const priceRegistry = '0x8c9b2Efb7c64C394119270bfecE7f54763b958Ad'; - const abi = [ - { - inputs: [ - { - components: [ - { - components: [ - { - internalType: 'address', - name: 'sourceToken', - type: 'address', - }, - { - internalType: 'uint224', - name: 'usdPerToken', - type: 'uint224', - }, - ], - internalType: 'struct TokenPriceUpdate[]', - name: 'tokenPriceUpdates', - type: 'tuple[]', - }, - { - components: [ - { - internalType: 'uint64', - name: 'destChainSelector', - type: 'uint64', - }, - { - internalType: 'uint224', - name: 'usdPerUnitGas', - type: 'uint224', - }, - ], - internalType: 'struct GasPriceUpdate[]', - name: 'gasPriceUpdates', - type: 'tuple[]', - }, - ], - internalType: 'struct PriceUpdates', - name: 'priceUpdates', - type: 'tuple', - }, - ], - name: 'updatePrices', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'uint64', - name: 'destChainSelector', - type: 'uint64', - }, - ], - name: 'getDestinationChainGasPrice', - outputs: [ - { - components: [ - { - internalType: 'uint224', - name: 'value', - type: 'uint224', - }, - { - internalType: 'uint32', - name: 'timestamp', - type: 'uint32', - }, - ], - internalType: 'struct TimestampedPackedUint224', - name: '', - type: 'tuple', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'token', - type: 'address', - }, - ], - name: 'getTokenPrice', - outputs: [ - { - components: [ - { - internalType: 'uint224', - name: 'value', - type: 'uint224', - }, - { - internalType: 'uint32', - name: 'timestamp', - type: 'uint32', - }, - ], - internalType: 'struct TimestampedPackedUint224', - name: '', - type: 'tuple', - }, - ], - stateMutability: 'view', - type: 'function', - }, - ]; + await dm.hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [commitStore], + }); - await dm.hre.network.provider.request({ - method: 'hardhat_impersonateAccount', - params: [commitStore], - }); + await dm.hre.network.provider.request({ + method: 'hardhat_setBalance', + params: [commitStore, '0x56bc75e2d63100000'], + }); + const commitStoreSigner = await dm.hre.ethers.getSigner(commitStore); - await dm.hre.network.provider.request({ - method: 'hardhat_setBalance', - params: [commitStore, '0x56bc75e2d63100000'], - }); - const commitStoreSigner = await dm.hre.ethers.getSigner(commitStore); + const registryContract = new Contract( + priceRegistry, + abi, + dm.hre.ethers.provider + ); - const registryContract = new Contract( - priceRegistry, - abi, - dm.hre.ethers.provider - ); + const tokenPrices = []; + const gasPrices = []; + for (const [network,, address] of tokens) { + if(network !== dm.network) continue; + const price = await registryContract.getTokenPrice(address); + tokenPrices.push([address, price.value]); + } - const tokenPrices = []; - const gasPrices = []; - for (const [, address] of tokens) { - const price = await registryContract.getTokenPrice(address); - tokenPrices.push([address, price.value]); - } - for (const [, address] of dest) { - const price = await registryContract.getDestinationChainGasPrice(address); - gasPrices.push([address, price.value]); + for (const [, chainSelector] of dest) { + try { + const price = await registryContract.getDestinationChainGasPrice(chainSelector); + gasPrices.push([chainSelector, price.value]); + } catch (e) { + continue; } + } - const tx0 = await commitStoreSigner.sendTransaction({ - to: priceRegistry, - data: registryContract.interface.encodeFunctionData('updatePrices', [ + if(tenderlyLogs) { + dm.stashRelayMessage( + priceRegistry, + registryContract.interface.encodeFunctionData('updatePrices', [ { tokenPriceUpdates: tokenPrices, gasPriceUpdates: gasPrices, }, ]), - }); - - await tx0.wait(); + commitStore + ); } + + const tx0 = await commitStoreSigner.sendTransaction({ + to: priceRegistry, + data: registryContract.interface.encodeFunctionData('updatePrices', [ + { + tokenPriceUpdates: tokenPrices, + gasPriceUpdates: gasPrices, + }, + ]), + }); + + await tx0.wait(); } const REDSTONE_FEEDS = { @@ -888,7 +987,7 @@ export async function tenderlyExecute( let proposals; if (chainId1 !== chainId2) { proposals = await relayMessage(gdm, bdm, parseFloat(B0.toString()), bundle[bundle.length - 1].transaction.transaction_info.logs); - + debug(`Proposals relayed: ${proposals.length}`); const timelockL2 = await bdm.getContractOrThrow('timelock'); const delay = await timelockL2.delay(); @@ -899,21 +998,21 @@ export async function tenderlyExecute( const B0L2 = Number(latestL2.number) + 1; const simsL2 = relayMessages.map((msg, i, arr) => { const isLast = i === arr.length - 1; - + const timestamp = isLast ? Number(T0L2) : latestL2.timestamp; - + const block = isLast ? B0L2 : latestL2.number; - + return { network_id: chainId2.toString(), from: msg.signer, - to: msg.messanger, + to: msg.messenger, block_number: Number(block), block_header: { - timestamp: gdm.hre.ethers.utils.hexlify(Number(timestamp)) + timestamp: bdm.hre.ethers.utils.hexlify(Number(timestamp)) }, input: msg.callData, save: true, @@ -921,14 +1020,6 @@ export async function tenderlyExecute( gas_price: 0, }; }); - - - while (!simsL1[0]) { - simsL1.shift(); - if (simsL1.length == 0) { - break; - } - } if (simsL2.length > 0) { const bundle2 = await simulateBundle(bdm, simsL2, Number(B0L2)); @@ -948,25 +1039,65 @@ async function simulateBundle( simulations: any[], blockNumber: number = 0 ): Promise { - const { username, project, accessKey } = (dm.hre.config as any).tenderly; - const body = { - simulations, - block_number: blockNumber, - simulation_type: 'full', - save: true, - }; + const rollingStateChanges = {}; + const results = []; + + for (const sim of simulations) { + const { username, project, accessKey } = (dm.hre.config as any).tenderly; + + // Merge rolling state changes with simulation's own state_objects + const stateObjects = sim.state_objects + ? { ...rollingStateChanges, ...sim.state_objects } + : rollingStateChanges; + + const body = { + simulations: [{ + ...sim, + state_objects: stateObjects, + block_number: sim.block_number || blockNumber, + simulation_type: 'full', + save: true, + save_if_fails: true, + }] + }; - const result = await axios.post( - `https://api.tenderly.co/api/v1/account/${username}/project/${project}/simulate-bundle`, - body, - { - headers: { - 'X-Access-Key': accessKey, - 'Content-Type': 'application/json', - }, + const result = await axios.post( + `https://api.tenderly.co/api/v1/account/${username}/project/${project}/simulate-bundle`, + body, + { + headers: { + 'X-Access-Key': accessKey, + 'Content-Type': 'application/json', + }, + } + ); + + // Extract and accumulate state changes from state_diff + const simResult = result.data.simulation_results[0]; + if (simResult?.transaction?.transaction_info?.call_trace?.state_diff) { + const stateDiff = simResult.transaction.transaction_info.call_trace.state_diff; + + // state_diff is an array of objects with { address, raw: [...] } + for (const stateDiffEntry of stateDiff) { + const address = stateDiffEntry.address; + + if (!rollingStateChanges[address]) { + rollingStateChanges[address] = { storage: {} }; + } + + if (stateDiffEntry.raw && Array.isArray(stateDiffEntry.raw)) { + for (const change of stateDiffEntry.raw) { + // Each change has: { address, key, original, dirty } + rollingStateChanges[address].storage[change.key] = change.dirty; + } + } + } } - ); - return result.data.simulation_results; + + results.push(simResult); + } + + return results; } async function shareSimulation(dm: DeploymentManager, simulationId: string) { @@ -1089,7 +1220,12 @@ export async function executeOpenProposal( console.log(`Updating CCIP prices...`); await updateCCIPStats(dm); - await governor.execute(id, { gasPrice: 0, gasLimit: 120000000 }); + const tx = await governor.execute(id, { gasPrice: 0, gasLimit: 120000000 }); + const receipt = await tx.wait(); + + if(receipt.gasUsed.toNumber() >= 16_777_215) { + throw new Error('Execution may have failed due to hitting gas limit'); + } } await redeployRenzoOracle(dm); @@ -1523,3 +1659,64 @@ export function applyL1ToL2Alias(address: string) { export function isTenderlyLog(log: any): log is { raw: { topics: string[], data: string } } { return !!log?.raw?.topics && !!log?.raw?.data; } + +/** + * Check if Comet supports extended pause functionality + * @param ctx The Comet context + * @returns true if Comet supports extended pause functions, false otherwise + */ +export async function supportsExtendedPause(ctx: CometContext): Promise { + try { + const comet = await ctx.getComet(); + const ethers = ctx.world.deploymentManager.hre.ethers; + + // Get the function selector for isLendersWithdrawPaused() + // This function only exists in CometWithExtendedAssetList + const iface = new ethers.utils.Interface([ + 'function isLendersWithdrawPaused() external view returns (bool)' + ]); + const functionSelector = iface.getSighash('isLendersWithdrawPaused'); + + // Try to call the function using a low-level static call + // If the function doesn't exist, this will revert + const result = await ethers.provider.call({ + to: comet.address, + data: functionSelector + }); + + // If the call succeeds (doesn't revert), the function exists + // Decode the result to verify it's a valid bool response + if (result && result !== '0x') { + return true; + } + return false; + } catch (e) { + // If the call reverts or fails, extended pause is not supported + return false; + } +} + +export async function supportsMarketAdminPermissionChecker(ctx: CometContext): Promise { + try { + const configurator = await ctx.getConfigurator(); + const ethers = ctx.world.deploymentManager.hre.ethers; + + // Use function selector to probe existence without reverting on unsupported networks + const iface = new ethers.utils.Interface([ + 'function marketAdminPermissionChecker() public view returns (address)' + ]); + const functionSelector = iface.getSighash('marketAdminPermissionChecker'); + + const result = await ethers.provider.call({ + to: configurator.address, + data: functionSelector + }); + + if (result && result !== '0x') { + return true; + } + return false; + } catch (e) { + return false; + } +} diff --git a/scenario/utils/relayMessage.ts b/scenario/utils/relayMessage.ts index 0bccd6467..fa0523665 100644 --- a/scenario/utils/relayMessage.ts +++ b/scenario/utils/relayMessage.ts @@ -16,7 +16,7 @@ export default async function relayMessage( tenderlyLogs?: any[] ) { const bridgeNetwork = bridgeDeploymentManager.network; - console.log(`Relaying messages from ${bridgeNetwork} -> ${governanceDeploymentManager.network}`); + console.log(`Relaying messages from ${governanceDeploymentManager.network} -> ${bridgeNetwork}`); let proposal; switch (bridgeNetwork) { case 'base': diff --git a/scenario/utils/relayRoninMessage.ts b/scenario/utils/relayRoninMessage.ts index 4a72cee87..e86c760b9 100644 --- a/scenario/utils/relayRoninMessage.ts +++ b/scenario/utils/relayRoninMessage.ts @@ -4,9 +4,13 @@ import { setNextBaseFeeToZero, setNextBlockTimestamp } from './hreUtils'; import { BigNumber, ethers } from 'ethers'; import { Log } from '@ethersproject/abstract-provider'; import { OpenBridgedProposal } from '../context/Gov'; -import { isTenderlyLog } from './index'; +import { isTenderlyLog, updateCCIPStats } from './index'; const roninChainSelector = '6916147374840168594'; +const mainnetChainSelector = '5009297550715157269'; + +const MAINNET_CCIP_ROUTER = '0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D'; +const MAINNET_RONIN_OFF_RAMP = '0x9a3Ed7007809CfD666999e439076B4Ce4120528D'; export default async function relayRoninMessage( governanceDeploymentManager: DeploymentManager, @@ -20,6 +24,7 @@ export default async function relayRoninMessage( const l2CCIPOffRamp = (await bridgeDeploymentManager.getContractOrThrow('l2CCIPOffRamp')); const bridgeReceiver = (await bridgeDeploymentManager.getContractOrThrow('bridgeReceiver')); const l1TokenAdminRegistry = await governanceDeploymentManager.getContractOrThrow('l1TokenAdminRegistry'); + const timelockMainnet = await governanceDeploymentManager.getContractOrThrow('timelock'); const l2TokenAdminRegistry = await bridgeDeploymentManager.existing( 'l2TokenAdminRegistry', @@ -27,7 +32,12 @@ export default async function relayRoninMessage( 'ronin' ); + const l2CCIPOnRamp = await bridgeDeploymentManager.getContractOrThrow('l2CCIPOnRamp'); + const l1CCIPRouter = await governanceDeploymentManager.existing('l1CCIPRouter', MAINNET_CCIP_ROUTER, 'mainnet'); + const l1CCIPOffRamp = await governanceDeploymentManager.existing('roninl1CCIPOffRamp', MAINNET_RONIN_OFF_RAMP, 'mainnet'); + const offRampSigner = await impersonateAddress(bridgeDeploymentManager, l2CCIPOffRamp.address); + const l1OffRampSigner = await impersonateAddress(governanceDeploymentManager, l1CCIPOffRamp.address); const openBridgedProposals: OpenBridgedProposal[] = []; @@ -80,7 +90,7 @@ export default async function relayRoninMessage( await bridgeDeploymentManager.hre.network.provider.request({ method: 'hardhat_setBalance', - params: [l2CCIPOffRamp.address, '0x1000000000000000000000'] + params: [offRampSigner.address, '0x1000000000000000000000'] }); await setNextBaseFeeToZero(bridgeDeploymentManager); @@ -99,7 +109,7 @@ export default async function relayRoninMessage( const callData = l2Router.interface.encodeFunctionData('routeMessage', [ any2EVMMessage, 25_000, - 2_000_000, + 10_000_000, internalMsg.receiver, ]); bridgeDeploymentManager.stashRelayMessage( @@ -136,7 +146,7 @@ export default async function relayRoninMessage( const routeTx = await l2Router.connect(offRampSigner).routeMessage( any2EVMMessage, 25_000, - 2_000_000, + 10_000_000, internalMsg.receiver, ); @@ -207,23 +217,6 @@ export default async function relayRoninMessage( } } - if (tenderlyLogs) { - const proposalFilter = bridgeReceiver.filters.ProposalCreated(); - const proposalEvents = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ - fromBlock: 'latest', - toBlock: 'latest', - address: bridgeReceiver.address, - topics: proposalFilter.topics - }); - - for (let event of proposalEvents) { - const { - args: { id, eta }, - } = bridgeReceiver.interface.parseLog(event); - openBridgedProposals.push({ id, eta }); - } - } - for (const proposal of openBridgedProposals) { const { id, eta } = proposal; await setNextBlockTimestamp(bridgeDeploymentManager, eta.toNumber() + 1); @@ -231,6 +224,7 @@ export default async function relayRoninMessage( if (tenderlyLogs) { const callData = bridgeReceiver.interface.encodeFunctionData('executeProposal', [id]); + await updateCCIPStats(bridgeDeploymentManager, tenderlyLogs); const signer = await bridgeDeploymentManager.getSigner(); bridgeDeploymentManager.stashRelayMessage( bridgeReceiver.address, @@ -238,9 +232,100 @@ export default async function relayRoninMessage( await signer.getAddress() ); } else { - await bridgeReceiver.executeProposal(id, { gasPrice: 0 }); + await updateCCIPStats(bridgeDeploymentManager); + const signer = await bridgeDeploymentManager.getSigner(); + await bridgeReceiver.connect(signer).executeProposal(id, { gasPrice: 0 }); + console.log(`[CCIP L2] Executed bridged proposal ${id.toString()}`); + } + } + if (tenderlyLogs) return openBridgedProposals; + + // Process L2→L1 (Ronin→Mainnet) messages + const filterCCIPL2ToL1 = l2CCIPOnRamp.filters.CCIPSendRequested(); + let logsCCIPL2ToL1: Log[] = []; + + const latestBlock = (await bridgeDeploymentManager.hre.ethers.provider.getBlock('latest')).number; + logsCCIPL2ToL1 = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ + fromBlock: latestBlock - 500, + toBlock: 'latest', + address: l2CCIPOnRamp.address, + topics: filterCCIPL2ToL1.topics || [] + }); + + const targetReceivers = [ + timelockMainnet.address.toLowerCase() + ]; + + for (const log of logsCCIPL2ToL1) { + const parsedLog = l2CCIPOnRamp.interface.parseLog(log); + + const internalMsg = parsedLog.args.message; + if (!targetReceivers.includes(internalMsg.receiver.toLowerCase())) continue; + console.log(`[CCIP L2->L1] Found CCIPSendRequested with messageId=${internalMsg.messageId}, receiver=${internalMsg.receiver}`); + + await governanceDeploymentManager.hre.network.provider.request({ + method: 'hardhat_setBalance', + params: [l1CCIPOffRamp.address, '0x1000000000000000000000'] + }); + + await setNextBaseFeeToZero(governanceDeploymentManager); + + const any2EVMMessage = { + messageId: internalMsg.messageId, + sourceChainSelector: internalMsg.sourceChainSelector, + sender: ethers.utils.defaultAbiCoder.encode(['address'], [internalMsg.sender]), + data: internalMsg.data, + destTokenAmounts: internalMsg.tokenAmounts.map((t: any) => ({ + token: t.token as string, + amount: BigNumber.from(t.amount) + })), + }; + + const routeTx = await l1CCIPRouter.connect(l1OffRampSigner).routeMessage( + any2EVMMessage, + 25_000, + 2_000_000, + internalMsg.receiver, + ); + + await routeTx.wait(); + + if (internalMsg.tokenAmounts.length) { + for (const tokenTransferData of internalMsg.tokenAmounts) { + const l2TokenPoolAddress = await l2TokenAdminRegistry.getPool(tokenTransferData.token); + const l2TokenPool = new ethers.Contract( + l2TokenPoolAddress, + ['function getRemoteToken(uint64) external view returns (bytes)'], + bridgeDeploymentManager.hre.ethers.provider + ); + const l1Token64 = await l2TokenPool.getRemoteToken(mainnetChainSelector); + const l1TokenAddress = ethers.utils.defaultAbiCoder.decode(['address'], l1Token64)[0]; + const l1TokenPool = await l1TokenAdminRegistry.getPool(l1TokenAddress); + const l1Token = new ethers.Contract( + l1TokenAddress, + [ + 'function balanceOf(address) external view returns (uint256)', + 'function transfer(address, uint256) external returns (bool)' + ], + governanceDeploymentManager.hre.ethers.provider + ); + + const poolSigner = await impersonateAddress(governanceDeploymentManager, l1TokenPool); + await governanceDeploymentManager.hre.network.provider.request({ + method: 'hardhat_setBalance', + params: [l1TokenPool, '0x1000000000000000000000'] + }); + + const poolBalance = await l1Token.balanceOf(l1TokenPool); + console.log(`[CCIP L2->L1] Token pool ${l1TokenPool} balance: ${poolBalance.toString()}, transferring ${tokenTransferData.amount.toString()} to ${internalMsg.receiver}`); + + const transferTx = await l1Token.connect(poolSigner).transfer(internalMsg.receiver, tokenTransferData.amount); + await transferTx.wait(); + console.log(`[CCIP L2->L1] Transferred ${tokenTransferData.amount.toString()} of ${l1TokenAddress} to ${internalMsg.receiver}`); + } } - console.log(`[CCIP L2] Executed bridged proposal ${id.toString()}`); + + console.log(`[CCIP L2->L1] Routed message to ${internalMsg.receiver}`); } return openBridgedProposals; diff --git a/scenario/utils/scenarioHelper.ts b/scenario/utils/scenarioHelper.ts index a104a629c..e9a629f71 100644 --- a/scenario/utils/scenarioHelper.ts +++ b/scenario/utils/scenarioHelper.ts @@ -22,6 +22,7 @@ const config = { transferBase: 1000, transferAsset: 5000, transferAsset1: 5000, + transferAsset2: 50, interestSeconds: 110, withdrawBase: 1000, withdrawAsset: 3000, @@ -29,7 +30,9 @@ const config = { withdrawAsset1: 3000, withdrawCollateral: 100, transferCollateral: 100, - supplyCollateral: 100 + supplyCollateral: 100, + supplyBase: 1000, + reservesBase: 5000, }; export function getConfigForScenario(ctx: CometContext, i?: number) { @@ -70,6 +73,14 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { config.liquidationAsset = 100; } + if (ctx.world.base.network === 'mainnet' && ctx.world.base.deployment === 'usdt') { + if(i == 12) { + config.supplyCollateral = 0; + config.transferCollateral = 0; + config.withdrawCollateral = 0; + } + } + if (ctx.world.base.network === 'base' && ctx.world.base.deployment === 'aero') { config.interestSeconds = 110; } @@ -111,10 +122,11 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'usdc') { - config.bulkerAsset = 10000; - config.bulkerAsset1 = 10000; - config.withdrawAsset = 7000; + config.bulkerAsset = 100000; + config.bulkerAsset1 = 100000; + config.withdrawAsset = 10000; config.transferAsset = 500000; + config.transferAsset1 = 500000; config.transferBase = 100; if(i == 8) { // tBTC config.supplyCollateral = 2; @@ -124,11 +136,12 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'usdt') { - config.withdrawAsset = 7000; - config.bulkerAsset = 10000; + config.rewardsAsset = 20000; + config.withdrawAsset = 20000; + config.bulkerAsset = 100000; config.bulkerAsset1 = 10000; - config.transferAsset = 10000; - config.transferAsset1 = 10000; + config.transferAsset = 100000; + config.transferAsset1 = 100000; if(i == 5) { // tBTC config.supplyCollateral = 2; config.transferCollateral = 2; @@ -137,11 +150,11 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'usdc.e') { - config.withdrawAsset = 7000; - config.bulkerAsset = 10000; - config.bulkerAsset1 = 10000; - config.transferAsset = 10000; - config.transferAsset1 = 10000; + config.withdrawAsset = 10000; + config.bulkerAsset = 100000; + config.bulkerAsset1 = 100000; + config.transferAsset = 500000; + config.transferAsset1 = 500000; config.liquidationDenominator = 84; config.liquidationBase = 100000; config.liquidationBase1 = 50000; @@ -153,18 +166,19 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } if (ctx.world.base.network === 'ronin' && ctx.world.base.deployment === 'weth') { + config.supplyBase = 100; config.transferBase = 10; - config.transferAsset = 200000; - config.transferAsset1 = 200000; + config.transferAsset = 4000000; + config.transferAsset1 = 800000; config.rewardsAsset = 1000000; config.rewardsBase = 200; config.withdrawBase = 10; - config.withdrawBase1 = 10; - config.withdrawAsset = 100000; - config.withdrawAsset1 = 10000; - config.liquidationBase = 150; + config.withdrawBase1 = 50; + config.withdrawAsset = 4000000; + config.withdrawAsset1 = 4000000; + config.liquidationBase = 200; config.liquidationBase1 = 50; - config.liquidationAsset = 5; + config.liquidationAsset = 6; config.bulkerAsset = 100000; config.bulkerAsset1 = 100000; config.bulkerComet = 100; diff --git a/scripts/exportNetworkConfigs.js b/scripts/exportNetworkConfigs.js index e16da6da0..60d76183c 100644 --- a/scripts/exportNetworkConfigs.js +++ b/scripts/exportNetworkConfigs.js @@ -29,6 +29,9 @@ try { } function getUrl(network) { + if(network === 'scroll') { + return 'https://rpc.scroll.io'; + } const config = configs.find(cfg => cfg.network === network); return config ? config.url : ''; } diff --git a/src/deploy/Network.ts b/src/deploy/Network.ts index f869e37ee..b2c0552cf 100644 --- a/src/deploy/Network.ts +++ b/src/deploy/Network.ts @@ -117,7 +117,7 @@ export async function deployNetworkComet( baseBorrowMin, targetReserves, assetConfigs, - rewardTokenAddress + rewardTokenAddress, } = await getConfiguration(deploymentManager, configOverrides); /* Deploy contracts */ @@ -136,7 +136,7 @@ export async function deployNetworkComet( let cometExt; if(withAssetList) { - const assetListFactory = await deploymentManager.deploy( + let assetListFactory = await deploymentManager.deploy( 'assetListFactory', 'AssetListFactory.sol', [], diff --git a/src/deploy/NetworkConfiguration.ts b/src/deploy/NetworkConfiguration.ts index 41cec432a..32fd51393 100644 --- a/src/deploy/NetworkConfiguration.ts +++ b/src/deploy/NetworkConfiguration.ts @@ -166,9 +166,10 @@ function getOverridesOrConfig( getContractAddress(config.rewardToken, contracts, config.rewardTokenAddress) : undefined, }); - return Object.entries(mapping()).reduce((acc, [k, f]) => { + const result = Object.entries(mapping()).reduce((acc, [k, f]) => { return { [k]: overrides[k] ?? f(config), ...acc }; }, {}); + return result; } export async function getConfiguration( diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 3757ac900..13679d853 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -79,12 +79,11 @@ export type TestnetProposal = [ // Ideally these wouldn't be hardcoded, but other solutions are much more complex, and slower export const COMP_WHALES = { mainnet: [ - '0x9aa835bc7b8ce13b9b0c9764a52fbf71ac62ccf1', - '0x683a4f9915d6216f73d6df50151725036bd26c02', + '0x66cD62c6F8A4BB0Cd8720488BCBd1A6221B765F9', + '0xb06df4dd01a5c5782f360ada9345c87e86adae3d', + '0x3FB19771947072629C8EEE7995a2eF23B72d4C8A', '0x8169522c2C57883E8EF80C498aAB7820dA539806', - '0x8d07D225a769b7Af3A923481E1FdF49180e6A265', - '0x7d1a02C0ebcF06E1A36231A54951E061673ab27f', - '0x54A37d93E57c5DA659F508069Cf65A381b61E189', + '0x36cc7B13029B5DEe4034745FB4F24034f3F2ffc6', ], testnet: ['0xbbfe34e868343e6f4f5e8b5308de980d7bd88c46'] @@ -113,6 +112,8 @@ export const WHALES = { '0x34C0bD5877A5Ee7099D0f5688D65F4bB9158BDE2', // sFRAX whale '0x9152e9C04e8fE8373EDaa8f5841E25d4015658B7', // pumpBTC whale '0x65906988ADEe75306021C417a1A3458040239602', // LBTC whale + '0xF469fBD2abcd6B9de8E169d128226C0Fc90a012e', // wbtc whale + '0x7667095Caa12b79fCa489ff6E2198Ca01fDAe057', ], polygon: [ '0xF977814e90dA44bFA03b6295A0616a897441aceC', // USDT whale @@ -147,18 +148,25 @@ export const WHALES = { '0x186cF879186986A20aADFb7eAD50e3C20cb26CeC', // tBTC whale '0x620Fe90b1EAcaEa936ea199e7B05F998CA65836a', // tBTC whale '0x54b5569deC8A6A8AE61A36Fd34e5c8945810db8b', // tBTC whale + '0xd98Be00b5D27fc98112BdE293e487f8D4cA57d07', // tBTC whale + '0x68863dDE14303BcED249cA8ec6AF85d4694dea6A', // tBTC whale '0xDBD974Eb5360d053ea0c56B4DaCF4A9D3E894Ee2', // tETH whale '0xbA1333333333a1BA1108E8412f11850A5C319bA9', // tETH whale + '0xEA1132120ddcDDA2F119e99Fa7A27a0d036F7Ac9', // ezETH whale ], base: [ '0x6D3c5a4a7aC4B1428368310E4EC3bB1350d01455', // USDbC whale '0x46e6b214b524310239732D51387075E0e70970bf', // ezETH whale + '0xDD5745756C2de109183c6B5bB886F9207bEF114D', // ezETH whale + '0x7c468017FA704A1Cca3aa3d075eC21018FAd5E72', // ezETH whale + '0x6e01Eb6BbEd4407deA78EBF532055B04d0d08f0F', // ezETH whale '0x07CFA5Df24fB17486AF0CBf6C910F24253a674D3', // cbETH whale TODO: need to update this whale, not enough '0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb', // cbETH whale '0x3bf93770f2d4a794c3d9EBEfBAeBAE2a8f09A5E5', // cbETH whale '0xcf3D55c10DB69f28fD1A75Bd73f3D8A2d9c595ad', // cbETH whale '0xb125E6687d4313864e53df431d5425969c15Eb2F', // cbETH whale '0x1539A4611f16a139891c14365Cab86599F3A8AFC', // tBTC whale + '0x0a1d576f3eFeF75b330424287a95A366e8281D54', // USDbC whale ], scroll: [ '0xaaaaAAAACB71BF2C8CaE522EA5fa455571A74106', // USDC whale @@ -171,6 +179,18 @@ export const WHALES = { '0xc45A479877e1e9Dfe9FcD4056c699575a1045dAA', // wstETH whale '0x6e57181D6b4b7c138a6F956AD16DAF4f27FC5E04', // COMP whale '0xE36A30D249f7761327fd973001A32010b521b6Fd', // ezETH whale + '0xb40DA71c49c745Dd3ab801882b1D410760541678', // ezETH whale + '0x540B1E0D69244057cD0Da2AF4Bca87dA87A824bE', // ezETH whale + '0x12ee4BE944b993C81b6840e088bA1dCc57F07B1D', // ezETH whale + '0x87711795890ea632E3c8851F6B47BA1c6b2CF0Ee', // ezETH whale + '0xE36A30D249f7761327fd973001A32010b521b6Fd', // weETH whale + '0xb2cFb909e8657C0EC44D3dD898C1053b87804755', // weETH whale + '0xb8051464C8c92209C92F3a4CD9C73746C4c3CFb3', // weETH whale + '0x2478d48B8a5Dd0A9876a12858C917D556EB93811', // weETH whale + '0xE36A30D249f7761327fd973001A32010b521b6Fd', // wrsETH whale + '0x181bA797ccF779D8aB339721ED6ee827E758668e', // wrsETH whale + '0xbA1333333333a1BA1108E8412f11850A5C319bA9', // wrsETH whale + '0x44ed9cE901B367B1EF9DDBD4974C82A514c50DEc', // wrsETH whale ], mantle: [ '0x588846213A30fd36244e0ae0eBB2374516dA836C', // USDe whale @@ -280,7 +300,7 @@ export async function proposal( const { target, value, signature, calldata: cd } = action as TargetAction; targets.push(target); values.push(value ?? 0); - calldatas.push(utils.id(signature).slice(0, 10) + cd.slice(2)); + calldatas.push(signature ? utils.id(signature).slice(0, 10) + cd.slice(2) : cd); signatures.push(''); } } diff --git a/test/absorb-test.ts b/test/absorb-test.ts index 2ab6c1e80..df557ff7e 100644 --- a/test/absorb-test.ts +++ b/test/absorb-test.ts @@ -1,9 +1,15 @@ -import { ethers } from 'ethers'; -import { event, expect, exp, factor, defaultAssets, makeProtocol, mulPrice, portfolio, totalsAndReserves, wait, bumpTotalsCollateral, setTotalsBasic } from './helpers'; +import { ContractTransaction, BigNumber } from 'ethers'; +import { event, expect, exp, factor, defaultAssets, makeProtocol, mulPrice, portfolio, totalsAndReserves, wait, bumpTotalsCollateral, setTotalsBasic, makeConfigurator, takeSnapshot, SnapshotRestorer, MAX_ASSETS, divPrice, presentValue, principalValue } from './helpers'; +import { ethers } from './helpers'; +import { CometExtAssetList, CometProxyAdmin, CometWithExtendedAssetList, Configurator, ConfiguratorProxy, FaucetToken, NonStandardFaucetFeeToken, PriceFeedWithRevert, PriceFeedWithRevert__factory, SimplePriceFeed } from 'build/types'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; describe('absorb', function () { it('reverts if total borrows underflows', async () => { - const { comet, users: [absorber, underwater] } = await makeProtocol(); + const { + comet, + users: [absorber, underwater], + } = await makeProtocol(); const _f0 = await comet.setBasePrincipal(underwater.address, -100); await expect(comet.absorb(absorber.address, [underwater.address])).to.be.revertedWith('code 0x11 (Arithmetic operation underflowed or overflowed outside of an unchecked block)'); @@ -19,7 +25,11 @@ describe('absorb', function () { borrowInterestRateSlopeHigh: 0, }; const protocol = await makeProtocol(params); - const { comet, priceFeeds, users: [absorber, underwater] } = protocol; + const { + comet, + priceFeeds, + users: [absorber, underwater], + } = protocol; await setTotalsBasic(comet, { totalBorrowBase: 100n }); @@ -73,7 +83,7 @@ describe('absorb', function () { borrower: underwater.address, basePaidOut: 100n, usdValue: mulPrice(100n, usdcPrice, baseScale), - } + }, }); }); @@ -87,7 +97,11 @@ describe('absorb', function () { borrowInterestRateSlopeHigh: 0, }; const protocol = await makeProtocol(params); - const { comet, priceFeeds, users: [absorber, underwater1, underwater2] } = protocol; + const { + comet, + priceFeeds, + users: [absorber, underwater1, underwater2], + } = protocol; await setTotalsBasic(comet, { totalBorrowBase: 2000n }); @@ -145,7 +159,7 @@ describe('absorb', function () { borrower: underwater1.address, basePaidOut: 100n, usdValue: mulPrice(100n, usdcPrice, baseScale), - } + }, }); expect(event(a0, 1)).to.be.deep.equal({ AbsorbDebt: { @@ -153,7 +167,7 @@ describe('absorb', function () { borrower: underwater2.address, basePaidOut: 700n, usdValue: mulPrice(700n, usdcPrice, baseScale), - } + }, }); }); @@ -167,7 +181,12 @@ describe('absorb', function () { borrowInterestRateSlopeHigh: 0, }; const protocol = await makeProtocol(params); - const { comet, tokens, priceFeeds, users: [absorber, underwater1, underwater2, underwater3] } = protocol; + const { + comet, + tokens, + priceFeeds, + users: [absorber, underwater1, underwater2, underwater3], + } = protocol; const { COMP, WBTC, WETH } = tokens; await setTotalsBasic(comet, { @@ -216,7 +235,7 @@ describe('absorb', function () { COMP: exp(1, 12) + exp(10, 18) + exp(10000, 18), USDC: exp(4e15, 6), WBTC: exp(50, 8), - WETH: exp(1, 18) + exp(50, 18) + WETH: exp(1, 18) + exp(50, 18), }); expect(cTR0.reserves).to.be.deep.equal({ COMP: 0n, USDC: -exp(1e15, 6), WBTC: 0n, WETH: 0n }); @@ -227,7 +246,7 @@ describe('absorb', function () { COMP: exp(1, 12) + exp(10, 18) + exp(10000, 18), USDC: -exp(1e15, 6) - exp(1, 6) - exp(1, 12) - exp(1, 18), WBTC: exp(50, 8), - WETH: exp(1, 18) + exp(50, 18) + WETH: exp(1, 18) + exp(50, 18), }); expect(pP0.internal).to.be.deep.equal({ COMP: 0n, USDC: 0n, WBTC: 0n, WETH: 0n }); @@ -235,7 +254,7 @@ describe('absorb', function () { COMP: exp(1, 12) + exp(10, 18) + exp(10000, 18), USDC: 0n, WBTC: exp(50, 8), - WETH: exp(1, 18) + exp(50, 18) + WETH: exp(1, 18) + exp(50, 18), }); expect(pA0.internal).to.be.deep.equal({ COMP: 0n, USDC: 0n, WBTC: 0n, WETH: 0n }); expect(pA0.external).to.be.deep.equal({ COMP: 0n, USDC: 0n, WBTC: 0n, WETH: 0n }); @@ -251,7 +270,7 @@ describe('absorb', function () { COMP: exp(1, 12) + exp(10, 18) + exp(10000, 18), USDC: 0n, WBTC: exp(50, 8), - WETH: exp(1, 18) + exp(50, 18) + WETH: exp(1, 18) + exp(50, 18), }); expect(pA1.internal).to.be.deep.equal({ COMP: 0n, USDC: 0n, WBTC: 0n, WETH: 0n }); expect(pA1.external).to.be.deep.equal({ COMP: 0n, USDC: 0n, WBTC: 0n, WETH: 0n }); @@ -283,7 +302,7 @@ describe('absorb', function () { asset: COMP.address, collateralAbsorbed: exp(1, 12), usdValue: mulPrice(exp(1, 12), compPrice, compScale), - } + }, }); expect(event(a0, 1)).to.be.deep.equal({ AbsorbDebt: { @@ -291,7 +310,7 @@ describe('absorb', function () { borrower: underwater1.address, basePaidOut: exp(1, 6), usdValue: mulPrice(exp(1, 6), usdcPrice, baseScale), - } + }, }); // Underwater account 2 expect(event(a0, 2)).to.be.deep.equal({ @@ -301,7 +320,7 @@ describe('absorb', function () { asset: COMP.address, collateralAbsorbed: exp(10, 18), usdValue: mulPrice(exp(10, 18), compPrice, compScale), - } + }, }); expect(event(a0, 3)).to.be.deep.equal({ AbsorbCollateral: { @@ -310,7 +329,7 @@ describe('absorb', function () { asset: WETH.address, collateralAbsorbed: exp(1, 18), usdValue: mulPrice(exp(1, 18), wethPrice, wethScale), - } + }, }); expect(event(a0, 4)).to.be.deep.equal({ AbsorbDebt: { @@ -318,7 +337,7 @@ describe('absorb', function () { borrower: underwater2.address, basePaidOut: exp(1, 12), usdValue: mulPrice(exp(1, 12), usdcPrice, baseScale), - } + }, }); // Underwater account 3 expect(event(a0, 5)).to.be.deep.equal({ @@ -328,7 +347,7 @@ describe('absorb', function () { asset: COMP.address, collateralAbsorbed: exp(10000, 18), usdValue: mulPrice(exp(10000, 18), compPrice, compScale), - } + }, }); expect(event(a0, 6)).to.be.deep.equal({ AbsorbCollateral: { @@ -337,7 +356,7 @@ describe('absorb', function () { asset: WETH.address, collateralAbsorbed: exp(50, 18), usdValue: mulPrice(exp(50, 18), wethPrice, wethScale), - } + }, }); expect(event(a0, 7)).to.be.deep.equal({ AbsorbCollateral: { @@ -346,7 +365,7 @@ describe('absorb', function () { asset: WBTC.address, collateralAbsorbed: exp(50, 8), usdValue: mulPrice(exp(50, 8), wbtcPrice, wbtcScale), - } + }, }); expect(event(a0, 8)).to.be.deep.equal({ AbsorbDebt: { @@ -354,7 +373,7 @@ describe('absorb', function () { borrower: underwater3.address, basePaidOut: exp(1, 18), usdValue: mulPrice(exp(1, 18), usdcPrice, baseScale), - } + }, }); }); @@ -369,10 +388,15 @@ describe('absorb', function () { assets: defaultAssets({ borrowCF: factor(1 / 2), liquidateCF: factor(2 / 3), - }) + }), }; const protocol = await makeProtocol(params); - const { comet, tokens, users: [absorber, underwater], priceFeeds } = protocol; + const { + comet, + tokens, + users: [absorber, underwater], + priceFeeds, + } = protocol; const { COMP, WBTC, WETH } = tokens; const finalDebt = 1n; @@ -445,7 +469,7 @@ describe('absorb', function () { asset: COMP.address, collateralAbsorbed: exp(1, 18), usdValue: mulPrice(exp(1, 18), compPrice, compScale), - } + }, }); expect(event(a0, 1)).to.be.deep.equal({ AbsorbCollateral: { @@ -454,7 +478,7 @@ describe('absorb', function () { asset: WETH.address, collateralAbsorbed: exp(1, 18), usdValue: mulPrice(exp(1, 18), wethPrice, wethScale), - } + }, }); expect(event(a0, 2)).to.be.deep.equal({ AbsorbCollateral: { @@ -463,7 +487,7 @@ describe('absorb', function () { asset: WBTC.address, collateralAbsorbed: exp(1, 8), usdValue: mulPrice(exp(1, 8), wbtcPrice, wbtcScale), - } + }, }); expect(event(a0, 3)).to.be.deep.equal({ AbsorbDebt: { @@ -471,19 +495,22 @@ describe('absorb', function () { borrower: underwater.address, basePaidOut: pU1.internal.USDC - startingDebt, usdValue: mulPrice(pU1.internal.USDC - startingDebt, usdcPrice, baseScale), - } + }, }); expect(event(a0, 4)).to.be.deep.equal({ Transfer: { amount: finalDebt, from: ethers.constants.AddressZero, to: underwater.address, - } + }, }); }); it('reverts if an account is not underwater', async () => { - const { comet, users: [alice, bob] } = await makeProtocol(); + const { + comet, + users: [alice, bob], + } = await makeProtocol(); await expect(comet.absorb(alice.address, [bob.address])).to.be.revertedWith("custom error 'NotLiquidatable()'"); }); @@ -494,7 +521,11 @@ describe('absorb', function () { it('reverts if absorb is paused', async () => { const protocol = await makeProtocol(); - const { comet, pauseGuardian, users: [alice, bob] } = protocol; + const { + comet, + pauseGuardian, + users: [alice, bob], + } = protocol; const cometAsB = comet.connect(bob); @@ -506,7 +537,11 @@ describe('absorb', function () { }); it('updates assetsIn for liquidated account', async () => { - const { comet, users: [absorber, underwater], tokens } = await makeProtocol(); + const { + comet, + users: [absorber, underwater], + tokens, + } = await makeProtocol(); const { COMP, WETH } = tokens; await bumpTotalsCollateral(comet, COMP, exp(1, 18)); @@ -515,10 +550,7 @@ describe('absorb', function () { await comet.setCollateralBalance(underwater.address, COMP.address, exp(1, 18)); await comet.setCollateralBalance(underwater.address, WETH.address, exp(1, 18)); - expect(await comet.getAssetList(underwater.address)).to.deep.equal([ - COMP.address, - WETH.address, - ]); + expect(await comet.getAssetList(underwater.address)).to.deep.equal([COMP.address, WETH.address]); const borrowAmount = exp(4000, 6); // borrow of $4k > collateral of $3k + $175 await comet.setBasePrincipal(underwater.address, -borrowAmount); @@ -580,10 +612,11 @@ describe('absorb', function () { }, reward: 'COMP', }); - const { cometWithExtendedAssetList : comet, tokens: { - COMP, - WETH, - }, users: [absorber, underwater] } = protocol; + const { + cometWithExtendedAssetList: comet, + tokens: { COMP, WETH }, + users: [absorber, underwater], + } = protocol; await bumpTotalsCollateral(comet, COMP, exp(1, 18)); await bumpTotalsCollateral(comet, WETH, exp(1, 18)); @@ -591,18 +624,13 @@ describe('absorb', function () { await comet.setCollateralBalance(underwater.address, COMP.address, exp(1, 18)); await comet.setCollateralBalance(underwater.address, WETH.address, exp(1, 18)); - for (let i = 3; i < 24; i++) { const asset = `ASSET${i}`; await bumpTotalsCollateral(comet, protocol.tokens[asset], exp(1, 18)); await comet.setCollateralBalance(underwater.address, protocol.tokens[asset].address, exp(1, 18)); } - expect(await comet.getAssetList(underwater.address)).to.deep.equal([ - COMP.address, - WETH.address, - ...Array.from({ length: 21 }, (_, i) => protocol.tokens[`ASSET${i + 3}`].address), - ]); + expect(await comet.getAssetList(underwater.address)).to.deep.equal([COMP.address, WETH.address, ...Array.from({ length: 21 }, (_, i) => protocol.tokens[`ASSET${i + 3}`].address)]); const borrowAmount = exp(4000, 6); // borrow of $4k > collateral of $3k + $175 await comet.setBasePrincipal(underwater.address, -borrowAmount); @@ -616,4 +644,595 @@ describe('absorb', function () { expect(await comet.getAssetList(underwater.address)).to.be.empty; }); -}); \ No newline at end of file + + /* + * This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * As a result, during absorption, the protocol would not be able to calculate the USD value of the collateral seized. + * + * This test suite verifies that the protocol behaves correctly in two scenarios: + * 1. Normal absorption (liquidation factor > 0): When collateral has a non-zero liquidation factor, + * the protocol can successfully liquidate/seize the collateral during absorption, calculate its USD value, + * and update all state correctly. + * 2. Delisted collateral (liquidation factor = 0): When collateral is delisted (liquidation factor set to 0), + * the protocol skips seizing that collateral during absorption, but still proceeds with debt absorption. + * This allows the protocol to continue functioning even when a price feed becomes unavailable, by + * setting the asset's liquidation factor to 0 to prevent attempts to calculate its USD value. + */ + describe('absorb semantics across liquidationFactor values', function () { + // Snapshot + let snapshot: SnapshotRestorer; + + // Configurator and protocol + let configurator: Configurator; + let configuratorProxy: ConfiguratorProxy; + let proxyAdmin: CometProxyAdmin; + let cometProxyAddress: string; + let assetListFactoryAddress: string; + let comet: CometWithExtendedAssetList; + let comet24Assets: CometWithExtendedAssetList; + let configuratorProxy24Assets: Configurator; + let proxyAdmin24Assets: CometProxyAdmin; + let cometExt: CometExtAssetList; + + // Tokens + let baseToken: FaucetToken | NonStandardFaucetFeeToken; + let compToken: FaucetToken | NonStandardFaucetFeeToken; + let baseToken24Assets: FaucetToken | NonStandardFaucetFeeToken; + let tokens24Assets: Record; + + // Users + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let underwater24Assets: SignerWithAddress; + let absorber24Assets: SignerWithAddress; + + // Price feeds + let compPriceFeed: SimplePriceFeed; + let priceFeeds24Assets: Record; + + // Constants + const aliceCompSupply = exp(1, 18); + + // Liquidation transaction + let liquidationTx: ContractTransaction; + + // Data before absorption + let userCollateralBeforeAbsorption: BigNumber; + let totalsSupplyAssetBeforeAbsorption: BigNumber; + let totalSupplyBase: BigNumber; + let totalBorrowBase: BigNumber; + let expectedUsdValue: bigint; + let oldBalance: bigint; + let oldPrincipal: bigint; + let newPrincipal: bigint; + let basePrice: BigNumber; + let baseScale: BigNumber; + let newBalance: bigint; + + before(async () => { + const configuratorAndProtocol = await makeConfigurator({ + base: 'USDC', + storeFrontPriceFactor: exp(0.8, 18), + assets: { + USDC: { initial: 1e6, decimals: 6, initialPrice: 1 }, + COMP: { + initial: 1e7, + decimals: 18, + initialPrice: 200, + liquidationFactor: exp(0.6, 18), + }, + }, + }); + // Note: Always interact with the proxy address, we'll upgrade implementation later + cometProxyAddress = configuratorAndProtocol.cometProxy.address; + comet = configuratorAndProtocol.cometWithExtendedAssetList.attach(cometProxyAddress) as CometWithExtendedAssetList; + configurator = configuratorAndProtocol.configurator; + configuratorProxy = configuratorAndProtocol.configuratorProxy; + proxyAdmin = configuratorAndProtocol.proxyAdmin; + assetListFactoryAddress = configuratorAndProtocol.assetListFactory.address; + comet = comet.attach(cometProxyAddress); + + // Tokens + baseToken = configuratorAndProtocol.tokens.USDC; + compToken = configuratorAndProtocol.tokens.COMP; + + compPriceFeed = configuratorAndProtocol.priceFeeds.COMP; + + alice = configuratorAndProtocol.users[0]; + bob = configuratorAndProtocol.users[1]; + + // Allocate base token to comet + await baseToken.allocateTo(comet.address, exp(1000, 6)); + + // Supply COMP from Alice + await compToken.allocateTo(alice.address, aliceCompSupply); + await compToken.connect(alice).approve(comet.address, aliceCompSupply); + await comet.connect(alice).supply(compToken.address, aliceCompSupply); + + // Borrow COMP from Alice + await comet.connect(alice).withdraw(baseToken.address, exp(150, 6)); + + // Drop COMP price from 200 to 100 to make Alice liquidatable + await compPriceFeed.setRoundData( + 0, // roundId + exp(100, 8), // answer + 0, // startedAt + 0, // updatedAt + 0 // answeredInRound + ); + + // Verify Alice is liquidatable + expect(await comet.isLiquidatable(alice.address)).to.be.true; + + // Save data before absorption + userCollateralBeforeAbsorption = (await comet.userCollateral(alice.address, compToken.address)).balance; + totalsSupplyAssetBeforeAbsorption = (await comet.totalsCollateral(compToken.address)).totalSupplyAsset; + + configurator = configurator.attach(configuratorProxy.address); + const CometExtAssetList = await ( + await ethers.getContractFactory('CometExtAssetList') + ).deploy( + { + name32: ethers.utils.formatBytes32String('Compound Comet'), + symbol32: ethers.utils.formatBytes32String('BASE'), + }, + assetListFactoryAddress + ); + await CometExtAssetList.deployed(); + await configurator.setExtensionDelegate(cometProxyAddress, CometExtAssetList.address); + // 2) switch factory to CometFactoryWithExtendedAssetList + const CometFactoryWithExtendedAssetList = await (await ethers.getContractFactory('CometFactoryWithExtendedAssetList')).deploy(); + await CometFactoryWithExtendedAssetList.deployed(); + await configurator.setFactory(cometProxyAddress, CometFactoryWithExtendedAssetList.address); + + /*////////////////////////////////////////////////////////////// + 24 ASSETS COMET + //////////////////////////////////////////////////////////////*/ + const collaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [ + `ASSET${j}`, + { + decimals: 18, + initialPrice: 200, + }, + ]) + ); + // Create protocol with configurator so we can update liquidationFactor later + const configuratorAndProtocol24Assets = await makeConfigurator({ assets: { USDC: { decimals: 6, initialPrice: 1 }, ...collaterals }}); + comet24Assets = configuratorAndProtocol24Assets.cometWithExtendedAssetList.attach(configuratorAndProtocol24Assets.cometProxy.address) as CometWithExtendedAssetList; + underwater24Assets = configuratorAndProtocol24Assets.users[0]; + absorber24Assets = configuratorAndProtocol24Assets.users[1]; + tokens24Assets = configuratorAndProtocol24Assets.tokens; + priceFeeds24Assets = configuratorAndProtocol24Assets.priceFeeds; + configuratorProxy24Assets = configuratorAndProtocol24Assets.configurator.attach(configuratorAndProtocol24Assets.configuratorProxy.address); + proxyAdmin24Assets = configuratorAndProtocol24Assets.proxyAdmin; + + const CometExtAssetList24Assets = await ( + await ethers.getContractFactory('CometExtAssetList') + ).deploy( + { + name32: ethers.utils.formatBytes32String('Compound Comet'), + symbol32: ethers.utils.formatBytes32String('BASE'), + }, + configuratorAndProtocol24Assets.assetListFactory.address + ); + await CometExtAssetList24Assets.deployed(); + await configuratorProxy24Assets.setExtensionDelegate(configuratorAndProtocol24Assets.cometProxy.address, CometExtAssetList24Assets.address); + await configuratorProxy24Assets.setFactory(configuratorAndProtocol24Assets.cometProxy.address, CometFactoryWithExtendedAssetList.address); + await configuratorAndProtocol24Assets.proxyAdmin.deployAndUpgradeTo(configuratorAndProtocol24Assets.configuratorProxy.address, configuratorAndProtocol24Assets.cometProxy.address); + + baseToken24Assets = configuratorAndProtocol24Assets.tokens['USDC']; + + cometExt = (await ethers.getContractAt('CometExtAssetList', comet.address)) as CometExtAssetList; + const totalBasics = await cometExt.totalsBasic(); + oldPrincipal = (await comet.userBasic(alice.address)).principal.toBigInt(); + totalSupplyBase = totalBasics.totalSupplyBase; + totalBorrowBase = totalBasics.totalBorrowBase; + oldBalance = presentValue(oldPrincipal, totalBasics.baseSupplyIndex, totalBasics.baseBorrowIndex); + basePrice = await comet.getPrice(await comet.baseTokenPriceFeed()); + baseScale = await comet.baseScale(); + + snapshot = await takeSnapshot(); + }); + + describe('liquidation factor > 0', function () { + /* + * This test suite verifies the standard absorption flow when liquidation factor > 0. + * + * Flow: + * 1. Setup: Alice supplies COMP collateral (1e18) and borrows base tokens (150e6 USDC) + * 2. Price drop: COMP price drops from 200 to 100, making Alice undercollateralized and liquidatable + * 3. Absorption: Bob (absorber) calls absorb() to liquidate Alice's account + * 4. When liquidation factor > 0: + * - Collateral is seized: Alice's COMP collateral is transferred to the protocol + * - AbsorbCollateral event is emitted with the seized amount and USD value + * - User collateral balance is set to 0 + * - totalsCollateral.totalSupplyAsset is reduced to 0 + * - User's assetsIn is reset to 0 + * - User principal is updated based on the USD value of seized collateral + * - AbsorbDebt event is emitted with the base amount paid out to absorber + * - Total borrow base is reduced by the repay amount + * - Transfer event is NOT emitted (since new principal becomes 0) + * + * This verifies that when an asset has a non-zero liquidation factor, it can be + * liquidated/seized during absorption, and all state updates occur correctly. + */ + + it('absorbs undercollateralized account', async () => { + liquidationTx = await comet.connect(bob).absorb(bob.address, [alice.address]); + + expect(liquidationTx).to.not.be.reverted; + }); + + it('emits AbsorbCollateral event', async () => { + const assetInfo = await comet.getAssetInfoByAddress(compToken.address); + const [_, price] = await compPriceFeed.latestRoundData(); + expectedUsdValue = mulPrice(aliceCompSupply, price, assetInfo.scale); + + expect(liquidationTx).to.emit(comet, 'AbsorbCollateral').withArgs(bob.address, alice.address, compToken.address, aliceCompSupply, expectedUsdValue); + }); + + it('reduces totals supply of the asset for seized asset', async () => { + const totals = await comet.totalsCollateral(compToken.address); + expect(totals.totalSupplyAsset).to.equal(0); + }); + + it('sets user collateral balance to 0', async () => { + expect((await comet.userCollateral(alice.address, compToken.address)).balance).to.equal(0); + }); + + it('reset user assetsIn to 0', async () => { + expect((await comet.userBasic(alice.address)).assetsIn).to.equal(0); + expect((await comet.userBasic(alice.address))._reserved).to.equal(0); + }); + + it('updates totals correctly after absorption', async () => { + // Calculate expected totals + const deltaBalance = divPrice(expectedUsdValue, basePrice, baseScale); + const totalsBasic = await cometExt.totalsBasic(); + + newBalance = oldBalance + deltaBalance; + if (newBalance < 0) newBalance = 0n; + newPrincipal = principalValue(newBalance, totalsBasic.baseSupplyIndex, totalsBasic.baseBorrowIndex); + + // Check that user principal is updated correctly + expect((await comet.userBasic(alice.address)).principal).to.equal(newPrincipal); + // Calculate repay and supply amounts + // We expect that new principal is greater than old principal + expect(newPrincipal > oldPrincipal).to.be.true; + // New principal becomes zero as we check before, thus we go strongly in case `newPrincipal <= 0` + expect(newPrincipal <= 0).to.be.true; + const repayAmount = newPrincipal - oldPrincipal; + const supplyAmount = 0n; + + const newTotalsBasic = await cometExt.totalsBasic(); + expect(newTotalsBasic.totalSupplyBase).to.equal(totalSupplyBase.toBigInt() + supplyAmount); + expect(newTotalsBasic.totalBorrowBase).to.equal(totalBorrowBase.toBigInt() - repayAmount); + }); + + it('updates user principal correctly after absorption', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(newPrincipal); + + await snapshot.restore(); + }); + + it('emits AbsorbDebt event', async () => { + const basePaidOut = newBalance - oldBalance; + const valueOfBasePaidOut = mulPrice(basePaidOut, basePrice, baseScale); + expect(liquidationTx).to.emit(comet, 'AbsorbDebt').withArgs(bob.address, alice.address, basePaidOut, valueOfBasePaidOut); + }); + + it('Transfer event is not emitted', async () => { + // Transfer event emits only when new principal is greater than 0 + expect(newPrincipal).to.equal(0); + expect(liquidationTx).to.not.emit(comet, 'Transfer'); + }); + }); + + describe('liquidation factor = 0', function () { + /* + * This test suite verifies the absorption flow when liquidation factor = 0. + * + * Flow: + * 1. Setup: Same initial state as above - Alice supplies COMP collateral and borrows base tokens + * 2. Configuration: COMP asset's liquidation factor is updated to 0 via configurator + * 3. Price drop: COMP price drops from 200 to 100, making Alice liquidatable + * 4. Absorption: Bob (absorber) calls absorb() to liquidate Alice's account + * 5. When liquidation factor = 0: + * - Collateral is NOT seized: Alice's COMP collateral remains untouched + * - AbsorbCollateral event is NOT emitted (asset is skipped during absorption) + * - User collateral balance remains unchanged (same as before absorption) + * - totalsCollateral.totalSupplyAsset remains unchanged + * - User principal is still updated (debt is absorbed, but no collateral value is applied) + * - AbsorbDebt event is still emitted (debt absorption occurs, but with 0 base paid out) + * - Total borrow base is still reduced (debt is repaid) + * - Transfer event is NOT emitted (since new principal becomes 0) + * + * This verifies that when an asset has a zero liquidation factor, it is skipped during + * absorption (not liquidated), but the absorption process still continues for debt + * repayment. This allows protocol admins to temporarily disable liquidation of specific + * assets while still allowing debt absorption to proceed. + */ + + it('liquidation factor can be updated to 0', async () => { + await configurator.updateAssetLiquidationFactor(cometProxyAddress, compToken.address, exp(0, 18)); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('liquidation factor becomes 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).liquidationFactor).to.equal(0); + }); + + it('absorbs undercollateralized account with 0 liquidation factor on asset', async () => { + liquidationTx = await comet.connect(bob).absorb(bob.address, [alice.address]); + + expect(liquidationTx).to.not.be.reverted; + }); + + it('does not emit AbsorbCollateral event', async () => { + expect(liquidationTx).to.not.emit(comet, 'AbsorbCollateral'); + }); + + it('does not affect user collateral balance', async () => { + expect((await comet.userCollateral(alice.address, compToken.address)).balance).to.equal(userCollateralBeforeAbsorption); + }); + + it('does not affect totals supply of the asset', async () => { + expect((await comet.totalsCollateral(compToken.address)).totalSupplyAsset).to.equal(totalsSupplyAssetBeforeAbsorption); + }); + + it('updates totals correctly after absorption', async () => { + // Expected USD value is 0 because of skipping absorption of the asset + expectedUsdValue = 0n; + + // Calculate expected totals + const deltaBalance = divPrice(expectedUsdValue, basePrice, baseScale); + const totalsBasic = await cometExt.totalsBasic(); + + let newBalance = oldBalance + deltaBalance; + if (newBalance < 0) newBalance = 0n; + newPrincipal = principalValue(newBalance, totalsBasic.baseSupplyIndex, totalsBasic.baseBorrowIndex); + + // Check that user principal is updated correctly + expect((await comet.userBasic(alice.address)).principal).to.equal(newPrincipal); + // Calculate repay and supply amounts + // We expect that new principal is greater than old principal + expect(newPrincipal > oldPrincipal).to.be.true; + // New principal becomes zero as we check before, thus we go strongly in case `newPrincipal <= 0` + expect(newPrincipal <= 0).to.be.true; + const repayAmount = newPrincipal - oldPrincipal; + const supplyAmount = 0n; + + const newTotalsBasic = await cometExt.totalsBasic(); + expect(newTotalsBasic.totalSupplyBase).to.equal(totalSupplyBase.toBigInt() + supplyAmount); + expect(newTotalsBasic.totalBorrowBase).to.equal(totalBorrowBase.toBigInt() - repayAmount); + }); + + it('updates user principal correctly after absorption', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(newPrincipal); + }); + + it('emits AbsorbDebt event', async () => { + const basePaidOut = newBalance - oldBalance; + const valueOfBasePaidOut = mulPrice(basePaidOut, basePrice, baseScale); + expect(liquidationTx).to.emit(comet, 'AbsorbDebt').withArgs(bob.address, alice.address, basePaidOut, valueOfBasePaidOut); + }); + + it('Transfer event is not emitted', async () => { + // Transfer event emits only when new principal is greater than 0 + expect(newPrincipal).to.equal(0); + expect(liquidationTx).to.not.emit(comet, 'Transfer'); + }); + }); + + describe('24 collateral assets', function () { + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`skips absorption of asset ${i - 1} with liquidation factor = 0 with collaterals ${i}`, async () => { + /** + * This parameterized test verifies that absorb skips assets with liquidation factor = 0. + * For each iteration (i = 1 to 24), it tests asset i-1 in a protocol with i total collaterals. + * The test: (1) supplies collateral and borrows to make the account liquidatable, + * (2) sets the target asset's liquidation factor to 0, (3) calls absorb, and + * (4) verifies that the target asset is skipped (user collateral balance and totalsCollateral totalSupplyAsset remain unchanged). + */ + + const targetSymbol = `ASSET${i - 1}`; + const targetToken = tokens24Assets[targetSymbol]; + + // Supply, borrow, and make liquidatable + const supplyAmount = exp(1, 18); + await targetToken.allocateTo(underwater24Assets.address, supplyAmount); + await targetToken.connect(underwater24Assets).approve(comet24Assets.address, supplyAmount); + await comet24Assets.connect(underwater24Assets).supply(targetToken.address, supplyAmount); + + const borrowAmount = exp(150, 6); + await baseToken24Assets.allocateTo(comet24Assets.address, borrowAmount); + await comet24Assets.connect(underwater24Assets).withdraw(baseToken24Assets.address, borrowAmount); + + // Drop price of token to make liquidatable + await priceFeeds24Assets[targetSymbol].setRoundData(0, 100, 0, 0, 0); + + expect(await comet24Assets.isLiquidatable(underwater24Assets.address)).to.be.true; + + // Step 3: Update liquidationFactor to 0 for target asset + await configuratorProxy24Assets.updateAssetLiquidationFactor(comet24Assets.address, targetToken.address, exp(0, 18)); + + // Upgrade proxy again after updating liquidationFactor + await proxyAdmin24Assets.deployAndUpgradeTo(configuratorProxy24Assets.address, comet24Assets.address); + + // Verify liquidationFactor is 0 + expect((await comet24Assets.getAssetInfoByAddress(targetToken.address)).liquidationFactor).to.equal(0); + + // Step 4: Save balances before absorb + const userCollateralBefore = (await comet24Assets.userCollateral(underwater24Assets.address, targetToken.address)).balance; + const totalsBefore = (await comet24Assets.totalsCollateral(targetToken.address)).totalSupplyAsset; + + expect(userCollateralBefore).to.equal(supplyAmount); + expect(totalsBefore).to.equal(supplyAmount); + + // Step 5: Absorb should skip this asset (no seizure) and balances remain unchanged + await comet24Assets.connect(absorber24Assets).absorb(absorber24Assets.address, [underwater24Assets.address]); + + // Verify balances remain unchanged + expect((await comet24Assets.userCollateral(underwater24Assets.address, targetToken.address)).balance).to.equal(userCollateralBefore); + expect((await comet24Assets.totalsCollateral(targetToken.address)).totalSupplyAsset).to.equal(totalsBefore); + }); + } + }); + + describe('edge cases', function () { + it('absorbs with mixed liquidation factors and skips zeroed assets', async () => { + /** + * This test checks that when there are five collateral assets with mixed liquidation factors, + * the absorb function only seizes (liquidates) those assets whose liquidationFactor is nonzero, + * and skips assets whose liquidationFactor is zero (leaving their balances unchanged after absorb). + * It sets up the protocol, configures various assets, updates some to have zero liquidation factor, + * and verifies that 'absorb' seizes only the correct collateral, without affecting those set to be skipped. + */ + + await snapshot.restore(); + + // Supply, borrow, and make liquidatable + const supplyAmount = exp(1, 18); + const targetSymbols = ['ASSET0', 'ASSET1', 'ASSET2', 'ASSET3', 'ASSET4']; + for (const sym of targetSymbols) { + const token = tokens24Assets[sym]; + await token.allocateTo(underwater24Assets.address, supplyAmount); + await token.connect(underwater24Assets).approve(comet24Assets.address, supplyAmount); + await comet24Assets.connect(underwater24Assets).supply(token.address, supplyAmount); + } + + const borrowAmount = exp(500, 6); + await baseToken24Assets.allocateTo(comet24Assets.address, borrowAmount); + await comet24Assets.connect(underwater24Assets).withdraw(baseToken24Assets.address, borrowAmount); + + // Drop price of all tokens to make liquidatable + for (const sym of targetSymbols) { + await priceFeeds24Assets[sym].setRoundData(0, 100, 0, 0, 0); + } + + expect(await comet24Assets.isLiquidatable(underwater24Assets.address)).to.be.true; + + // Update liquidationFactor to 0 for three assets (ASSET1, ASSET3, ASSET4) + const zeroLfSymbols = ['ASSET1', 'ASSET3', 'ASSET4']; + for (const sym of zeroLfSymbols) { + await configuratorProxy24Assets.updateAssetLiquidationFactor(comet24Assets.address, tokens24Assets[sym].address, exp(0, 18)); + } + + // Upgrade proxy again after updating liquidationFactor + await proxyAdmin24Assets.deployAndUpgradeTo(configuratorProxy24Assets.address, comet24Assets.address); + + // Save balances before absorb for two categories + // - Should be seized: ASSET0, ASSET2 + // - Should be skipped (unchanged): ASSET1, ASSET3, ASSET4 + const userBefore: Record = {} as any; + const totalsBefore: Record = {} as any; + for (const sym of ['ASSET0', 'ASSET1', 'ASSET2', 'ASSET3', 'ASSET4']) { + userBefore[sym] = (await comet24Assets.userCollateral(underwater24Assets.address, tokens24Assets[sym].address)).balance; + totalsBefore[sym] = (await comet24Assets.totalsCollateral(tokens24Assets[sym].address)).totalSupplyAsset; + expect(userBefore[sym]).to.equal(supplyAmount); + expect(totalsBefore[sym]).to.equal(supplyAmount); + } + + // Absorb - should skip assets with LF = 0 + await comet24Assets.connect(absorber24Assets).absorb(absorber24Assets.address, [underwater24Assets.address]); + + // Verify skipped assets remain unchanged + for (const sym of ['ASSET1', 'ASSET3', 'ASSET4']) { + expect((await comet24Assets.userCollateral(underwater24Assets.address, tokens24Assets[sym].address)).balance).to.equal(userBefore[sym]); + expect((await comet24Assets.totalsCollateral(tokens24Assets[sym].address)).totalSupplyAsset).to.equal(totalsBefore[sym]); + } + + // Verify seized assets set user balance to 0 and reduce totals + for (const sym of ['ASSET0', 'ASSET2']) { + expect((await comet24Assets.userCollateral(underwater24Assets.address, tokens24Assets[sym].address)).balance).to.equal(0); + expect((await comet24Assets.totalsCollateral(tokens24Assets[sym].address)).totalSupplyAsset).to.equal(0); + } + }); + }); + + describe('revert on price feed side', function () { + /* + * This test suite reproduces the "price feed paralysis" edge case on top of the + * Comet/Configurator deployment and user positions that are already set up in the + * outer `before` block. + * + * At the point we enter this `describe`, Alice already has a borrow position that is + * liquidatable under normal (non-reverting) price feeds; this suite does NOT open that + * position, it just reuses it. + * + * The tests then walk through the problematic sequence: + * 1. Assert that Alice is liquidatable with the normal COMP price feed. + * 2. Have governance update COMP's price feed to `PriceFeedWithRevert`, which always + * reverts on `latestRoundData`, and verify that the feed address on Comet changed. + * 3. Show that any call that needs the COMP price (`isLiquidatable`, `isBorrowCollateralized`, + * or `absorb`) now reverts with the `Reverted` custom error, effectively freezing + * liquidations for that collateral. + * 4. Finally, revert the price feed back to the normal implementation and verify that + * the same calls succeed again, demonstrating that the paralysis is solely due to + * the reverting price feed. + * + * Each `it` in this `describe` advances the shared state one step on top of the common + * baseline snapshot: from "liquidatable and working normally" → "paralyzed by a reverting + * price feed" → "recovered after restoring a healthy feed". + */ + let priceFeedWithRevert: PriceFeedWithRevert; + before(async () => { + // Start from the common baseline state for this suite + await snapshot.restore(); + + const PriceFeedWithRevert = await ethers.getContractFactory('PriceFeedWithRevert') as PriceFeedWithRevert__factory; + priceFeedWithRevert = await PriceFeedWithRevert.deploy(100, 8); + await priceFeedWithRevert.deployed(); + }); + + it('alice is liquidable', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.true; + }); + + it('governance updates price feed to reverting implementation', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, compToken.address, priceFeedWithRevert.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('price feed updated to reverting implementation', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).priceFeed).to.equal(priceFeedWithRevert.address); + }); + + it('isLiquidatable now reverts due to reverting price feed', async () => { + await expect(comet.isLiquidatable(alice.address)).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('isBorrowCollateralized now reverts due to reverting price feed', async () => { + await expect(comet.isBorrowCollateralized(alice.address)).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('absorb reverts when collateral price cannot be fetched', async () => { + await expect( + comet.connect(bob).absorb(bob.address, [alice.address]) + ).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('governance updates price feed to normal implementation', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, compToken.address, compPriceFeed.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('price feed updated to normal implementation', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).priceFeed).to.equal(compPriceFeed.address); + }); + + it('isLiquidatable now does not revert', async () => { + expect(await comet.isLiquidatable(alice.address)).to.not.be.reverted; + }); + + it('isBorrowCollateralized now does not revert', async () => { + expect(await comet.isBorrowCollateralized(alice.address)).to.not.be.reverted; + }); + + it('absorb does not revert', async () => { + await expect(comet.connect(bob).absorb(bob.address, [alice.address])).to.not.be.reverted; + }); + }); + }); +}); diff --git a/test/allow-by-sig-test.ts b/test/allow-by-sig-test.ts index 82195d7e6..2d422d449 100644 --- a/test/allow-by-sig-test.ts +++ b/test/allow-by-sig-test.ts @@ -29,7 +29,7 @@ const types = { describe('allowBySig', function () { beforeEach(async () => { - comet = (await makeProtocol()).comet; + comet = (await makeProtocol()).cometWithExtendedAssetList; [_admin, pauseGuardian, signer, manager] = await ethers.getSigners(); domain = { diff --git a/test/collateral-deactivation-test.ts b/test/collateral-deactivation-test.ts new file mode 100644 index 000000000..8d888550b --- /dev/null +++ b/test/collateral-deactivation-test.ts @@ -0,0 +1,271 @@ +import { CometExt, CometHarnessInterfaceExtendedAssetList } from 'build/types'; +import { MAX_ASSETS, expect, makeProtocol } from './helpers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { ContractTransaction } from 'ethers'; + +/** + * @title Collateral deactivation and reactivation tests + * @notice + * This test suite documents and verifies the collateral deactivation feature that was + * introduced after the wUSDM and deUSD incident. In that incident, the protocol needed + * to react quickly to compromised / risky collateral, but the only available control + * surface was the governance proposal system, which introduces latency and coordination + * overhead. + * + * To address this, Comet was extended with a dedicated collateral deactivation mechanism: + * - The `pauseGuardian` can immediately deactivate a collateral asset by index via + * `CometExt.deactivateCollateral(assetIndex)`. + * - Deactivation sets a bit in `deactivatedCollaterals` storage and, for the given asset: + * - marks the asset as deactivated in core `Comet` (`isCollateralDeactivated`), + * - pauses supply of that collateral (via `collateralsSupplyPauseFlags`), + * - pauses transfer of that collateral (via `collateralsTransferPauseFlags`). + * - Once the risk is understood and resolved, the `governor` can later reactivate the + * asset via `CometExt.activateCollateral(assetIndex)`, which: + * - clears the deactivation bit in `deactivatedCollaterals`, + * - unpauses supply and transfer for that asset. + * + * This design allows: + * - **Fast, operational safety response** (pauseGuardian can respond without waiting for a + * full governance proposal lifecycle). + * - **Granularity per asset** (deactivate / activate by asset index, without impacting + * other collaterals). + * - **Clear separation of roles**: + * - `pauseGuardian`: emergency, short-term safety actions (deactivation). + * - `governor`: long-term policy decisions and re-enabling assets (activation). + * + * @dev + * What is tested in this file: + * + * 1. **Collateral deactivation happy path** + * - The `pauseGuardian` can successfully call `deactivateCollateral(assetIndex)`. + * - The transaction emits: + * - `CollateralDeactivated(assetIndex)` to signal that the asset has been marked + * as deactivated in protocol storage. + * - `CollateralAssetSupplyPauseAction(assetIndex, true)` to signal that new supply + * of the asset is paused. + * - `CollateralAssetTransferPauseAction(assetIndex, true)` to signal that transfers + * of that collateral are paused. + * - The core `Comet` contract reflects the updated state: + * - `isCollateralDeactivated(assetIndex)` returns `true`. + * - `deactivatedCollaterals()` has the corresponding bit set. + * - `isCollateralAssetSupplyPaused(assetIndex)` and + * `isCollateralAssetTransferPaused(assetIndex)` both return `true`. + * + * 2. **Collateral deactivation failure modes** + * - Only the `pauseGuardian` may deactivate collateral: + * - Calls from `governor` (or any non-pauseGuardian address) revert with the + * `OnlyPauseGuardian` custom error. + * - Asset index bounds are enforced: + * - Using an out-of-range index (`MAX_ASSETS`) reverts with `InvalidAssetIndex`. + * + * 3. **Collateral activation happy path** + * - The `governor` can successfully call `activateCollateral(assetIndex)` to re-enable + * a previously deactivated asset. + * - The transaction emits: + * - `CollateralActivated(assetIndex)` to signal that the deactivation flag for the + * asset has been cleared. + * - `CollateralAssetSupplyPauseAction(assetIndex, false)` to signal that new + * supply is allowed again. + * - `CollateralAssetTransferPauseAction(assetIndex, false)` to signal that + * transfers are allowed again. + * - Core `Comet` state is updated: + * - `isCollateralDeactivated(assetIndex)` returns `false`. + * - `deactivatedCollaterals()` is updated to clear the corresponding bit. + * - `isCollateralAssetSupplyPaused(assetIndex)` and + * `isCollateralAssetTransferPaused(assetIndex)` both return `false`. + * + * 4. **Collateral activation failure modes** + * - Only the `governor` may activate collateral: + * - Calls from the `pauseGuardian` (or any non-governor address) revert with + * the `OnlyGovernor` custom error. + * - Asset index bounds are enforced: + * - Using an out-of-range index (`MAX_ASSETS`) reverts with `InvalidAssetIndex`. + * + * 5. **MAX_ASSETS scalability and coverage** + * - The suite constructs a protocol with `MAX_ASSETS` collaterals and iterates over + * all valid indices. + * - For each `assetIndex` in `[0, MAX_ASSETS - 1]`: + * - `deactivateCollateral(assetIndex)` is callable by the `pauseGuardian` and + * marks the asset as deactivated in `Comet` (`isCollateralDeactivated` is `true`). + * - `activateCollateral(assetIndex)` is callable by the `governor` and clears the + * deactivated flag (`isCollateralDeactivated` is `false`). + * - This proves that the deactivation / activation bitmaps and pause flags scale across + * the entire configured collateral set, including those whose bits are stored in both + * `assetsIn` and `_reserved` segments on the core contract side. + * + * Together, these tests ensure that after the wUSDM and deUSD incident: + * - the protocol has a robust, low-latency mechanism to quarantine risky collateral + * without waiting on governance, + * - the mechanism is correctly wired to both storage-level flags and high-level events, + * - and it behaves safely and predictably across all supported asset indices and roles. + */ +describe('collateral deactivation functionality', function () { + // Contracts + let comet: CometHarnessInterfaceExtendedAssetList; + let cometExt: CometExt; + + // Signers + let governor: SignerWithAddress; + let pauseGuardian: SignerWithAddress; + + // Constants + const ASSET_INDEX = 0; + + before(async function () { + const collaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, {}]) + ); + const protocol = await makeProtocol({ + assets: { USDC: {}, ...collaterals }, + }); + comet = protocol.cometWithExtendedAssetList; + cometExt = comet.attach(comet.address) as CometExt; + governor = protocol.governor; + pauseGuardian = protocol.pauseGuardian; + }); + + describe('collateral deactivation', function () { + describe('happy path', function () { + let deactivateCollateralTx: ContractTransaction; + + it('allows to deactivate by pause guardian', async function () { + deactivateCollateralTx = await cometExt.connect(pauseGuardian).deactivateCollateral(ASSET_INDEX); + await expect(deactivateCollateralTx).to.not.be.reverted; + }); + + it('emits CollateralDeactivated event', async function () { + expect(deactivateCollateralTx).to.emit(cometExt, 'CollateralDeactivated').withArgs(ASSET_INDEX); + }); + + it('emits CollateralAssetSupplyPauseAction event', async function () { + expect(deactivateCollateralTx).to.emit(cometExt, 'CollateralAssetSupplyPauseAction').withArgs(ASSET_INDEX, true); + }); + + it('emits CollateralAssetTransferPauseAction event', async function () { + expect(deactivateCollateralTx).to.emit(cometExt, 'CollateralAssetTransferPauseAction').withArgs(ASSET_INDEX, true); + }); + + it('sets collateral as deactivated in comet', async function () { + expect(await comet.isCollateralDeactivated(ASSET_INDEX)).to.be.true; + }); + + it('updates deactivated collaterals flag in comet storage', async function () { + expect(await comet.deactivatedCollaterals()).to.equal(1); + }); + + it('updates pause flags for deactivated collateral', async function () { + expect(await comet.isCollateralAssetSupplyPaused(ASSET_INDEX)).to.be.true; + expect(await comet.isCollateralAssetTransferPaused(ASSET_INDEX)).to.be.true; + }); + }); + + describe('reverts when', function () { + it('caller is not pause guardian', async function () { + await expect(cometExt.connect(governor).deactivateCollateral(ASSET_INDEX)).to.be.revertedWithCustomError(cometExt, 'OnlyPauseGuardian'); + }); + + it('asset index is invalid', async function () { + await expect(cometExt.connect(pauseGuardian).deactivateCollateral(MAX_ASSETS)).to.be.revertedWithCustomError(cometExt, 'InvalidAssetIndex'); + }); + + it('collateral is already deactivated', async function () { + await expect( + cometExt.connect(pauseGuardian).deactivateCollateral(ASSET_INDEX) + ).to.be.revertedWithCustomError(cometExt, 'CollateralIsDeactivated') + .withArgs(ASSET_INDEX); + }); + }); + }); + + describe('collateral activation', function () { + describe('happy path', function () { + let activateCollateralTx: ContractTransaction; + + it('allows to activate by governor', async function () { + activateCollateralTx = await cometExt.connect(governor).activateCollateral(ASSET_INDEX); + await expect(activateCollateralTx).to.not.be.reverted; + }); + + it('emits CollateralActivated event', async function () { + expect(activateCollateralTx).to.emit(cometExt, 'CollateralActivated').withArgs(ASSET_INDEX); + }); + + it('emits CollateralAssetSupplyPauseAction event', async function () { + expect(activateCollateralTx).to.emit(cometExt, 'CollateralAssetSupplyPauseAction').withArgs(ASSET_INDEX, false); + }); + + it('emits CollateralAssetTransferPauseAction event', async function () { + expect(activateCollateralTx).to.emit(cometExt, 'CollateralAssetTransferPauseAction').withArgs(ASSET_INDEX, false); + }); + + it('sets collateral as activated in comet', async function () { + expect(await comet.isCollateralDeactivated(ASSET_INDEX)).to.be.false; + }); + + it('updates deactivated collaterals flag in comet storage', async function () { + expect(await comet.deactivatedCollaterals()).to.equal(0); + }); + + it('updates pause flags for activated collateral', async function () { + expect(await comet.isCollateralAssetSupplyPaused(ASSET_INDEX)).to.be.false; + expect(await comet.isCollateralAssetTransferPaused(ASSET_INDEX)).to.be.false; + }); + }); + + describe('reverts when', function () { + it('caller is not governor', async function () { + await expect(cometExt.connect(pauseGuardian).activateCollateral(ASSET_INDEX)).to.be.revertedWithCustomError(cometExt, 'OnlyGovernor'); + }); + + it('asset index is invalid', async function () { + await expect(cometExt.connect(governor).activateCollateral(MAX_ASSETS)).to.be.revertedWithCustomError(cometExt, 'InvalidAssetIndex'); + }); + + it('collateral is already activated', async function () { + await expect(cometExt.connect(governor).activateCollateral(ASSET_INDEX)) + .to.be.revertedWithCustomError(cometExt, 'CollateralIsActivated') + .withArgs(ASSET_INDEX); + }); + }); + }); + + describe(`${MAX_ASSETS} assets support`, function () { + describe('deactivation', function () { + for (let i = 1; i <= MAX_ASSETS; i++) { + let assetIndex = i - 1; + + it(`allows to deactivate for asset ${i}`, async function () { + await cometExt.connect(pauseGuardian).deactivateCollateral(assetIndex); + + // Verify that the collateral at index i is deactivated + expect(await comet.isCollateralDeactivated(assetIndex)).to.be.true; + }); + + it('reverts on double deactivation', async function () { + await expect(cometExt.connect(pauseGuardian).deactivateCollateral(assetIndex)) + .to.be.revertedWithCustomError(cometExt, 'CollateralIsDeactivated') + .withArgs(assetIndex); + }); + } + }); + + describe('activation', function () { + for (let i = 1; i <= MAX_ASSETS; i++) { + let assetIndex = i - 1; + + it(`allows to activate for asset ${i}`, async function () { + await cometExt.connect(governor).activateCollateral(assetIndex); + + // Verify that the collateral at index i is activated + expect(await comet.isCollateralDeactivated(assetIndex)).to.be.false; + }); + + it('reverts on double activation', async function () { + await expect(cometExt.connect(governor).activateCollateral(assetIndex)) + .to.be.revertedWithCustomError(cometExt, 'CollateralIsActivated') + .withArgs(assetIndex); + }); + } + }); + }); +}); \ No newline at end of file diff --git a/test/extended-pause-test.ts b/test/extended-pause-test.ts new file mode 100644 index 000000000..5c499f132 --- /dev/null +++ b/test/extended-pause-test.ts @@ -0,0 +1,1227 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect, makeProtocol, MAX_ASSETS } from './helpers'; +import { CometExt, CometHarnessInterfaceExtendedAssetList } from 'build/types'; +import { ContractTransaction } from 'ethers'; + +/** + * Context: Written after the USDM incident (a Chainlink price feed removal). The protocol added + * an "extended pause" layer to selectively disable sensitive flows without halting the entire market. + * + * What extended pause is: + * - A set of fine‑grained, role‑gated pause flags exposed by the extension (`CometExt`) and enforced by + * the core (`CometWithExtendedAssetList`). + * - Governor or Pause Guardian can toggle offsets that affect: + * - Base/collateral Supply: global base (`pauseBaseSupply`) and global collateral (`pauseCollateralSupply`), + * plus per‑collateral asset supply (`pauseCollateralAssetSupply(assetIndex, ...)`). + * - Withdraw: lenders vs borrowers paths for base (`pauseLendersWithdraw`, `pauseBorrowersWithdraw`), + * global collateral withdraw (`pauseCollateralWithdraw`), plus per‑asset collateral withdraw + * (`pauseCollateralAssetWithdraw(assetIndex, ...)`). + * - Transfer: lenders vs borrowers paths for base (`pauseLendersTransfer`, `pauseBorrowersTransfer`), + * global collateral transfer (`pauseCollateralTransfer`), plus per‑asset collateral transfer + * (`pauseCollateralAssetTransfer(assetIndex, ...)`). + * - These are separate from the legacy coarse flags (`pause(...)` on the core) and are checked in addition to them. + * + * Where it is enforced (core checks in `CometWithExtendedAssetList`): + * - Supply: `supplyInternal` → base path checks `isBaseSupplyPaused()`, collateral path checks + * `isCollateralSupplyPaused()` and per‑asset `isCollateralAssetSupplyPaused(offset)`. + * - Withdraw (base): `withdrawBase` branches to lenders/borrowers and reverts with + * `LendersWithdrawPaused` or `BorrowersWithdrawPaused`. + * - Withdraw (collateral): `withdrawCollateral` checks global `isCollateralWithdrawPaused()` and per‑asset + * `isCollateralAssetWithdrawPaused(offset)`. + * - Transfer (base): `transferBase` branches to lenders/borrowers and reverts with + * `LendersTransferPaused` or `BorrowersTransferPaused`. + * - Transfer (collateral): `transferCollateral` checks global `isCollateralTransferPaused()` and per‑asset + * `isCollateralAssetTransferPaused(offset)`. + * + * What this suite verifies: + * - Only Governor or Pause Guardian can toggle (access control via `onlyGovernorOrPauseGuardian`). + * - Idempotency protection: attempting to set an already‑set status reverts with + * `OffsetStatusAlreadySet` or `CollateralAssetOffsetStatusAlreadySet`. + * - Each pause flag blocks exactly its intended flow and does not affect unrelated flows: + * - Base vs collateral supply; lenders vs borrowers withdraw/transfer; global vs per‑asset flags. + * - Per‑asset flags override behavior for a single collateral by index without impacting others. + * - Unpausing per-asset collateral supply/transfer requires the collateral to be active; + * unpause attempts on deactivated collateral revert with `CollateralIsDeactivated`. + * - Boundary conditions: `isValidAssetIndex` enforced; invalid indices revert with `InvalidAssetIndex`. + * - Coexistence with legacy pause flags: both layers are respected (extended flags are additional gates). + * - Events are emitted for each toggle action from `CometExt` methods. + */ +describe('extended pause functionality', function () { + // Contracts + let comet: CometHarnessInterfaceExtendedAssetList; + let cometExt: CometExt; + let cometWithMaxAssets: CometHarnessInterfaceExtendedAssetList; + let cometExtWithMaxAssets: CometExt; + + // Signers + let governor: SignerWithAddress; + let pauseGuardian: SignerWithAddress; + let users: SignerWithAddress[] = []; + + // Constants + const assetIndex = 0; + + before(async function () { + const protocol = await makeProtocol({ assets: { USDC: {}, ASSET1: {} } }); + comet = protocol.cometWithExtendedAssetList; + cometExt = comet.attach(comet.address) as CometExt; + governor = protocol.governor; + pauseGuardian = protocol.pauseGuardian; + users = protocol.users; + + // Setup protocol with MAX_ASSETS collaterals + const collaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, {}]) + ); + const protocolWithMaxAssets = await makeProtocol({ + assets: { USDC: {}, ...collaterals }, + }); + cometWithMaxAssets = protocolWithMaxAssets.cometWithExtendedAssetList; + cometExtWithMaxAssets = cometWithMaxAssets.attach(cometWithMaxAssets.address) as CometExt; + }); + + describe('withdraw pause functions', function () { + describe('pauseLendersWithdraw', function () { + describe('happy cases', function () { + let pauseLendersWithdrawTx: ContractTransaction; + + it('allows governor to call pauseLendersWithdraw', async function () { + pauseLendersWithdrawTx = await cometExt + .connect(governor) + .pauseLendersWithdraw(true); + await expect(pauseLendersWithdrawTx).to.not.be.reverted; + }); + + it('emits LendersWithdrawPauseAction event when pausing by governor', async function () { + expect(pauseLendersWithdrawTx) + .to.emit(cometExt, 'LendersWithdrawPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isLendersWithdrawPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect(cometExt.connect(governor).pauseLendersWithdraw(false)) + .to.emit(cometExt, 'LendersWithdrawPauseAction') + .withArgs(false); + }); + + it('sets to false when pausing by governor', async function () { + expect(await comet.isLendersWithdrawPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseLendersWithdraw', async function () { + pauseLendersWithdrawTx = await cometExt + .connect(pauseGuardian) + .pauseLendersWithdraw(true); + + await expect(pauseLendersWithdrawTx).to.not.be.reverted; + }); + + it('emits LendersWithdrawPauseAction event when pausing by pause guardian', async function () { + expect(pauseLendersWithdrawTx) + .to.emit(cometExt, 'LendersWithdrawPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isLendersWithdrawPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseLendersWithdraw(false) + ) + .to.emit(cometExt, 'LendersWithdrawPauseAction') + .withArgs(false); + }); + + it('sets to false when pausing by pause guardian', async function () { + expect(await comet.isLendersWithdrawPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseLendersWithdraw(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseLendersWithdraw(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseLendersWithdraw(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseBorrowersWithdraw', function () { + describe('happy cases', function () { + let pauseBorrowersWithdrawTx: ContractTransaction; + + it('allows governor to call pauseBorrowersWithdraw', async function () { + pauseBorrowersWithdrawTx = await cometExt + .connect(governor) + .pauseBorrowersWithdraw(true); + await expect(pauseBorrowersWithdrawTx).to.not.be.reverted; + }); + + it('emits BorrowersWithdrawPauseAction event when pausing by governor', async function () { + expect(pauseBorrowersWithdrawTx) + .to.emit(cometExt, 'BorrowersWithdrawPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isBorrowersWithdrawPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect(cometExt.connect(governor).pauseBorrowersWithdraw(false)) + .to.emit(cometExt, 'BorrowersWithdrawPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isBorrowersWithdrawPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseBorrowersWithdraw', async function () { + pauseBorrowersWithdrawTx = await cometExt + .connect(pauseGuardian) + .pauseBorrowersWithdraw(true); + await expect(pauseBorrowersWithdrawTx).to.not.be.reverted; + }); + + it('emits BorrowersWithdrawPauseAction event when pausing by pause guardian', async function () { + expect(pauseBorrowersWithdrawTx) + .to.emit(cometExt, 'BorrowersWithdrawPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isBorrowersWithdrawPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseBorrowersWithdraw(false) + ) + .to.emit(cometExt, 'BorrowersWithdrawPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isBorrowersWithdrawPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseBorrowersWithdraw(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseBorrowersWithdraw(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseBorrowersWithdraw(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseCollateralWithdraw', function () { + describe('happy cases', function () { + let pauseCollateralWithdrawTx: ContractTransaction; + + it('allows governor to call pauseCollateralWithdraw', async function () { + pauseCollateralWithdrawTx = await cometExt + .connect(governor) + .pauseCollateralWithdraw(true); + await expect(pauseCollateralWithdrawTx).to.not.be.reverted; + }); + + it('emits CollateralWithdrawPauseAction event when pausing by governor', async function () { + expect(pauseCollateralWithdrawTx) + .to.emit(cometExt, 'CollateralWithdrawPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isCollateralWithdrawPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect( + cometExt.connect(governor).pauseCollateralWithdraw(false) + ) + .to.emit(cometExt, 'CollateralWithdrawPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralWithdrawPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseCollateralWithdraw', async function () { + pauseCollateralWithdrawTx = await cometExt + .connect(pauseGuardian) + .pauseCollateralWithdraw(true); + await expect(pauseCollateralWithdrawTx).to.not.be.reverted; + }); + + it('emits CollateralWithdrawPauseAction event when pausing by pause guardian', async function () { + expect(pauseCollateralWithdrawTx) + .to.emit(cometExt, 'CollateralWithdrawPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isCollateralWithdrawPaused()).to.be.true; + }); + + it('allows governor to unpause after pause guardian', async function () { + await expect( + cometExt.connect(governor).pauseCollateralWithdraw(false) + ) + .to.emit(cometExt, 'CollateralWithdrawPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralWithdrawPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseCollateralWithdraw(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseCollateralWithdraw(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseCollateralWithdraw(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseCollateralAssetWithdraw', function () { + describe('happy cases', function () { + let pauseCollateralAssetWithdrawTx: ContractTransaction; + + it('allows governor to call pauseCollateralAssetWithdraw', async function () { + pauseCollateralAssetWithdrawTx = await cometExt + .connect(governor) + .pauseCollateralAssetWithdraw(assetIndex, true); + await expect(pauseCollateralAssetWithdrawTx).to.not.be.reverted; + }); + + it('emits CollateralAssetWithdrawPauseAction event when pausing by governor', async function () { + expect(pauseCollateralAssetWithdrawTx) + .to.emit(cometExt, 'CollateralAssetWithdrawPauseAction') + .withArgs(assetIndex, true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isCollateralAssetWithdrawPaused(assetIndex)).to.be + .true; + }); + + it('allows governor to unpause', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetWithdraw(assetIndex, false) + ) + .to.emit(cometExt, 'CollateralAssetWithdrawPauseAction') + .withArgs(assetIndex, false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralAssetWithdrawPaused(assetIndex)).to.be + .false; + }); + + it('allows pause guardian to call pauseCollateralAssetWithdraw', async function () { + pauseCollateralAssetWithdrawTx = await cometExt + .connect(pauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, true); + await expect(pauseCollateralAssetWithdrawTx).to.not.be.reverted; + }); + + it('emits CollateralAssetWithdrawPauseAction event when pausing by pause guardian', async function () { + expect(pauseCollateralAssetWithdrawTx) + .to.emit(cometExt, 'CollateralAssetWithdrawPauseAction') + .withArgs(assetIndex, true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isCollateralAssetWithdrawPaused(assetIndex)).to.be + .true; + }); + + it('allows governor to unpause after pause guardian', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetWithdraw(assetIndex, false) + ) + .to.emit(cometExt, 'CollateralAssetWithdrawPauseAction') + .withArgs(assetIndex, false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralAssetWithdrawPaused(assetIndex)).to.be + .false; + }); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to pause collateral asset withdraw for asset ${i} with ${MAX_ASSETS} collaterals`, async function () { + const assetIndex = i - 1; + + // Pause the collateral at index i + await cometExtWithMaxAssets + .connect(governor) + .pauseCollateralAssetWithdraw(assetIndex, true); + + // Verify that the asset at index i is paused + expect(await cometWithMaxAssets.isCollateralAssetWithdrawPaused(assetIndex)).to + .be.true; + }); + } + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to unpause collateral asset withdraw for asset ${i} with ${MAX_ASSETS} collaterals`, async function () { + const assetIndex = i - 1; + + // Unpause the collateral at index i + await cometExtWithMaxAssets + .connect(governor) + .pauseCollateralAssetWithdraw(assetIndex, false); + + // Verify that the asset at index i is paused + expect(await cometWithMaxAssets.isCollateralAssetWithdrawPaused(assetIndex)).to + .be.false; + }); + } + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt + .connect(users[0]) + .pauseCollateralAssetWithdraw(assetIndex, true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetWithdraw(assetIndex, false) + ).to.be.revertedWithCustomError( + cometExt, + 'CollateralAssetOffsetStatusAlreadySet' + ); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt + .connect(pauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, false) + ).to.be.revertedWithCustomError( + cometExt, + 'CollateralAssetOffsetStatusAlreadySet' + ); + }); + + it('reverts with InvalidAssetIndex when assetIndex >= numAssets', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetWithdraw(await comet.numAssets(), true) + ).to.be.revertedWithCustomError(cometExt, 'InvalidAssetIndex'); + }); + }); + }); + }); + + describe('supply pause functions', function () { + describe('pauseCollateralSupply', function () { + describe('happy cases', function () { + let pauseCollateralSupplyTx: ContractTransaction; + + it('allows governor to call pauseCollateralSupply', async function () { + pauseCollateralSupplyTx = await cometExt + .connect(governor) + .pauseCollateralSupply(true); + await expect(pauseCollateralSupplyTx).to.not.be.reverted; + }); + + it('emits LendersSupplyPauseAction event when pausing by governor', async function () { + expect(pauseCollateralSupplyTx) + .to.emit(cometExt, 'LendersSupplyPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isCollateralSupplyPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect(cometExt.connect(governor).pauseCollateralSupply(false)) + .to.emit(cometExt, 'CollateralSupplyPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralSupplyPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseCollateralSupply', async function () { + pauseCollateralSupplyTx = await cometExt + .connect(pauseGuardian) + .pauseCollateralSupply(true); + await expect(pauseCollateralSupplyTx).to.not.be.reverted; + }); + + it('emits LendersSupplyPauseAction event when pausing by pause guardian', async function () { + expect(pauseCollateralSupplyTx) + .to.emit(cometExt, 'LendersSupplyPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isCollateralSupplyPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseCollateralSupply(false) + ) + .to.emit(cometExt, 'CollateralSupplyPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isCollateralSupplyPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseCollateralSupply(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseCollateralSupply(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseCollateralSupply(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseBaseSupply', function () { + describe('happy cases', function () { + let pauseBaseSupplyTx: ContractTransaction; + + it('allows governor to call pauseBaseSupply', async function () { + pauseBaseSupplyTx = await cometExt + .connect(governor) + .pauseBaseSupply(true); + await expect(pauseBaseSupplyTx).to.not.be.reverted; + }); + + it('emits BorrowersSupplyPauseAction event when pausing by governor', async function () { + expect(pauseBaseSupplyTx) + .to.emit(cometExt, 'BorrowersSupplyPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isBaseSupplyPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect(cometExt.connect(governor).pauseBaseSupply(false)) + .to.emit(cometExt, 'BaseSupplyPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isBaseSupplyPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseBaseSupply', async function () { + pauseBaseSupplyTx = await cometExt + .connect(pauseGuardian) + .pauseBaseSupply(true); + await expect(pauseBaseSupplyTx).to.not.be.reverted; + }); + + it('emits BorrowersSupplyPauseAction event when pausing by pause guardian', async function () { + expect(pauseBaseSupplyTx) + .to.emit(cometExt, 'BorrowersSupplyPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isBaseSupplyPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect(cometExt.connect(pauseGuardian).pauseBaseSupply(false)) + .to.emit(cometExt, 'BaseSupplyPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isBaseSupplyPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseBaseSupply(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseBaseSupply(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseBaseSupply(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseCollateralAssetSupply', function () { + describe('happy cases', function () { + let pauseCollateralAssetSupplyTx: ContractTransaction; + + it('allows governor to call pauseCollateralAssetSupply', async function () { + pauseCollateralAssetSupplyTx = await cometExt + .connect(governor) + .pauseCollateralAssetSupply(assetIndex, true); + await expect(pauseCollateralAssetSupplyTx).to.not.be.reverted; + }); + + it('emits CollateralAssetSupplyPauseAction event when pausing by governor', async function () { + expect(pauseCollateralAssetSupplyTx) + .to.emit(cometExt, 'CollateralAssetSupplyPauseAction') + .withArgs(assetIndex, true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isCollateralAssetSupplyPaused(assetIndex)).to.be + .true; + }); + + it('allows governor to unpause', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetSupply(assetIndex, false) + ) + .to.emit(cometExt, 'CollateralAssetSupplyPauseAction') + .withArgs(assetIndex, false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralAssetSupplyPaused(assetIndex)).to.be + .false; + }); + + it('allows pause guardian to call pauseCollateralAssetSupply', async function () { + pauseCollateralAssetSupplyTx = await cometExt + .connect(pauseGuardian) + .pauseCollateralAssetSupply(assetIndex, true); + await expect(pauseCollateralAssetSupplyTx).to.not.be.reverted; + }); + + it('emits CollateralAssetSupplyPauseAction event when pausing by pause guardian', async function () { + expect(pauseCollateralAssetSupplyTx) + .to.emit(cometExt, 'CollateralAssetSupplyPauseAction') + .withArgs(assetIndex, true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isCollateralAssetSupplyPaused(assetIndex)).to.be + .true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt + .connect(pauseGuardian) + .pauseCollateralAssetSupply(assetIndex, false) + ) + .to.emit(cometExt, 'CollateralAssetSupplyPauseAction') + .withArgs(assetIndex, false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isCollateralAssetSupplyPaused(assetIndex)).to.be + .false; + }); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to pause collateral asset supply for asset ${i} with ${MAX_ASSETS} collaterals`, async function () { + const assetIndex = i - 1; + + // Pause the collateral at index i + await cometExtWithMaxAssets + .connect(governor) + .pauseCollateralAssetSupply(assetIndex, true); + + // Verify that the asset at index i is paused + expect(await cometWithMaxAssets.isCollateralAssetSupplyPaused(assetIndex)).to.be + .true; + }); + } + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to unpause collateral asset supply for asset ${i} with ${MAX_ASSETS} collaterals`, async function () { + const assetIndex = i - 1; + + // Pause the collateral at index i + await cometExtWithMaxAssets + .connect(governor) + .pauseCollateralAssetSupply(assetIndex, false); + + // Verify that the asset at index i is paused + expect(await cometWithMaxAssets.isCollateralAssetSupplyPaused(assetIndex)).to.be + .false; + }); + } + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt + .connect(users[0]) + .pauseCollateralAssetSupply(assetIndex, true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetSupply(assetIndex, false) + ).to.be.revertedWithCustomError( + cometExt, + 'CollateralAssetOffsetStatusAlreadySet' + ); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt + .connect(pauseGuardian) + .pauseCollateralAssetSupply(assetIndex, false) + ).to.be.revertedWithCustomError( + cometExt, + 'CollateralAssetOffsetStatusAlreadySet' + ); + }); + + it('reverts with InvalidAssetIndex when assetIndex >= numAssets', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetSupply(await comet.numAssets(), true) + ).to.be.revertedWithCustomError(cometExt, 'InvalidAssetIndex'); + }); + + it('reverts when unpausing a deactivated collateral asset', async function () { + // Deactivate the collateral asset + await cometExt.connect(pauseGuardian).deactivateCollateral(assetIndex); + + await expect( + cometExt.connect(pauseGuardian).pauseCollateralAssetSupply(assetIndex, false) + ).to.be.revertedWithCustomError(cometExt, 'CollateralIsDeactivated'); + + // Activate the collateral asset + await cometExt.connect(governor).activateCollateral(assetIndex); + }); + }); + }); + }); + + describe('transfer pause functions', function () { + describe('pauseLendersTransfer', function () { + describe('happy cases', function () { + let pauseLendersTransferTx: ContractTransaction; + + it('allows governor to call pauseLendersTransfer', async function () { + pauseLendersTransferTx = await cometExt + .connect(governor) + .pauseLendersTransfer(true); + await expect(pauseLendersTransferTx).to.not.be.reverted; + }); + + it('emits LendersTransferPauseAction event when pausing by governor', async function () { + expect(pauseLendersTransferTx) + .to.emit(cometExt, 'LendersTransferPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isLendersTransferPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect(cometExt.connect(governor).pauseLendersTransfer(false)) + .to.emit(cometExt, 'LendersTransferPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isLendersTransferPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseLendersTransfer', async function () { + pauseLendersTransferTx = await cometExt + .connect(pauseGuardian) + .pauseLendersTransfer(true); + await expect(pauseLendersTransferTx).to.not.be.reverted; + }); + + it('emits LendersTransferPauseAction event when pausing by pause guardian', async function () { + expect(pauseLendersTransferTx) + .to.emit(cometExt, 'LendersTransferPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isLendersTransferPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseLendersTransfer(false) + ) + .to.emit(cometExt, 'LendersTransferPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isLendersTransferPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseLendersTransfer(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseLendersTransfer(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseLendersTransfer(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseBorrowersTransfer', function () { + describe('happy cases', function () { + let pauseBorrowersTransferTx: ContractTransaction; + + it('allows governor to call pauseBorrowersTransfer', async function () { + pauseBorrowersTransferTx = await cometExt + .connect(governor) + .pauseBorrowersTransfer(true); + await expect(pauseBorrowersTransferTx).to.not.be.reverted; + }); + + it('emits BorrowersTransferPauseAction event when pausing by governor', async function () { + expect(pauseBorrowersTransferTx) + .to.emit(cometExt, 'BorrowersTransferPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isBorrowersTransferPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect(cometExt.connect(governor).pauseBorrowersTransfer(false)) + .to.emit(cometExt, 'BorrowersTransferPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isBorrowersTransferPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseBorrowersTransfer', async function () { + pauseBorrowersTransferTx = await cometExt + .connect(pauseGuardian) + .pauseBorrowersTransfer(true); + await expect(pauseBorrowersTransferTx).to.not.be.reverted; + }); + + it('emits BorrowersTransferPauseAction event when pausing by pause guardian', async function () { + expect(pauseBorrowersTransferTx) + .to.emit(cometExt, 'BorrowersTransferPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isBorrowersTransferPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseBorrowersTransfer(false) + ) + .to.emit(cometExt, 'BorrowersTransferPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isBorrowersTransferPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseBorrowersTransfer(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseBorrowersTransfer(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseBorrowersTransfer(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseCollateralTransfer', function () { + describe('happy cases', function () { + let pauseCollateralTransferTx: ContractTransaction; + + it('allows governor to call pauseCollateralTransfer', async function () { + pauseCollateralTransferTx = await cometExt + .connect(governor) + .pauseCollateralTransfer(true); + await expect(pauseCollateralTransferTx).to.not.be.reverted; + }); + + it('emits CollateralTransferPauseAction event when pausing by governor', async function () { + expect(pauseCollateralTransferTx) + .to.emit(cometExt, 'CollateralTransferPauseAction') + .withArgs(true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isCollateralTransferPaused()).to.be.true; + }); + + it('allows governor to unpause', async function () { + await expect( + cometExt.connect(governor).pauseCollateralTransfer(false) + ) + .to.emit(cometExt, 'CollateralTransferPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralTransferPaused()).to.be.false; + }); + + it('allows pause guardian to call pauseCollateralTransfer', async function () { + pauseCollateralTransferTx = await cometExt + .connect(pauseGuardian) + .pauseCollateralTransfer(true); + await expect(pauseCollateralTransferTx).to.not.be.reverted; + }); + + it('emits CollateralTransferPauseAction event when pausing by pause guardian', async function () { + expect(pauseCollateralTransferTx) + .to.emit(cometExt, 'CollateralTransferPauseAction') + .withArgs(true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isCollateralTransferPaused()).to.be.true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseCollateralTransfer(false) + ) + .to.emit(cometExt, 'CollateralTransferPauseAction') + .withArgs(false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isCollateralTransferPaused()).to.be.false; + }); + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt.connect(users[0]).pauseCollateralTransfer(true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt.connect(governor).pauseCollateralTransfer(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt.connect(pauseGuardian).pauseCollateralTransfer(false) + ).to.be.revertedWithCustomError(cometExt, 'OffsetStatusAlreadySet'); + }); + }); + }); + + describe('pauseCollateralAssetTransfer', function () { + describe('happy cases', function () { + let pauseCollateralAssetTransferTx: ContractTransaction; + + it('allows governor to call pauseCollateralAssetTransfer', async function () { + pauseCollateralAssetTransferTx = await cometExt + .connect(governor) + .pauseCollateralAssetTransfer(assetIndex, true); + await expect(pauseCollateralAssetTransferTx).to.not.be.reverted; + }); + + it('emits CollateralAssetTransferPauseAction event when pausing by governor', async function () { + expect(pauseCollateralAssetTransferTx) + .to.emit(cometExt, 'CollateralAssetTransferPauseAction') + .withArgs(assetIndex, true); + }); + + it('changes state when called by governor', async function () { + expect(await comet.isCollateralAssetTransferPaused(assetIndex)).to.be + .true; + }); + + it('allows governor to unpause', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetTransfer(assetIndex, false) + ) + .to.emit(cometExt, 'CollateralAssetTransferPauseAction') + .withArgs(assetIndex, false); + }); + + it('sets to false when unpausing by governor', async function () { + expect(await comet.isCollateralAssetTransferPaused(assetIndex)).to.be + .false; + }); + + it('allows pause guardian to call pauseCollateralAssetTransfer', async function () { + pauseCollateralAssetTransferTx = await cometExt + .connect(pauseGuardian) + .pauseCollateralAssetTransfer(assetIndex, true); + await expect(pauseCollateralAssetTransferTx).to.not.be.reverted; + }); + + it('emits CollateralAssetTransferPauseAction event when pausing by pause guardian', async function () { + expect(pauseCollateralAssetTransferTx) + .to.emit(cometExt, 'CollateralAssetTransferPauseAction') + .withArgs(assetIndex, true); + }); + + it('changes state when called by pause guardian', async function () { + expect(await comet.isCollateralAssetTransferPaused(assetIndex)).to.be + .true; + }); + + it('allows pause guardian to unpause', async function () { + await expect( + cometExt + .connect(pauseGuardian) + .pauseCollateralAssetTransfer(assetIndex, false) + ) + .to.emit(cometExt, 'CollateralAssetTransferPauseAction') + .withArgs(assetIndex, false); + }); + + it('sets to false when unpausing by pause guardian', async function () { + expect(await comet.isCollateralAssetTransferPaused(assetIndex)).to.be + .false; + }); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to pause collateral asset transfer for asset ${i} with ${MAX_ASSETS} collaterals`, async function () { + const assetIndex = i - 1; + + // Pause the collateral at index i + await cometExtWithMaxAssets + .connect(governor) + .pauseCollateralAssetTransfer(assetIndex, true); + + // Verify that the asset at index i is paused + expect(await cometWithMaxAssets.isCollateralAssetTransferPaused(assetIndex)).to + .be.true; + }); + } + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to unpause collateral asset transfer for asset ${i} with ${MAX_ASSETS} collaterals`, async function () { + const assetIndex = i - 1; + + // Pause the collateral at index i + await cometExtWithMaxAssets + .connect(governor) + .pauseCollateralAssetTransfer(assetIndex, false); + + // Verify that the asset at index i is paused + expect(await cometWithMaxAssets.isCollateralAssetTransferPaused(assetIndex)).to + .be.false; + }); + } + }); + + describe('revert cases', function () { + it('reverts when called by unauthorized user', async function () { + await expect( + cometExt + .connect(users[0]) + .pauseCollateralAssetTransfer(assetIndex, true) + ).to.be.revertedWithCustomError( + cometExt, + 'OnlyPauseGuardianOrGovernor' + ); + }); + + it('reverts duplicate status setting (governor)', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetTransfer(assetIndex, false) + ).to.be.revertedWithCustomError( + cometExt, + 'CollateralAssetOffsetStatusAlreadySet' + ); + }); + + it('reverts duplicate status setting (pause guardian)', async function () { + await expect( + cometExt + .connect(pauseGuardian) + .pauseCollateralAssetTransfer(assetIndex, false) + ).to.be.revertedWithCustomError( + cometExt, + 'CollateralAssetOffsetStatusAlreadySet' + ); + }); + + it('reverts with InvalidAssetIndex when assetIndex >= numAssets', async function () { + await expect( + cometExt + .connect(governor) + .pauseCollateralAssetTransfer(await comet.numAssets(), true) + ).to.be.revertedWithCustomError(cometExt, 'InvalidAssetIndex'); + }); + + it('reverts when unpausing a deactivated collateral asset', async function () { + // Deactivate the collateral asset + await cometExt.connect(pauseGuardian).deactivateCollateral(assetIndex); + + await expect( + cometExt.connect(pauseGuardian).pauseCollateralAssetTransfer(assetIndex, false) + ).to.be.revertedWithCustomError(cometExt, 'CollateralIsDeactivated'); + }); + }); + }); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts index c343729b7..415057c74 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -34,6 +34,7 @@ import { Configurator__factory, CometHarnessInterface, CometInterface, + CometMainInterface, NonStandardFaucetFeeToken, NonStandardFaucetFeeToken__factory, AssetListFactory, @@ -46,6 +47,12 @@ import { BigNumber } from 'ethers'; import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider'; import { TotalsBasicStructOutput, TotalsCollateralStructOutput } from '../build/types/CometHarness'; +// Snapshot +export { takeSnapshot, SnapshotRestorer } from './helpers/snapshot'; + +// Network helpers +export * from './helpers/network-helpers'; + export { Comet, ethers, expect, hre }; export type Numeric = number | bigint; @@ -151,12 +158,30 @@ export type BulkerInfo = { bulker: BaseBulker; }; +export type UserCollateral = { + balance: BigNumber; + _reserved: BigNumber; +}; + +export type UserBasic = { + principal: BigNumber; + baseTrackingIndex: BigNumber; + baseTrackingAccrued: BigNumber; + assetsIn: number; + _reserved: number; +}; + export function dfn(x: T | undefined | null, dflt: T): T { return x == undefined ? dflt : x; } export function exp(i: number, d: Numeric = 0, r: Numeric = 6): bigint { - return (BigInt(Math.floor(i * 10 ** Number(r))) * 10n ** BigInt(d)) / 10n ** BigInt(r); + const sign = i < 0 ? -1n : 1n; + const parts = Math.abs(i).toString().split('.'); + const intPart = parts[0]; + const fracPart = (parts[1] || '').padEnd(Number(r), '0').slice(0, Number(r)); + const scaled = BigInt(intPart + fracPart); + return sign * (scaled * 10n ** BigInt(d)) / 10n ** BigInt(r); } export function factor(f: number): bigint { @@ -170,14 +195,70 @@ export function defactor(f: bigint | BigNumber): number { // Truncates a factor to a certain number of decimals export function truncateDecimals(factor: bigint | BigNumber, decimals = 4) { const descaleFactor = factorScale / exp(1, decimals); - return toBigInt(factor) / descaleFactor * descaleFactor; + return (toBigInt(factor) / descaleFactor) * descaleFactor; } export function mulPrice(n: bigint, price: bigint | BigNumber, fromScale: bigint | BigNumber): bigint { - return n * toBigInt(price) / toBigInt(fromScale); + return (n * toBigInt(price)) / toBigInt(fromScale); +} + +export function mulFactor(n: bigint, factor: bigint):bigint { + return n * factor / factorScale; +} + +export function divPrice(n: bigint, price: bigint | BigNumber, toScale: bigint | BigNumber): bigint { + return (n * toBigInt(toScale)) / toBigInt(price); +} + +const BASE_INDEX_SCALE = 10n ** 15n; + +export function presentValueSupply(baseSupplyIndex: bigint | BigNumber, principalValue: bigint | BigNumber): bigint { + const principal = toBigInt(principalValue); + const index = toBigInt(baseSupplyIndex); + return principal * index / BASE_INDEX_SCALE; +} + +function presentValueBorrow(baseBorrowIndex: bigint | BigNumber, principalValue: bigint | BigNumber): bigint { + const principal = toBigInt(principalValue); + const index = toBigInt(baseBorrowIndex); + return principal * index / BigInt(BASE_INDEX_SCALE); +} + +export function presentValue( + principalValue: bigint | BigNumber, + baseSupplyIndex: bigint | BigNumber, + baseBorrowIndex: bigint | BigNumber +): bigint { + const principal = toBigInt(principalValue); + if (principal >= 0n) { + return presentValueSupply(baseSupplyIndex, principal); + } else { + return -presentValueBorrow(baseBorrowIndex, -principal); + } +} + +function principalValueSupply(baseSupplyIndex: bigint, presentValue: bigint): bigint { + return (presentValue * BigInt(BASE_INDEX_SCALE)) / baseSupplyIndex; +} + +function principalValueBorrow(baseBorrowIndex: bigint, presentValue: bigint): bigint { + return (presentValue * BigInt(BASE_INDEX_SCALE) + baseBorrowIndex - 1n) / baseBorrowIndex; +} + +export function principalValue( + presentValue: bigint | BigNumber, + baseSupplyIndex: bigint | BigNumber, + baseBorrowIndex: bigint | BigNumber +): bigint { + const pv = toBigInt(presentValue); + if (pv >= 0n) { + return principalValueSupply(toBigInt(baseSupplyIndex), pv); + } else { + return -principalValueBorrow(toBigInt(baseBorrowIndex), -pv); + } } -function toBigInt(f: bigint | BigNumber): bigint { +export function toBigInt(f: bigint | BigNumber): bigint { if (typeof f === 'bigint') { return f; } else { @@ -195,25 +276,41 @@ export function toYears(seconds: number, secondsPerYear = 31536000): number { export function defaultAssets(overrides = {}, perAssetOverrides = {}) { return { - COMP: Object.assign({ - initial: 1e7, - decimals: 18, - initialPrice: 175, - }, overrides, perAssetOverrides['COMP'] || {}), - USDC: Object.assign({ - initial: 1e6, - decimals: 6, - }, overrides, perAssetOverrides['USDC'] || {}), - WETH: Object.assign({ - initial: 1e4, - decimals: 18, - initialPrice: 3000, - }, overrides, perAssetOverrides['WETH'] || {}), - WBTC: Object.assign({ - initial: 1e3, - decimals: 8, - initialPrice: 41000, - }, overrides, perAssetOverrides['WBTC'] || {}), + COMP: Object.assign( + { + initial: 1e7, + decimals: 18, + initialPrice: 175, + }, + overrides, + perAssetOverrides['COMP'] || {} + ), + USDC: Object.assign( + { + initial: 1e6, + decimals: 6, + }, + overrides, + perAssetOverrides['USDC'] || {} + ), + WETH: Object.assign( + { + initial: 1e4, + decimals: 18, + initialPrice: 3000, + }, + overrides, + perAssetOverrides['WETH'] || {} + ), + WBTC: Object.assign( + { + initial: 1e3, + decimals: 8, + initialPrice: 41000, + }, + overrides, + perAssetOverrides['WBTC'] || {} + ), }; } @@ -221,6 +318,10 @@ export const factorDecimals = 18; export const factorScale = factor(1); export const ONE = factorScale; export const ZERO = factor(0); +export const ZERO_ADDRESS = ethers.constants.AddressZero; +export const DEFAULT_PRICEFEED_DECIMALS = 8; +export const MAX_ASSETS = 24; +export const MAX_SUPPORTED_UTILIZATION = exp(2, 18); export async function getBlock(n?: number, ethers_ = ethers): Promise { const blockNumber = n == undefined ? await ethers_.provider.getBlockNumber() : n; @@ -247,8 +348,8 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { priceFeeds[asset] = priceFeed; } - const name32 = ethers.utils.formatBytes32String((opts.name || 'Compound Comet')); - const symbol32 = ethers.utils.formatBytes32String((opts.symbol || '📈BASE')); + const name32 = ethers.utils.formatBytes32String(opts.name || 'Compound Comet'); + const symbol32 = ethers.utils.formatBytes32String(opts.symbol || '📈BASE'); const governor = opts.governor || signers[0]; const pauseGuardian = opts.pauseGuardian || signers[1]; const users = signers.slice(2); // guaranteed to not be governor or pause guardian @@ -279,7 +380,7 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { const name = config.name || symbol; const factory = config.factory || FaucetFactory; let token; - token = (tokens[symbol] = await factory.deploy(initial, name, decimals, symbol)); + token = tokens[symbol] = await factory.deploy(initial, name, decimals, symbol); await token.deployed(); } @@ -287,7 +388,7 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { const AssetListFactory = (await ethers.getContractFactory('AssetListFactory')) as AssetListFactory__factory; const assetListFactory = await AssetListFactory.deploy(); - await assetListFactory.deployed(); + await assetListFactory.deployed(); let extensionDelegate = opts.extensionDelegate; if (extensionDelegate === undefined) { @@ -327,7 +428,7 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { borrowCollateralFactor: dfn(config.borrowCF, ONE - 1n), liquidateCollateralFactor: dfn(config.liquidateCF, ONE), liquidationFactor: dfn(config.liquidationFactor, ONE), - supplyCap: dfn(config.supplyCap, exp(100, dfn(config.decimals, 18))), + supplyCap: dfn(config.supplyCap, exp(150000, dfn(config.decimals, 18))), }); } return acc; @@ -345,7 +446,7 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { borrowCollateralFactor: dfn(config.borrowCF, ONE - 1n), liquidateCollateralFactor: dfn(config.liquidateCF, ONE), liquidationFactor: dfn(config.liquidationFactor, ONE), - supplyCap: dfn(config.supplyCap, exp(100, dfn(config.decimals, 18))), + supplyCap: dfn(config.supplyCap, exp(150000, dfn(config.decimals, 18))), }); } return acc; @@ -382,8 +483,8 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { users, base, reward, - comet: await ethers.getContractAt('CometHarnessInterface', comet.address) as Comet, - cometWithExtendedAssetList: await ethers.getContractAt('CometHarnessInterfaceExtendedAssetList', cometWithExtendedAssetList.address) as CometWithExtendedAssetList, + comet: (await ethers.getContractAt('CometHarnessInterface', comet.address)) as Comet, + cometWithExtendedAssetList: (await ethers.getContractAt('CometHarnessInterfaceExtendedAssetList', cometWithExtendedAssetList.address)) as CometWithExtendedAssetList, assetListFactory: assetListFactory, tokens, unsupportedToken, @@ -465,7 +566,7 @@ export async function getConfigurationForConfigurator( borrowCollateralFactor: dfn(config.borrowCF, ONE - 1n), liquidateCollateralFactor: dfn(config.liquidateCF, ONE), liquidationFactor: dfn(config.liquidationFactor, ONE), - supplyCap: dfn(config.supplyCap, exp(100, dfn(config.decimals, 18))), + supplyCap: dfn(config.supplyCap, exp(150000, dfn(config.decimals, 18))), }); } return acc; @@ -540,11 +641,7 @@ export async function makeConfigurator(opts: ProtocolOpts = {}): Promise { return { opts, governor, - rewards + rewards, }; } @@ -633,12 +730,14 @@ export async function makeBulker(opts: BulkerOpts): Promise { return { opts, - bulker + bulker, }; } export async function bumpTotalsCollateral(comet: CometHarnessInterface, token: FaucetToken | NonStandardFaucetFeeToken, delta: bigint): Promise { const t0 = await comet.totalsCollateral(token.address); - const t1 = Object.assign({}, t0, { totalSupplyAsset: t0.totalSupplyAsset.toBigInt() + delta }); + const t1 = Object.assign({}, t0, { + totalSupplyAsset: t0.totalSupplyAsset.toBigInt() + delta, + }); await token.allocateTo(comet.address, delta); await wait(comet.setTotalsCollateral(token.address, t1)); return t1; @@ -651,6 +750,31 @@ export async function setTotalsBasic(comet: CometHarnessInterface, overrides = { return t1; } +export async function updateAssetBorrowCollateralFactor(configurator: Configurator, cometProxyAdmin: CometProxyAdmin, cometAddress: string, assetAddress: string, borrowCF: bigint) { + await configurator.updateAssetBorrowCollateralFactor(cometAddress, assetAddress, borrowCF); + await cometProxyAdmin.deployAndUpgradeTo(configurator.address, cometAddress); +} + +export async function updateAssetLiquidateCollateralFactor(configurator: Configurator, cometProxyAdmin: CometProxyAdmin, cometAddress: string, assetAddress: string, liquidateCF: bigint, governor: SignerWithAddress) { + await configurator.connect(governor).updateAssetLiquidateCollateralFactor(cometAddress, assetAddress, liquidateCF); + await cometProxyAdmin.connect(governor).deployAndUpgradeTo(configurator.address, cometAddress); +} + +export async function getLiquidity(comet: CometWithExtendedAssetList, token: FaucetToken | NonStandardFaucetFeeToken, amount: bigint): Promise { + const assetInfo = await comet.getAssetInfoByAddress(token.address); + const priceUSD = mulPrice(amount, await comet.getPrice(assetInfo.priceFeed), assetInfo.scale); + return BigNumber.from(priceUSD).mul(assetInfo.borrowCollateralFactor).div(factorScale); +} + +export async function getLiquidityWithLiquidateCF(comet: CometMainInterface, token: FaucetToken | NonStandardFaucetFeeToken, amount: bigint): Promise { + const assetInfo = await comet.getAssetInfoByAddress(token.address); + const priceUSD = mulPrice(amount, await comet.getPrice(assetInfo.priceFeed), assetInfo.scale); + if (assetInfo.liquidateCollateralFactor.eq(0)) { + return BigNumber.from(0); + } + return BigNumber.from(priceUSD).mul(assetInfo.liquidateCollateralFactor).div(factorScale); +} + export function objectify(arrayObject) { const obj = {}; for (const key in arrayObject) { @@ -679,7 +803,7 @@ type Portfolio = { external: { [symbol: string]: bigint; }; -} +}; type TotalsAndReserves = { totals: { @@ -688,7 +812,7 @@ type TotalsAndReserves = { reserves: { [symbol: string]: bigint; }; -} +}; export async function portfolio({ comet, base, tokens }, account): Promise { const internal = { [base]: await baseBalanceOf(comet, account) }; @@ -703,7 +827,9 @@ export async function portfolio({ comet, base, tokens }, account): Promise { - const totals = { [base]: BigInt((await comet.totalsBasic()).totalSupplyBase) }; + const totals = { + [base]: BigInt((await comet.totalsBasic()).totalSupplyBase), + }; const reserves = { [base]: BigInt(await comet.getReserves()) }; for (const symbol in tokens) { if (symbol != base) { @@ -718,9 +844,7 @@ export interface TransactionResponseExt extends TransactionResponse { receipt: TransactionReceipt; } -export async function wait( - tx: TransactionResponse | Promise -): Promise { +export async function wait(tx: TransactionResponse | Promise): Promise { const tx_ = await tx; let receipt = await tx_.wait(); return { @@ -730,7 +854,8 @@ export async function wait( } export function event(tx, index) { - const ev = tx.receipt.events[index], args = {}; + const ev = tx.receipt.events[index], + args = {}; for (const k in ev.args) { const v = ev.args[k]; if (isNaN(Number(k))) { @@ -762,3 +887,23 @@ function convertToBigInt(arr) { export function getGasUsed(tx: TransactionResponseExt): bigint { return tx.receipt.gasUsed.mul(tx.receipt.effectiveGasPrice).toBigInt(); } + +/*////////////////////////////////////////////////////////////// + FORK SETUP +//////////////////////////////////////////////////////////////*/ + +export async function setupFork(blockNumber?: number, jsonRpcUrl?: string) { + const mainnetConfig = hre.config.networks.mainnet as any; + + await hre.network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: jsonRpcUrl ?? mainnetConfig.url, + blockNumber: blockNumber ?? undefined, + }, + }, + ], + }); +} \ No newline at end of file diff --git a/test/helpers/network-helpers.ts b/test/helpers/network-helpers.ts new file mode 100644 index 000000000..1ac5e1522 --- /dev/null +++ b/test/helpers/network-helpers.ts @@ -0,0 +1,76 @@ +import hre from 'hardhat'; +import { ethers } from 'hardhat'; + +interface EthersBigNumberLike { + toHexString(): string; +} + +interface BNLike { + toNumber(): number; + toString(base?: number): string; +} + +export type NumberLike = + | number + | bigint + | string + | EthersBigNumberLike + | BNLike; + +/** + * Sets the balance for the given address. + * + * @param address The address whose balance will be edited. + * @param balance The new balance to set for the given address, in wei. + */ +export async function setBalance( + address: string, + balance: NumberLike +): Promise { + if (!ethers.utils.isAddress(address)) { + throw new Error(`${address} is not a valid address`); + } + + let balanceHex: string; + if (typeof balance === 'bigint' || typeof balance === 'number') { + balanceHex = `0x${balance.toString(16)}`; + } else if (typeof balance === 'string') { + if (!balance.startsWith('0x')) { + balanceHex = `0x${BigInt(balance).toString(16)}`; + } else { + balanceHex = balance; + } + } else { + // This should never happen with the current type signature, but handle it gracefully + balanceHex = `0x${String(balance)}`; + } + + // Normalize hex string (remove leading zeros) + if (balanceHex === '0x0') { + balanceHex = '0x0'; + } else { + balanceHex = balanceHex.replace(/^0x0+/, '0x') || '0x0'; + } + + await hre.network.provider.request({ + method: 'hardhat_setBalance', + params: [address, balanceHex], + }); +} + +/** + * Allows Hardhat Network to sign transactions as the given address + * + * @param address The address to impersonate + */ +export async function impersonateAccount(address: string): Promise { + if (!ethers.utils.isAddress(address)) { + throw new Error(`${address} is not a valid address`); + } + + await hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [address], + }); +} + diff --git a/test/helpers/snapshot.ts b/test/helpers/snapshot.ts new file mode 100644 index 000000000..ce6c79f78 --- /dev/null +++ b/test/helpers/snapshot.ts @@ -0,0 +1,44 @@ +import hre from 'hardhat'; + +export interface SnapshotRestorer { + /** + * Resets the state of the blockchain to the point in which the snapshot was + * taken. + */ + restore(): Promise; + snapshotId: string; +} + +export async function takeSnapshot(): Promise { + const provider = hre.network.provider; + let snapshotId = await provider.request({ + method: 'evm_snapshot', + }); + + if (typeof snapshotId !== 'string') { + throw new Error('EVM_SNAPSHOT_VALUE_NOT_A_STRING'); + } + + return { + restore: async () => { + const reverted = await provider.request({ + method: 'evm_revert', + params: [snapshotId], + }); + + if (typeof reverted !== 'boolean') { + throw new Error('EVM_REVERT_VALUE_NOT_A_BOOLEAN'); + } + + if (!reverted) { + throw new Error('INVALID_SNAPSHOT'); + } + + // Re-take the snapshot so that `restore` can be called again + snapshotId = await provider.request({ + method: 'evm_snapshot', + }); + }, + snapshotId, + }; +} diff --git a/test/interest-rate-test.ts b/test/interest-rate-test.ts index 214c046cc..6945da670 100644 --- a/test/interest-rate-test.ts +++ b/test/interest-rate-test.ts @@ -1,119 +1,2187 @@ -import { expect, exp, makeProtocol, wait } from './helpers'; - -// Interest rate calculations can be checked with this Google Sheet: -// https://docs.google.com/spreadsheets/d/1G3BWcFPEQYnH-IrHHye5oA0oFIP0Jyj7pybdpMuDOuI - -// The minimum required precision between the actual and expected annual rate for tests to pass. -const MINIMUM_PRECISION_WEI = 1e8; // 1e8 wei of precision - -const SECONDS_PER_YEAR = 31_536_000; - -function assertInterestRatesMatch(expectedRate, actualRate, precision = MINIMUM_PRECISION_WEI) { - expect((actualRate.sub(expectedRate)).abs()).lte(precision); -} - -const interestRateParams = { - supplyKink: exp(0.8, 18), - supplyInterestRateBase: exp(0, 18), - supplyInterestRateSlopeLow: exp(0.04, 18), - supplyInterestRateSlopeHigh: exp(0.4, 18), - borrowKink: exp(0.8, 18), - borrowInterestRateBase: exp(0.01, 18), - borrowInterestRateSlopeLow: exp(0.05, 18), - borrowInterestRateSlopeHigh: exp(0.3, 18), -}; - -describe('interest rates', function () { - it('when below kink utilization', async () => { - const { comet } = await makeProtocol(interestRateParams); - - // 10% utilization - const totals = { - trackingSupplyIndex: 0, - trackingBorrowIndex: 0, - baseSupplyIndex: 2e15, - baseBorrowIndex: 4e15, - totalSupplyBase: 500n, - totalBorrowBase: 25n, - lastAccrualTime: 0, - pauseFlags: 0, - }; - await wait(comet.setTotalsBasic(totals)); - - const utilization = await comet.getUtilization(); - const supplyRate = await comet.getSupplyRate(utilization); - const borrowRate = await comet.getBorrowRate(utilization); - - // totalBorrowBase / totalSupplyBase - // = 10 / 100 = 0.1 - expect(utilization).to.be.equal(exp(0.1, 18)); - // interestRateBase + interestRateSlopeLow * utilization - // = 0 + 0.04 * 0.1 = 0.004 - assertInterestRatesMatch(exp(.004, 18), supplyRate.mul(SECONDS_PER_YEAR)); - // interestRateBase + interestRateSlopeLow * utilization - // = 0.01 + 0.05 * 0.1 = 0.015 - assertInterestRatesMatch(exp(0.015, 18), borrowRate.mul(SECONDS_PER_YEAR)); +import { + CometHarnessInterfaceExtendedAssetList, + FaucetToken, + SimplePriceFeed, +} from 'build/types'; +import { expect, exp, makeProtocol, ethers, DEFAULT_PRICEFEED_DECIMALS, SnapshotRestorer, takeSnapshot, factorScale, MAX_SUPPORTED_UTILIZATION } from './helpers'; +import { BigNumber } from 'ethers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; + +describe('interest calculation', function () { + let baseToken: FaucetToken; + let collaterals: { [symbol: string]: FaucetToken } = {}; + let priceFeeds: { [symbol: string]: SimplePriceFeed } = {}; + + let comet: CometHarnessInterfaceExtendedAssetList; + let lastUpdatedTime: number; + + let baseSupplyRate: BigNumber, + supplyLowSlope: BigNumber, + supplyHighSlope: BigNumber, + supplyKink: BigNumber; + let baseBorrowRate: BigNumber, + borrowLowSlope: BigNumber, + borrowHighSlope: BigNumber, + borrowKink: BigNumber; + + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let charlie: SignerWithAddress; + let dave: SignerWithAddress; + let eve: SignerWithAddress; + let other: SignerWithAddress; + + const baseDecimals = 6; + + const interestRateParams = { + supplyKink: exp(0.8, 18), + supplyInterestRateBase: exp(0.001, 18), + supplyInterestRateSlopeLow: exp(0.04, 18), + supplyInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowInterestRateBase: exp(0.01, 18), + borrowInterestRateSlopeLow: exp(0.05, 18), + borrowInterestRateSlopeHigh: exp(0.3, 18), + }; + + before(async function () { + const protocol = await makeProtocol({ + ...interestRateParams, + base: 'USDC', + assets: { + COMP: { + decimals: 18, + supplyCap: exp(10_000_000_000, 18), + initialPrice: 175, + }, + USDC: { + initialPrice: 1, + decimals: 6, + }, + }, + }); + + comet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens['USDC'] as FaucetToken; + + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + baseSupplyRate = await comet.supplyPerSecondInterestRateBase(); + supplyLowSlope = await comet.supplyPerSecondInterestRateSlopeLow(); + supplyHighSlope = await comet.supplyPerSecondInterestRateSlopeHigh(); + supplyKink = await comet.supplyKink(); + + baseBorrowRate = await comet.borrowPerSecondInterestRateBase(); + borrowLowSlope = await comet.borrowPerSecondInterestRateSlopeLow(); + borrowHighSlope = await comet.borrowPerSecondInterestRateSlopeHigh(); + borrowKink = await comet.borrowKink(); + + const tokens = protocol.tokens; + for (let asset in tokens) { + if (asset === 'USDC') continue; + collaterals[asset] = tokens[asset] as FaucetToken; + priceFeeds[asset] = protocol.priceFeeds[asset]; + } + priceFeeds['USDC'] = protocol.priceFeeds['USDC']; + [alice, bob, charlie, dave, eve, other] = protocol.users; + + await baseToken.allocateTo(alice.address, exp(1e10, baseDecimals)); + await baseToken.allocateTo(bob.address, exp(1e10, baseDecimals)); + await collaterals['COMP'].allocateTo(alice.address, exp(1e10, 18)); + await collaterals['COMP'].allocateTo(bob.address, exp(1e10, 18)); + await collaterals['COMP'].allocateTo(charlie.address, exp(1e10, 18)); }); - it('when above kink utilization', async () => { - const { comet } = await makeProtocol(interestRateParams); - - // 90% utilization - const totals = { - trackingSupplyIndex: 0, - trackingBorrowIndex: 0, - baseSupplyIndex: 2e15, - baseBorrowIndex: 3e15, - totalSupplyBase: 50n, - totalBorrowBase: 30n, - lastAccrualTime: 0, - pauseFlags: 0, - }; - await wait(comet.setTotalsBasic(totals)); - - const utilization = await comet.getUtilization(); - const supplyRate = await comet.getSupplyRate(utilization); - const borrowRate = await comet.getBorrowRate(utilization); - - // totalBorrowBase / totalSupplyBase - // = 90 / 100 = 0.9 - expect(utilization).to.be.equal(exp(0.9, 18)); - // interestRateBase + interestRateSlopeLow * kink + interestRateSlopeHigh * (utilization - kink) - // = 0 + 0.04 * 0.8 + 0.4 * 0.1 = 0.072 - assertInterestRatesMatch(exp(0.072, 18), supplyRate.mul(SECONDS_PER_YEAR)); - // interestRateBase + interestRateSlopeLow * kink + interestRateSlopeHigh * (utilization - kink) - // = 0.01 + 0.05 * 0.8 + 0.3 * 0.1 = 0.08 - assertInterestRatesMatch(exp(0.08, 18), borrowRate.mul(SECONDS_PER_YEAR)); + /// Note: testcases in "regular logic" testset are dependent as they form a single flow which can be + /// often met in the work of the protocol: + /// create market -> supply -> supply collateral -> borrow -> borrow more to higher utilization -> + /// -> supply to decrease utilization + describe('regular logic', function () { + const SUPPLY_AMOUNT: BigNumber = BigNumber.from(exp(10000, baseDecimals)); // 10k$ + const SUPPLY_AMOUNT_UNDER_KINK: BigNumber = BigNumber.from( + exp(10000, baseDecimals) + ); // 10k$ + const COLLATERAL_VALUE: BigNumber = BigNumber.from( + exp(90000, baseDecimals) + ); // 80k$ + let COLLATERAL_AMOUNT: BigNumber; // will be calculated from the price at later testcase + const BORROW_AMOUNT: BigNumber = BigNumber.from(exp(2000, baseDecimals)); // 2k$ + const BORROW_AMOUNT_OVER_KINK: BigNumber = BigNumber.from( + exp(6100, baseDecimals) + ); // 6.1k$ + const BORROW_AMOUNT_OVERUTILIZATION: BigNumber = BigNumber.from( + exp(2100, baseDecimals) + ); // 2.1k$ + const BORROW_AMOUNT_EXCEEDS_LIMIT: BigNumber = BigNumber.from( + exp(10000, baseDecimals) + ); // 10k$ + + const WITHDRAW_AMOUNT_EXCEEDS_LIMIT: BigNumber = BigNumber.from( + exp(16000, baseDecimals) + ); // 12k$ + const WITHDRAW_AMOUNT_EXTRA: BigNumber = BigNumber.from( + exp(2000, baseDecimals) + ); // 2k$ + + const AVERAGE_WAIT_TIME = 3600; // 1 hr + + let aliceDepositTimestamp: number; + + describe('empty market', function () { + before(async function () { + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('utilization is 0 for empty market', async () => { + expect(await comet.getUtilization()).to.equal(0); + }); + + it('supply rate is 0 for empty market', async () => { + expect(await comet.getSupplyRate(0)).to.equal(0); + }); + + it('borrow rate is 0 for empty market', async () => { + expect(await comet.getBorrowRate(0)).to.equal(0); + }); + + it('initial supply index = 1', async () => { + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal( + exp(1, 15) + ); + }); + + it('initial borrow index = 1', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal( + exp(1, 15) + ); + }); + + it('perform accrue to update state of the market (accrue action in test)', async () => { + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index is not growing without supplies into the market', async () => { + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal( + exp(1, 15) + ); + }); + + it('borrow index is not growing without supplies into the market', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal( + exp(1, 15) + ); + }); + }); + + describe('supplies with no borrows and no reserves', function () { + let prevSupplyIndex: BigNumber; + let snapshot: SnapshotRestorer; + + before(async function () { + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + + snapshot = await takeSnapshot(); + }); + + this.afterAll(async () => await snapshot.restore()); + + it('first supply to the market with no borrows accrues the state (user action in test)', async () => { + await baseToken.connect(alice).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(alice).supply(baseToken.address, SUPPLY_AMOUNT); + + const curUpdatedTime: number = (await comet.totalsBasic()).lastAccrualTime; + expect(curUpdatedTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + aliceDepositTimestamp = curUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('but does not change supply indexe (as accrue is performed before supply state changes)', async () => { + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(exp(1, 15)); + }); + + it('and does not change borrow index (as no borrows performed)', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(exp(1, 15)); + }); + + it('supplies to the market does not spike utilization if there are no borrows', async () => { + expect(await comet.getUtilization()).to.equal(0); + }); + + it('supply rate equals 0 for supplies with no borrows', async () => { + expect(await comet.getSupplyRate(0)).to.equal(0); + }); + + it('borrow rate equals 0 (no borrows)', async () => { + expect(await comet.getBorrowRate(0)).to.equal(0); + }); + + it('wait some time and get previous state', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('accrue after some time updates state of the market (accrue action in test)', async () => { + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()).lastAccrualTime; + expect(curUpdatedTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + }); + + it('supply index does not change without reserves on the market', async () => { + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(prevSupplyIndex); + }); + + it('utilization is not growing', async () => { + expect(await comet.getUtilization()).to.equal(0); + }); + + it('borrow index is not growing without borrows on the market', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(exp(1, 15)); + }); + + it('supply rate is not growing without borrows on the market', async () => { + expect(await comet.getSupplyRate(0)).to.equal(0); + }); + + it('borrow rate equals 0 (no borrows)', async () => { + expect(await comet.getBorrowRate(0)).to.equal(0); + }); + + it('alice lend displayed principle (balanceOf) is not growing without reserves on the market', async () => { + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(prevSupplyIndex); + + const principal = (await comet.userBasic(alice.address)).principal; + const expectedBalance = principal.mul(prevSupplyIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(alice.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + }); + + describe('supplies with no borrows and reserves', function () { + let timeElapsed: number; + let prevSupplyIndex: BigNumber; + + before(async function () { + /// allocate reserves to the market + await baseToken.allocateTo(comet.address, exp(5000, baseDecimals)); + + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('first supply to the market with no borrows accrues the state (user action in test)', async () => { + await baseToken.connect(alice).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(alice).supply(baseToken.address, SUPPLY_AMOUNT); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + aliceDepositTimestamp = curUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('but does not change supply indexe (as accrue is performed before supply state changes)', async () => { + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal( + exp(1, 15) + ); + }); + + it('and does not change borrow index (as no borrows performed)', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal( + exp(1, 15) + ); + }); + + it('supplies to the market does not spike utilization if there are no borrows', async () => { + expect(await comet.getUtilization()).to.equal(0); + }); + + it('supply rate equals to base rate for supplies with no borrows', async () => { + expect(await comet.getSupplyRate(0)).to.equal(baseSupplyRate); + }); + + it('borrow rate equals 0 (no borrows)', async () => { + expect(await comet.getBorrowRate(0)).to.equal(0); + }); + + it('wait some time and get previous state', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('accrue after some time updates state of the market (accrue action in test)', async () => { + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows according to the base rate', async () => { + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex.mul(baseSupplyRate).mul(timeElapsed).div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('utilization is not growing', async () => { + expect(await comet.getUtilization()).to.equal(0); + }); + + it('borrow index is not growing without borrows on the market', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal( + exp(1, 15) + ); + }); + + it('supply rate equals to base rate for supplies with no borrows', async () => { + expect(await comet.getSupplyRate(0)).to.equal(baseSupplyRate); + }); + + it('borrow rate equals 0 (no borrows)', async () => { + expect(await comet.getBorrowRate(0)).to.equal(0); + }); + + it('alice lend displayed principle (balanceOf) grows according to the base rate', async () => { + timeElapsed = lastUpdatedTime - aliceDepositTimestamp; + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex.mul(baseSupplyRate).mul(timeElapsed).div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(alice.address)).principal; + const expectedBalance = principal.mul(accruedIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(alice.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + }); + + describe('supplies and borrows (low slope)', function () { + describe('supplies collateral', function () { + let prevSupplyIndex: BigNumber; + let timeElapsed: number; + + before(async function () { + const colPrice = (await priceFeeds['COMP'].latestRoundData())[1]; + const colPriceInBase = colPrice + .mul(exp(1, baseDecimals)) + .div(exp(1, DEFAULT_PRICEFEED_DECIMALS)); // as base is USDC its price is 1 + COLLATERAL_AMOUNT = BigNumber.from(COLLATERAL_VALUE) + .mul(exp(1, 18)) + .div(colPriceInBase); + + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('bob supplies collateral (user action in test)', async () => { + await collaterals['COMP'] + .connect(bob) + .approve(comet.address, COLLATERAL_AMOUNT); + await comet + .connect(bob) + .supply(collaterals['COMP'].address, COLLATERAL_AMOUNT); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('but does not impact utilization', async () => { + expect(await comet.getUtilization()).to.equal(0); + }); + + it('and does not impact borrow rate (as there is no borrow)', async () => { + expect(await comet.getBorrowRate(0)).to.equal(0); + }); + + it('and does not impact borrow index (as there is no borrow)', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal( + exp(1, 15) + ); + }); + + it('supply rate is still == base rate (as there is no borrows)', async () => { + expect(await comet.getSupplyRate(0)).to.equal(baseSupplyRate); + }); + + it('supply index grows based on the base rate', async () => { + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex.mul(baseSupplyRate).mul(timeElapsed).div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + }); + + describe('market gets first borrow', function () { + let prevSupplyIndex: BigNumber, prevBorrowIndex: BigNumber; + let prevUtilization: BigNumber; + let timeElapsed: number; + + before(async function () { + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = BigNumber.from(0); + }); + + it('first borrow from the market accrues the state (user action in test)', async () => { + await comet.connect(bob).withdraw(baseToken.address, BORROW_AMOUNT); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + aliceDepositTimestamp = curUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('but does not change borrow index (as index is accrued before storage change)', async () => { + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal( + exp(1, 15) + ); + }); + + it('supply rate grows to the low slope of the interest curve', async () => { + const expectedSupplyRate = baseSupplyRate.add( + supplyLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + const curSupplyRate = await comet.getSupplyRate(prevUtilization); + + expect(curSupplyRate).equal(expectedSupplyRate); + }); + + it('borrow rate grows to the low slope of the interest curve', async () => { + const expectedBorrowRate = baseBorrowRate.add( + borrowLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + const curBorrowRate = await comet.getBorrowRate(prevUtilization); + + expect(curBorrowRate).equal(expectedBorrowRate); + }); + + it('utilization grows based on the borrowed amount', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = BORROW_AMOUNT.mul(curBorrowIndex).div( + exp(1, 15) + ); + const scaledSupply = SUPPLY_AMOUNT.mul(curSupplyIndex).div( + exp(1, 15) + ); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 20% + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + }); + + it('wait some time and get previous state', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('accrue after some time updates state of the market (accrue action in test)', async () => { + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the low slope of the interest curve', async () => { + const expectedSupplyRate = baseSupplyRate.add( + supplyLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the low slope of the interest curve', async () => { + const expectedBorrowRate = baseBorrowRate.add( + borrowLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it("alice's lend displayed principle (balanceOf) grows according to the low slope", async () => { + const expectedSupplyRate = baseSupplyRate.add( + supplyLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(alice.address)).principal; + const expectedBalance = principal.mul(accruedIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(alice.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it("bob's displayed borrow (borrowBalanceOf) grows according to the low slope", async () => { + const expectedBorrowRate = baseBorrowRate.add( + borrowLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseBorrowIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(bob.address)).principal; + const expectedBalance = principal + .mul(accruedIndex) + .div(exp(1, 15)) + .mul(-1); /// -1 as principal < 0 + + const balance = await comet.borrowBalanceOf(bob.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + }); + }); + + describe('supplies and borrows (high slope)', function () { + let prevSupplyIndex: BigNumber, prevBorrowIndex: BigNumber; + let prevUtilization: BigNumber; + let timeElapsed: number; + + before(async function () { + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + }); + + it('borrow which pushes utilization over the kink accrues the state (user action in test)', async () => { + await comet + .connect(bob) + .withdraw(baseToken.address, BORROW_AMOUNT_OVER_KINK); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the low slope of the interest curve (as supply state is updated after the accrual)', async () => { + const expectedSupplyRate = baseSupplyRate.add( + supplyLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the low slope of the interest curve (as borrow state is updated after the accrual)', async () => { + const expectedBorrowRate = baseBorrowRate.add( + borrowLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('over the kink utilization is reached', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = (await comet.userBasic(bob.address)).principal + .mul(curBorrowIndex) + .div(exp(1, 15)) + .mul(-1); // for borrow + const scaledSupply = (await comet.userBasic(alice.address)).principal + .mul(curSupplyIndex) + .div(exp(1, 15)); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 80% + + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + expect(currentUtilization).to.be.greaterThanOrEqual(supplyKink); + expect(currentUtilization).to.be.greaterThanOrEqual(borrowKink); + }); + + it('supply rate grows to the high slope of the interest curve', async () => { + const curUtilization = await comet.getUtilization(); + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(curUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const curSupplyRate = await comet.getSupplyRate(curUtilization); + + expect(curSupplyRate).to.equal(expectedSupplyRate); + }); + + it('borrow rate grows to the high slope of the interest curve', async () => { + const curUtilization = await comet.getUtilization(); + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(curUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const curBorrowRate = await comet.getBorrowRate(curUtilization); + + expect(curBorrowRate).to.equal(expectedBorrowRate); + }); + + it('accrue updates state of the market (accrue action in test)', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the high slope of the interest curve', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the high slope of the interest curve', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('utiization corresponds to the market state', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = (await comet.userBasic(bob.address)).principal + .mul(curBorrowIndex) + .div(exp(1, 15)) + .mul(-1); // for borrow + const scaledSupply = (await comet.userBasic(alice.address)).principal + .mul(curSupplyIndex) + .div(exp(1, 15)); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 80% + + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + }); + + it("alice's lend displayed principle (balanceOf) grows according to the high slope", async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(alice.address)).principal; + const expectedBalance = principal.mul(accruedIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(alice.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it("bob's displayed borrow (borrowBalanceOf) grows according to the high slope", async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseBorrowIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(bob.address)).principal; + const expectedBalance = principal + .mul(accruedIndex) + .div(exp(1, 15)) + .mul(-1); /// -1 as principal < 0 + + const balance = await comet.borrowBalanceOf(bob.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + }); + + describe('over utilization', function () { + let prevSupplyIndex: BigNumber, prevBorrowIndex: BigNumber; + let prevUtilization: BigNumber; + let timeElapsed: number; + + describe('through transfer operation', function () { + const COLLATERAL_AMOUNT_TRANSFER = exp(50, 18); + const BORROW_AMOUNT_TRANSFER = exp(7000, 6); + + let snapshot: SnapshotRestorer; + let currTotalSupplyBase: BigNumber; + let currTotalBorrowBase: BigNumber; + let expectedLastAccrualTime: number; + + before(async function () { + snapshot = await takeSnapshot(); + + // Supply collateral from Dave + await collaterals['COMP'].allocateTo( + dave.address, + COLLATERAL_AMOUNT_TRANSFER * 3n + ); + await collaterals['COMP'] + .connect(dave) + .approve(comet.address, COLLATERAL_AMOUNT_TRANSFER * 3n); + await comet + .connect(dave) + .supply(collaterals['COMP'].address, COLLATERAL_AMOUNT_TRANSFER); + + await baseToken.allocateTo( + comet.address, + BORROW_AMOUNT_TRANSFER * 3n + ); + + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + }); + + it('can borrow via transfer to reach utilization > 100% (borrow from reserves) (user action in test)', async () => { + await comet + .connect(dave) + .transfer(eve.address, BORROW_AMOUNT_TRANSFER); + + // Get totals after transfer + currTotalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; + currTotalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; + + const withdrawTx = await comet + .connect(eve) + .withdraw(baseToken.address, BORROW_AMOUNT_TRANSFER); + expectedLastAccrualTime = (await ethers.provider.getBlock((await withdrawTx.wait()).blockNumber)).timestamp; + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal(expectedLastAccrualTime); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the high slope of the interest curve', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.be.approximately(accruedIndex, exp(1, 7)); + }); + + it('borrow index grows based on the high slope of the interest curve', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.be.approximately(accruedIndex, exp(1, 7)); + }); + + it('over 100% utilization is reached', async () => { + expect(await comet.getUtilization()).to.be.greaterThanOrEqual( + exp(1, 18) + ); // > 100% + }); + + it('exceeds supported utilization not reached', async () => { + // Check that total supply is greater than 0 + expect(currTotalSupplyBase).to.be.greaterThan(0); + + // Check that utilization is not exceeded + const totalSupplyWithoutDst = currTotalSupplyBase.sub(BORROW_AMOUNT_TRANSFER); + const utilization = currTotalBorrowBase.mul(factorScale).div(totalSupplyWithoutDst); + expect(utilization).to.be.lessThan(MAX_SUPPORTED_UTILIZATION); + }); + + it('supply rate grows to the high slope of the interest curve (> 100%)', async () => { + const curUtilization = await comet.getUtilization(); + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(curUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const curSupplyRate = await comet.getSupplyRate(curUtilization); + + expect(curSupplyRate).to.equal(expectedSupplyRate); + }); + + it('borrow rate grows to the high slope of the interest curve (> 100%)', async () => { + const curUtilization = await comet.getUtilization(); + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(curUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const curBorrowRate = await comet.getBorrowRate(curUtilization); + + expect(curBorrowRate).to.equal(expectedBorrowRate); + }); + + it('accrue updates state of the market (accrue action in test)', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + const accrueTx = await comet.accrueAccount(ethers.constants.AddressZero); + expectedLastAccrualTime = (await ethers.provider.getBlock((await accrueTx.wait()).blockNumber)).timestamp; + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + expectedLastAccrualTime + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the high slope of the interest curve (> 100%)', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the high slope of the interest curve (> 100%)', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('utiization corresponds to the market state (> 100%)', async () => { + expect(await comet.getUtilization()).to.be.greaterThanOrEqual( + exp(1, 18) + ); // > 100% + }); + + it("eve's lend displayed principle (balanceOf) grows according to the high slope (> 100%)", async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(eve.address)).principal; + const expectedBalance = principal.mul(accruedIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(eve.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it("bob's displayed borrow (borrowBalanceOf) grows according to the high slope (> 100%)", async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseBorrowIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(bob.address)).principal; + const expectedBalance = principal + .mul(accruedIndex) + .div(exp(1, 15)) + .mul(-1); /// -1 as principal < 0 + + const balance = await comet.borrowBalanceOf(bob.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it('should revert for bob borrow which reach utilization over 200%', async () => { + // First supply collateral to cover future debt + await comet + .connect(dave) + .supply(collaterals['COMP'].address, COLLATERAL_AMOUNT_TRANSFER); + + // Get totals after supply + currTotalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; + currTotalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; + + // Then try to borrow + await expect(comet + .connect(dave) + .transfer(eve.address, BORROW_AMOUNT_TRANSFER)).to.be.revertedWithCustomError(comet, 'ExceedsSupportedUtilization'); + }); + + it('exceeds supported utilization is reached during tranfer', async () => { + // Check that total supply is greater than 0 + expect(currTotalSupplyBase).to.be.greaterThan(0); + + // Check that utilization is not exceeded + const totalSupplyWithoutDst = currTotalSupplyBase.sub(BORROW_AMOUNT_TRANSFER); + const utilization = currTotalBorrowBase.mul(factorScale).div(totalSupplyWithoutDst); + expect(utilization).to.be.greaterThan(MAX_SUPPORTED_UTILIZATION); + }); + + it('should revert for any new user pushing utilization over 200%', async () => { + // First supply collateral to cover future debt + await collaterals['COMP'].allocateTo(charlie.address, COLLATERAL_AMOUNT_TRANSFER); + await collaterals['COMP'].connect(charlie).approve(comet.address, COLLATERAL_AMOUNT_TRANSFER); + await comet.connect(charlie).supply(collaterals['COMP'].address, COLLATERAL_AMOUNT_TRANSFER); + + // Get totals after supply + currTotalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; + currTotalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; + + // Check that utilization will be exceeded + const totalSupplyWithoutDst = currTotalSupplyBase.sub(BORROW_AMOUNT_TRANSFER); + const utilization = currTotalBorrowBase.mul(factorScale).div(totalSupplyWithoutDst); + expect(utilization).to.be.greaterThan(MAX_SUPPORTED_UTILIZATION); + + // Then try to borrow + await expect(comet.connect(charlie).transfer(eve.address, BORROW_AMOUNT_TRANSFER)).to.be.revertedWithCustomError(comet, 'ExceedsSupportedUtilization'); + + await snapshot.restore(); + }); + }); + + describe('through withdraw operation', function () { + before(async function () { + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + await baseToken.allocateTo( + comet.address, + BORROW_AMOUNT_OVERUTILIZATION + ); + }); + + it('can borrow to reach utilization > 100% (borrow from reserves) (user action in test)', async () => { + await comet + .connect(bob) + .withdraw(baseToken.address, BORROW_AMOUNT_OVERUTILIZATION); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the high slope of the interest curve', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the high slope of the interest curve', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('over 100% utilization is reached', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = (await comet.userBasic(bob.address)).principal + .mul(curBorrowIndex) + .div(exp(1, 15)) + .mul(-1); // for borrow + const scaledSupply = (await comet.userBasic(alice.address)).principal + .mul(curSupplyIndex) + .div(exp(1, 15)); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 100% + + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + expect(currentUtilization).to.be.greaterThanOrEqual(exp(1, 18)); // > 100% + }); + + it('supply rate grows to the high slope of the interest curve (> 100%)', async () => { + const curUtilization = await comet.getUtilization(); + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(curUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const curSupplyRate = await comet.getSupplyRate(curUtilization); + + expect(curSupplyRate).to.equal(expectedSupplyRate); + }); + + it('borrow rate grows to the high slope of the interest curve (> 100%)', async () => { + const curUtilization = await comet.getUtilization(); + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(curUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const curBorrowRate = await comet.getBorrowRate(curUtilization); + + expect(curBorrowRate).to.equal(expectedBorrowRate); + }); + + it('accrue updates state of the market (accrue action in test)', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the high slope of the interest curve (> 100%)', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the high slope of the interest curve (> 100%)', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('utiization corresponds to the market state (> 100%)', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = (await comet.userBasic(bob.address)).principal + .mul(curBorrowIndex) + .div(exp(1, 15)) + .mul(-1); // for borrow + const scaledSupply = (await comet.userBasic(alice.address)).principal + .mul(curSupplyIndex) + .div(exp(1, 15)); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 100% + + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + expect(currentUtilization).to.be.greaterThanOrEqual(exp(1, 18)); // > 100% + }); + + it("alice's lend displayed principle (balanceOf) grows according to the high slope (> 100%)", async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(alice.address)).principal; + const expectedBalance = principal.mul(accruedIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(alice.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it("bob's displayed borrow (borrowBalanceOf) grows according to the high slope (> 100%)", async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseBorrowIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(bob.address)).principal; + const expectedBalance = principal + .mul(accruedIndex) + .div(exp(1, 15)) + .mul(-1); /// -1 as principal < 0 + + const balance = await comet.borrowBalanceOf(bob.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it('should revert for bob borrow which reach utilization over 200%', async () => { + await expect( + comet + .connect(bob) + .withdraw(baseToken.address, BORROW_AMOUNT_EXCEEDS_LIMIT) + ).to.revertedWithCustomError(comet, 'ExceedsSupportedUtilization'); + }); + + it('should revert for any new user pushing utilization over 200%', async () => { + await collaterals['COMP'] + .connect(charlie) + .approve(comet.address, COLLATERAL_AMOUNT); + await comet + .connect(charlie) + .supply(collaterals['COMP'].address, COLLATERAL_AMOUNT); + await expect( + comet + .connect(charlie) + .withdraw(baseToken.address, BORROW_AMOUNT_EXCEEDS_LIMIT) + ).to.revertedWithCustomError(comet, 'ExceedsSupportedUtilization'); + }); + }); + }); + + describe('new supply pushes utilization back under the kink', function () { + let prevSupplyIndex: BigNumber, prevBorrowIndex: BigNumber; + let prevUtilization: BigNumber; + let timeElapsed: number; + + before(async function () { + // wait some time + await ethers.provider.send('evm_increaseTime', [AVERAGE_WAIT_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + }); + + it('supply to the market to decrease utilization accrues state (user action in test)', async () => { + await baseToken + .connect(alice) + .approve(comet.address, SUPPLY_AMOUNT_UNDER_KINK); + await comet + .connect(alice) + .supply(baseToken.address, SUPPLY_AMOUNT_UNDER_KINK); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the high slope of the interest curve (as supply state is updated after acrrual)', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(supplyKink).div(exp(1, 18)) + ); + expectedSupplyRate = expectedSupplyRate.add( + supplyHighSlope.mul(prevUtilization.sub(supplyKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the high slope of the interest curve (as supply state is updated after acrrual)', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(borrowKink).div(exp(1, 18)) + ); + expectedBorrowRate = expectedBorrowRate.add( + borrowHighSlope.mul(prevUtilization.sub(borrowKink)).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('utilization is pushed under the kink', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = (await comet.userBasic(bob.address)).principal + .mul(curBorrowIndex) + .div(exp(1, 15)) + .mul(-1); // for borrow + const scaledSupply = (await comet.userBasic(alice.address)).principal + .mul(curSupplyIndex) + .div(exp(1, 15)); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 50% + + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + expect(currentUtilization).to.be.lessThanOrEqual(supplyKink); + expect(currentUtilization).to.be.lessThanOrEqual(borrowKink); + }); + + it('supply rate grows based on the low slope of the interest curve', async () => { + const curUtilization = await comet.getUtilization(); + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(curUtilization).div(exp(1, 18)) + ); + + const curSupplyRate = await comet.getSupplyRate(curUtilization); + + expect(curSupplyRate).to.equal(expectedSupplyRate); + }); + + it('borrow rate grows based on the low slope of the interest curve', async () => { + const curUtilization = await comet.getUtilization(); + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(curUtilization).div(exp(1, 18)) + ); + + const curBorrowRate = await comet.getBorrowRate(curUtilization); + + expect(curBorrowRate).to.equal(expectedBorrowRate); + }); + + it('accrue updates state of the market (accrue action in test)', async () => { + prevSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + prevBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + prevUtilization = await comet.getUtilization(); + lastUpdatedTime = (await comet.totalsBasic()).lastAccrualTime; + + await comet.accrueAccount(ethers.constants.AddressZero); + + const curUpdatedTime: number = (await comet.totalsBasic()) + .lastAccrualTime; + expect(curUpdatedTime).to.equal( + (await ethers.provider.getBlock('latest')).timestamp + ); + expect(curUpdatedTime).to.be.greaterThan(lastUpdatedTime); + + timeElapsed = curUpdatedTime - lastUpdatedTime; + lastUpdatedTime = curUpdatedTime; + }); + + it('supply index grows based on the low slope of the interest curve', async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseSupplyIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('borrow index grows based on the low slope of the interest curve', async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + const index = (await comet.totalsBasic()).baseBorrowIndex; + + expect(index).to.equal(accruedIndex); + }); + + it('utiization corresponds to the market state (< kink%)', async () => { + const curSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + const curBorrowIndex = (await comet.totalsBasic()).baseBorrowIndex; + + const scaledBorrow = (await comet.userBasic(bob.address)).principal + .mul(curBorrowIndex) + .div(exp(1, 15)) + .mul(-1); // for borrow + const scaledSupply = (await comet.userBasic(alice.address)).principal + .mul(curSupplyIndex) + .div(exp(1, 15)); + const expectedUtilization = scaledBorrow + .mul(exp(1, 18)) + .div(scaledSupply); // 100% + + const currentUtilization: BigNumber = await comet.getUtilization(); + + /// we can loose some weis of accuracy based on rounding errors + expect(currentUtilization).to.be.approximately( + expectedUtilization, + exp(1, 4) + ); + expect(currentUtilization).to.be.lessThanOrEqual(supplyKink); + expect(currentUtilization).to.be.lessThanOrEqual(borrowKink); + }); + + it("alice's lend displayed principle (balanceOf) grows according to the low slope", async () => { + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add( + supplyLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevSupplyIndex.add( + prevSupplyIndex + .mul(expectedSupplyRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(alice.address)).principal; + const expectedBalance = principal.mul(accruedIndex).div(exp(1, 15)); + + const balance = await comet.balanceOf(alice.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + + it("bob's displayed borrow (borrowBalanceOf) grows according to the low slope", async () => { + let expectedBorrowRate = baseBorrowRate; + expectedBorrowRate = expectedBorrowRate.add( + borrowLowSlope.mul(prevUtilization).div(exp(1, 18)) + ); + + const accruedIndex = prevBorrowIndex.add( + prevBorrowIndex + .mul(expectedBorrowRate) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseBorrowIndex; + expect(index).to.equal(accruedIndex); + + const principal = (await comet.userBasic(bob.address)).principal; + const expectedBalance = principal + .mul(accruedIndex) + .div(exp(1, 15)) + .mul(-1); /// -1 as principal < 0 + + const balance = await comet.borrowBalanceOf(bob.address); + // 1 wei difference is possible + expect(balance).to.be.approximately(expectedBalance, 1); + }); + }); + + describe('lenders can withdraw from the market even peaking utilization', function () { + it('withdraw by lenders does not revert if reaching >200% utilization from regular level in one step', async () => { + await baseToken.allocateTo( + comet.address, + WITHDRAW_AMOUNT_EXCEEDS_LIMIT + ); + + let curUtilization = await comet.getUtilization(); + expect(curUtilization).to.be.lessThan(exp(1, 18)); // < 100% + + await expect( + comet + .connect(alice) + .withdraw(baseToken.address, WITHDRAW_AMOUNT_EXCEEDS_LIMIT) + ).to.not.be.reverted; + + // 20k supplied, 8k borrowed -> withdraw of 16k will spike utilization over 200% + curUtilization = await comet.getUtilization(); + expect(curUtilization).to.be.greaterThanOrEqual(exp(2, 18)); // > 200% + }); + + it('withdraw by lenders does not revert within 200%+ utilization', async () => { + let curUtilization = await comet.getUtilization(); + expect(curUtilization).to.be.greaterThanOrEqual(exp(2, 18)); // > 200% + + await expect( + comet + .connect(alice) + .withdraw(baseToken.address, WITHDRAW_AMOUNT_EXTRA) + ).to.not.be.reverted; + + // 4k supplied, 8k borrowed -> withdraw of 2k will spike utilization over 400% + curUtilization = await comet.getUtilization(); + expect(curUtilization).to.be.greaterThanOrEqual(exp(4, 18)); // > 200% + }); + + it('withdraw by lenders does not revert if reaching utilization above uint64 limit (> 1900%)', async () => { + /// withdraw everything except 1$ + const curBalance = await comet.balanceOf(alice.address); + + await expect( + comet + .connect(alice) + .withdraw(baseToken.address, curBalance.sub(exp(1, baseDecimals))) + ).to.not.be.reverted; + + // 2k supplied, 8k borrowed -> withdraw of 2k - 1$ will spike utilization over 8000%, exceeding uint64 limit + const curUtilization = await comet.getUtilization(); + expect(curUtilization).to.be.greaterThanOrEqual(exp(80, 18)); // > 8000%, far exceedint uint64 limit + }); + }); }); - it('when 0 utilization', async () => { - const { comet } = await makeProtocol(interestRateParams); - - // 0% utilization - const totals = { - trackingSupplyIndex: 0, - trackingBorrowIndex: 0, - baseSupplyIndex: 2e15, - baseBorrowIndex: 3e15, - totalSupplyBase: 50n, - totalBorrowBase: 0, - lastAccrualTime: 0, - pauseFlags: 0, - }; - await wait(comet.setTotalsBasic(totals)); - - const utilization = await comet.getUtilization(); - const supplyRate = await comet.getSupplyRate(utilization); - const borrowRate = await comet.getBorrowRate(utilization); - - // totalBorrowBase / totalSupplyBase - // = 0 / 100 = 0 - expect(utilization).to.be.equal(0); - // interestRateBase + interestRateSlopeLow * utilization - // = 0 + 0.04 * 0 = 0 - assertInterestRatesMatch(0, supplyRate.mul(SECONDS_PER_YEAR)); - // interestRateBase + interestRateSlopeLow * utilization - // = 0.01 + 0.05 * 0 = 0.01 - assertInterestRatesMatch(exp(0.01, 18), borrowRate.mul(SECONDS_PER_YEAR)); + describe('edge cases', function () { + describe('supply interest will not exceed reserves in case of no borrows for new market', function () { + let testComet: CometHarnessInterfaceExtendedAssetList; + const SUPPLY_AMOUNT: BigNumber = BigNumber.from(exp(1000000, baseDecimals)); // 1mln$ + const BORROW_AMOUNT: BigNumber = BigNumber.from(exp(2000, baseDecimals)); // 2k$ + const COLLATERAL_VALUE: BigNumber = BigNumber.from(exp(90000, baseDecimals)); // 80k$ + const INITIAL_RESERVES: BigNumber = BigNumber.from(exp(5, baseDecimals)); // 5$ + let COLLATERAL_AMOUNT: BigNumber; // will be calculated from the price at later testcase + let expectedTimeElapsed: BigNumber; + + let baseToken: FaucetToken; + let collateral: FaucetToken; + + before(async function () { + const protocol = await makeProtocol(interestRateParams); + testComet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens['USDC'] as FaucetToken; + collateral = protocol.tokens['COMP'] as FaucetToken; + + await baseToken.allocateTo(alice.address, SUPPLY_AMOUNT); + await baseToken.connect(alice).approve(testComet.address, SUPPLY_AMOUNT); + await testComet.connect(alice).supply(baseToken.address, SUPPLY_AMOUNT); + + await baseToken.allocateTo(bob.address, SUPPLY_AMOUNT); + await baseToken.connect(bob).approve(testComet.address, SUPPLY_AMOUNT); + await testComet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + await baseToken.allocateTo(testComet.address, INITIAL_RESERVES); + + const colPrice = (await protocol.priceFeeds['COMP'].latestRoundData())[1]; + const colPriceInBase = colPrice.mul(exp(1, baseDecimals)).div(exp(1, DEFAULT_PRICEFEED_DECIMALS)); // as base is USDC its price is 1 + COLLATERAL_AMOUNT = BigNumber.from(COLLATERAL_VALUE).mul(exp(1, 18)).div(colPriceInBase); + + await collateral.allocateTo(charlie.address, COLLATERAL_AMOUNT); + await collateral.connect(charlie).approve(testComet.address, COLLATERAL_AMOUNT); + await testComet.connect(charlie).supply(collateral.address, COLLATERAL_AMOUNT); + }); + + it('comet balance is a sum of seed reserves and 2 deposits', async () => { + const curBalance = await baseToken.balanceOf(testComet.address); + + expect(curBalance).to.equal(INITIAL_RESERVES.add(SUPPLY_AMOUNT).add(SUPPLY_AMOUNT)); + }); + + it('supply rate corresponds to the base rate', async () => { + // cur utilization is 0, as there is no borrows + const curSupplyRate = await testComet.getSupplyRate(0); + expect(curSupplyRate).to.equal(baseSupplyRate); + }); + + it('get expected time elapsed on which reserves spend will happen', async () => { + // since we deposited just once, we can use the initial principal + // if more deposits are performed, it will only speed things up, so we can rely on 1 deposit only + const alicePrincipal = (await testComet.userBasic(alice.address)).principal; + + // the balance we want to achieve is deposit + half of reserve (for 2 users) + const expectedBalance = INITIAL_RESERVES.div(2).add(SUPPLY_AMOUNT); + + // get the expected supply index + // presentValue = principal * supplyIndex / 1e15 + // => expected index = presentValue * 1e15 / principal + const expectedSupplyIndex = expectedBalance.mul(exp(1, 15)).div(alicePrincipal); + + // since utilization = 0, lenders will get only baseRate of interest + const expectedSupplyRate = baseSupplyRate; + + // since we started from the initial deposit, the initial index is 1 + const prevSupplyIndex = BigNumber.from(exp(1, 15)); + + // get the time elapsed until the required balance + // accrued index = supply index + supply index * supply rate * time elapsed + // => time elapsed = (accrued index - supply index) / (supply index * supply rate) + expectedTimeElapsed = expectedSupplyIndex.sub(prevSupplyIndex).div(prevSupplyIndex.mul(expectedSupplyRate).div(exp(1, 18))); + }); + + it('accrue market right after the expected time elapsed', async () => { + await ethers.provider.send('evm_increaseTime', [expectedTimeElapsed.toNumber()]); + await ethers.provider.send('evm_mine', []); + + await testComet.accrueAccount(ethers.constants.AddressZero); + }); + + it('supply rate is growing as total supply grows', async () => { + expect(await baseToken.balanceOf(testComet.address)).to.be.approximately(await testComet.totalSupply(), 1); + expect(await testComet.getSupplyRate(0)).to.equal(baseSupplyRate); + }); + + it('supply index becomes equal to max possible index', async () => { + const baseBalance = await baseToken.balanceOf(testComet.address); + const maxIndex = baseBalance.mul(exp(1, 15)).div((await testComet.totalsBasic()).totalSupplyBase); + expect((await testComet.totalsBasic()).baseSupplyIndex).to.equal(maxIndex); + }); + + it('accrue market does not change the supply index', async () => { + const prevIndex = (await testComet.totalsBasic()).baseSupplyIndex; + + await ethers.provider.send('evm_increaseTime', [60]); + await ethers.provider.send('evm_mine', []); + + await testComet.accrueAccount(ethers.constants.AddressZero); + + const curIndex = (await testComet.totalsBasic()).baseSupplyIndex; + + expect(curIndex).to.equal(prevIndex); + }); + + it('charlie borrows some asset and activates the supply rate again', async () => { + await testComet.connect(charlie).withdraw(baseToken.address, BORROW_AMOUNT); + + const curUtilization = await testComet.getUtilization(); + expect(curUtilization).to.be.greaterThan(0); + }); + + it('supply rate equals the expected supply rate', async () => { + const curUtilization = await testComet.getUtilization(); + + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add(supplyLowSlope.mul(curUtilization).div(exp(1, 18))); + + expect(await testComet.getSupplyRate(curUtilization)).to.equal(expectedSupplyRate); + }); + + it('accrue market increases index as expected', async () => { + const prevSupplyIndex = (await testComet.totalsBasic()).baseSupplyIndex; + const prevUtilization = await testComet.getUtilization(); + const lastAccrualTime = (await testComet.totalsBasic()).lastAccrualTime; + + await ethers.provider.send('evm_increaseTime', [60]); + await ethers.provider.send('evm_mine', []); + + await testComet.accrueAccount(ethers.constants.AddressZero); + + const timeElapsed = (await testComet.totalsBasic()).lastAccrualTime - lastAccrualTime; + + let expectedSupplyRate = baseSupplyRate; + expectedSupplyRate = expectedSupplyRate.add(supplyLowSlope.mul(prevUtilization).div(exp(1, 18))); + + const accruedIndex = prevSupplyIndex.add(prevSupplyIndex.mul(expectedSupplyRate).mul(timeElapsed).div(exp(1, 18))); + + // healthcheck than current index is re-calculated correctly + const index = (await testComet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + }); + }); + + describe('utilization cannot be inflated for empty market', function () { + let testComet: CometHarnessInterfaceExtendedAssetList; + let baseToken: FaucetToken; + let colPriceInBase: BigNumber; + let collateral: FaucetToken; + + before(async function () { + const protocol = await makeProtocol({ base: 'USDC' }); + testComet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens['USDC'] as FaucetToken; + collateral = protocol.tokens['COMP'] as FaucetToken; + + const colPrice = ( + await protocol.priceFeeds['COMP'].latestRoundData() + )[1]; + colPriceInBase = colPrice + .mul(exp(1, baseDecimals)) + .div(exp(1, DEFAULT_PRICEFEED_DECIMALS)); // as base is USDC its price is 1 + + await baseToken.allocateTo(alice.address, exp(1e10, baseDecimals)); + await collateral.allocateTo(bob.address, exp(1e10, 18)); + }); + + it('initial utilization is for fresh comet', async () => { + expect(await testComet.getUtilization()).to.equal(0); + }); + + it('alice supplies small amount', async () => { + await baseToken + .connect(alice) + .approve(testComet.address, exp(1, baseDecimals)); + await testComet + .connect(alice) + .supply(baseToken.address, exp(1, baseDecimals)); + + expect(await testComet.getUtilization()).to.equal(0); + }); + + it('bob supplies collateral worth of 10k$', async () => { + const amount = BigNumber.from(exp(10001, baseDecimals)) + .mul(exp(1, 18)) + .div(colPriceInBase); + + await collateral.connect(bob).approve(testComet.address, amount); + await testComet.connect(bob).supply(collateral.address, amount); + + expect(await testComet.getUtilization()).to.equal(0); + }); + + it('bob borrow of base asset at max will revert due to the utilization spike', async () => { + // default collateral factor is set as 80% + const amount = BigNumber.from(exp(8000, baseDecimals)); + + await expect( + testComet.connect(bob).withdraw(baseToken.address, amount) + ).to.revertedWithCustomError(testComet, 'ExceedsSupportedUtilization'); + }); + }); + + describe('chain liquidation cannot be initiated because of the inflated utilization', function () { + let testComet: CometHarnessInterfaceExtendedAssetList; + let baseToken: FaucetToken; + let collateral: FaucetToken; + let colPriceInBase: BigNumber; + + before(async function () { + const protocol = await makeProtocol({ + base: 'USDC', + assets: { + COMP: { + borrowCF: exp(0.8, 18), + liquidateCF: exp(0.85, 18), + liquidationFactor: exp(0.9, 18), + initialPrice: 175, + }, + USDC: { + initialPrice: 1, + decimals: 6, + }, + }, + }); + testComet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens['USDC'] as FaucetToken; + collateral = protocol.tokens['COMP'] as FaucetToken; + + const colPrice = ( + await protocol.priceFeeds['COMP'].latestRoundData() + )[1]; + colPriceInBase = colPrice + .mul(exp(1, baseDecimals)) + .div(exp(1, DEFAULT_PRICEFEED_DECIMALS)); // as base is USDC its price is 1 + + await baseToken.allocateTo(other.address, exp(1e10, baseDecimals)); + await collateral.allocateTo(alice.address, exp(1e10, 18)); + await collateral.allocateTo(bob.address, exp(1e10, 18)); + }); + + it('initial utilization is for fresh comet', async () => { + expect(await testComet.getUtilization()).to.equal(0); + }); + + it('lender supplies base asset worth of 10k$', async () => { + await baseToken + .connect(other) + .approve(testComet.address, exp(10000, baseDecimals)); + await testComet + .connect(other) + .supply(baseToken.address, exp(10000, baseDecimals)); + + expect(await testComet.getUtilization()).to.equal(0); + }); + + it('alice and bob take supply collateral ~3.5k$ each', async () => { + const amount = BigNumber.from(exp(3500, baseDecimals)) + .mul(exp(1, 18)) + .div(colPriceInBase); + + await collateral.connect(alice).approve(testComet.address, amount); + await testComet.connect(alice).supply(collateral.address, amount); + + await collateral.connect(bob).approve(testComet.address, amount); + await testComet.connect(bob).supply(collateral.address, amount); + + expect(await testComet.getUtilization()).to.equal(0); + }); + + it('alice and bob borrow assets at max (80% borrow factor)', async () => { + const aliceBalanceBefore = await baseToken.balanceOf(alice.address); + const bobBalanceBefore = await baseToken.balanceOf(bob.address); + + // collateral factor is set as 80% + const amount = BigNumber.from(exp(3500, baseDecimals)).mul(80).div(100); + await testComet.connect(alice).withdraw(baseToken.address, amount); + const aliceBalanceAfter = await baseToken.balanceOf(alice.address); + + expect(aliceBalanceAfter.sub(aliceBalanceBefore)).to.equal(amount); + + await testComet.connect(bob).withdraw(baseToken.address, amount); + const bobBalanceAfter = await baseToken.balanceOf(bob.address); + + expect(bobBalanceAfter.sub(bobBalanceBefore)).to.equal(amount); + }); + + it('utilization is expected to be 56% (5.6k borrow vs 10k supply)', async () => { + const currentUtilization: BigNumber = await testComet.getUtilization(); + /// utilization is scaled by 1e18, so 56% -> 56e16 + expect(currentUtilization).to.be.approximately(exp(56e16), exp(1, 12)); + }); + + it('charlie deposits 100k$ worth of collateral', async () => { + const amount = BigNumber.from(exp(101000, baseDecimals)) + .mul(exp(1, 18)) + .div(colPriceInBase); + + await collateral.allocateTo(charlie.address, amount); + await collateral.connect(charlie).approve(testComet.address, amount); + await testComet.connect(charlie).supply(collateral.address, amount); + + /// utilization is unchanged + const currentUtilization: BigNumber = await testComet.getUtilization(); + /// utilization is scaled by 1e18, so 56% -> 56e16 + expect(currentUtilization).to.be.approximately(exp(56e16), exp(1, 12)); + }); + + it('increase time to bring alice and bob to 1% from liqudiation', async () => { + await ethers.provider.send('evm_increaseTime', [3600 * 24 * 360]); + await ethers.provider.send('evm_mine', []); + await testComet.accrueAccount(ethers.constants.AddressZero); + + expect(await testComet.isLiquidatable(bob.address)).to.be.false; + expect(await testComet.isLiquidatable(alice.address)).to.be.false; + }); + + it('charlie cannot spike utilization over 200% to force liquidation of users in shortened time', async () => { + // default collateral factor is set as 80% + const amount2 = BigNumber.from(exp(80000, baseDecimals)); + await expect( + testComet.connect(charlie).withdraw(baseToken.address, amount2) + ).to.revertedWithCustomError(testComet, 'ExceedsSupportedUtilization'); + + expect(await testComet.isLiquidatable(bob.address)).to.be.false; + expect(await testComet.isLiquidatable(alice.address)).to.be.false; + + await ethers.provider.send('evm_increaseTime', [7200]); + await ethers.provider.send('evm_mine', []); + await testComet.accrueAccount(alice.address); + + expect(await testComet.isLiquidatable(bob.address)).to.be.false; + expect(await testComet.isLiquidatable(alice.address)).to.be.false; + }); + + it('alice and bob become liquidatable in regular time', async () => { + await ethers.provider.send('evm_increaseTime', [3600 * 24 * 60]); + await ethers.provider.send('evm_mine', []); + await testComet.accrueAccount(alice.address); + await testComet.accrueAccount(bob.address); + + expect(await testComet.isLiquidatable(bob.address)).to.be.true; + expect(await testComet.isLiquidatable(alice.address)).to.be.true; + }); + }); }); }); diff --git a/test/is-borrow-collateralized-test.ts b/test/is-borrow-collateralized-test.ts index 9b44da0bd..759536666 100644 --- a/test/is-borrow-collateralized-test.ts +++ b/test/is-borrow-collateralized-test.ts @@ -1,4 +1,7 @@ -import { expect, exp, makeProtocol } from './helpers'; +import { CometProxyAdmin, Configurator, CometHarnessInterfaceExtendedAssetList as CometWithExtendedAssetList, FaucetToken, NonStandardFaucetFeeToken, PriceFeedWithRevert, PriceFeedWithRevert__factory } from 'build/types'; +import { expect, exp, makeProtocol, makeConfigurator, ethers, updateAssetBorrowCollateralFactor, getLiquidity, SnapshotRestorer, takeSnapshot, MAX_ASSETS } from './helpers'; +import { BigNumber } from 'ethers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; describe('isBorrowCollateralized', function () { it('defaults to true', async () => { @@ -93,7 +96,12 @@ describe('isBorrowCollateralized', function () { } = await makeProtocol({ assets: { USDC: { decimals: 6 }, - COMP: { initial: 1e7, decimals: 18, initialPrice: 1, borrowCF: exp(0.2, 18) }, + COMP: { + initial: 1e7, + decimals: 18, + initialPrice: 1, + borrowCF: exp(0.2, 18), + }, }, }); const { COMP } = tokens; @@ -106,13 +114,312 @@ describe('isBorrowCollateralized', function () { expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; await priceFeeds.COMP.setRoundData( - 0, // roundId + 0, // roundId exp(0.5, 8), // answer - 0, // startedAt - 0, // updatedAt - 0 // answeredInRound + 0, // startedAt + 0, // updatedAt + 0 // answeredInRound ); expect(await comet.isBorrowCollateralized(alice.address)).to.be.false; }); + + /** + * This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that when a price feed becomes unavailable, the protocol cannot calculate the USD value + * of collateral (e.g., during absorption when trying to getPrice() for a delisted asset). + * + * Flow tested: + * The `isBorrowCollateralized` function iterates through a user's collateral assets to calculate their total liquidity. + * When an asset's `borrowCollateralFactor` is set to 0, the contract skips that asset in the liquidity calculation + * (see CometWithExtendedAssetList.sol lines 402-405), effectively excluding it from contributing to the user's + * collateralization. This prevents the protocol from calling `getPrice()` on unavailable price feeds. + * + * Test scenarios: + * 1. Positions with positive borrowCF are properly collateralized and can borrow + * 2. When borrowCF is set to 0 (simulating a price feed becoming unavailable), the collateral is excluded + * from liquidity calculations, causing positions to become undercollateralized and preventing further borrowing + * 3. Mixed scenarios where some assets have borrowCF=0 and others have positive values - only assets with + * positive borrowCF contribute to liquidity + * 4. All assets individually tested to ensure each can be excluded when borrowCF=0 + * + * This mitigation allows governance to set borrowCF to 0 for assets with unavailable price feeds, preventing + * protocol paralysis while ensuring users cannot borrow against collateral that cannot be properly valued. + * Unlike `isLiquidatable` which uses `liquidateCollateralFactor`, this function determines whether a user + * can initiate new borrows, making it critical for preventing new positions from being opened with + * unpriceable collateral. + */ + describe('isBorrowCollateralized semantics across borrowCollateralFactor values', function () { + // Snapshot + let snapshot: SnapshotRestorer; + + // Configurator and protocol + let configurator: Configurator; + let configuratorProxyAddress: string; + let proxyAdmin: CometProxyAdmin; + let cometProxyAddress: string; + let comet: CometWithExtendedAssetList; + + // Tokens + let baseSymbol: string; + let baseToken: FaucetToken | NonStandardFaucetFeeToken; + let collateralToken: FaucetToken | NonStandardFaucetFeeToken; + let tokens: Record; + + // Users + let alice: SignerWithAddress; + + // Values + let supplyAmount: bigint; + let borrowAmount: bigint; + + before(async () => { + const collaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [ + `ASSET${j}`, + { + decimals: 18, + initialPrice: 200, + borrowCF: exp(0.75, 18), + liquidateCF: exp(0.8, 18), + }, + ]) + ); + const protocol = await makeConfigurator({ assets: { USDC: { decimals: 6, initialPrice: 1 }, ...collaterals }}); + + configurator = protocol.configurator; + configuratorProxyAddress = protocol.configuratorProxy.address; + proxyAdmin = protocol.proxyAdmin; + cometProxyAddress = protocol.cometProxy.address; + comet = protocol.cometWithExtendedAssetList.attach(cometProxyAddress) as CometWithExtendedAssetList; + tokens = protocol.tokens; + + baseSymbol = protocol.base; + baseToken = protocol.tokens[baseSymbol]; + collateralToken = protocol.tokens['ASSET0']; + alice = protocol.users[0]; + + // Upgrade proxy to extended asset list implementation to support many assets + const assetListFactory = protocol.assetListFactory; + configurator = configurator.attach(configuratorProxyAddress); + const CometExtAssetList = await ( + await ethers.getContractFactory('CometExtAssetList') + ).deploy( + { + name32: ethers.utils.formatBytes32String('Compound Comet'), + symbol32: ethers.utils.formatBytes32String('BASE'), + }, + assetListFactory.address + ); + await CometExtAssetList.deployed(); + await configurator.setExtensionDelegate(cometProxyAddress, CometExtAssetList.address); + const CometFactoryWithExtendedAssetList = await (await ethers.getContractFactory('CometFactoryWithExtendedAssetList')).deploy(); + await CometFactoryWithExtendedAssetList.deployed(); + await configurator.setFactory(cometProxyAddress, CometFactoryWithExtendedAssetList.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxyAddress, cometProxyAddress); + + snapshot = await takeSnapshot(); + + // Supply collateral and borrow base + supplyAmount = exp(10, 18); + borrowAmount = exp(5, 6); + + await collateralToken.allocateTo(alice.address, supplyAmount); + await collateralToken.connect(alice).approve(cometProxyAddress, supplyAmount); + await comet.connect(alice).supply(collateralToken.address, supplyAmount); + + await baseToken.allocateTo(cometProxyAddress, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // With positive borrowCF, position is collateralized + expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; + }); + + it('liquidity calculation includes collateral with positive borrowCF', async () => { + const liquidity = await getLiquidity(comet, collateralToken, supplyAmount); + expect(liquidity).to.be.greaterThan(0); + }); + + it('borrowCF can be updated to 0', async () => { + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n); + }); + + it('borrowCF becomes 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).borrowCollateralFactor).to.equal(0); + }); + + it('liquidity calculation excludes collateral with zero borrowCF', async () => { + const liquidity = await getLiquidity(comet, collateralToken, supplyAmount); + expect(liquidity).to.eq(0); + }); + + it('collateralization becomes false when borrowCF is set to 0', async () => { + expect(await comet.isBorrowCollateralized(alice.address)).to.be.false; + + await snapshot.restore(); + }); + + it('isBorrowCollateralized with mixed borrow factors counts only positive CF assets', async () => { + /** + * This test verifies that when some assets have + * borrowCollateralFactor set to 0, they contribute zero liquidity and + * are ignored by isBorrowCollateralized, while assets with positive + * borrowCF still count toward collateralization. + */ + + // Supply equal collateral in all 5 assets + const supplyAmount = exp(1, 18); + const symbols = ['ASSET0', 'ASSET1', 'ASSET2', 'ASSET3', 'ASSET4']; + for (const sym of symbols) { + const token = tokens[sym]; + await token.allocateTo(alice.address, supplyAmount); + await token.connect(alice).approve(comet.address, supplyAmount); + await comet.connect(alice).supply(token.address, supplyAmount); + } + + // Borrow base against the collateral + // With 5 assets at price 200, borrowCF 0.9: each asset contributes ~180 USDC liquidity + // Total liquidity: 5 * 180 = 900 USDC. Borrow 400 to stay well collateralized initially. + // After zeroing 3 assets, only 2 contribute (360 total) < 400 borrowed, so undercollateralized. + const borrowAmount = exp(400, 6); + await baseToken.allocateTo(comet.address, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // Verify collateralized initially + expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; + + // Zero borrowCF for three assets: ASSET1, ASSET3, ASSET4 + const zeroBcfSymbols = ['ASSET1', 'ASSET3', 'ASSET4']; + for (const sym of zeroBcfSymbols) { + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, tokens[sym].address, 0n); + } + + // Verify borrowCF=0 excludes those assets from liquidity + const liquidityByAsset: Record = {} as Record; + for (const sym of symbols) { + liquidityByAsset[sym] = await getLiquidity(comet, tokens[sym], supplyAmount); + } + + for (const sym of zeroBcfSymbols) { + expect(liquidityByAsset[sym].eq(0)).to.be.true; + } + for (const sym of ['ASSET0', 'ASSET2']) { + expect(liquidityByAsset[sym].gt(0)).to.be.true; + } + + // With only two assets contributing (price 200, borrowCF 0.9), + // each contributes ~180 USDC liquidity, total ~360 USDC vs 400 borrowed + // Position should be undercollateralized + expect(await comet.isBorrowCollateralized(alice.address)).to.be.false; + + await snapshot.restore(); + }); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`skips liquidity of asset ${i - 1} with borrowCF=0`, async () => { + const supplyAmount = exp(1, 18); + const targetSymbol = `ASSET${i - 1}`; + const targetToken = tokens[targetSymbol]; + await targetToken.allocateTo(alice.address, supplyAmount); + await targetToken.connect(alice).approve(comet.address, supplyAmount); + await comet.connect(alice).supply(targetToken.address, supplyAmount); + + // Borrow an amount collateralized by the single supplied asset (~180 USDC liquidity) + const borrowAmount = exp(150, 6); + await baseToken.allocateTo(comet.address, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // Initially collateralized with single asset active + expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; + + // Zero borrowCF for target asset (last one) + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, targetToken.address, 0n); + + // Verify target asset liquidity is zero + const liq = await getLiquidity(comet, targetToken, supplyAmount); + expect(liq).to.equal(0); + + // After zeroing the only supplied asset's borrowCF, position should be undercollateralized + expect(await comet.isBorrowCollateralized(alice.address)).to.equal(false); + + await snapshot.restore(); + }); + } + + /* + * Edge cases around price feeds and isBorrowCollateralized. + * + * These tests simulate a governance action that replaces a collateral asset's price feed + * with a feed that always reverts on `latestRoundData` (PriceFeedWithRevert). This mirrors + * the "price feed paralysis" scenario exercised in the absorb and quoteCollateral tests, + * but focused on `isBorrowCollateralized`: + * + * 1. With the normal price feed, isBorrowCollateralized should succeed for Alice's position. + * 2. After governance updates the asset's price feed to PriceFeedWithRevert, isBorrowCollateralized + * should revert with the `Reverted` custom error, since it calls getPrice(asset.priceFeed) + * while iterating over collateral assets. + * 3. When governance restores the original (non-reverting) price feed, isBorrowCollateralized + * should succeed again, showing that the paralysis is solely caused by the reverting feed. + */ + describe('edge cases', function () { + describe('revert on price feed side', function () { + let priceFeedWithRevert: PriceFeedWithRevert; + let originalPriceFeed: string; + + before(async () => { + // Restore to the common baseline for this semantics suite + await snapshot.restore(); + + // Make Alice's position (collateral supply and base borrow) + supplyAmount = exp(10, 18); + borrowAmount = exp(5, 6); + await collateralToken.allocateTo(alice.address, supplyAmount); + await collateralToken.connect(alice).approve(cometProxyAddress, supplyAmount); + await comet.connect(alice).supply(collateralToken.address, supplyAmount); + await baseToken.allocateTo(cometProxyAddress, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // Capture the current (normal) price feed for the collateral token + originalPriceFeed = (await comet.getAssetInfoByAddress(collateralToken.address)).priceFeed; + + // Deploy a price feed that always reverts on latestRoundData + const PriceFeedWithRevertFactory = (await ethers.getContractFactory('PriceFeedWithRevert')) as PriceFeedWithRevert__factory; + priceFeedWithRevert = await PriceFeedWithRevertFactory.deploy(100, 8); + await priceFeedWithRevert.deployed(); + }); + + it('sanity check: isBorrowCollateralized works with the normal price feed', async () => { + expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; + }); + + it('governance updates collateral price feed to a reverting implementation', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, collateralToken.address, priceFeedWithRevert.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxyAddress, cometProxyAddress); + }); + + it('price feed for collateral asset is now the reverting implementation', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).priceFeed).to.equal(priceFeedWithRevert.address); + }); + + it('isBorrowCollateralized reverts when collateral price feed reverts', async () => { + await expect( + comet.isBorrowCollateralized(alice.address) + ).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('governance restores the normal collateral price feed', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, collateralToken.address, originalPriceFeed); + await proxyAdmin.deployAndUpgradeTo(configuratorProxyAddress, cometProxyAddress); + }); + + it('price feed for collateral asset is restored to the normal implementation', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).priceFeed).to.equal(originalPriceFeed); + }); + + it('isBorrowCollateralized works again after restoring the normal price feed', async () => { + expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; + }); + }); + }); + }); }); diff --git a/test/is-liquidatable-test.ts b/test/is-liquidatable-test.ts index 2984910d9..77814d48f 100644 --- a/test/is-liquidatable-test.ts +++ b/test/is-liquidatable-test.ts @@ -1,4 +1,7 @@ -import { expect, exp, makeProtocol } from './helpers'; +import { CometProxyAdmin, CometWithExtendedAssetList, Configurator, FaucetToken, NonStandardFaucetFeeToken, PriceFeedWithRevert, PriceFeedWithRevert__factory } from 'build/types'; +import { expect, exp, makeProtocol, makeConfigurator, ethers, updateAssetLiquidateCollateralFactor, getLiquidityWithLiquidateCF, MAX_ASSETS, takeSnapshot, SnapshotRestorer, updateAssetBorrowCollateralFactor } from './helpers'; +import { BigNumber } from 'ethers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; /* Prices are set in terms of the base token (USDC with 6 decimals, by default): @@ -145,13 +148,329 @@ describe('isLiquidatable', function () { // price drops await priceFeeds.COMP.setRoundData( - 0, // roundId + 0, // roundId exp(0.5, 8), // answer - 0, // startedAt - 0, // updatedAt - 0 // answeredInRound + 0, // startedAt + 0, // updatedAt + 0 // answeredInRound ); expect(await comet.isLiquidatable(alice.address)).to.be.true; }); + + /** + * This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that when a price feed becomes unavailable, the protocol cannot calculate the USD value + * of collateral (e.g., during absorption when trying to getPrice() for a delisted asset). + * + * Flow tested: + * The `isLiquidatable` function iterates through a user's collateral assets to calculate their total liquidity. + * When an asset's `liquidateCollateralFactor` is set to 0, the contract skips that asset in the liquidity calculation + * effectively excluding it from contributing to the user's + * collateralization. This prevents the protocol from calling `getPrice()` on unavailable price feeds. + * + * Test scenarios: + * 1. Positions with positive liquidateCF are properly collateralized and not liquidatable + * 2. When liquidateCF is set to 0 (simulating a price feed becoming unavailable), the collateral is excluded + * from liquidity calculations, causing positions to become liquidatable + * 3. Mixed scenarios where some assets have liquidateCF=0 and others have positive values - only assets with + * positive liquidateCF contribute to liquidity + * 4. All assets individually tested to ensure each can be excluded when liquidateCF=0 + * + * This mitigation allows governance to set liquidateCF to 0 for assets with unavailable price feeds, preventing + * protocol paralysis while ensuring undercollateralized positions can still be liquidated. + */ + describe('isLiquidatable semantics across liquidateCollateralFactor values', function () { + // Snapshot + let snapshot: SnapshotRestorer; + + // Configurator and protocol + let comet: CometWithExtendedAssetList; + let configurator: Configurator; + let configuratorProxyAddress: string; + let proxyAdmin: CometProxyAdmin; + let cometProxyAddress: string; + + // Tokens + let baseSymbol: string; + let baseToken: FaucetToken | NonStandardFaucetFeeToken; + let collateralToken: FaucetToken | NonStandardFaucetFeeToken; + let tokens: Record; + + // Users + let alice: SignerWithAddress; + let governor: SignerWithAddress; + + // Values + let supplyAmount: bigint; + let borrowAmount: bigint; + + let liquidateCF: bigint; + + before(async () => { + const collaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [ + `ASSET${j}`, + { + decimals: 18, + initialPrice: 200, + borrowCF: exp(0.75, 18), + liquidateCF: exp(0.8, 18), + }, + ]) + ); + const protocol = await makeConfigurator({ assets: { USDC: { decimals: 6, initialPrice: 1 }, ...collaterals } }); + + configurator = protocol.configurator; + configuratorProxyAddress = protocol.configuratorProxy.address; + proxyAdmin = protocol.proxyAdmin; + cometProxyAddress = protocol.cometProxy.address; + comet = protocol.cometWithExtendedAssetList.attach(cometProxyAddress) as CometWithExtendedAssetList; + + baseSymbol = protocol.base; + baseToken = protocol.tokens[baseSymbol]; + collateralToken = protocol.tokens['ASSET0']; + tokens = protocol.tokens; + alice = protocol.users[0]; + governor = protocol.governor; + + // Upgrade proxy to extended asset list implementation to support many assets + const assetListFactory = protocol.assetListFactory; + configurator = configurator.attach(configuratorProxyAddress); + const CometExtAssetList = await ( + await ethers.getContractFactory('CometExtAssetList') + ).deploy( + { + name32: ethers.utils.formatBytes32String('Compound Comet'), + symbol32: ethers.utils.formatBytes32String('BASE'), + }, + assetListFactory.address + ); + await CometExtAssetList.deployed(); + await configurator.setExtensionDelegate(cometProxyAddress, CometExtAssetList.address); + const CometFactoryWithExtendedAssetList = await (await ethers.getContractFactory('CometFactoryWithExtendedAssetList')).deploy(); + await CometFactoryWithExtendedAssetList.deployed(); + await configurator.setFactory(cometProxyAddress, CometFactoryWithExtendedAssetList.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxyAddress, cometProxyAddress); + + liquidateCF = (await comet.getAssetInfoByAddress(collateralToken.address)).liquidateCollateralFactor.toBigInt(); + + snapshot = await takeSnapshot(); + + // Supply collateral and borrow base + supplyAmount = exp(10, 18); + borrowAmount = exp(5, 6); + + await collateralToken.allocateTo(alice.address, supplyAmount); + await collateralToken.connect(alice).approve(cometProxyAddress, supplyAmount); + await comet.connect(alice).supply(collateralToken.address, supplyAmount); + + await baseToken.allocateTo(cometProxyAddress, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // With positive liquidateCF and ample collateral, not liquidatable + expect(await comet.isLiquidatable(alice.address)).to.be.false; + + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n); + }); + + it('liquidity calculation includes collateral with positive liquidateCF', async () => { + const liquidity = await getLiquidityWithLiquidateCF(comet, collateralToken, supplyAmount); + expect(liquidity).to.be.greaterThan(0); + }); + + it('liquidateCF can be updated to 0', async () => { + await updateAssetLiquidateCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n, governor); + }); + + it('liquidateCF becomes 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).liquidateCollateralFactor).to.equal(0); + }); + + it('liquidity calculation excludes collateral with zero liquidateCF', async () => { + const liquidity = await getLiquidityWithLiquidateCF(comet, collateralToken, supplyAmount); + expect(liquidity).to.equal(0); + }); + + it('position becomes liquidatable when liquidateCF is set to 0', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.true; + + await snapshot.restore(); + }); + + it('liquidateCF can be restored back', async function () { + await updateAssetLiquidateCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, liquidateCF, governor); + }); + + it('liquidateCF is restored back after upgrade', async function () { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).liquidateCollateralFactor).to.equal(liquidateCF); + }); + + it('position is not liquidatable when liquidateCF is restored back', async function () { + expect(await comet.isLiquidatable(alice.address)).to.be.false; + }); + + it('liquidity calculation includes collateral with positive liquidateCF after restore', async function () { + const liquidity = await getLiquidityWithLiquidateCF(comet, collateralToken, supplyAmount); + expect(liquidity).to.be.greaterThan(0); + }); + + it('isLiquidatable with mixed liquidate factors counts only positive CF assets', async () => { + // Supply equal collateral in all 5 assets + const supplyAmount = exp(1, 18); + const symbols = ['ASSET0', 'ASSET1', 'ASSET2', 'ASSET3', 'ASSET4']; + for (const sym of symbols) { + const token = tokens[sym]; + await token.allocateTo(alice.address, supplyAmount); + await token.connect(alice).approve(comet.address, supplyAmount); + await comet.connect(alice).supply(token.address, supplyAmount); + } + + // Borrow base against the collateral + // With 5 assets at price 200, liquidateCF 0.8: each asset contributes ~160 USDC liquidation value + // Total liquidation value: 5 * 160 = 800 USDC. Borrow 400 so not liquidatable initially. + // After zeroing 3 assets, only 2 contribute (320 total) < 400 borrowed, so liquidatable. + const borrowAmount = exp(400, 6); + await baseToken.allocateTo(comet.address, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // Verify NOT liquidatable initially + expect(await comet.isLiquidatable(alice.address)).to.be.false; + + // Zero liquidateCF for three assets: ASSET1, ASSET3, ASSET4 + const zeroLcfSymbols = ['ASSET1', 'ASSET3', 'ASSET4']; + for (const sym of zeroLcfSymbols) { + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, tokens[sym].address, 0n); + await updateAssetLiquidateCollateralFactor(configurator, proxyAdmin, comet.address, tokens[sym].address, 0n, governor); + } + + // Verify liquidateCF=0 excludes those assets from liquidity + const liquidityByAsset: Record = {} as Record; + for (const sym of symbols) { + liquidityByAsset[sym] = await getLiquidityWithLiquidateCF(comet, tokens[sym], supplyAmount); + } + + for (const sym of zeroLcfSymbols) { + expect(liquidityByAsset[sym].eq(0)).to.be.true; + } + for (const sym of ['ASSET0', 'ASSET2']) { + expect(liquidityByAsset[sym].gt(0)).to.be.true; + } + + // With only two assets contributing (price 200, liquidateCF 0.8), + // each contributes ~160 USDC, total ~320 USDC vs 400 borrowed + // Position should become liquidatable + expect(await comet.isLiquidatable(alice.address)).to.be.true; + + await snapshot.restore(); + }); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`skips liquidation value of asset ${i - 1} with liquidateCF=0`, async () => { + const supplyAmount = exp(1, 18); + const targetSymbol = `ASSET${i - 1}`; + const targetToken = tokens[targetSymbol]; + await targetToken.allocateTo(alice.address, supplyAmount); + await targetToken.connect(alice).approve(comet.address, supplyAmount); + await comet.connect(alice).supply(targetToken.address, supplyAmount); + + // Borrow amount collateralized by the single supplied asset under liquidation values (~170 USDC) + const borrowAmount = exp(150, 6); + await baseToken.allocateTo(comet.address, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // Initially not liquidatable with positive liquidateCF + expect(await comet.isLiquidatable(alice.address)).to.be.false; + + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, targetToken.address, 0n); + + // Zero liquidateCF for target asset (last one) + await updateAssetLiquidateCollateralFactor(configurator, proxyAdmin, comet.address, targetToken.address, 0n, governor); + + expect((await comet.getAssetInfoByAddress(targetToken.address)).liquidateCollateralFactor).to.equal(0); + + // After zeroing the only supplied asset's liquidateCF, position should be liquidatable + expect(await comet.isLiquidatable(alice.address)).to.equal(true); + + await snapshot.restore(); + }); + } + + /* + * Edge cases around price feeds and isLiquidatable. + * + * These tests simulate a governance action that replaces a collateral asset's price feed + * with a feed that always reverts on `latestRoundData` (PriceFeedWithRevert). This mirrors + * the "price feed paralysis" scenario exercised in the absorb and quoteCollateral tests, + * but focused on `isLiquidatable`: + * + * 1. With the normal price feed, isLiquidatable should succeed for Alice's position. + * 2. After governance updates the asset's price feed to PriceFeedWithRevert, isLiquidatable + * should revert with the `Reverted` custom error, since it calls getPrice(asset.priceFeed) + * while iterating over collateral assets. + * 3. When governance restores the original (non-reverting) price feed, isLiquidatable + * should succeed again, showing that the paralysis is solely caused by the reverting feed. + */ + describe('edge cases', function () { + describe('revert on price feed side', function () { + let priceFeedWithRevert: PriceFeedWithRevert; + let originalPriceFeed: string; + + before(async () => { + // Restore to the common baseline for this semantics suite + await snapshot.restore(); + + // Make Alice's position (collateral supply and base borrow) + supplyAmount = exp(10, 18); + borrowAmount = exp(5, 6); + await collateralToken.allocateTo(alice.address, supplyAmount); + await collateralToken.connect(alice).approve(cometProxyAddress, supplyAmount); + await comet.connect(alice).supply(collateralToken.address, supplyAmount); + await baseToken.allocateTo(cometProxyAddress, borrowAmount); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount); + + // With positive liquidateCF and ample collateral, not liquidatable + expect(await comet.isLiquidatable(alice.address)).to.be.false; + + // Capture the current (normal) price feed for the collateral token + originalPriceFeed = (await comet.getAssetInfoByAddress(collateralToken.address)).priceFeed; + + // Deploy a price feed that always reverts on latestRoundData + const PriceFeedWithRevertFactory = (await ethers.getContractFactory('PriceFeedWithRevert')) as PriceFeedWithRevert__factory; + priceFeedWithRevert = await PriceFeedWithRevertFactory.deploy(100, 8); + await priceFeedWithRevert.deployed(); + }); + + it('sanity check: isLiquidatable works with the normal price feed', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.false; + }); + + it('governance updates collateral price feed to a reverting implementation', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, collateralToken.address, priceFeedWithRevert.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxyAddress, cometProxyAddress); + }); + + it('price feed for collateral asset is now the reverting implementation', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).priceFeed).to.equal(priceFeedWithRevert.address); + }); + + it('isLiquidatable reverts when collateral price feed reverts', async () => { + await expect(comet.isLiquidatable(alice.address)).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('governance restores the normal collateral price feed', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, collateralToken.address, originalPriceFeed); + await proxyAdmin.deployAndUpgradeTo(configuratorProxyAddress, cometProxyAddress); + }); + + it('price feed for collateral asset is restored to the normal implementation', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).priceFeed).to.equal(originalPriceFeed); + }); + + it('isLiquidatable works again after restoring the normal price feed', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.false; + }); + }); + }); + }); }); diff --git a/test/quote-collateral-test.ts b/test/quote-collateral-test.ts index ea0e367da..4215bfc4c 100644 --- a/test/quote-collateral-test.ts +++ b/test/quote-collateral-test.ts @@ -1,4 +1,7 @@ -import { expect, exp, makeProtocol } from './helpers'; +import { CometProxyAdmin, CometWithExtendedAssetList, Configurator, ConfiguratorProxy, FaucetToken, NonStandardFaucetFeeToken, PriceFeedWithRevert, PriceFeedWithRevert__factory } from 'build/types'; +import { expect, exp, makeProtocol, makeConfigurator, factorScale, mulFactor, ethers, MAX_ASSETS, SnapshotRestorer, takeSnapshot } from './helpers'; +import { BigNumber } from 'ethers'; +import { AssetInfoStructOutput } from 'build/types/CometWithExtendedAssetList'; describe('quoteCollateral', function () { it('quotes the collateral correctly for a positive base amount', async () => { @@ -18,7 +21,7 @@ describe('quoteCollateral', function () { initialPrice: 200, liquidationFactor: exp(0.6, 18), }, - } + }, }); const { comet, tokens } = protocol; const { COMP } = tokens; @@ -32,9 +35,9 @@ describe('quoteCollateral', function () { const assetPriceDiscounted = exp(160, 8); const basePrice = exp(1, 8); const assetScale = exp(1, 18); - const assetWeiPerUnitBase = assetScale * basePrice / assetPriceDiscounted; + const assetWeiPerUnitBase = (assetScale * basePrice) / assetPriceDiscounted; const baseScale = exp(1, 6); - expect(q0).to.be.equal(assetWeiPerUnitBase * baseAmount / baseScale); + expect(q0).to.be.equal((assetWeiPerUnitBase * baseAmount) / baseScale); expect(q0).to.be.equal(exp(1.25, 18)); }); @@ -53,7 +56,7 @@ describe('quoteCollateral', function () { decimals: 18, initialPrice: 200, }, - } + }, }); const { comet, tokens } = protocol; const { COMP } = tokens; @@ -81,7 +84,7 @@ describe('quoteCollateral', function () { initialPrice: 200, liquidationFactor: exp(0.6, 18), }, - } + }, }); const { comet, tokens } = protocol; const { COMP } = tokens; @@ -95,9 +98,9 @@ describe('quoteCollateral', function () { const assetPriceDiscounted = exp(200, 8); const basePrice = exp(1, 8); const assetScale = exp(1, 18); - const assetWeiPerUnitBase = assetScale * basePrice / assetPriceDiscounted; + const assetWeiPerUnitBase = (assetScale * basePrice) / assetPriceDiscounted; const baseScale = exp(1, 6); - expect(q0).to.be.equal(assetWeiPerUnitBase * baseAmount / baseScale); + expect(q0).to.be.equal((assetWeiPerUnitBase * baseAmount) / baseScale); expect(q0).to.be.equal(exp(1, 18)); }); @@ -119,7 +122,7 @@ describe('quoteCollateral', function () { initialPrice: 9, liquidationFactor: exp(0.8, 18), }, - } + }, }); const { comet, tokens } = protocol; const { COMP } = tokens; @@ -150,7 +153,7 @@ describe('quoteCollateral', function () { initialPrice: 200, liquidationFactor: exp(0.75, 18), }, - } + }, }); const { comet, tokens } = protocol; const { COMP } = tokens; @@ -163,4 +166,251 @@ describe('quoteCollateral', function () { // 1e18 USDC should give 1e15 / (0.8 * 200) = 6.25e12 COMP expect(q0).to.be.equal(exp(6.25, 12 + 18)); }); + + /* + * This test suite was written after the USDM incident, when a token price feed was removed from Chainlink. + * The incident revealed that when a price feed becomes unavailable, the protocol cannot calculate the USD value + * of collateral (e.g., during absorption when trying to getPrice() for a delisted asset). + * + * The solution was to set the asset's liquidationFactor to 0 for delisted collateral. This affects both: + * - Absorption: Assets with liquidationFactor = 0 are skipped (cannot calculate their USD value) + * - quoteCollateral: When liquidationFactor = 0, the store front discount becomes 0, and quoteCollateral + * quotes at market price without any discount (see quoteCollateral() in CometWithExtendedAssetList.sol) + * + * This test suite verifies that quoteCollateral behaves correctly when liquidationFactor is set to 0: + * - It should quote at market price (no discount) when liquidationFactor = 0 + * - It should handle the transition from liquidationFactor > 0 to liquidationFactor = 0 correctly + * - It should work correctly for all assets in the protocol, even when at the maximum asset limit + */ + describe('quote without discount', function () { + // This describe block tests quoteCollateral behavior when liquidationFactor = 0 (no discount scenario). + // It verifies that: + // 1. quoteCollateral correctly quotes at market price when liquidationFactor > 0 (with discount) + // 2. After setting liquidationFactor to 0, quoteCollateral quotes at market price (no discount) + // 3. The transition between states works correctly for all assets, including at MAX_ASSETS limit + + // Snapshot + let snapshot: SnapshotRestorer; + + // Contracts + let comet: CometWithExtendedAssetList; + let configurator: Configurator; + let configuratorProxy: ConfiguratorProxy; + let proxyAdmin: CometProxyAdmin; + let cometProxyAddress: string; + let assetListFactoryAddress: string; + + // Constants + const QUOTE_AMOUNT = exp(200, 6); + + // Variables + let quoteAmount: BigNumber; + let quoteCollateralToken: FaucetToken | NonStandardFaucetFeeToken; + let tokens: Record; + + // Quote calculations data + let assetInfo: AssetInfoStructOutput; + let assetPrice: BigNumber; + let basePrice: BigNumber; + let baseScale: BigNumber; + + before(async () => { + const collaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [ + `ASSET${j}`, + { + decimals: 18, + initialPrice: 200, + liquidationFactor: exp(0.6, 18), + }, + ]) + ); + const configuratorAndProtocol = await makeConfigurator({ assets: { USDC: { decimals: 6, initialPrice: 1 }, ...collaterals }}); + + cometProxyAddress = configuratorAndProtocol.cometProxy.address; + comet = configuratorAndProtocol.cometWithExtendedAssetList.attach(cometProxyAddress) as CometWithExtendedAssetList; + configurator = configuratorAndProtocol.configurator; + configuratorProxy = configuratorAndProtocol.configuratorProxy; + proxyAdmin = configuratorAndProtocol.proxyAdmin; + tokens = configuratorAndProtocol.tokens; + assetListFactoryAddress = configuratorAndProtocol.assetListFactory.address; + quoteCollateralToken = tokens[`ASSET1`]; + configurator = configurator.attach(configuratorProxy.address); + + const CometExtAssetList = await ( + await ethers.getContractFactory('CometExtAssetList') + ).deploy( + { + name32: ethers.utils.formatBytes32String('Compound Comet'), + symbol32: ethers.utils.formatBytes32String('BASE'), + }, + assetListFactoryAddress + ); + await CometExtAssetList.deployed(); + await configurator.setExtensionDelegate(cometProxyAddress, CometExtAssetList.address); + const CometFactoryWithExtendedAssetList = await (await ethers.getContractFactory('CometFactoryWithExtendedAssetList')).deploy(); + await CometFactoryWithExtendedAssetList.deployed(); + await configurator.setFactory(cometProxyAddress, CometFactoryWithExtendedAssetList.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + + // Culculation data + assetInfo = await comet.getAssetInfoByAddress(quoteCollateralToken.address); + assetPrice = await comet.getPrice(assetInfo.priceFeed); + basePrice = await comet.getPrice(await comet.baseTokenPriceFeed()); + baseScale = await comet.baseScale(); + + snapshot = await takeSnapshot(); + }); + + it('quotes with discount if liquidationFactor > 0', async () => { + // Ensure liquidationFactor is not zero (discount present) + expect(assetInfo.liquidationFactor).to.not.eq(0); + + quoteAmount = await comet.quoteCollateral(quoteCollateralToken.address, QUOTE_AMOUNT); + }); + + it('computes expected discount and matches contract value', async () => { + // discount = storeFrontPriceFactor * (1e18 - liquidationFactor) + const discountFactor = mulFactor((await comet.storeFrontPriceFactor()).toBigInt(), BigNumber.from(factorScale).sub(assetInfo.liquidationFactor).toBigInt()); + // assetPriceDiscounted = assetPrice * (1e18 - discount) + const assetPriceDiscounted = mulFactor(assetPrice.toBigInt(), BigNumber.from(factorScale).sub(discountFactor).toBigInt()); + // expected quote calculation + const expectedQuoteWithDiscount = basePrice.mul(QUOTE_AMOUNT).mul(assetInfo.scale).div(assetPriceDiscounted).div(baseScale); + + expect(quoteAmount).to.eq(expectedQuoteWithDiscount); + }); + + it('update liquidationFactor to 0 to remove discount', async () => { + await configurator.updateAssetLiquidationFactor(cometProxyAddress, quoteCollateralToken.address, exp(0, 18)); + + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('liquidation factor becomes 0 after upgrade', async () => { + assetInfo = await comet.getAssetInfoByAddress(quoteCollateralToken.address); + expect(assetInfo.liquidationFactor).to.eq(0); + }); + + it('quotes with discount if liquidationFactor = 0', async () => { + quoteAmount = await comet.quoteCollateral(quoteCollateralToken.address, QUOTE_AMOUNT); + + // Expected quote calculation + const expectedQuoteWithoutDiscount = basePrice.mul(QUOTE_AMOUNT).mul(assetInfo.scale).div(assetPrice).div(baseScale); + + // Verify quote calculation + expect(quoteAmount).to.eq(expectedQuoteWithoutDiscount); + + await snapshot.restore(); + }); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`quotes with discount for asset ${i}`, async () => { + const asset = tokens[`ASSET${i - 1}`]; + + // First quote with discount + quoteAmount = await comet.quoteCollateral(asset.address, QUOTE_AMOUNT); + + // discount = storeFrontPriceFactor * (1e18 - liquidationFactor) + assetInfo = await comet.getAssetInfoByAddress(asset.address); + const discountFactor = mulFactor((await comet.storeFrontPriceFactor()).toBigInt(), BigNumber.from(factorScale).sub(assetInfo.liquidationFactor).toBigInt()); + // assetPriceDiscounted = assetPrice * (1e18 - discount) + const assetPriceDiscounted = mulFactor(assetPrice.toBigInt(), BigNumber.from(factorScale).sub(discountFactor).toBigInt()); + // expected quote calculation + const expectedQuoteWithDiscount = basePrice.mul(QUOTE_AMOUNT).mul(assetInfo.scale).div(assetPriceDiscounted).div(baseScale); + + expect(quoteAmount).to.eq(expectedQuoteWithDiscount); + + // Update liquidation factor to 0 to remove discount + await configurator.updateAssetLiquidationFactor(cometProxyAddress, asset.address, exp(0, 18)); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + + assetInfo = await comet.getAssetInfoByAddress(asset.address); + expect(assetInfo.liquidationFactor).to.eq(0); + + // Second quote without discount + quoteAmount = await comet.quoteCollateral(asset.address, QUOTE_AMOUNT); + + const expectedQuoteWithoutDiscount = basePrice.mul(QUOTE_AMOUNT).mul(assetInfo.scale).div(assetPrice).div(baseScale); + + // Verify quote calculation + expect(quoteAmount).to.eq(expectedQuoteWithoutDiscount); + }); + } + + + describe('edge cases', function () { + describe('revert on price feed side', function () { + /* + * Edge cases around price feeds and quoteCollateral. + * + * These tests simulate a governance action that replaces the collateral asset's price feed + * with a feed that always reverts on `latestRoundData` (PriceFeedWithRevert). This mirrors + * the "price feed paralysis" scenario exercised in the absorb tests, but focused on + * `quoteCollateral`: + * + * 1. With the normal price feed, quoteCollateral should succeed for the target collateral. + * 2. After governance updates the asset's price feed to PriceFeedWithRevert, quoteCollateral + * should revert with the `Reverted` custom error, since it calls getPrice(asset.priceFeed). + * 3. When governance restores the original (non-reverting) price feed, quoteCollateral should + * succeed again, showing that the paralysis is solely caused by the reverting feed. + */ + let priceFeedWithRevert: PriceFeedWithRevert; + let originalPriceFeed: string; + let targetAsset: FaucetToken | NonStandardFaucetFeeToken; + + before(async () => { + // Start from the common baseline state for this suite + await snapshot.restore(); + + targetAsset = quoteCollateralToken; + + // Record the current (normal) price feed for the quoted asset + const assetInfoBefore = await comet.getAssetInfoByAddress(targetAsset.address); + originalPriceFeed = assetInfoBefore.priceFeed; + + // Deploy a price feed that always reverts on latestRoundData + const PriceFeedWithRevertFactory = (await ethers.getContractFactory('PriceFeedWithRevert')) as PriceFeedWithRevert__factory; + priceFeedWithRevert = await PriceFeedWithRevertFactory.deploy(100, 8); + await priceFeedWithRevert.deployed(); + }); + + it('quoteCollateral works with the normal price feed', async () => { + // Sanity check: initial call should not revert + const quote = await comet.quoteCollateral(targetAsset.address, QUOTE_AMOUNT); + expect(quote).to.be.gt(0); + }); + + it('governance updates collateral price feed to a reverting implementation', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, targetAsset.address, priceFeedWithRevert.address); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('price feed for quoted asset is now the reverting implementation', async () => { + const assetInfoAfter = await comet.getAssetInfoByAddress(targetAsset.address); + expect(assetInfoAfter.priceFeed).to.equal(priceFeedWithRevert.address); + }); + + it('quoteCollateral reverts when collateral price feed reverts', async () => { + await expect( + comet.quoteCollateral(targetAsset.address, QUOTE_AMOUNT) + ).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('governance restores the normal collateral price feed', async () => { + await configurator.updateAssetPriceFeed(cometProxyAddress, targetAsset.address, originalPriceFeed); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('price feed for quoted asset is restored to the normal implementation', async () => { + const assetInfoAfter = await comet.getAssetInfoByAddress(targetAsset.address); + expect(assetInfoAfter.priceFeed).to.equal(originalPriceFeed); + }); + + it('quoteCollateral works again after restoring the normal price feed', async () => { + const quote = await comet.quoteCollateral(targetAsset.address, QUOTE_AMOUNT); + expect(quote).to.be.gt(0); + }); + }); + }); + }); }); diff --git a/test/supply-test.ts b/test/supply-test.ts index c883fdb8f..f476980d6 100644 --- a/test/supply-test.ts +++ b/test/supply-test.ts @@ -1,665 +1,2128 @@ -import { ethers, event, expect, exp, makeProtocol, portfolio, ReentryAttack, setTotalsBasic, wait, fastForward, defaultAssets } from './helpers'; -import { EvilToken, EvilToken__factory, NonStandardFaucetFeeToken__factory, NonStandardFaucetFeeToken } from '../build/types'; - -describe('supplyTo', function () { - it('supplies base from sender if the asset is base', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - const _i0 = await USDC.allocateTo(bob.address, 100e6); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _a0 = await wait(baseAsB.approve(comet.address, 100e6)); - const s0 = await wait(cometAsB.supplyTo(alice.address, USDC.address, 100e6)); - const t1 = await comet.totalsBasic(); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: BigInt(100e6), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Supply: { - from: bob.address, - dst: alice.address, - amount: BigInt(100e6), - } - }); - expect(event(s0, 2)).to.be.deep.equal({ - Transfer: { - from: ethers.constants.AddressZero, - to: alice.address, - amount: BigInt(100e6), - } - }); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase.add(100e6)); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(124000); - }); +import { ethers, event, expect, exp, makeProtocol, portfolio, ReentryAttack, setTotalsBasic, wait, fastForward, defaultAssets, ZERO_ADDRESS, takeSnapshot, SnapshotRestorer, UserCollateral, MAX_ASSETS } from './helpers'; +import { EvilToken, EvilToken__factory, NonStandardFaucetFeeToken__factory, NonStandardFaucetFeeToken, CometHarnessInterface, FaucetToken, CometExtAssetList, CometHarnessInterfaceExtendedAssetList } from '../build/types'; +import { BigNumber, ContractTransaction } from 'ethers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { TotalsCollateralStruct } from 'build/types/CometHarness'; - it('supplies max base borrow balance (including accrued) from sender if the asset is base', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(bob.address, 100e6); - await setTotalsBasic(comet, { - totalSupplyBase: 100e6, - totalBorrowBase: 50e6, // non-zero borrow to accrue interest - }); - await comet.setBasePrincipal(alice.address, -50e6); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - // Fast forward to accrue some interest - await fastForward(86400); - await ethers.provider.send('evm_mine', []); - - const t0 = await comet.totalsBasic(); - const a0 = await portfolio(protocol, alice.address); - const b0 = await portfolio(protocol, bob.address); - await wait(baseAsB.approve(comet.address, 100e6)); - const aliceAccruedBorrowBalance = (await comet.callStatic.borrowBalanceOf(alice.address)).toBigInt(); - const s0 = await wait(cometAsB.supplyTo(alice.address, USDC.address, ethers.constants.MaxUint256)); - const t1 = await comet.totalsBasic(); - const a1 = await portfolio(protocol, alice.address); - const b1 = await portfolio(protocol, bob.address); - - expect(s0.receipt['events'].length).to.be.equal(2); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: aliceAccruedBorrowBalance, - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Supply: { - from: bob.address, - dst: alice.address, - amount: aliceAccruedBorrowBalance, - } - }); - - expect(-aliceAccruedBorrowBalance).to.not.equal(exp(-50, 6)); - expect(a0.internal).to.be.deep.equal({ USDC: -aliceAccruedBorrowBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.external).to.be.deep.equal({ USDC: exp(100, 6) - aliceAccruedBorrowBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); - expect(t1.totalBorrowBase).to.be.equal(0n); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(120000); - }); +// Note: isolated supply functionality, withdraw and repay are tested in separate testsets +describe('supply', function () { + // Constants + const baseTokenDecimals = 6; + // Contracts + let comet: CometHarnessInterfaceExtendedAssetList; + let baseToken: FaucetToken | NonStandardFaucetFeeToken; + // Tokens + let collaterals: { + [symbol: string]: FaucetToken | NonStandardFaucetFeeToken; + }; + let unsupportedToken: FaucetToken; + // Accounts + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let dave: SignerWithAddress; + let pauseGuardian: SignerWithAddress; + let governor: SignerWithAddress; + + /*////////////////////////////////////////////////////////////// + 24 COLLATERALS COMET SETUP + //////////////////////////////////////////////////////////////*/ + // Contracts + let cometWith24Collaterals: CometHarnessInterfaceExtendedAssetList; + let tokensWith24Collaterals: { [symbol: string]: FaucetToken } = {}; + // Constants + const collateralTokenSupplyAmount = BigInt(8e8); + // Storage + let deactivatedCollateralIndex: number; + let totalsCollateralBefore: TotalsCollateralStruct; + let bobUserCollateralBefore: UserCollateral; + let aliceUserCollateralBefore: UserCollateral; + // Tokens + let collateralToken: FaucetToken; + let deactivateCollateralTx: ContractTransaction; + let activateCollateralTx: ContractTransaction; + + let snapshot: SnapshotRestorer; + + before(async function () { + const protocol = await makeProtocol({base: 'USDC'}); + + comet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens[protocol.base]; + collaterals = Object.fromEntries( + Object.entries(protocol.tokens).filter(([_symbol, token]) => token.address !== baseToken.address) + ); + pauseGuardian = protocol.pauseGuardian; + unsupportedToken = protocol.unsupportedToken; + governor = protocol.governor; + [alice, bob, dave] = protocol.users; + + await baseToken.allocateTo(alice.address, exp(1e10, baseTokenDecimals)); + await baseToken.allocateTo(bob.address, exp(1e10, baseTokenDecimals)); + + /*////////////////////////////////////////////////////////////// + 24 COLLATERALS COMET SETUP + //////////////////////////////////////////////////////////////*/ + + const collaterals24Assets = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, { + initialPrice: 100, + decimals: 18, + }]) + ); + const protocolWith24Collaterals = await makeProtocol({ + assets: { USDC: {initialPrice: 1, decimals: 6 }, ...collaterals24Assets, }, + }); + cometWith24Collaterals = protocolWith24Collaterals.cometWithExtendedAssetList; + for (const asset in protocolWith24Collaterals.tokens) { + if (asset === 'USDC') continue; + tokensWith24Collaterals[asset] = protocolWith24Collaterals.tokens[asset] as FaucetToken; + } + + collateralToken = collaterals['COMP'] as FaucetToken; - it('supply max base should supply 0 if user has no borrow position', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(bob.address, 100e6); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const a0 = await portfolio(protocol, alice.address); - const b0 = await portfolio(protocol, bob.address); - await wait(baseAsB.approve(comet.address, 100e6)); - const s0 = await wait(cometAsB.supplyTo(alice.address, USDC.address, ethers.constants.MaxUint256)); - const t1 = await comet.totalsBasic(); - const a1 = await portfolio(protocol, alice.address); - const b1 = await portfolio(protocol, bob.address); - - expect(s0.receipt['events'].length).to.be.equal(2); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: 0n, - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Supply: { - from: bob.address, - dst: alice.address, - amount: 0n, - } - }); - - expect(a0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(120000); + const collateralAssetInfo = await comet.getAssetInfoByAddress(collateralToken.address); + deactivatedCollateralIndex = collateralAssetInfo.offset; + totalsCollateralBefore = await comet.totalsCollateral(collateralToken.address); + bobUserCollateralBefore = await comet.userCollateral(bob.address, collateralToken.address); + aliceUserCollateralBefore = await comet.userCollateral(alice.address, collateralToken.address); + + snapshot = await takeSnapshot(); }); - it('does not emit Transfer for 0 mint', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(bob.address, 100e6); - await comet.setBasePrincipal(alice.address, -100e6); - await setTotalsBasic(comet, { - totalBorrowBase: 100e6, - }); - - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - const _a0 = await wait(baseAsB.approve(comet.address, 100e6)); - const s0 = await wait(cometAsB.supplyTo(alice.address, USDC.address, 100e6)); - expect(s0.receipt['events'].length).to.be.equal(2); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: BigInt(100e6), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Supply: { - from: bob.address, - dst: alice.address, - amount: BigInt(100e6), - } + describe('supply base asset', function () { + describe('default state (un-accrued)', function () { + it('supply is not paused by default', async () => { + expect(await comet.isSupplyPaused()).to.be.false; + }); + + it('base supply is not paused by default', async () => { + expect(await comet.isBaseSupplyPaused()).to.be.false; + }); + + it('no base token on the comet', async () => { + expect(await baseToken.balanceOf(comet.address)).to.equal(0); + }); + + it('no collateral tokens on the comet', async () => { + Object.values(collaterals).forEach(async (collateral) => { + expect(await collateral.balanceOf(comet.address)).to.equal(0); + }); + }); + + it('default supply index', async () => { + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(exp(1, 15)); + }); + + it('no stored total supply with interest by default', async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(0); + }); + + it('no displayed total supply with interest by default', async () => { + expect(await comet.totalSupply()).to.equal(0); + }); + + it('no stored user\'s balance by default', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(0); + }); + + it('no displayed user\'s balance by default', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(0); + }); }); - }); - // This is an edge-case that can occur when a user supplies 0 base. - // When `amount=0` in `supplyBase`, `dstPrincipalNew = principalValue(presentValue(dstPrincipal))` - // In some cases, `dstPrincipalNew` can actually be less than `dstPrincipal` due to the fact - // that the principal value and present value functions round down. This breaks our assumption - // in `repayAndSupplyAmount` that `newPrincipal >= oldPrincipal` MUST be true. In the old code, - // this would cause `supplyAmount` to be an extremely large number (uint104(-1)), which would - // later cause an overflow during an addition operation. The new code now explicitly checks - // this assumption and sets both `repayAmount` and `supplyAmount` to 0 if the assumption is - // violated. - it('supplies 0 and does not revert when dstPrincipalNew < dstPrincipal', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice] } = protocol; - const { USDC } = tokens; - - await comet.setBasePrincipal(alice.address, 99999992291226); - await setTotalsBasic(comet, { - totalSupplyBase: 699999944771920, - baseSupplyIndex: 1000000131467072, - }); - - const s0 = await wait(comet.connect(alice).supply(USDC.address, 0)); - - expect(s0.receipt['events'].length).to.be.equal(2); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: alice.address, - to: comet.address, - amount: BigInt(0), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Supply: { - from: alice.address, - dst: alice.address, - amount: BigInt(0), - } + describe('supply base asset: reverts', function () { + it('reverts if supply is paused', async () => { + await comet.connect(pauseGuardian).pause(true, false, false, false, false); + expect(await comet.isSupplyPaused()).to.be.true; + + await baseToken.connect(alice).approve(comet.address, 1); + await expect(comet.connect(alice).supply(baseToken.address, 1)).to.be.revertedWithCustomError(comet, 'Paused'); + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if base supply is paused', async () => { + await comet.connect(pauseGuardian).pauseBaseSupply(true); + expect(await comet.isBaseSupplyPaused()).to.be.true; + + await expect(comet.connect(alice).supply(baseToken.address, 1)).to.be.revertedWithCustomError(comet, 'BaseSupplyPaused'); + await comet.connect(pauseGuardian).pauseBaseSupply(false); + }); + + it('reverts for not enough base asset balance', async () => { + const balanceBefore = await baseToken.balanceOf(alice.address); + + await baseToken.connect(alice).approve(comet.address, balanceBefore.add(1)); + await expect(comet.connect(alice).supply(baseToken.address, balanceBefore.add(1))).to.be.reverted; + await baseToken.connect(alice).approve(comet.address, 0); + }); + + it('reverts if the asset is neither collateral nor base', async () => { + await unsupportedToken.allocateTo(alice.address, exp(1, 18)); + + await unsupportedToken.connect(alice).approve(comet.address, exp(1, 18)); + await expect(comet.connect(alice).supply(unsupportedToken.address, 1)).to.be.revertedWithCustomError(comet, 'BadAsset'); + }); }); - }); - it('user supply is same as total supply', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [bob] } = protocol; - const { USDC } = tokens; - - await setTotalsBasic(comet, { - totalSupplyBase: 100, - baseSupplyIndex: exp(1.085, 15), - }); - - const _i0 = await USDC.allocateTo(bob.address, 10); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const p0 = await portfolio(protocol, bob.address); - const _a0 = await wait(baseAsB.approve(comet.address, 10)); - const s0 = await wait(cometAsB.supplyTo(bob.address, USDC.address, 10)); - const t1 = await comet.totalsBasic(); - const p1 = await portfolio(protocol, bob.address); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 10n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 9n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(109); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(124000); - }); + describe('supply base asset into empty pool', function () { + const BASE_AMOUNT: bigint = exp(5e9, baseTokenDecimals); + let aliceBalanceBefore: BigNumber; + let aliceBalanceAfter: BigNumber; + let supplyTx: ContractTransaction; - it('supplies collateral from sender if the asset is collateral', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; - - const _i0 = await COMP.allocateTo(bob.address, 8e8); - const baseAsB = COMP.connect(bob); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsCollateral(COMP.address); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _a0 = await wait(baseAsB.approve(comet.address, 8e8)); - const s0 = await wait(cometAsB.supplyTo(alice.address, COMP.address, 8e8)); - const t1 = await comet.totalsCollateral(COMP.address); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: BigInt(8e8), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - SupplyCollateral: { - from: bob.address, - dst: alice.address, - asset: COMP.address, - amount: BigInt(8e8), - } - }); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: exp(8, 8), WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: exp(8, 8), WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyAsset).to.be.equal(t0.totalSupplyAsset.add(8e8)); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(153000); - }); + it('wait and accrue state', async () => { + // wait with empty comet for a while + await ethers.provider.send('evm_increaseTime', [60 * 60]); // 1 hr + await ethers.provider.send('evm_mine', []); - it('calculates base principal correctly', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(bob.address, 100e6); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - const totals0 = await setTotalsBasic(comet, { - baseSupplyIndex: 2e15, - }); - - const alice0 = await portfolio(protocol, alice.address); - const bob0 = await portfolio(protocol, bob.address); - const aliceBasic0 = await comet.userBasic(alice.address); - - await wait(baseAsB.approve(comet.address, 100e6)); - await wait(cometAsB.supplyTo(alice.address, USDC.address, 100e6)); - const t1 = await comet.totalsBasic(); - const alice1 = await portfolio(protocol, alice.address); - const bob1 = await portfolio(protocol, bob.address); - const aliceBasic1 = await comet.userBasic(alice.address); - - expect(alice0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob0.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice1.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(totals0.totalSupplyBase.add(50e6)); // 100e6 in present value - expect(t1.totalBorrowBase).to.be.equal(totals0.totalBorrowBase); - expect(aliceBasic1.principal).to.be.equal(aliceBasic0.principal.add(50e6)); // 100e6 in present value - }); + await comet.accrueAccount(alice.address); + }); + + it('supply base asset into empty pool is successful', async () => { + aliceBalanceBefore = await baseToken.balanceOf(alice.address); + + await baseToken.connect(alice).approve(comet.address, BASE_AMOUNT); + supplyTx = await comet.connect(alice).supply(baseToken.address, BASE_AMOUNT); + await expect(supplyTx).to.not.be.reverted; + + aliceBalanceAfter = await baseToken.balanceOf(alice.address); + }); + + it('emits Supply event when supplies base asset into empty pool', async () => { + await expect(supplyTx) + .emit(comet, 'Supply') + .withArgs(alice.address, alice.address, BASE_AMOUNT); + }); + + it('emits Transfer event when supplies base asset into empty pool (as supply growths)', async () => { + const principalFromBase = BASE_AMOUNT; // default index for the empty pool gives same supply amount + + await expect(supplyTx) + .emit(comet, 'Transfer') + .withArgs(ZERO_ADDRESS, alice.address, principalFromBase); + }); + + it('should supply the exact balance as passed as a parameter', async () => { + expect(aliceBalanceBefore.sub(aliceBalanceAfter)).to.equal(BASE_AMOUNT); + }); + + it("comet's token balance is increased", async () => { + expect(await baseToken.balanceOf(comet.address)).to.equal(BASE_AMOUNT); + }); + + it("user's stored principle is increased", async () => { + const principalFromBase = BASE_AMOUNT; // default index for the empty pool gives same supply amount - it('reverts if supplying collateral exceeds the supply cap', async () => { - const protocol = await makeProtocol({ - assets: { - COMP: { initial: 1e7, decimals: 18, supplyCap: 0 }, - USDC: { initial: 1e6, decimals: 6 }, - } + expect((await comet.userBasic(alice.address)).principal).to.equal(principalFromBase); + }); + + it("user's displayed principle is increased", async () => { + const presentFromBase = BASE_AMOUNT; // default index for the empty pool gives same supply amount + + expect(await comet.balanceOf(alice.address)).to.equal(presentFromBase); + }); + + it("comet's stored total supply is increased", async () => { + const principalFromBase = BASE_AMOUNT; // default index for the empty pool gives same supply amount + + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(principalFromBase); + }); + + it("comet's displayed total supply is increased", async () => { + const presentFromBase = BASE_AMOUNT; // default index for the empty pool gives same supply amount + + expect(await comet.totalSupply()).to.equal(presentFromBase); + }); + + it('user supply is same as total supply', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(await comet.totalSupply()); + }); }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; - const _i0 = await COMP.allocateTo(bob.address, 8e8); - const baseAsB = COMP.connect(bob); - const cometAsB = comet.connect(bob); + describe('supply base asset: happy path', function () { + const SUPPLIED_AMOUNT_ALICE: bigint = exp(2e9, baseTokenDecimals); + let aliceBalanceBefore: BigNumber; + let cometBalanceBefore: BigNumber; + let aliceDisplayBalanceBefore: BigNumber; + let alicePrincipalBefore: BigNumber; + let cometSupplyIndexBefore: BigNumber; + let cometSupplyRateBefore: BigNumber; + let cometUpdatedTimeBefore: number; - const _a0 = await wait(baseAsB.approve(comet.address, 8e8)); - await expect(cometAsB.supplyTo(alice.address, COMP.address, 8e8)).to.be.revertedWith("custom error 'SupplyCapExceeded()'"); - }); + const SUPPLIED_AMOUNT_BOB: bigint = exp(1e9, baseTokenDecimals); + let bobBalanceBefore: BigNumber; - it('reverts if the asset is neither collateral nor base', async () => { - const protocol = await makeProtocol(); - const { comet, users: [alice, bob], unsupportedToken: USUP } = protocol; + before(async function () { + aliceBalanceBefore = await baseToken.balanceOf(alice.address); + cometBalanceBefore = await baseToken.balanceOf(comet.address); + aliceDisplayBalanceBefore = await comet.balanceOf(alice.address); + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal; + cometSupplyIndexBefore = (await comet.totalsBasic()).baseSupplyIndex; + cometSupplyRateBefore = await comet.getSupplyRate(0); + cometUpdatedTimeBefore = (await comet.totalsBasic()).lastAccrualTime; - const _i0 = await USUP.allocateTo(bob.address, 1); - const baseAsB = USUP.connect(bob); - const cometAsB = comet.connect(bob); + // wait with empty comet for a while + await ethers.provider.send('evm_increaseTime', [60 * 60]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('initial state: totalSupply > 0 and supplyRate = 0', async () => { + const storedSupply = (await comet.totalsBasic()).totalSupplyBase; + expect(storedSupply).to.be.greaterThan(0); + + const displayedSupply = storedSupply.mul((await comet.totalsBasic()).baseSupplyIndex).div(exp(1, 15)); + expect(await comet.totalSupply()).to.eq(displayedSupply); + + /// No borrows, but lenders got stimulus from seed reserves + expect(await comet.getSupplyRate(0)).to.eq(0); + }); + + it('should allow 2nd deposit from alice: emits Supply event for existing supply', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, SUPPLIED_AMOUNT_ALICE); + expect(await comet.connect(alice).supply(baseToken.address, SUPPLIED_AMOUNT_ALICE)) + .emit(comet, 'Supply') + .withArgs(alice.address, alice.address, SUPPLIED_AMOUNT_ALICE); + + await snapshot.restore(); + }); + + it('should allow 2nd deposit from alice: emits Transfer event for existing supply', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + + await baseToken.connect(alice).approve(comet.address, SUPPLIED_AMOUNT_ALICE); + expect(await comet.connect(alice).supply(baseToken.address, SUPPLIED_AMOUNT_ALICE)) + .emit(comet, 'Transfer') + .withArgs( + ethers.constants.AddressZero, + alice.address, + await getPrincipalChange(comet, lastUpdated, 0, alice.address, BigNumber.from(SUPPLIED_AMOUNT_ALICE)) + ); + + await snapshot.restore(); + }); + + it('should allow 2nd deposit from alice: accrues the state', async () => { + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + + await baseToken.connect(alice).approve(comet.address, SUPPLIED_AMOUNT_ALICE); + await comet.connect(alice).supply(baseToken.address, SUPPLIED_AMOUNT_ALICE); + + expect((await comet.totalsBasic()).lastAccrualTime).to.be.greaterThan(lastUpdated); + expect((await comet.totalsBasic()).lastAccrualTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + }); + + it('supples from alice the exact balance as in parameter', async () => { + const aliceBalanceAfter = await baseToken.balanceOf(alice.address); + + expect(aliceBalanceBefore.sub(aliceBalanceAfter)).to.equal(SUPPLIED_AMOUNT_ALICE); + }); + + it('Comet token balance growths', async () => { + const cometBalanceAfter = await baseToken.balanceOf(comet.address); + + expect(cometBalanceAfter.sub(cometBalanceBefore)).to.equal(SUPPLIED_AMOUNT_ALICE); + }); + + it("alice's principal growths", async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add(cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18))); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const oldBalance = alicePrincipalBefore.mul(accruedIndex).div(1e15); + const newPrincipal = oldBalance.add(SUPPLIED_AMOUNT_ALICE).mul(1e15).div(accruedIndex); + + expect((await comet.userBasic(alice.address)).principal).to.be.greaterThan(alicePrincipalBefore); + expect((await comet.userBasic(alice.address)).principal).to.equal(newPrincipal); + }); + + it("alice's displayed balance growths", async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add(cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18))); - const _a0 = await wait(baseAsB.approve(comet.address, 1)); - await expect(cometAsB.supplyTo(alice.address, USUP.address, 1)).to.be.reverted; - }); + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const oldBalance = alicePrincipalBefore.mul(cometSupplyIndexBefore).div(exp(1, 15)); + const newBalanceNaive = oldBalance.add(SUPPLIED_AMOUNT_ALICE); + + const newPrincipal = (await comet.userBasic(alice.address)).principal; + const newBalanceFromPrincipal = newPrincipal.mul(accruedIndex).div(exp(1, 15)); + + const newBalance = await comet.balanceOf(alice.address); + expect(newBalance).to.be.greaterThanOrEqual(newBalanceNaive); + expect(newBalance.sub(aliceDisplayBalanceBefore)).to.be.greaterThanOrEqual(SUPPLIED_AMOUNT_ALICE); + expect(newBalance).to.equal(newBalanceFromPrincipal); + }); + + it("Comet's stored total supply corresponds to provided principal", async () => { + /// currently it is an accrued state, so we can compare directly + /// single supplier at the moment + expect((await comet.totalsBasic()).totalSupplyBase).to.equal((await comet.userBasic(alice.address)).principal); + }); + + it("Comet's displayed total supply corresponds to provided token balance", async () => { + /// currently it is an accrued state, so we can compare directly + /// single supplier at the moment + expect(await comet.totalSupply()).to.equal(await comet.balanceOf(alice.address)); + }); + + it('wait for new state for bob and update global variables', async () => { + bobBalanceBefore = await baseToken.balanceOf(bob.address); + cometBalanceBefore = await baseToken.balanceOf(comet.address); + /// no deposits from bob yet + expect((await comet.userBasic(bob.address)).principal).to.equal(0); + + cometSupplyIndexBefore = (await comet.totalsBasic()).baseSupplyIndex; + cometSupplyRateBefore = await comet.getSupplyRate(0); + cometUpdatedTimeBefore = (await comet.totalsBasic()).lastAccrualTime; + + // wait with empty comet for a while + await ethers.provider.send('evm_increaseTime', [60 * 60]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('should allow deposit from bob (new user): emits Supply event for existing supply', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(bob).approve(comet.address, SUPPLIED_AMOUNT_BOB); + expect(await comet.connect(bob).supply(baseToken.address, SUPPLIED_AMOUNT_BOB)) + .emit(comet, 'Supply') + .withArgs(bob.address, bob.address, SUPPLIED_AMOUNT_BOB); + + await snapshot.restore(); + }); + + it('should allow deposit from bob (new user): emits Transfer event for existing supply', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + + await baseToken.connect(bob).approve(comet.address, SUPPLIED_AMOUNT_BOB); + expect(await comet.connect(bob).supply(baseToken.address, SUPPLIED_AMOUNT_BOB)) + .emit(comet, 'Transfer') + .withArgs( + ethers.constants.AddressZero, + bob.address, + await getPrincipalChange(comet, lastUpdated, 0, bob.address, BigNumber.from(SUPPLIED_AMOUNT_BOB)) + ); + + await snapshot.restore(); + }); + + it('should allow deposit from bob (new user): accrues the state', async () => { + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + + await baseToken.connect(bob).approve(comet.address, SUPPLIED_AMOUNT_BOB); + await comet.connect(bob).supply(baseToken.address, SUPPLIED_AMOUNT_BOB); + + expect((await comet.totalsBasic()).lastAccrualTime).to.be.greaterThan(lastUpdated); + expect((await comet.totalsBasic()).lastAccrualTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + }); + + it('supples from bob the exact balance as in parameter', async () => { + const bobBalanceAfter = await baseToken.balanceOf(bob.address); + + expect(bobBalanceBefore.sub(bobBalanceAfter)).to.equal(SUPPLIED_AMOUNT_BOB); + }); + + it('Comet token balance growths', async () => { + const cometBalanceAfter = await baseToken.balanceOf(comet.address); + + expect(cometBalanceAfter.sub(cometBalanceBefore)).to.equal(SUPPLIED_AMOUNT_BOB); + }); + + it("bob's principal growths", async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add(cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18))); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + /// old balance == 0 + const oldBalance: BigNumber = BigNumber.from(0); + const newPrincipal = oldBalance.add(SUPPLIED_AMOUNT_BOB).mul(exp(1, 15)).div(accruedIndex); + + expect((await comet.userBasic(bob.address)).principal).to.be.greaterThan(0); + expect((await comet.userBasic(bob.address)).principal).to.equal(newPrincipal); + }); + + it("bob's displayed balance growths", async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add(cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18))); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const newPrincipal = (await comet.userBasic(bob.address)).principal; + + // old balance for bob is 0 + const newBalanceFromPrincipal = newPrincipal.mul(accruedIndex).div(exp(1, 15)); + + const newBalance = await comet.balanceOf(bob.address); + expect(newBalance).to.equal(newBalanceFromPrincipal); + }); + + it("Comet's stored total supply corresponds to provided principals from all users", async () => { + /// currently it is an accrued state, so we can compare directly + /// get alice's and bob's suppleis together + const alicePrincipal = (await comet.userBasic(alice.address)).principal; + const bobPrincipal = (await comet.userBasic(bob.address)).principal; + const totalStoredSupply = alicePrincipal.add(bobPrincipal); + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalStoredSupply); + }); + + it("balanceOf() is >= bob's deposit", async () => { + const newBalanceNaive = SUPPLIED_AMOUNT_BOB; - it('reverts if supply is paused', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, pauseGuardian, users: [alice, bob] } = protocol; - const { USDC } = tokens; + /// Note: since there is a rounding error, the immediate comet.balanceOf() may return value + /// which is 1 wei less than the deposited amount. Though the difference will be neglected + /// in around 1 block of supply interest (in case if ) - await USDC.allocateTo(bob.address, 1); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); + const newBalance = await comet.balanceOf(bob.address); - // Pause supply - await wait(comet.connect(pauseGuardian).pause(true, false, false, false, false)); - expect(await comet.isSupplyPaused()).to.be.true; + expect(newBalance.sub(newBalanceNaive)).to.be.approximately(0, 1); + }); - await wait(baseAsB.approve(comet.address, 1)); - await expect(cometAsB.supplyTo(alice.address, USDC.address, 1)).to.be.revertedWith("custom error 'Paused()'"); + it("Comet's displayed total supply corresponds to displayed balances from all users", async () => { + /// currently it is an accrued state, so we can compare directly + /// get alice's and bob's suppleis together + const alicePresent = await comet.balanceOf(alice.address); + const bobPresent = await comet.balanceOf(bob.address); + const totalPresentSupply = alicePresent.add(bobPresent); + + /// Note: because of the rounding errors accumulated (supplied amount -> principle -> present value) + /// There is a high chance to have around 1 wei difference in the displayed market supply (totalSupply()) + /// and the sum of all balances from all users + expect(await comet.totalSupply()).to.be.approximately(totalPresentSupply, 1); + }); + }); + + describe('supply max base (repay borrow)', function () { + it('supplies max base borrow balance (including accrued) from sender', async () => { + const protocol = await makeProtocol({ base: 'USDC' }); + const { comet, tokens, users: [alice, bob] } = protocol; + const { USDC } = tokens; + + await USDC.allocateTo(bob.address, 100e6); + await setTotalsBasic(comet, { + totalSupplyBase: 100e6, + totalBorrowBase: 50e6, + }); + await comet.setBasePrincipal(alice.address, -50e6); + + // Fast forward to accrue interest + await fastForward(86400); + await ethers.provider.send('evm_mine', []); + + const t0 = await comet.totalsBasic(); + const a0 = await portfolio(protocol, alice.address); + const b0 = await portfolio(protocol, bob.address); + + await wait(USDC.connect(bob).approve(comet.address, 100e6)); + const aliceAccruedBorrowBalance = (await comet.callStatic.borrowBalanceOf(alice.address)).toBigInt(); + const s0 = await wait(comet.connect(bob).supplyTo(alice.address, USDC.address, ethers.constants.MaxUint256)); + + const t1 = await comet.totalsBasic(); + const a1 = await portfolio(protocol, alice.address); + const b1 = await portfolio(protocol, bob.address); + + // Only 2 events (no mint Transfer since repaying borrow) + expect(s0.receipt['events'].length).to.be.equal(2); + expect(event(s0, 0)).to.be.deep.equal({ + Transfer: { + from: bob.address, + to: comet.address, + amount: aliceAccruedBorrowBalance, + } + }); + expect(event(s0, 1)).to.be.deep.equal({ + Supply: { + from: bob.address, + dst: alice.address, + amount: aliceAccruedBorrowBalance, + } + }); + + // Interest accrued + expect(-aliceAccruedBorrowBalance).to.not.equal(exp(-50, 6)); + + // Alice borrow repaid + expect(a0.internal).to.be.deep.equal({ USDC: -aliceAccruedBorrowBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); + expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); + + // Bob paid + expect(b0.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); + expect(b1.external).to.be.deep.equal({ USDC: exp(100, 6) - aliceAccruedBorrowBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); + + // Totals updated + expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); + expect(t1.totalBorrowBase).to.be.equal(0n); + + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(120000); + }); + + it('supply max base should supply 0 if user has no borrow position', async () => { + const protocol = await makeProtocol({ base: 'USDC' }); + const { comet, tokens, users: [alice, bob] } = protocol; + const { USDC } = tokens; + + await USDC.allocateTo(bob.address, 100e6); + + const t0 = await comet.totalsBasic(); + await wait(USDC.connect(bob).approve(comet.address, 100e6)); + const s0 = await wait(comet.connect(bob).supplyTo(alice.address, USDC.address, ethers.constants.MaxUint256)); + + const t1 = await comet.totalsBasic(); + const a1 = await portfolio(protocol, alice.address); + const b1 = await portfolio(protocol, bob.address); + + // Events show 0 amount + expect(s0.receipt['events'].length).to.be.equal(2); + expect(event(s0, 0)).to.be.deep.equal({ + Transfer: { from: bob.address, to: comet.address, amount: 0n } + }); + expect(event(s0, 1)).to.be.deep.equal({ + Supply: { from: bob.address, dst: alice.address, amount: 0n } + }); + + // No tokens transferred + expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); + expect(b1.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); + + // Totals unchanged + expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); + expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); + + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(120000); + }); + + it('does not emit Transfer for 0 mint when repaying exact borrow', async () => { + const protocol = await makeProtocol({ base: 'USDC' }); + const { comet, tokens, users: [alice, bob] } = protocol; + const { USDC } = tokens; + + await USDC.allocateTo(bob.address, 100e6); + await comet.setBasePrincipal(alice.address, -100e6); + await setTotalsBasic(comet, { + totalBorrowBase: 100e6, + }); + + await wait(USDC.connect(bob).approve(comet.address, 100e6)); + const s0 = await wait(comet.connect(bob).supplyTo(alice.address, USDC.address, 100e6)); + + // Only 2 events - no mint Transfer + expect(s0.receipt['events'].length).to.be.equal(2); + expect(event(s0, 0)).to.be.deep.equal({ + Transfer: { from: bob.address, to: comet.address, amount: BigInt(100e6) } + }); + expect(event(s0, 1)).to.be.deep.equal({ + Supply: { from: bob.address, dst: alice.address, amount: BigInt(100e6) } + }); + }); + + // Edge-case: when supplying 0, dstPrincipalNew can be less than dstPrincipal due to rounding + it('supplies 0 and does not revert when dstPrincipalNew < dstPrincipal', async () => { + const { comet, tokens, users: [alice] } = await makeProtocol({ base: 'USDC' }); + const { USDC } = tokens; + + await comet.setBasePrincipal(alice.address, 99999992291226); + await setTotalsBasic(comet, { + totalSupplyBase: 699999944771920, + baseSupplyIndex: 1000000131467072, + }); + + const s0 = await wait(comet.connect(alice).supply(USDC.address, 0)); + + expect(s0.receipt['events'].length).to.be.equal(2); + expect(event(s0, 0)).to.be.deep.equal({ + Transfer: { from: alice.address, to: comet.address, amount: BigInt(0) } + }); + expect(event(s0, 1)).to.be.deep.equal({ + Supply: { from: alice.address, dst: alice.address, amount: BigInt(0) } + }); + }); + + it('reverts if supply max for a collateral asset', async () => { + const { comet, tokens, users: [alice, bob] } = await makeProtocol({ base: 'USDC' }); + const { COMP } = tokens; + + await COMP.allocateTo(bob.address, 100e6); + await wait(COMP.connect(bob).approve(COMP.address, 100e6)); + + await expect( + comet.connect(bob).supplyTo(alice.address, COMP.address, ethers.constants.MaxUint256) + ).to.be.revertedWith("custom error 'InvalidUInt128()'"); + }); + }); }); - it('reverts if supply max for a collateral asset', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; + describe('supply collateral', function () { + const ASSET_SYMBOL = 'COMP'; + let collateral: FaucetToken | NonStandardFaucetFeeToken; - await COMP.allocateTo(bob.address, 100e6); - const baseAsB = COMP.connect(bob); - const cometAsB = comet.connect(bob); + before(async function () { + collateral = collaterals[ASSET_SYMBOL]; + const collateralIndex = (await comet.getAssetInfoByAddress(collateral.address)).offset; + const supplyCap = (await comet.getAssetInfo(collateralIndex)).supplyCap; + await collateral.allocateTo(alice.address, supplyCap.add(exp(1, 18))); + await collateral.allocateTo(bob.address, exp(1e10, 18)); + }); + + describe('reverts', function () { + it('reverts if supply is paused', async () => { + await comet.connect(pauseGuardian).pause(true, false, false, false, false); + expect(await comet.isSupplyPaused()).to.be.true; + + await expect(comet.connect(alice).supply(collateral.address, 1)).to.be.revertedWithCustomError(comet, 'Paused'); + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if collateral supply is paused', async () => { + await comet.connect(pauseGuardian).pauseCollateralSupply(true); + expect(await comet.isCollateralSupplyPaused()).to.be.true; + + await expect(comet.connect(alice).supply(collateral.address, 1)).to.be.revertedWithCustomError(comet, 'CollateralSupplyPaused'); + await comet.connect(pauseGuardian).pauseCollateralSupply(false); + }); + + it('reverts if specific collateral supply is paused', async () => { + await comet.connect(pauseGuardian).pauseCollateralAssetSupply(0, true); + expect(await comet.isCollateralAssetSupplyPaused(0)).to.be.true; + + await collateral.connect(alice).approve(comet.address, 1); + await expect(comet.connect(alice).supply(collateral.address, 1)).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused'); + await comet.connect(pauseGuardian).pauseCollateralAssetSupply(0, false); + }); + + it('reverts for not enough collateral balance', async () => { + const balanceBefore = await collateral.balanceOf(alice.address); + + await collateral.connect(alice).approve(comet.address, balanceBefore.add(1)); + await expect(comet.connect(alice).supply(collateral.address, balanceBefore.add(1))).to.be.reverted; + await collateral.connect(alice).approve(comet.address, 0); + }); + + it('reverts if supplying collateral exceeds the supply cap', async () => { + const collateralIndex = (await comet.getAssetInfoByAddress(collateral.address)).offset; + const supplyCap = (await comet.getAssetInfo(collateralIndex)).supplyCap; + + // health check + expect(await collateral.balanceOf(alice.address)).is.greaterThan(supplyCap); + + await collateral.connect(alice).approve(comet.address, supplyCap.add(1)); + await expect(comet.connect(alice).supply(collateral.address, supplyCap.add(1))).to.be.revertedWithCustomError( + comet, + 'SupplyCapExceeded' + ); + await collateral.connect(alice).approve(comet.address, 0); + }); + }); + + describe('supply collateral: happy path', function () { + const ALICE_COLLATERAL_AMOUNT: bigint = exp(5, 17); //0.5 of token + const ALICE_ANOTHER_COLLATERAL_AMOUNT: bigint = exp(1, 17); + const SKIP_TIME: number = 60 * 60; // 1 hr + let aliceCollateralBalanceBefore: BigNumber; + let totalSupplyBefore: BigNumber; + let alicePrincipalBefore: BigNumber; + let cometUpdatedTimeBefore: number; + let supplyTx: ContractTransaction; + let cometSupplyIndexBefore: BigNumber; + let cometSupplyRateBefore: BigNumber; + let aliceDisplayBalanceBefore: BigNumber; + let supplyTimestamp: BigNumber; + let cometBorrowIndexBefore: BigNumber; + let trackingSupplyIndexBefore: BigNumber; + let trackingBorrowIndexBefore: BigNumber; + let aliceBaseTrackingIndexBefore: BigNumber; + let aliceBaseTrackingAccruedBefore: BigNumber; + let baseTrackingSupplySpeedVal: BigNumber; + let trackingIndexScaleVal: BigNumber; + let borrowRateBefore: BigNumber; + let utilizationBefore: BigNumber; + + before(async function () { + // Accrue state before supply + await comet.accrueAccount(ethers.constants.AddressZero); + + const totals = await comet.totalsBasic(); + aliceCollateralBalanceBefore = await collateral.balanceOf(alice.address); + + totalSupplyBefore = totals.totalSupplyBase; + cometSupplyIndexBefore = totals.baseSupplyIndex; + cometSupplyRateBefore = await comet.getSupplyRate(0); + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal; + aliceDisplayBalanceBefore = await comet.balanceOf(alice.address); + + cometUpdatedTimeBefore = totals.lastAccrualTime; + + cometBorrowIndexBefore = totals.baseBorrowIndex; + trackingSupplyIndexBefore = totals.trackingSupplyIndex; + trackingBorrowIndexBefore = totals.trackingBorrowIndex; + utilizationBefore = await comet.getUtilization(); + borrowRateBefore = await comet.getBorrowRate(utilizationBefore); + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + trackingIndexScaleVal = await comet.trackingIndexScale(); + const aliceBasic = await comet.userBasic(alice.address); + aliceBaseTrackingIndexBefore = aliceBasic.baseTrackingIndex; + aliceBaseTrackingAccruedBefore = aliceBasic.baseTrackingAccrued; + + // wait for a while to have impact from accrual + await ethers.provider.send('evm_increaseTime', [SKIP_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('should not have collateral registered for a user', async () => { + const collateralIndex = (await comet.getAssetInfoByAddress(collateral.address)).offset; + const userData = await comet.userBasic(alice.address); + const offset = 1 << collateralIndex; + + expect(userData.assetsIn & offset).to.equal(0); + }); + + it('should not collateral in the storage', async () => { + expect((await comet.totalsCollateral(collateral.address)).totalSupplyAsset).to.equal(0); + expect((await comet.userCollateral(alice.address, collateral.address)).balance).to.equal(0); + }); + + it('should not have collateral on the balance', async () => { + expect(await collateral.balanceOf(comet.address)).to.equal(0); + }); + + it('should allow collateral deposit', async () => { + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + supplyTx = await comet.connect(alice).supply(collateral.address, ALICE_COLLATERAL_AMOUNT); + await expect(supplyTx).to.not.be.reverted; + }); + + it('should emit event during 1st collateral deposit', async () => { + await expect(supplyTx) + .to.emit(comet, 'SupplyCollateral') + .withArgs(alice.address, alice.address, collateral.address, ALICE_COLLATERAL_AMOUNT); + }); + + it("collateral is added to user's tokens", async () => { + const collateralIndex = (await comet.getAssetInfoByAddress(collateral.address)).offset; + const userData = await comet.userBasic(alice.address); + const offset = 1 << collateralIndex; + + expect(userData.assetsIn & offset).to.equal(offset); + }); + + it('exact collateral token balance is supplied from alice', async () => { + const aliceCollateralBalanceAfter = await collateral.balanceOf(alice.address); + expect(aliceCollateralBalanceBefore.sub(aliceCollateralBalanceAfter)).to.equal(ALICE_COLLATERAL_AMOUNT); + }); + + it("Comet's collateral token balance growths", async () => { + expect(await collateral.balanceOf(comet.address)).to.equal(ALICE_COLLATERAL_AMOUNT); + }); + + it("should correctly set alice's collateral balance", async () => { + expect((await comet.userCollateral(alice.address, collateral.address)).balance).to.equal(ALICE_COLLATERAL_AMOUNT); + }); + + it("should correctly set comet's total balance", async () => { + expect((await comet.totalsCollateral(collateral.address)).totalSupplyAsset).to.equal(ALICE_COLLATERAL_AMOUNT); + }); + + it('should accrue state during collateral supply', async () => { + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + supplyTimestamp = BigNumber.from( + (await ethers.provider.getBlock((await supplyTx.wait()).blockNumber)).timestamp + ); + + expect(lastUpdated - cometUpdatedTimeBefore).to.be.approximately(SKIP_TIME, 2); // 2 seconds tolerance + expect(lastUpdated).to.equal(supplyTimestamp); + }); + + it('should not change alice principal after accrual (no collateral effect on principal)', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(alicePrincipalBefore); + }); + + it('should have correct display of alice principal', async () => { + const timeElapsed = supplyTimestamp.sub(cometUpdatedTimeBefore); + const accruedIndex = cometSupplyIndexBefore.add(cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18))); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const newBalanceFromPrincipal = alicePrincipalBefore.mul(accruedIndex).div(exp(1, 15)); + + // current balance + const newBalance = await comet.balanceOf(alice.address); + + expect(newBalance).to.equal(newBalanceFromPrincipal); + // check the invariant that lender's balance can only grow + expect(newBalance).to.be.eq(aliceDisplayBalanceBefore); + }); + + it("should change comet's total supply correctly after accrual (no collateral effect on supply)", async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalSupplyBefore); + }); + + it('should have correct display of total supply', async () => { + // current displayed supply + const newSupply = await comet.totalSupply(); + + // check the invariant that lender's balance can only grow + expect(newSupply).to.be.equal(totalSupplyBefore); + }); + + it('trackingSupplyIndex grows correctly during collateral supply accrual', async () => { + // accrueInternal() updates trackingSupplyIndex when totalSupplyBase >= baseMinForRewards: + // trackingSupplyIndex += divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase) + // = baseTrackingSupplySpeed * timeElapsed * baseScale / totalSupplyBase + // baseScale = 1e6 for USDC; trackingSupplyIndex is independent of the interest rate + // Example: speed=1e15, elapsed~3600, totalSupplyBase~3e15 (3e9 USDC principal) + // → delta = 1e15 * 3600 * 1e6 / 3e15 = 1200 + const timeElapsed = supplyTimestamp.sub(cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const expectedTrackingSupplyIndex = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + expect((await comet.totalsBasic()).trackingSupplyIndex).to.equal(expectedTrackingSupplyIndex); + }); + + it('trackingBorrowIndex is unchanged when totalBorrowBase is zero', async () => { + // sanity check that totalBorrowBase < baseMinForRewards + expect((await comet.totalsBasic()).totalBorrowBase).to.be.lessThan(await comet.baseMinForRewards()); + + // accrueInternal() only updates trackingBorrowIndex if totalBorrowBase >= baseMinForRewards + // With no active borrows, totalBorrowBase = 0 and the condition is not satisfied + expect((await comet.totalsBasic()).trackingBorrowIndex).to.equal(trackingBorrowIndexBefore); + }); + + it('baseSupplyIndex accrues correctly during collateral supply', async () => { + // baseSupplyIndex += mulFactor(baseSupplyIndex, supplyRate * timeElapsed) + // = baseSupplyIndex + baseSupplyIndex * supplyRate * timeElapsed / 1e18 + // With utilization = 0 (no borrows), supplyRate = 0 and the index is unchanged + const timeElapsed = supplyTimestamp.sub(cometUpdatedTimeBefore); + const expectedBaseSupplyIndex = cometSupplyIndexBefore.add( + cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(expectedBaseSupplyIndex); + }); + + it('baseBorrowIndex accrues correctly during collateral supply', async () => { + // baseBorrowIndex += mulFactor(baseBorrowIndex, borrowRate * timeElapsed) + // = baseBorrowIndex + baseBorrowIndex * borrowRate * timeElapsed / 1e18 + // With no borrows, getBorrowRate returns 0 and the borrow index is unchanged + const timeElapsed = supplyTimestamp.sub(cometUpdatedTimeBefore); + const expectedBaseBorrowIndex = cometBorrowIndexBefore.add( + cometBorrowIndexBefore.mul(borrowRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(expectedBaseBorrowIndex); + }); + + it('alice baseTrackingAccrued increases via supply tracking during collateral supply', async () => { + // accrueAccountInternal(alice) calls updateBasePrincipal, accumulating rewards since her last sync. + // alice.principal >= 0 so supply tracking applies: + // indexDelta = trackingSupplyIndex_new - alice.baseTrackingIndex_before + // baseTrackingAccrued += principal * indexDelta / trackingIndexScale / accrualDescaleFactor + // accrualDescaleFactor = baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6 = 1 for USDC + // trackingIndexScale = 1e15 (default) + const timeElapsed = supplyTimestamp.sub(cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const trackingSupplyIndexNew = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + // indexDelta spans from alice's last synced tracking index to the new global index + const indexDelta = trackingSupplyIndexNew.sub(aliceBaseTrackingIndexBefore); + // accrualDescaleFactor = 1 for USDC (baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6) + const expectedAccrued = aliceBaseTrackingAccruedBefore.add( + alicePrincipalBefore.mul(indexDelta).div(trackingIndexScaleVal) + ); + expect((await comet.userBasic(alice.address)).baseTrackingAccrued).to.equal(expectedAccrued); + }); + + it('utilization is zero after collateral supply when there are no borrows', async () => { + // Supplying collateral does not change totalSupplyBase or totalBorrowBase (principals unchanged) + // With totalBorrowBase = 0, getUtilization() returns 0 + expect(await comet.getUtilization()).to.equal(0); + expect(await comet.getUtilization()).to.equal(utilizationBefore); + }); + + it('should allow deposit more of the same collateral', async () => { + aliceCollateralBalanceBefore = (await comet.userCollateral(alice.address, collateral.address)).balance; + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collateral.address, ALICE_COLLATERAL_AMOUNT); + + expect((await comet.userCollateral(alice.address, collateral.address)).balance).to.equal( + aliceCollateralBalanceBefore.add(ALICE_COLLATERAL_AMOUNT) + ); + }); + + it('should allow deposit another collateral token', async () => { + await collaterals['WETH'].allocateTo(alice.address, ALICE_ANOTHER_COLLATERAL_AMOUNT); //0.1 token + + // health check + expect((await comet.userCollateral(alice.address, collaterals['WETH'].address)).balance).to.equal(0); + + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_ANOTHER_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_ANOTHER_COLLATERAL_AMOUNT); + + expect((await comet.userCollateral(alice.address, collaterals['WETH'].address)).balance).to.equal(ALICE_ANOTHER_COLLATERAL_AMOUNT); + }); + + it('should have no impact on a previous collateral deposit', async () => { + expect((await comet.userCollateral(alice.address, collateral.address)).balance).to.equal( + aliceCollateralBalanceBefore.add(ALICE_COLLATERAL_AMOUNT) + ); + }); + + it('supply of collateral from Bob should not affect Alice', async () => { + const aliceBalanceBefore = (await comet.userCollateral(alice.address, collateral.address)).balance; + const totalCollateralSupplyBefore = (await comet.totalsCollateral(collateral.address)).totalSupplyAsset; + + await collateral.connect(bob).approve(comet.address, ALICE_ANOTHER_COLLATERAL_AMOUNT); + await comet.connect(bob).supply(collateral.address, ALICE_ANOTHER_COLLATERAL_AMOUNT); + + expect((await comet.userCollateral(alice.address, collateral.address)).balance).to.equal(aliceBalanceBefore); + expect((await comet.totalsCollateral(collateral.address)).totalSupplyAsset).to.equal(totalCollateralSupplyBefore.add(ALICE_ANOTHER_COLLATERAL_AMOUNT)); + }); + }); - await wait(baseAsB.approve(COMP.address, 100e6)); - await expect(cometAsB.supplyTo(alice.address, COMP.address, ethers.constants.MaxUint256)).to.be.revertedWith("custom error 'InvalidUInt128()'"); + // Tests that supplyCollateral correctly accrues indices when the recipient holds a net borrow + // position (negative base principal). This complements the zero-borrow describe above by + // showing indices that were flat there (baseSupplyIndex, baseBorrowIndex, trackingBorrowIndex) + // now grow, and alice's baseTrackingAccrued accrues via borrow tracking (not supply tracking). + describe('supply collateral: accrual with active borrow (non-zero utilization)', function () { + const SKIP_TIME = 3600; // 1 hour + // 400 USDC borrow — alice's collateral (1 COMP + 0.1 WETH) supports up to ~$475 of borrows + const ALICE_BORROW_AMOUNT: bigint = exp(400, 6); + const ALICE_COLLATERAL_SUPPLY: bigint = exp(1, 18); // 1 COMP + + let baseSupplyIndexBefore: BigNumber; + let baseBorrowIndexBefore: BigNumber; + let trackingSupplyIndexBefore: BigNumber; + let trackingBorrowIndexBefore: BigNumber; + let totalSupplyBaseBefore: BigNumber; + let totalBorrowBaseBefore: BigNumber; + let lastAccrualTimeBefore: number; + let alicePrincipalBefore: BigNumber; + let aliceBaseTrackingIndexBefore: BigNumber; + let aliceBaseTrackingAccruedBefore: BigNumber; + let baseTrackingSupplySpeedVal: BigNumber; + let baseTrackingBorrowSpeedVal: BigNumber; + let trackingIndexScaleVal: BigNumber; + let supplyRateBefore: BigNumber; + let borrowRateBefore: BigNumber; + let utilizationBefore: BigNumber; + let supplyCollateralTx: ContractTransaction; + let supplyTimestamp: number; + + // As this is additional edge case block, we take snapshot to restore state after it + let snapshot: SnapshotRestorer; + + before(async function () { + snapshot = await takeSnapshot(); + // Alice withdraws her entire base supply balance plus ALICE_BORROW_AMOUNT in a single call. + // This transitions alice from a net supplier to a net borrower (negative principal), + // which makes totalBorrowBase > 0 and creates non-zero utilization and rates. + // Her existing collateral (1 COMP + 0.1 WETH ≈ $475) covers the $400 USDC net borrow. + const aliceDisplayBalance = await comet.balanceOf(alice.address); + await comet.connect(alice).withdraw(baseToken.address, aliceDisplayBalance.add(ALICE_BORROW_AMOUNT)); + + // Capture global indices and alice state right before the time advance + const totals = await comet.totalsBasic(); + baseSupplyIndexBefore = totals.baseSupplyIndex; + baseBorrowIndexBefore = totals.baseBorrowIndex; + trackingSupplyIndexBefore = totals.trackingSupplyIndex; + trackingBorrowIndexBefore = totals.trackingBorrowIndex; + totalSupplyBaseBefore = totals.totalSupplyBase; + totalBorrowBaseBefore = totals.totalBorrowBase; + lastAccrualTimeBefore = totals.lastAccrualTime; + + utilizationBefore = await comet.getUtilization(); + supplyRateBefore = await comet.getSupplyRate(utilizationBefore); + borrowRateBefore = await comet.getBorrowRate(utilizationBefore); + + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + baseTrackingBorrowSpeedVal = await comet.baseTrackingBorrowSpeed(); + trackingIndexScaleVal = await comet.trackingIndexScale(); + + // alice.principal is now negative; baseTrackingIndex was set to trackingBorrowIndex during withdrawal + const aliceBasic = await comet.userBasic(alice.address); + alicePrincipalBefore = aliceBasic.principal; + aliceBaseTrackingIndexBefore = aliceBasic.baseTrackingIndex; + aliceBaseTrackingAccruedBefore = aliceBasic.baseTrackingAccrued; + + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_SUPPLY); + }); + + it('health check: alice has a negative base principal (net borrower)', () => { + expect(alicePrincipalBefore).to.be.lessThan(0); + }); + + it('health check: totalBorrowBase exceeds baseMinForRewards', async () => { + const baseMinForRewards = await comet.baseMinForRewards(); + expect(totalBorrowBaseBefore).to.be.greaterThan(baseMinForRewards); + }); + + it('health check: utilization is greater than zero', () => { + expect(utilizationBefore).to.be.greaterThan(0); + }); + + it('1 hour passes', async () => { + await ethers.provider.send('evm_increaseTime', [SKIP_TIME]); + await ethers.provider.send('evm_mine', []); + }); + + it('alice supplies COMP collateral, triggering accrueAccountInternal', async () => { + supplyCollateralTx = await comet.connect(alice).supply(collateral.address, ALICE_COLLATERAL_SUPPLY); + await expect(supplyCollateralTx).to.not.be.reverted; + supplyTimestamp = (await ethers.provider.getBlock((await supplyCollateralTx.wait()).blockNumber)).timestamp; + }); + + it('baseSupplyIndex grows when supply rate is non-zero', async () => { + // baseSupplyIndex += mulFactor(baseSupplyIndex, supplyRate * timeElapsed) + // = baseSupplyIndex + baseSupplyIndex * supplyRate * timeElapsed / 1e18 + // supplyRate > 0 because utilization > 0 (alice's 400 USDC borrow) + // Unlike the zero-borrow case above, this index now actually grows + const timeElapsed = BigNumber.from(supplyTimestamp - lastAccrualTimeBefore); + const expectedIndex = baseSupplyIndexBefore.add( + baseSupplyIndexBefore.mul(supplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(expectedIndex); + }); + + it('baseBorrowIndex grows when borrow rate is non-zero', async () => { + // baseBorrowIndex += mulFactor(baseBorrowIndex, borrowRate * timeElapsed) + // = baseBorrowIndex + baseBorrowIndex * borrowRate * timeElapsed / 1e18 + // borrowRate > 0 because totalBorrowBase > 0 and utilization > 0 + const timeElapsed = BigNumber.from(supplyTimestamp - lastAccrualTimeBefore); + const expectedIndex = baseBorrowIndexBefore.add( + baseBorrowIndexBefore.mul(borrowRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(expectedIndex); + }); + + it('trackingBorrowIndex grows when totalBorrowBase exceeds baseMinForRewards', async () => { + // trackingBorrowIndex += divBaseWei(baseTrackingBorrowSpeed * timeElapsed, totalBorrowBase) + // = baseTrackingBorrowSpeed * timeElapsed * baseScale / totalBorrowBase + const timeElapsed = BigNumber.from(supplyTimestamp - lastAccrualTimeBefore); + const baseScale = exp(1, 6); + const expectedIndex = trackingBorrowIndexBefore.add( + baseTrackingBorrowSpeedVal.mul(timeElapsed).mul(baseScale).div(totalBorrowBaseBefore) + ); + expect((await comet.totalsBasic()).trackingBorrowIndex).to.equal(expectedIndex); + }); + + it('trackingSupplyIndex also grows when totalSupplyBase exceeds baseMinForRewards', async () => { + // trackingSupplyIndex += divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase) + // = baseTrackingSupplySpeed * timeElapsed * baseScale / totalSupplyBase + const timeElapsed = BigNumber.from(supplyTimestamp - lastAccrualTimeBefore); + const baseScale = exp(1, 6); + const expectedIndex = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBaseBefore) + ); + expect((await comet.totalsBasic()).trackingSupplyIndex).to.equal(expectedIndex); + }); + + it('alice baseTrackingAccrued accumulates borrow rewards via trackingBorrowIndex', async () => { + // alice.principal < 0 (net borrower), so updateBasePrincipal uses borrow tracking: + // indexDelta = trackingBorrowIndex_new - alice.baseTrackingIndex_before + // baseTrackingAccrued += |principal| * indexDelta / trackingIndexScale / accrualDescaleFactor + // alice.baseTrackingIndex was set to trackingBorrowIndex at withdrawal time (same block as capture), + // so indexDelta = trackingBorrowIndex_new - trackingBorrowIndexBefore + // accrualDescaleFactor = baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6 = 1 for USDC + const timeElapsed = BigNumber.from(supplyTimestamp - lastAccrualTimeBefore); + const baseScale = exp(1, 6); + const trackingBorrowIndexNew = trackingBorrowIndexBefore.add( + baseTrackingBorrowSpeedVal.mul(timeElapsed).mul(baseScale).div(totalBorrowBaseBefore) + ); + // indexDelta spans from alice's last synced borrow tracking index to the new global value + const indexDelta = trackingBorrowIndexNew.sub(aliceBaseTrackingIndexBefore); + // accrualDescaleFactor = 1 for USDC (baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6) + const expectedAccrued = aliceBaseTrackingAccruedBefore.add( + alicePrincipalBefore.abs().mul(indexDelta).div(trackingIndexScaleVal) + ); + expect((await comet.userBasic(alice.address)).baseTrackingAccrued).to.equal(expectedAccrued); + }); + + it('utilization is greater than zero after collateral supply accrual', async () => { + // Active borrow (alice's 400 USDC net position) keeps utilization above zero. + // Supplying collateral does not change totalSupplyBase or totalBorrowBase principals. + expect(await comet.getUtilization()).to.be.greaterThan(0); + }); + + it('utilization after supply collateral matches exact calculation from accrued indices', async () => { + // getUtilization() = presentValue(borrow) * FACTOR_SCALE / presentValue(supply) + // = totalBorrowBase * baseBorrowIndex_new / 1e15 * 1e18 / (totalSupplyBase * baseSupplyIndex_new / 1e15) + const totals = await comet.totalsBasic(); + const totalBorrowPresent = totals.totalBorrowBase.mul(totals.baseBorrowIndex).div(exp(1, 15)); + const totalSupplyPresent = totals.totalSupplyBase.mul(totals.baseSupplyIndex).div(exp(1, 15)); + const expectedUtilization = totalBorrowPresent.mul(exp(1, 18)).div(totalSupplyPresent); + expect(await comet.getUtilization()).to.equal(expectedUtilization); + + // restore state + await snapshot.restore(); + }); + }); }); - it('supplies base the correct amount in a fee-like situation', async () => { - const assets = defaultAssets(); - // Add USDT to assets on top of default assets - assets['USDT'] = { - initial: 1e6, - decimals: 6, - factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, - }; - const protocol = await makeProtocol({ base: 'USDT', assets: assets }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDT } = tokens; - - // Set fee to 0.1% - await (USDT as NonStandardFaucetFeeToken).setParams(10, 10); - - const _i0 = await USDT.allocateTo(bob.address, 1000e6); - const baseAsB = USDT.connect(bob); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _a0 = await wait(baseAsB.approve(comet.address, 1000e6)); - const s0 = await wait(cometAsB.supplyTo(alice.address, USDT.address, 1000e6)); - const t1 = await comet.totalsBasic(); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: BigInt(999e6), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Supply: { - from: bob.address, - dst: alice.address, - amount: BigInt(999e6), - } - }); - expect(event(s0, 2)).to.be.deep.equal({ - Transfer: { - from: ethers.constants.AddressZero, - to: alice.address, - amount: BigInt(999e6), - } - }); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: exp(1000, 6) }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: exp(999, 6) }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase.add(999e6)); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - // Fee Token logics will cost a bit more gas than standard ERC20 token with no fee calculation - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(151000); + describe('supply flows variations (from/to)', function () { + const ALICE_BASE_AMOUNT: BigNumber = ethers.utils.parseUnits('0.05', baseTokenDecimals); //0.05 of base token + const ALICE_COLLATERAL_AMOUNT: BigNumber = ethers.utils.parseUnits('0.2', 18); //0.2 of token + let cometBaseBalanceBefore: BigNumber; + let aliceBaseBalanceBefore: BigNumber; + let cometCollateralBalanceBefore: BigNumber; + let aliceCollateralBalanceBefore: BigNumber; + let aliceCollateralBefore: BigNumber; + let bobCollateralBefore: BigNumber; + + let alicePrincipalBefore: BigNumber; + let bobPrincipalBefore: BigNumber; + let cometSupplyIndexBefore: BigNumber; + + let collateral: FaucetToken | NonStandardFaucetFeeToken; + + before(async function () { + collateral = collaterals['COMP']; + const collateralIndex = (await comet.getAssetInfoByAddress(collateral.address)).offset; + const supplyCap = (await comet.getAssetInfo(collateralIndex)).supplyCap; + await collateral.allocateTo(alice.address, supplyCap.add(exp(1, 18))); + await collateral.allocateTo(bob.address, exp(1e10, 18)); + + const totals = await comet.totalsBasic(); + cometBaseBalanceBefore = await baseToken.balanceOf(comet.address); + aliceBaseBalanceBefore = await baseToken.balanceOf(alice.address); + cometCollateralBalanceBefore = await collateral.balanceOf(comet.address); + aliceCollateralBalanceBefore = await collateral.balanceOf(alice.address); + + aliceCollateralBefore = (await comet.userCollateral(alice.address, collateral.address)).balance; + bobCollateralBefore = (await comet.userCollateral(bob.address, collateral.address)).balance; + + cometSupplyIndexBefore = totals.baseSupplyIndex; + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal; + bobPrincipalBefore = (await comet.userBasic(bob.address)).principal; + + // wait for a while to have impact from accrual + await ethers.provider.send('evm_increaseTime', [60 * 60]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + describe('supplyTo', function () { + it('reverts for asset other than base of collateral', async () => { + await unsupportedToken.allocateTo(alice.address, exp(1, 18)); + await unsupportedToken.connect(alice).approve(comet.address, exp(1, 18)); + await expect(comet.connect(alice).supplyTo(bob.address, unsupportedToken.address, 1)).to.be.revertedWithCustomError(comet, 'BadAsset'); + }); + + it('reverts when protocol paused', async () => { + await comet.connect(pauseGuardian).pause(true, false, false, false, false); + expect(await comet.isSupplyPaused()).to.be.true; + + await baseToken.connect(alice).approve(comet.address, 1); + await expect(comet.connect(alice).supplyTo(bob.address, baseToken.address, 1)).to.be.revertedWithCustomError(comet, 'Paused'); + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if base supply is paused', async () => { + await comet.connect(pauseGuardian).pauseBaseSupply(true); + expect(await comet.isBaseSupplyPaused()).to.be.true; + + await expect(comet.connect(alice).supplyTo(bob.address, baseToken.address, 1)).to.be.revertedWithCustomError(comet, 'BaseSupplyPaused'); + await comet.connect(pauseGuardian).pauseBaseSupply(false); + }); + + it('should accrue state (same as supply())', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_AMOUNT); + await comet.connect(alice).supplyTo(bob.address, baseToken.address, ALICE_BASE_AMOUNT); + + expect((await comet.totalsBasic()).lastAccrualTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + // correctness of index calculation is already checked in previous testcases + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(cometSupplyIndexBefore); + + await snapshot.restore(); + }); + + it('should supply base asset to the dst', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_AMOUNT); + await comet.connect(alice).supplyTo(bob.address, baseToken.address, ALICE_BASE_AMOUNT); + + // token is transferred + expect(aliceBaseBalanceBefore.sub(await baseToken.balanceOf(alice.address))).to.equal(ALICE_BASE_AMOUNT); + expect((await baseToken.balanceOf(comet.address)).sub(cometBaseBalanceBefore)).to.equal(ALICE_BASE_AMOUNT); + + // alice principal is unchanged + const alicePrincipalAfter = (await comet.userBasic(alice.address)).principal; + expect(alicePrincipalBefore.sub(alicePrincipalAfter)).to.equal(0); + + // bob's princiapl grows + // correctness of principal calculation is already checked in previous testcases + expect((await comet.userBasic(bob.address)).principal).to.be.greaterThan(bobPrincipalBefore); + + await snapshot.restore(); + }); + + it('should supply base asset if dst == msg.sender', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_AMOUNT); + await comet.connect(alice).supplyTo(alice.address, baseToken.address, ALICE_BASE_AMOUNT); + + // token is transferred + expect(aliceBaseBalanceBefore.sub(await baseToken.balanceOf(alice.address))).to.equal(ALICE_BASE_AMOUNT); + expect((await baseToken.balanceOf(comet.address)).sub(cometBaseBalanceBefore)).to.equal(ALICE_BASE_AMOUNT); + + // alice principal is grows + // correctness of principal calculation is already checked in previous testcases + expect((await comet.userBasic(alice.address)).principal).to.be.greaterThan(alicePrincipalBefore); + + await snapshot.restore(); + }); + + it('should supply collateral asset to the dst', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supplyTo(bob.address, collateral.address, ALICE_COLLATERAL_AMOUNT); + + // token is transferred + expect(aliceCollateralBalanceBefore.sub(await collateral.balanceOf(alice.address))).to.equal(ALICE_COLLATERAL_AMOUNT); + expect((await collateral.balanceOf(comet.address)).sub(cometCollateralBalanceBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + // alice collateral balance is unchanged + const aliceCollateralAfter = (await comet.userCollateral(alice.address, collateral.address)).balance; + expect(aliceCollateralBefore.sub(aliceCollateralAfter)).to.equal(0); + + // bob's collateral balance grows + const bobCollateralAfter = (await comet.userCollateral(bob.address, collateral.address)).balance; + expect(bobCollateralAfter.sub(bobCollateralBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + await snapshot.restore(); + }); + + it('should supply collateral asset if dst == msg.sender', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supplyTo(alice.address, collateral.address, ALICE_COLLATERAL_AMOUNT); + + // token is transferred + expect(aliceCollateralBalanceBefore.sub(await collateral.balanceOf(alice.address))).to.equal(ALICE_COLLATERAL_AMOUNT); + expect((await collateral.balanceOf(comet.address)).sub(cometCollateralBalanceBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + // alice's collateral balance grows + const aliceCollateralAfter = (await comet.userCollateral(alice.address, collateral.address)).balance; + expect(aliceCollateralAfter.sub(aliceCollateralBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + await snapshot.restore(); + }); + }); + + describe('supplyFrom', function () { + // Note: tests assume, that supplyFrom() is a clone of supply(), thus only key cases are checked + it('allows supply to zero address (burns tokens)', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.allocateTo(alice.address, 1); + await baseToken.connect(alice).approve(comet.address, 1); + + await expect(comet.connect(alice).supplyFrom(alice.address, ethers.constants.AddressZero, baseToken.address, 1)) + .to.emit(comet, 'Supply') + .withArgs(alice.address, ethers.constants.AddressZero, 1); + + await snapshot.restore(); + }); + + it('reverts for asset other than base of collateral', async () => { + await unsupportedToken.allocateTo(alice.address, exp(1, 18)); + await unsupportedToken.connect(alice).approve(comet.address, exp(1, 18)); + await expect(comet.connect(alice).supplyFrom(alice.address, bob.address, unsupportedToken.address, 1)).to.be.revertedWithCustomError( + comet, + 'BadAsset' + ); + }); + + it('reverts when protocol paused', async () => { + await comet.connect(pauseGuardian).pause(true, false, false, false, false); + expect(await comet.isSupplyPaused()).to.be.true; + + await baseToken.connect(alice).approve(comet.address, 1); + await expect(comet.connect(alice).supplyFrom(alice.address, bob.address, baseToken.address, 1)).to.be.revertedWithCustomError( + comet, + 'Paused' + ); + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if base supply is paused', async () => { + await comet.connect(pauseGuardian).pauseBaseSupply(true); + expect(await comet.isBaseSupplyPaused()).to.be.true; + + await expect(comet.connect(alice).supplyFrom(alice.address, bob.address, baseToken.address, 1)).to.be.revertedWithCustomError(comet, 'BaseSupplyPaused'); + await comet.connect(pauseGuardian).pauseBaseSupply(false); + }); + + it('should accrue state (same as supply())', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_AMOUNT); + await comet.connect(alice).supplyFrom(alice.address, bob.address, baseToken.address, ALICE_BASE_AMOUNT); + + expect((await comet.totalsBasic()).lastAccrualTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + // correctness of index calculation is already checked in previous testcases + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(cometSupplyIndexBefore); + + await snapshot.restore(); + }); + + it('should supply base asset to the dst', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_AMOUNT); + await comet.connect(alice).supplyFrom(alice.address, bob.address, baseToken.address, ALICE_BASE_AMOUNT); + + // token is transferred + expect(aliceBaseBalanceBefore.sub(await baseToken.balanceOf(alice.address))).to.equal(ALICE_BASE_AMOUNT); + expect((await baseToken.balanceOf(comet.address)).sub(cometBaseBalanceBefore)).to.equal(ALICE_BASE_AMOUNT); + + // alice principal is unchanged + const alicePrincipalAfter = (await comet.userBasic(alice.address)).principal; + expect(alicePrincipalBefore.sub(alicePrincipalAfter)).to.equal(0); + + // bob's princiapl grows + // correctness of principal calculation is already checked in previous testcases + expect((await comet.userBasic(bob.address)).principal).to.be.greaterThan(bobPrincipalBefore); + + await snapshot.restore(); + }); + + it('should supply base asset if dst == msg.sender', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_AMOUNT); + await comet.connect(alice).supplyFrom(alice.address, alice.address, baseToken.address, ALICE_BASE_AMOUNT); + + // token is transferred + expect(aliceBaseBalanceBefore.sub(await baseToken.balanceOf(alice.address))).to.equal(ALICE_BASE_AMOUNT); + expect((await baseToken.balanceOf(comet.address)).sub(cometBaseBalanceBefore)).to.equal(ALICE_BASE_AMOUNT); + + // alice principal is grows + // correctness of principal calculation is already checked in previous testcases + expect((await comet.userBasic(alice.address)).principal).to.be.greaterThan(alicePrincipalBefore); + + await snapshot.restore(); + }); + + it('should supply collateral asset to the dst', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supplyFrom(alice.address, bob.address, collateral.address, ALICE_COLLATERAL_AMOUNT); + + // token is transferred + expect(aliceCollateralBalanceBefore.sub(await collateral.balanceOf(alice.address))).to.equal(ALICE_COLLATERAL_AMOUNT); + expect((await collateral.balanceOf(comet.address)).sub(cometCollateralBalanceBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + // alice collateral balance is unchanged + const aliceCollateralAfter = (await comet.userCollateral(alice.address, collateral.address)).balance; + expect(aliceCollateralBefore.sub(aliceCollateralAfter)).to.equal(0); + + // bob's collateral balance grows + const bobCollateralAfter = (await comet.userCollateral(bob.address, collateral.address)).balance; + expect(bobCollateralAfter.sub(bobCollateralBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + await snapshot.restore(); + }); + + it('should supply collateral asset if dst == msg.sender', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await collateral.connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supplyFrom(alice.address, alice.address, collateral.address, ALICE_COLLATERAL_AMOUNT); + + // token is transferred + expect(aliceCollateralBalanceBefore.sub(await collateral.balanceOf(alice.address))).to.equal(ALICE_COLLATERAL_AMOUNT); + expect((await collateral.balanceOf(comet.address)).sub(cometCollateralBalanceBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + // alice's collateral balance grows + const aliceCollateralAfter = (await comet.userCollateral(alice.address, collateral.address)).balance; + expect(aliceCollateralAfter.sub(aliceCollateralBefore)).to.equal(ALICE_COLLATERAL_AMOUNT); + + await snapshot.restore(); + }); + }); }); - it('supplies collateral the correct amount in a fee-like situation', async () => { - const assets = defaultAssets(); - // Add FeeToken Collateral to assets on top of default assets - assets['FeeToken'] = { - initial: 1e8, - decimals: 18, - factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, - }; - - const protocol = await makeProtocol({ base: 'USDC', assets: assets }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { FeeToken } = tokens; - - // Set fee to 0.1% - await (FeeToken as NonStandardFaucetFeeToken).setParams(10, 10); - - const _i0 = await FeeToken.allocateTo(bob.address, 2000e8); - const baseAsB = FeeToken.connect(bob); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsCollateral(FeeToken.address); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _a0 = await wait(baseAsB.approve(comet.address, 2000e8)); - const s0 = await wait(cometAsB.supplyTo(alice.address, FeeToken.address, 2000e8)); - const t1 = await comet.totalsCollateral(FeeToken.address); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: comet.address, - amount: BigInt(1998e8), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - SupplyCollateral: { - from: bob.address, - dst: alice.address, - asset: FeeToken.address, - amount: BigInt(1998e8), - } - }); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: exp(2000, 8) }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: exp(1998, 8) }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); - expect(t1.totalSupplyAsset).to.be.equal(t0.totalSupplyAsset.add(1998e8)); - // Fee Token logics will cost a bit more gas than standard ERC20 token with no fee calculation - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(186000); + describe('supply 24 collaterals', function () { + const SUPPLY_COLLATERAL_AMOUNT: bigint = exp(1, 18); + let supplyTxs: ContractTransaction[] = []; + let alicePrincipalBefore: BigNumber; + let davePrincipalBefore: BigNumber; + + before(async () => { + alicePrincipalBefore = (await cometWith24Collaterals.userBasic(alice.address)).principal; + davePrincipalBefore = (await cometWith24Collaterals.userBasic(dave.address)).principal; + }); + + describe('pause can be set for each collateral', function () { + it('should allow to pause each collateral supply', async () => { + for (let i = 0; i < MAX_ASSETS; i++) { + await cometWith24Collaterals.connect(pauseGuardian).pauseCollateralAssetSupply(i, true); + expect(await cometWith24Collaterals.isCollateralAssetSupplyPaused(i)).to.be.true; + } + }); + + it('should revert if specific collateral supply is paused', async () => { + for (let i = 0; i < MAX_ASSETS; i++) { + await tokensWith24Collaterals[`ASSET${i}`].allocateTo(alice.address, SUPPLY_COLLATERAL_AMOUNT); + await tokensWith24Collaterals[`ASSET${i}`].connect(alice).approve(cometWith24Collaterals.address, SUPPLY_COLLATERAL_AMOUNT); + await expect(cometWith24Collaterals.connect(alice).supply(tokensWith24Collaterals[`ASSET${i}`].address, SUPPLY_COLLATERAL_AMOUNT)).to.be.revertedWithCustomError(cometWith24Collaterals, 'CollateralAssetSupplyPaused').withArgs(i); + } + }); + + it('should allow to unpause each collateral supply', async () => { + for (let i = 0; i < MAX_ASSETS; i++) { + await cometWith24Collaterals.connect(pauseGuardian).pauseCollateralAssetSupply(i, false); + expect(await cometWith24Collaterals.isCollateralAssetSupplyPaused(i)).to.be.false; + } + }); + }); + + describe('supply', function () { + this.afterAll(async () => snapshot.restore()); + + it(`each collateral supply is successful`, async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + await asset.allocateTo(alice.address, SUPPLY_COLLATERAL_AMOUNT); + await asset.connect(alice).approve(cometWith24Collaterals.address, SUPPLY_COLLATERAL_AMOUNT); + const supplyTx = await cometWith24Collaterals.connect(alice).supply(asset.address, SUPPLY_COLLATERAL_AMOUNT); + expect(supplyTx).to.not.be.reverted; + supplyTxs.push(supplyTx); + } + }); + + it(`SupplyCollateral event is emitted for each collateral`, async () => { + for (let i = 0; i < supplyTxs.length; i++) { + await expect(supplyTxs[i]) + .to.emit(cometWith24Collaterals, 'SupplyCollateral') + .withArgs(alice.address, alice.address, Object.values(tokensWith24Collaterals)[i].address, SUPPLY_COLLATERAL_AMOUNT); + } + // reset supplyTxs + supplyTxs = []; + }); + + it(`each collateral balance is equal to supplied amount`, async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(await cometWith24Collaterals.collateralBalanceOf(alice.address, asset.address)).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice asset list contains all collaterals', async () => { + const assetList = await cometWith24Collaterals.getAssetList(alice.address); + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(assetList).to.include(asset.address); + } + }); + + it('each collateral comet total supplied collateral amount is equal to alice supplied amount', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect((await cometWith24Collaterals.totalsCollateral(asset.address)).totalSupplyAsset).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice principal is not changed', async () => { + expect((await cometWith24Collaterals.userBasic(alice.address)).principal).to.be.equal(alicePrincipalBefore); + }); + }); + + describe('supplyTo', function () { + before(async () => { + await cometWith24Collaterals.connect(dave).allow(alice.address, true); + }); + + this.afterAll(async () => snapshot.restore()); + + it(`each collateral supplyTo is successful`, async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + await asset.allocateTo(alice.address, SUPPLY_COLLATERAL_AMOUNT); + await asset.connect(alice).approve(cometWith24Collaterals.address, SUPPLY_COLLATERAL_AMOUNT); + const supplyToTx = await cometWith24Collaterals.connect(alice).supplyTo(dave.address, asset.address, SUPPLY_COLLATERAL_AMOUNT); + expect(supplyToTx).to.not.be.reverted; + supplyTxs.push(supplyToTx); + } + }); + + it(`SupplyCollateral event is emitted for each collateral`, async () => { + const assets = Object.values(tokensWith24Collaterals); + for (let i = 0; i < assets.length; i++) { + await expect(supplyTxs[i]) + .to.emit(cometWith24Collaterals, 'SupplyCollateral') + .withArgs(alice.address, dave.address, assets[i].address, SUPPLY_COLLATERAL_AMOUNT); + } + // reset supplyTxs + supplyTxs = []; + }); + + it(`each collateral balance for dave is equal to supplied amount`, async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(await cometWith24Collaterals.collateralBalanceOf(dave.address, asset.address)).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('dave asset list contains all collaterals', async () => { + const assetList = await cometWith24Collaterals.getAssetList(dave.address); + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(assetList).to.include(asset.address); + } + }); + + it('each collateral comet total supplied collateral amount is equal to alice supplied amount', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect((await cometWith24Collaterals.totalsCollateral(asset.address)).totalSupplyAsset).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('dave principal is not changed', async () => { + expect((await cometWith24Collaterals.userBasic(dave.address)).principal).to.be.equal(davePrincipalBefore); + }); + }); + + describe('supplyFrom', function () { + before(async () => { + await cometWith24Collaterals.connect(alice).allow(dave.address, true); + }); + + this.afterAll(async () => snapshot.restore()); + + it(`each collateral supplyFrom is successful`, async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + await asset.allocateTo(alice.address, SUPPLY_COLLATERAL_AMOUNT); + await asset.connect(alice).approve(cometWith24Collaterals.address, SUPPLY_COLLATERAL_AMOUNT); + const supplyFromTx = await cometWith24Collaterals.connect(dave).supplyFrom(alice.address, alice.address, asset.address, SUPPLY_COLLATERAL_AMOUNT); + expect(supplyFromTx).to.not.be.reverted; + supplyTxs.push(supplyFromTx); + } + }); + + it(`SupplyCollateral event is emitted for each collateral`, async () => { + const assets = Object.values(tokensWith24Collaterals); + for (let i = 0; i < assets.length; i++) { + await expect(supplyTxs[i]) + .to.emit(cometWith24Collaterals, 'SupplyCollateral') + .withArgs(alice.address, alice.address, assets[i].address, SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it(`each collateral balance for alice is equal to supplied amount`, async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(await cometWith24Collaterals.collateralBalanceOf(alice.address, asset.address)).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice asset list contains all collaterals', async () => { + const assetList = await cometWith24Collaterals.getAssetList(alice.address); + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(assetList).to.include(asset.address); + } + }); + + it('each collateral comet total supplied collateral amount is equal to alice supplied amount', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect((await cometWith24Collaterals.totalsCollateral(asset.address)).totalSupplyAsset).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice principal is not changed', async () => { + expect((await cometWith24Collaterals.userBasic(alice.address)).principal).to.be.equal(alicePrincipalBefore); + }); + }); }); - it('blocks reentrancy from exceeding the supply cap', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol({ - assets: { - USDC: { - decimals: 6 - }, - EVIL: { + describe('non-standard tokens', function () { + describe('USDT-like token', function () { + let comet: CometHarnessInterface; + let alice: SignerWithAddress; + let usdt: NonStandardFaucetFeeToken; + let nonStdCollateral: NonStandardFaucetFeeToken; + const USDT_AMOUNT = exp(1, 6); + const NON_STD_COLLATERAL_AMOUNT = exp(1, 18); + + before(async function () { + const assets = defaultAssets(); + assets['USDT'] = { + initial: 1e6, decimals: 6, - initialPrice: 2, - factory: await ethers.getContractFactory('EvilToken') as EvilToken__factory, - supplyCap: 100e6 - } - } - }); - const { EVIL } = <{ EVIL: EvilToken }>tokens; - - const attack = Object.assign({}, await EVIL.getAttack(), { - attackType: ReentryAttack.SupplyFrom, - source: alice.address, - destination: bob.address, - asset: EVIL.address, - amount: 75e6, - maxCalls: 1 - }); - await EVIL.setAttack(attack); - - await comet.connect(alice).allow(EVIL.address, true); - await wait(EVIL.connect(alice).approve(comet.address, 75e6)); - await EVIL.allocateTo(alice.address, 75e6); - await expect( - comet.connect(alice).supplyTo(bob.address, EVIL.address, 75e6) - ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + assets['NonStdCollateral'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + comet = protocol.comet; + alice = protocol.users[0]; + + const tokens = protocol.tokens; + + usdt = tokens['USDT'] as NonStandardFaucetFeeToken; + nonStdCollateral = tokens['NonStdCollateral'] as NonStandardFaucetFeeToken; + }); + + it('can supply base token - non-standard ERC20 (without return interface) e.g. USDT', async () => { + await usdt.allocateTo(alice.address, USDT_AMOUNT); + + await usdt.connect(alice).approve(comet.address, USDT_AMOUNT); + await expect(comet.connect(alice).supply(usdt.address, USDT_AMOUNT)).to.not.be.reverted; + + // as per the initial test case, 1st deposit will end with the same principal + expect((await comet.userBasic(alice.address)).principal).to.equal(USDT_AMOUNT); + }); + + it('can supply collateral - non-standard ERC20 (without return interface) e.g. USDT', async () => { + await nonStdCollateral.allocateTo(alice.address, NON_STD_COLLATERAL_AMOUNT); + + await nonStdCollateral.connect(alice).approve(comet.address, NON_STD_COLLATERAL_AMOUNT); + await expect(comet.connect(alice).supply(nonStdCollateral.address, NON_STD_COLLATERAL_AMOUNT)).to.not.be.reverted; + + expect((await comet.userCollateral(alice.address, nonStdCollateral.address)).balance).to.equal(NON_STD_COLLATERAL_AMOUNT); + }); + }); + + describe('fee-on-transfer token', function () { + const BASE_TOKEN_AMOUNT = exp(1, 6); + const COLLATERAL_TOKEN_AMOUNT = exp(0.5, 18); + const NUMERATOR = 10; + const DENOMINATOR = 10000; + let feeComet: CometHarnessInterface; + let feeBaseToken: NonStandardFaucetFeeToken; + let feeCollateral: NonStandardFaucetFeeToken; + let alice: SignerWithAddress; + let baseTokenFeeTx: ContractTransaction; + let collateralFeeTx: ContractTransaction; + + before(async function () { + const assets = defaultAssets(); + assets['USDT'] = { + initial: 1e6, + decimals: 6, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + assets['FeeCollateral'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + + feeComet = protocol.comet; + feeBaseToken = protocol.tokens['USDT'] as NonStandardFaucetFeeToken; + feeCollateral = protocol.tokens['FeeCollateral'] as NonStandardFaucetFeeToken; + alice = protocol.users[0]; + }); + + it('can supply base token - fee-on-transfer token', async () => { + // Set fee to 0.1% + await feeBaseToken.setParams(10, exp(100, 18)); + + await feeBaseToken.allocateTo(alice.address, BASE_TOKEN_AMOUNT); + const feeBalanceBefore = await feeBaseToken.balanceOf(feeBaseToken.address); + const userBalanceBefore = await feeBaseToken.balanceOf(alice.address); + + const amountDeposited = BigNumber.from(BASE_TOKEN_AMOUNT); + const fee = amountDeposited.mul(NUMERATOR).div(DENOMINATOR); + const amountWithoutFee = amountDeposited.sub(fee); + + await feeBaseToken.connect(alice).approve(feeComet.address, amountDeposited); + baseTokenFeeTx = await feeComet.connect(alice).supply(feeBaseToken.address, amountDeposited); + expect(baseTokenFeeTx).to.not.be.reverted; + + const feeBalanceAfter = await feeBaseToken.balanceOf(feeBaseToken.address); + const userBalanceAfter = await feeBaseToken.balanceOf(alice.address); + + // we are checking that the (amount - fee) is considered as deposit + expect((await feeComet.userBasic(alice.address)).principal).to.equal(amountWithoutFee); + + // full amount is charged from user + expect(userBalanceBefore.sub(userBalanceAfter)).to.equal(amountDeposited); + + // commission is in right place + expect(feeBalanceAfter.sub(feeBalanceBefore)).to.equal(fee); + }); + + it('correct amount in the Supply event - fee-on-transfer token', async () => { + const amountDeposited = BigNumber.from(BASE_TOKEN_AMOUNT); + const fee = amountDeposited.mul(NUMERATOR).div(DENOMINATOR); + const amountWithoutFee = amountDeposited.sub(fee); + + // event should contain amount without fee - the actual received on the contract + expect(baseTokenFeeTx).to.emit(feeComet, 'Supply').withArgs(alice.address, alice.address, amountWithoutFee.toBigInt()); + }); + + it('can supply collateral token - fee-on-transfer token', async () => { + // Set fee to 0.1% + await feeCollateral.setParams(10, exp(100, 18)); + + await feeCollateral.allocateTo(alice.address, COLLATERAL_TOKEN_AMOUNT); + const feeBalanceBefore = await feeCollateral.balanceOf(feeCollateral.address); + const userBalanceBefore = await feeCollateral.balanceOf(alice.address); + + const amountDeposited = BigNumber.from(COLLATERAL_TOKEN_AMOUNT); + const fee = amountDeposited.mul(NUMERATOR).div(DENOMINATOR); + const amountWithoutFee = amountDeposited.sub(fee); + + await feeCollateral.connect(alice).approve(feeComet.address, amountDeposited); + collateralFeeTx = await feeComet.connect(alice).supply(feeCollateral.address, amountDeposited); + expect(collateralFeeTx).to.not.be.reverted; + + const feeBalanceAfter = await feeCollateral.balanceOf(feeCollateral.address); + const userBalanceAfter = await feeCollateral.balanceOf(alice.address); + + // we are checking that the (amount - fee) is considered as collateral deposit + expect((await feeComet.userCollateral(alice.address, feeCollateral.address)).balance).to.equal(amountWithoutFee); + + // full amount is charged from user + expect(userBalanceBefore.sub(userBalanceAfter)).to.equal(amountDeposited); + + // commission is in right place + expect(feeBalanceAfter.sub(feeBalanceBefore)).to.equal(fee); + }); + + it('correct amount in the SupplyCollateral event - fee-on-transfer token', async () => { + const amountDeposited = BigNumber.from(COLLATERAL_TOKEN_AMOUNT); + const fee = amountDeposited.mul(NUMERATOR).div(DENOMINATOR); + const amountWithoutFee = amountDeposited.sub(fee); + + // event should contain amount without fee - the actual received on the contract + expect(collateralFeeTx).to.emit(feeComet, 'SupplyCollateral').withArgs(alice.address, alice.address, feeCollateral.address, amountWithoutFee.toBigInt()); + }); + }); }); -}); -describe('supply', function () { - it('supplies to sender by default', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [bob] } = protocol; - const { USDC } = tokens; - - const _i0 = await USDC.allocateTo(bob.address, 100e6); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); - - const _t0 = await comet.totalsBasic(); - const q0 = await portfolio(protocol, bob.address); - const _a0 = await wait(baseAsB.approve(comet.address, 100e6)); - const _s0 = await wait(cometAsB.supply(USDC.address, 100e6)); - const _t1 = await comet.totalsBasic(); - const q1 = await portfolio(protocol, bob.address); - - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); + describe('reentrancy protection', function () { + it('blocks reentrancy from exceeding the supply cap', async () => { + const { comet, tokens, users: [alice, bob] } = await makeProtocol({ + assets: { + USDC: { decimals: 6 }, + EVIL: { + decimals: 6, + initialPrice: 2, + factory: await ethers.getContractFactory('EvilToken') as EvilToken__factory, + supplyCap: 100e6 + } + } + }); + const { EVIL } = <{ EVIL: EvilToken }>tokens; + + const attack = Object.assign({}, await EVIL.getAttack(), { + attackType: ReentryAttack.SupplyFrom, + source: alice.address, + destination: bob.address, + asset: EVIL.address, + amount: 75e6, + maxCalls: 1 + }); + await EVIL.setAttack(attack); + + await comet.connect(alice).allow(EVIL.address, true); + await EVIL.connect(alice).approve(comet.address, 75e6); + await EVIL.allocateTo(alice.address, 75e6); + + await expect( + comet.connect(alice).supplyTo(bob.address, EVIL.address, 75e6) + ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); + }); }); - it('reverts if supply is paused', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, pauseGuardian, users: [bob] } = protocol; - const { USDC } = tokens; + /*////////////////////////////////////////////////////////////// + DEACTIVATE COLLATERAL FEATURE + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Supply path behavior when collateral is deactivated and reactivated. + * @dev + * While a collateral is deactivated by the `pauseGuardian`, `supply` of that + * asset reverts with `CollateralAssetSupplyPaused(index)`. After the `governor` + * reactivates it, `supply` succeeds and updates `totalsCollateral` and + * `userCollateral` accordingly. The MAX_ASSETS loop asserts the same + * deactivate-revert / reactivate-succeed behavior for every asset index in a + * full `cometWith24Collaterals` configuration. + * + * Context: in the wUSDM / deUSD incident scenario, deactivation must block any + * new supply of the affected collateral until governance reactivates it. + */ + describe('deactivated token supply flow', function () { + it('allows pause guardian to deactivate a token', async function () { + await expect(comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); + + it('supply call reverts', async function () { + await expect( + comet.connect(bob).supply(collateralToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(deactivatedCollateralIndex); + }); - await USDC.allocateTo(bob.address, 100e6); - const baseAsB = USDC.connect(bob); - const cometAsB = comet.connect(bob); + it('allows governor to activate a token', async function () { + await expect(comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - // Pause supply - await wait(comet.connect(pauseGuardian).pause(true, false, false, false, false)); - expect(await comet.isSupplyPaused()).to.be.true; + it('allows to supply activated collateral', async function () { + await collateralToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await collateralToken.connect(bob).approve(comet.address, collateralTokenSupplyAmount); + await expect(comet.connect(bob).supply(collateralToken.address, collateralTokenSupplyAmount)).to.not.be.reverted; + }); - await wait(baseAsB.approve(comet.address, 100e6)); - await expect(cometAsB.supply(USDC.address, 100e6)).to.be.revertedWith("custom error 'Paused()'"); - }); -}); + it('updates total supply asset amount in comet', async function () { + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).add(collateralTokenSupplyAmount); + expect((await comet.totalsCollateral(collateralToken.address)).totalSupplyAsset).to.be.equal(expectedTotalSupplyAsset); + }); + + it('updates user collateral in comet', async function () { + const expectedBobUserCollateral = BigNumber.from(bobUserCollateralBefore.balance).add(collateralTokenSupplyAmount); + expect((await comet.userCollateral(bob.address, collateralToken.address)).balance).to.be.equal(expectedBobUserCollateral); + }); + + for(let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; + + it(`reverts on deactivated collateral supply with index ${i}`, async function () { + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); + + const supplyToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + await supplyToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await supplyToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + + await expect( + cometWith24Collaterals.connect(bob).supply(supplyToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(cometWith24Collaterals, 'CollateralAssetSupplyPaused').withArgs(assetIndex); + }); + + it(`allows to supplyTo re-activated collateral with index ${i}`, async function () { + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + const supplyToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; -describe('supplyFrom', function () { - it('supplies from `from` if specified and sender has permission', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; - - const _i0 = await COMP.allocateTo(bob.address, 7); - const baseAsB = COMP.connect(bob); - const cometAsB = comet.connect(bob); - const cometAsC = comet.connect(charlie); - - const _a0 = await wait(baseAsB.approve(comet.address, 7)); - const _a1 = await wait(cometAsB.allow(charlie.address, true)); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _s0 = await wait(cometAsC.supplyFrom(bob.address, alice.address, COMP.address, 7)); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 7n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 7n, WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); + await expect( + cometWith24Collaterals.connect(bob).supply(supplyToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + + expect((await cometWith24Collaterals.userCollateral(bob.address, supplyToken.address)).balance).to.be.equal(collateralTokenSupplyAmount); + }); + } }); - it('reverts if `from` is specified and sender does not have permission', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; + describe('deactivated token supplyTo flow', function () { + it('allows pause guardian to deactivate a token', async function () { + await snapshot.restore(); + + deactivateCollateralTx = await comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex); + await expect(deactivateCollateralTx).to.not.be.reverted; + }); + + it('emits CollateralAssetSupplyPauseAction event with true argument', async function () { + expect(deactivateCollateralTx).to.emit(comet, 'CollateralAssetSupplyPauseAction').withArgs(deactivatedCollateralIndex, true); + }); + + it('emits CollateralDeactivated event', async function () { + expect(deactivateCollateralTx).to.emit(comet, 'CollateralDeactivated').withArgs(deactivatedCollateralIndex); + }); + + it('sets collateral as deactivated in comet', async function () { + expect(await comet.isCollateralDeactivated(deactivatedCollateralIndex)).to.be.true; + }); - const _i0 = await COMP.allocateTo(bob.address, 7); - const cometAsC = comet.connect(charlie); + it('updates collateral supply pause flag in comet storage', async function () { + expect(await comet.isCollateralAssetSupplyPaused(deactivatedCollateralIndex)).to.be.true; + }); - await expect(cometAsC.supplyFrom(bob.address, alice.address, COMP.address, 7)) - .to.be.revertedWith("custom error 'Unauthorized()'"); + it('supplyTo call reverts', async function () { + await expect( + comet.connect(bob).supplyTo(alice.address, collateralToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(deactivatedCollateralIndex); + }); + + it('allows governor to activate a token', async function () { + activateCollateralTx = await comet.connect(governor).activateCollateral(deactivatedCollateralIndex); + await expect(activateCollateralTx).to.not.be.reverted; + }); + + it('emits CollateralAssetSupplyPauseAction event with false argument', async function () { + expect(activateCollateralTx).to.emit(comet, 'CollateralAssetSupplyPauseAction').withArgs(deactivatedCollateralIndex, false); + }); + + it('emits CollateralActivated event', async function () { + expect(activateCollateralTx).to.emit(comet, 'CollateralActivated').withArgs(deactivatedCollateralIndex); + }); + + it('sets collateral as activated in comet', async function () { + expect(await comet.isCollateralDeactivated(deactivatedCollateralIndex)).to.be.false; + }); + + it('updates collateral supply pause flag in comet storage', async function () { + expect(await comet.isCollateralAssetSupplyPaused(deactivatedCollateralIndex)).to.be.false; + }); + + it('allows to supplyTo activated collateral', async function () { + await collateralToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await collateralToken.connect(bob).approve(comet.address, collateralTokenSupplyAmount); + await expect( + comet.connect(bob).supplyTo(alice.address, collateralToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + }); + + it('updates total supply asset amount in comet', async function () { + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).add(collateralTokenSupplyAmount); + expect((await comet.totalsCollateral(collateralToken.address)).totalSupplyAsset).to.be.equal(expectedTotalSupplyAsset); + }); + + it('updates user collateral in comet', async function () { + const expectedAliceUserCollateral = BigNumber.from(aliceUserCollateralBefore.balance).add(collateralTokenSupplyAmount); + expect((await comet.userCollateral(alice.address, collateralToken.address)).balance).to.be.equal(expectedAliceUserCollateral); + }); + + it('updates user collateral in comet', async function () { + expect((await comet.userCollateral(bob.address, collateralToken.address)).balance).to.be.equal(bobUserCollateralBefore.balance); + }); + + for(let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; + + it(`reverts on deactivated collateral supplyTo with index ${i}`, async function () { + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); + + const supplyToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + await supplyToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await supplyToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + + await expect( + cometWith24Collaterals.connect(bob).supplyTo(alice.address, supplyToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(cometWith24Collaterals, 'CollateralAssetSupplyPaused').withArgs(assetIndex); + }); + + it(`allows to supplyTo re-activated collateral with index ${i}`, async function () { + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + const supplyToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + + await expect( + cometWith24Collaterals.connect(bob).supplyTo(alice.address, supplyToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + + expect((await cometWith24Collaterals.userCollateral(bob.address, supplyToken.address)).balance).to.be.equal(bobUserCollateralBefore.balance); + }); + } }); - it('reverts if supply is paused', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, pauseGuardian, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; + describe('deactivated token supplyFrom flow', function () { + it('allows pause guardian to deactivate a token', async function () { + await snapshot.restore(); + + await expect(comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await COMP.allocateTo(bob.address, 7); - const baseAsB = COMP.connect(bob); - const cometAsB = comet.connect(bob); - const cometAsC = comet.connect(charlie); + it('supplyFrom call reverts', async function () { + await comet.connect(bob).allow(alice.address, true); + await expect( + comet.connect(alice).supplyFrom(bob.address, alice.address, collateralToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(deactivatedCollateralIndex); + }); - // Pause supply - await wait(comet.connect(pauseGuardian).pause(true, false, false, false, false)); - expect(await comet.isSupplyPaused()).to.be.true; + it('allows governor to activate a token', async function () { + await expect(comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await wait(baseAsB.approve(comet.address, 7)); - await wait(cometAsB.allow(charlie.address, true)); - await expect(cometAsC.supplyFrom(bob.address, alice.address, COMP.address, 7)).to.be.revertedWith("custom error 'Paused()'"); + it('allows to supplyFrom activated collateral', async function () { + await collateralToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await collateralToken.connect(bob).approve(comet.address, collateralTokenSupplyAmount); + await expect( + comet.connect(alice).supplyFrom(bob.address, alice.address, collateralToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + }); + + it('updates total supply asset amount in comet', async function () { + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).add(collateralTokenSupplyAmount); + expect((await comet.totalsCollateral(collateralToken.address)).totalSupplyAsset).to.be.equal(expectedTotalSupplyAsset); + }); + + it('updates user collateral in comet', async function () { + const expectedAliceUserCollateral = BigNumber.from(aliceUserCollateralBefore.balance).add(collateralTokenSupplyAmount); + expect((await comet.userCollateral(alice.address, collateralToken.address)).balance).to.be.equal(expectedAliceUserCollateral); + }); + + it('updates user collateral in comet', async function () { + expect((await comet.userCollateral(bob.address, collateralToken.address)).balance).to.be.equal(bobUserCollateralBefore.balance); + }); + + for(let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; + + it(`reverts on deactivated collateral supplyFrom with index ${i}`, async function () { + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); + + const supplyToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + await supplyToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await supplyToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).allow(alice.address, true); + + await expect( + cometWith24Collaterals.connect(alice).supplyFrom(bob.address, alice.address, supplyToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(cometWith24Collaterals, 'CollateralAssetSupplyPaused').withArgs(assetIndex); + }); + + it(`allows to supplyFrom re-activated collateral with index ${i}`, async function () { + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + const supplyToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + + await expect( + cometWith24Collaterals.connect(alice).supplyFrom(bob.address, alice.address, supplyToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + + expect((await cometWith24Collaterals.userCollateral(alice.address, supplyToken.address)).balance).to.be.equal(collateralTokenSupplyAmount); + }); + } }); -}); \ No newline at end of file +}); + +async function getPrincipalChange( + comet: CometHarnessInterface, + lastUpdated: number, + utilization: number, + user: string, + amount: BigNumber +): Promise { + const cometExtension: CometExtAssetList = (await ethers.getContractAt('CometExtAssetList', comet.address)) as CometExtAssetList; + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + + const timeElapsed = curTime - lastUpdated; + + const prevIndex = (await cometExtension.totalsBasic()).baseSupplyIndex; + const accruedIndex = prevIndex.add( + prevIndex + .mul(await comet.getSupplyRate(utilization)) + .mul(timeElapsed) + .div(exp(1, 18)) + ); + + const oldPrincipal = (await comet.userBasic(user)).principal; + const oldBalance = oldPrincipal.mul(accruedIndex).div(1e15); + const newPrincipal = oldBalance.add(amount).mul(1e15).div(accruedIndex); + + return newPrincipal.sub(oldPrincipal); +} diff --git a/test/transfer-test.ts b/test/transfer-test.ts index 3d3f411c6..83683710e 100644 --- a/test/transfer-test.ts +++ b/test/transfer-test.ts @@ -1,426 +1,2092 @@ -import { baseBalanceOf, ethers, event, expect, exp, makeProtocol, portfolio, setTotalsBasic, wait, fastForward } from './helpers'; +import { CometHarnessInterfaceExtendedAssetList, FaucetToken, NonStandardFaucetFeeToken, NonStandardFaucetFeeToken__factory } from 'build/types'; +import { ethers, expect, exp, makeProtocol, presentValue, ZERO_ADDRESS, presentValueSupply, mulPrice, mulFactor, defaultAssets, MAX_ASSETS, UserBasic, UserCollateral } from './helpers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { BigNumber, ContractTransaction } from 'ethers'; +import { SnapshotRestorer, takeSnapshot } from './helpers/snapshot'; describe('transfer', function () { - it('transfers base from sender if the asset is base', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { - comet, - tokens, - users: [alice, bob], - } = protocol; - const { USDC } = tokens; - - const _i0 = await comet.setBasePrincipal(bob.address, 100e6); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const s0 = await wait(cometAsB.transferAsset(alice.address, USDC.address, 100e6)); - const t1 = await comet.totalsBasic(); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: ethers.constants.AddressZero, - amount: BigInt(100e6), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Transfer: { - from: ethers.constants.AddressZero, - to: alice.address, - amount: BigInt(100e6), - } + // Constants + const baseTokenDecimals = 6; + // Contracts + let comet: CometHarnessInterfaceExtendedAssetList; + let baseToken: FaucetToken; + let collaterals: { [symbol: string]: FaucetToken } = {}; + let unsupportedToken: FaucetToken; + // Accounts + let users: SignerWithAddress[]; + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let dave: SignerWithAddress; + let pauseGuardian: SignerWithAddress; + let governor: SignerWithAddress; + // Comet parameters + let baseBorrowMin: bigint; + + /*////////////////////////////////////////////////////////////// + 24 COLLATERALS COMET SETUP + //////////////////////////////////////////////////////////////*/ + // Contracts + let cometWith24Collaterals: CometHarnessInterfaceExtendedAssetList; + let tokensWith24Collaterals: { [symbol: string]: FaucetToken } = {}; + // Constants + const baseTokenSupplyAmount = exp(100, 6); + const collateralTokenSupplyAmount = exp(1, 18); + const collateralTokenTransferAmount = collateralTokenSupplyAmount / 4n; + // Storage + let deactivatedCollateralIndex: number; + let aliceCollateralBefore: UserCollateral; + let aliceBasicBefore: UserBasic; + let daveCollateralBefore: UserCollateral; + let daveBasicBefore: UserBasic; + + let collateralToken: FaucetToken; + + // Snapshot + let snapshot: SnapshotRestorer; + + before(async () => { + const protocol = await makeProtocol({ base: 'USDC'}); + comet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens.USDC as FaucetToken; + for (const asset in protocol.tokens) { + if (asset === 'USDC') continue; + collaterals[asset] = protocol.tokens[asset] as FaucetToken; + } + pauseGuardian = protocol.pauseGuardian; + unsupportedToken = protocol.unsupportedToken; + governor = protocol.governor; + users = protocol.users; + [alice, bob, dave] = protocol.users; + + baseBorrowMin = (await comet.baseBorrowMin()).toBigInt(); + + /*////////////////////////////////////////////////////////////// + 24 COLLATERALS COMET SETUP + //////////////////////////////////////////////////////////////*/ + + const collaterals24Assets = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, { + initialPrice: 100, + decimals: 18, + }]) + ); + const protocolWith24Collaterals = await makeProtocol({ + assets: { USDC: {initialPrice: 1, decimals: 6 }, ...collaterals24Assets, }, }); + cometWith24Collaterals = protocolWith24Collaterals.cometWithExtendedAssetList; + for (const asset in protocolWith24Collaterals.tokens) { + if (asset === 'USDC') continue; + tokensWith24Collaterals[asset] = protocolWith24Collaterals.tokens[asset] as FaucetToken; + } + + collateralToken = collaterals['COMP'] as FaucetToken; - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(90000); + const collateralAssetInfo = await comet.getAssetInfoByAddress(collateralToken.address); + deactivatedCollateralIndex = collateralAssetInfo.offset; + + snapshot = await takeSnapshot(); }); - it('does not emit Transfer if 0 mint/burn', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { - comet, - tokens, - users: [alice, bob], - } = protocol; - const { USDC, WETH } = tokens; + describe('base token', function () { + const SUPPLY_AMOUNT:bigint = exp(100, baseTokenDecimals); + const TRANSFER_AMOUNT:bigint = SUPPLY_AMOUNT / 2n; + + before(async () => { + // Allocate base tokens to Alice + await baseToken.allocateTo(alice.address, SUPPLY_AMOUNT); + // Supply base tokens to Comet from Alice + await baseToken.connect(alice).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(alice).supply(baseToken.address, SUPPLY_AMOUNT); + }); - await comet.setCollateralBalance(bob.address, WETH.address, exp(1, 18)); - await comet.setBasePrincipal(alice.address, -100e6); - await setTotalsBasic(comet, { - totalSupplyBase: 100e6, - totalBorrowBase: 100e6, + describe('revert on', function () { + let principal: bigint; + let baseSupplyIndex: bigint; + let baseBorrowIndex: bigint; + + before(async () => { + principal = (await comet.userBasic(alice.address)).principal.toBigInt(); + const totalsBasic = await comet.totalsBasic(); + baseSupplyIndex = totalsBasic.baseSupplyIndex.toBigInt(); + baseBorrowIndex = totalsBasic.baseBorrowIndex.toBigInt(); + }); + + it('self-transfer', async () => { + await expect(comet.connect(alice).transfer(alice.address, SUPPLY_AMOUNT)).to.be.revertedWithCustomError(comet, 'NoSelfTransfer'); + }); + + it('transfer is paused', async () => { + // Pause transfer + await comet.connect(pauseGuardian).pause(false, true, false, false, false); + + await expect(comet.connect(alice).transfer(alice.address, SUPPLY_AMOUNT)).to.be.revertedWithCustomError(comet, 'Paused'); + + // Unpause transfer + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('lenders transfer is paused', async () => { + // Pause lenders transfer + await comet.connect(pauseGuardian).pauseLendersTransfer(true); + + await expect(comet.connect(alice).transfer(bob.address, SUPPLY_AMOUNT)).to.be.revertedWithCustomError(comet, 'LendersTransferPaused'); + + // Unpause lenders transfer + await comet.connect(pauseGuardian).pauseLendersTransfer(false); + }); + + // In case when user has no collateral supplied and lend position + // transfering will revert with BorrowTooSmall, as amount to transfer is greater than + // user's balance, he'll become a borrower and his balance will be negative on 1 wei + // which is less than baseBorrowMin + it('exceeds balance (no collateral supplied & newSrcBalance < baseBorrowMin)', async () => { + const amountToTransfer = SUPPLY_AMOUNT + 1n; + const srcBalance = presentValue(principal, baseSupplyIndex, baseBorrowIndex) - amountToTransfer; + + // Ensure -srcBalance < baseBorrowMin + expect(baseBorrowMin).to.be.greaterThan(-srcBalance); + + await expect(comet.connect(alice).transfer(bob.address, SUPPLY_AMOUNT + 1n)).to.be.revertedWithCustomError(comet, 'BorrowTooSmall'); + }); + + // In case when user has no collateral supplied and lend position + // transfering will revert with NotCollateralized, as amount to transfer is greater than + // user's balance, he'll become a borrower and his amount to borrow will be >= to baseBorrowMin + // which will trigger NotCollateralized + it('exceeds balance (no collateral supplied & newSrcBalance >= baseBorrowMin)', async () => { + const amountToTransfer = SUPPLY_AMOUNT + baseBorrowMin; + const srcBalance = presentValue(principal, baseSupplyIndex, baseBorrowIndex) - amountToTransfer; + + // Ensure -srcBalance >= baseBorrowMin + expect(baseBorrowMin).to.lessThanOrEqual(-srcBalance); + + await expect(comet.connect(alice).transfer(bob.address, amountToTransfer)).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + }); + + it('borrowers transfer is paused', async () => { + // Pause borrowers transfer + await comet.connect(pauseGuardian).pauseBorrowersTransfer(true); + + const baseBorrowMin = (await comet.baseBorrowMin()).toBigInt(); + // Transfer will make Alice a borrower, so amount to transfer is greater than her balance + const transferAmount = SUPPLY_AMOUNT + baseBorrowMin; + await expect(comet.connect(alice).transfer(bob.address, transferAmount)).to.be.revertedWithCustomError(comet, 'BorrowersTransferPaused'); + + // Unpause borrowers transfer + await comet.connect(pauseGuardian).pauseBorrowersTransfer(false); + }); }); - const cometAsB = comet.connect(bob); + describe('happy path (without interest)', function () { + let alicePrincipalBefore: bigint; + let bobPrincipalBefore: bigint; + + let transferTx: ContractTransaction; + + let totalSupplyBaseBefore: bigint; + let totalBorrowBaseBefore: bigint; + let baseSupplyIndex: bigint; + + before(async () => { + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal.toBigInt(); + bobPrincipalBefore = (await comet.userBasic(bob.address)).principal.toBigInt(); + const totalsBasic = await comet.totalsBasic(); + totalSupplyBaseBefore = totalsBasic.totalSupplyBase.toBigInt(); + totalBorrowBaseBefore = totalsBasic.totalBorrowBase.toBigInt(); + baseSupplyIndex = totalsBasic.baseSupplyIndex.toBigInt(); + }); + + it('alice has principal equal to supplied amount', async () => { + expect(alicePrincipalBefore).to.equal(SUPPLY_AMOUNT); + }); + + it('bob has 0 principal', async () => { + expect(bobPrincipalBefore).to.equal(0n); + }); + + it('alice has 0 borrow balance', async () => { + expect(await comet.borrowBalanceOf(alice.address)).to.equal(0n); + }); + + it('bob has 0 borrow balance', async () => { + expect(await comet.borrowBalanceOf(bob.address)).to.equal(0n); + }); + + it('alice balanceOf equals to supplied amount', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(SUPPLY_AMOUNT); + }); + + it('bob balanceOf equals to 0', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(0n); + }); + + it('total supply base equals to supplied amount', async () => { + expect(totalSupplyBaseBefore).to.equal(SUPPLY_AMOUNT); + }); + + it('total borrow base equals to 0', async () => { + expect(totalBorrowBaseBefore).to.equal(0n); + }); + + it('transfer is successful', async () => { + transferTx = await comet.connect(alice).transfer(bob.address, TRANSFER_AMOUNT); + await expect(transferTx).to.not.be.reverted; + }); + + it('accrue interest', async () => { + expect((await comet.totalsBasic()).lastAccrualTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + }); + + it('alice princiapal decreased by transfer amount', async () => { + const alicePrincipalAfter = (await comet.userBasic(alice.address)).principal.toBigInt(); + expect(alicePrincipalAfter).to.equal(alicePrincipalBefore - TRANSFER_AMOUNT); + }); + + it('bob principal increased by transfer amount', async () => { + const bobPrincipalAfter = (await comet.userBasic(bob.address)).principal.toBigInt(); + expect(bobPrincipalAfter).to.equal(bobPrincipalBefore + TRANSFER_AMOUNT); + }); + + it('alice balanceOf becomes transferred amount', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(TRANSFER_AMOUNT); + }); + + it('bob balanceOf becomes transferred amount', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(TRANSFER_AMOUNT); + }); + + it('alice borrow balance is not changed', async () => { + expect(await comet.borrowBalanceOf(alice.address)).to.equal(0n); + }); + + it('bob borrow balance is not changed', async () => { + expect(await comet.borrowBalanceOf(bob.address)).to.equal(0n); + }); + + it('total supply base is not changed', async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalSupplyBaseBefore); + }); + + it('total borrow base is not changed', async () => { + expect((await comet.totalsBasic()).totalBorrowBase).to.equal(totalBorrowBaseBefore); + }); + + it('emits Transfer event for alice', async () => { + await expect(transferTx) + .to.emit(comet, 'Transfer') + .withArgs(alice.address, ZERO_ADDRESS, presentValueSupply(baseSupplyIndex, TRANSFER_AMOUNT)); + }); + + it('emits Transfer event for bob', async () => { + await expect(transferTx) + .to.emit(comet, 'Transfer') + .withArgs(ZERO_ADDRESS, bob.address, presentValueSupply(baseSupplyIndex, TRANSFER_AMOUNT)); + }); + }); - const s0 = await wait(cometAsB.transferAsset(alice.address, USDC.address, 100e6)); + describe('max balance variations', function () { + describe('without interest', function () { + let alicePrincipalBefore: bigint; + let bobPrincipalBefore: bigint; + + let transferTx: ContractTransaction; + + let totalSupplyBaseBefore: bigint; + let totalBorrowBaseBefore: bigint; + let baseSupplyIndex: bigint; + + before(async () => { + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal.toBigInt(); + bobPrincipalBefore = (await comet.userBasic(bob.address)).principal.toBigInt(); + const totalsBasic = await comet.totalsBasic(); + totalSupplyBaseBefore = totalsBasic.totalSupplyBase.toBigInt(); + totalBorrowBaseBefore = totalsBasic.totalBorrowBase.toBigInt(); + baseSupplyIndex = totalsBasic.baseSupplyIndex.toBigInt(); + }); + + it('alice has principal equal to supplied amount', async () => { + expect(alicePrincipalBefore).to.equal(TRANSFER_AMOUNT); + }); + + it('bob has 0 principal', async () => { + expect(bobPrincipalBefore).to.equal(TRANSFER_AMOUNT); + }); + + it('alice has 0 borrow balance', async () => { + expect(await comet.borrowBalanceOf(alice.address)).to.equal(0n); + }); + + it('bob has 0 borrow balance', async () => { + expect(await comet.borrowBalanceOf(bob.address)).to.equal(0n); + }); + + it('alice balanceOf equals to transferred amount', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(TRANSFER_AMOUNT); + }); + + it('bob balanceOf equals to transferred amount', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(TRANSFER_AMOUNT); + }); + + it('total supply base equals to supplied amount', async () => { + expect(totalSupplyBaseBefore).to.equal(SUPPLY_AMOUNT); + }); + + it('total borrow base equals to 0', async () => { + expect(totalBorrowBaseBefore).to.equal(0n); + }); + + it('transfer is successful', async () => { + transferTx = await comet.connect(alice).transfer(bob.address, ethers.constants.MaxUint256); + await expect(transferTx).to.not.be.reverted; + }); + + it('alice princiapal becomes 0', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(0n); + }); + + it('bob principal increased by transfer amount', async () => { + const bobPrincipalAfter = (await comet.userBasic(bob.address)).principal.toBigInt(); + expect(bobPrincipalAfter).to.equal(bobPrincipalBefore + TRANSFER_AMOUNT); + }); + + it('alice balanceOf becomes 0', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(0n); + }); + + it('bob balanceOf becomes alice supplied amount', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(SUPPLY_AMOUNT); + }); + + it('alice borrow balance is not changed', async () => { + expect(await comet.borrowBalanceOf(alice.address)).to.equal(0n); + }); + + it('bob borrow balance is not changed', async () => { + expect(await comet.borrowBalanceOf(bob.address)).to.equal(0n); + }); + + it('total supply base is not changed', async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalSupplyBaseBefore); + }); + + it('total borrow base is not changed', async () => { + expect((await comet.totalsBasic()).totalBorrowBase).to.equal(totalBorrowBaseBefore); + }); + + it('emits Transfer event for alice', async () => { + await expect(transferTx) + .to.emit(comet, 'Transfer') + .withArgs(alice.address, ZERO_ADDRESS, presentValueSupply(baseSupplyIndex, TRANSFER_AMOUNT)); + }); + + it('emits Transfer event for bob', async () => { + await expect(transferTx) + .to.emit(comet, 'Transfer') + .withArgs(ZERO_ADDRESS, bob.address, presentValueSupply(baseSupplyIndex, TRANSFER_AMOUNT)); + }); + }); + + describe('with accrued interest', function () { + const interestRateParams = { + supplyKink: exp(0.8, 18), + supplyInterestRateBase: exp(0.01, 18), + supplyInterestRateSlopeLow: exp(0.04, 18), + supplyInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowInterestRateBase: exp(0.01, 18), + borrowInterestRateSlopeLow: exp(0.05, 18), + borrowInterestRateSlopeHigh: exp(0.3, 18), + }; + const SUPPLY_AMOUNT:bigint = exp(100, baseTokenDecimals); + + let testComet: CometHarnessInterfaceExtendedAssetList; + let testBaseToken: FaucetToken; + + let alice: SignerWithAddress; + let bob: SignerWithAddress; + + let newAlicePrincipal: BigNumber; + let newAliceBalanceOf: BigNumber; + let bobPrincipalBefore: BigNumber; + + let earnedInterest: bigint; + + let transferTx: ContractTransaction; + + before(async () => { + const protocol = await makeProtocol({ ...interestRateParams, base: 'USDC'}); + testComet = protocol.cometWithExtendedAssetList; + testBaseToken = protocol.tokens.USDC as FaucetToken; + + [alice, bob] = protocol.users; + + // Allocate tokens to Alice + await testBaseToken.allocateTo(alice.address, SUPPLY_AMOUNT); + + // Supply base tokens to Comet from Alice + await testBaseToken.connect(alice).approve(testComet.address, SUPPLY_AMOUNT); + await testComet.connect(alice).supply(testBaseToken.address, SUPPLY_AMOUNT); + }); + + it('alice has principal equal to supplied amount', async () => { + newAlicePrincipal = (await testComet.userBasic(alice.address)).principal; + expect(newAlicePrincipal).to.be.approximately(SUPPLY_AMOUNT, 1n); // 1 wei precision + }); + + it('bob has 0 principal', async () => { + bobPrincipalBefore = (await testComet.userBasic(bob.address)).principal; + expect(bobPrincipalBefore).to.equal(0n); + }); + + it('alice balanceOf equal to supplied amount', async () => { + newAliceBalanceOf = await testComet.balanceOf(alice.address); + expect(newAliceBalanceOf).to.be.approximately(SUPPLY_AMOUNT, 1n); // 1 wei precision + }); + + it('bob balanceOf equal to 0', async () => { + expect(await testComet.balanceOf(bob.address)).to.equal(0n); + }); + + it('alice borrow balance is 0', async () => { + expect(await testComet.borrowBalanceOf(alice.address)).to.equal(0n); + }); + + it('bob borrow balance is 0', async () => { + expect(await testComet.borrowBalanceOf(bob.address)).to.equal(0n); + }); + + it('total supply base is equal to supplied amount', async () => { + expect((await testComet.totalsBasic()).totalSupplyBase).to.be.approximately(SUPPLY_AMOUNT, 1n); // 1 wei precision + }); + + it('total borrow base is equal to 0', async () => { + expect((await testComet.totalsBasic()).totalBorrowBase).to.equal(0n); + }); + + it('wait some time to accrue interest', async () => { + await ethers.provider.send('evm_increaseTime', [60 * 3600]); + await ethers.provider.send('evm_mine', []); + + await testComet.accrueAccount(ZERO_ADDRESS); + }); + + it('alice principal is not changed', async () => { + expect((await testComet.userBasic(alice.address)).principal).to.equal(newAlicePrincipal); + }); + + it('earned interest is not changed', async () => { + const baseSupplyIndex = (await testComet.totalsBasic()).baseSupplyIndex; + earnedInterest = presentValueSupply(baseSupplyIndex, newAlicePrincipal) - SUPPLY_AMOUNT; + expect(earnedInterest).to.equal(0n); + }); + + it('alice balanceOf is increased', async () => { + const updatedAliceBalanceOf = await testComet.balanceOf(alice.address); + expect(updatedAliceBalanceOf).to.be.approximately(newAliceBalanceOf.add(earnedInterest), 1n); // 1 wei precision + newAliceBalanceOf = updatedAliceBalanceOf; + }); + + it('bob principal and balances are not changed after some time', async () => { + expect((await testComet.userBasic(bob.address)).principal).to.equal(bobPrincipalBefore); + expect(await testComet.balanceOf(bob.address)).to.equal(0n); + expect(await testComet.borrowBalanceOf(bob.address)).to.equal(0n); + }); + + it('trasnfer is successful', async () => { + transferTx = await testComet.connect(alice).transfer(bob.address, ethers.constants.MaxUint256); + await expect(transferTx).to.not.be.reverted; + }); + + it('alice principal becomes 0', async () => { + expect((await testComet.userBasic(alice.address)).principal).to.equal(0n); + }); + + it('bob principal becomes alice principal after transfer', async () => { + expect((await testComet.userBasic(bob.address)).principal).to.be.approximately(newAlicePrincipal, 1n); // 1 wei precision + }); + + it('alice balanceOf becomes 0', async () => { + expect(await testComet.balanceOf(alice.address)).to.equal(0n); + }); + + it('bob balanceOf becomes supplied amount + earned interest', async () => { + expect(await testComet.balanceOf(bob.address)).to.be.approximately(SUPPLY_AMOUNT + earnedInterest, 1n); // 1 wei precision + }); + }); + }); - expect(s0.receipt['events'].length).to.be.equal(0); + describe('edge cases', function () { + describe('becomes borrower by transferring amount greater than base balance', function () { + const BORROW_AMOUNT = exp(10, baseTokenDecimals); + const TRANSFER_AMOUNT = SUPPLY_AMOUNT + BORROW_AMOUNT; + const COLLATERAL_AMOUNT = exp(1, 18); // 1 WETH + + let bobPrincipalBefore: bigint; + let alicePrincipalBefore: bigint; + let transferTx: ContractTransaction; + let totalSupplyBaseBefore: bigint; + let totalBorrowBaseBefore: bigint; + let baseSupplyIndex: bigint; + let weth: FaucetToken; + + let snapshot: SnapshotRestorer; + + before(async () => { + // Bob already has base balance (SUPPLY_AMOUNT) from previous "transfer max base balance" describe. + // Supply collateral to bob so he can become a borrower when transferring more than his balance. + weth = collaterals['WETH'] as FaucetToken; + await weth.allocateTo(bob.address, COLLATERAL_AMOUNT); + await weth.connect(bob).approve(comet.address, COLLATERAL_AMOUNT); + await comet.connect(bob).supply(weth.address, COLLATERAL_AMOUNT); + + bobPrincipalBefore = (await comet.userBasic(bob.address)).principal.toBigInt(); + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal.toBigInt(); + const totalsBasic = await comet.totalsBasic(); + totalSupplyBaseBefore = totalsBasic.totalSupplyBase.toBigInt(); + totalBorrowBaseBefore = totalsBasic.totalBorrowBase.toBigInt(); + baseSupplyIndex = totalsBasic.baseSupplyIndex.toBigInt(); + + snapshot = await takeSnapshot(); + }); + + it('bob has base balance equal to supplied amount', async () => { + expect(bobPrincipalBefore).to.equal(SUPPLY_AMOUNT); + }); + + it('alice has 0 principal', async () => { + expect(alicePrincipalBefore).to.equal(0n); + }); + + it('bob has collateral supplied', async () => { + expect(await comet.collateralBalanceOf(bob.address, weth.address)).to.equal(COLLATERAL_AMOUNT); + }); + + it('transfer is successful (bob transfers more than base balance, becomes borrower)', async () => { + transferTx = await comet.connect(bob).transfer(alice.address, TRANSFER_AMOUNT); + await expect(transferTx).to.not.be.reverted; + }); + + it('bob principal is negative (borrow position)', async () => { + expect((await comet.userBasic(bob.address)).principal).to.be.lessThan(0n); + }); + + it('bob borrow balance equals borrow amount', async () => { + expect(await comet.borrowBalanceOf(bob.address)).to.equal(BORROW_AMOUNT); + }); + + it('alice principal increased by transfer amount', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(alicePrincipalBefore + TRANSFER_AMOUNT); + }); + + it('alice balanceOf equals transfer amount', async () => { + expect(await comet.balanceOf(alice.address)).to.equal(TRANSFER_AMOUNT); + }); + + it('bob balanceOf is 0', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(0n); + }); + + it('total supply base increased by borrow amount (alice receives supply, bob withdraws)', async () => { + // Net change: + (SUPPLY_AMOUNT + BORROW_AMOUNT) to alice, - SUPPLY_AMOUNT from bob = + BORROW_AMOUNT + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalSupplyBaseBefore + BORROW_AMOUNT); + }); + + it('total borrow base increased by bob borrow amount', async () => { + expect((await comet.totalsBasic()).totalBorrowBase).to.be.approximately(totalBorrowBaseBefore + BORROW_AMOUNT, 400n); + }); + + it('emits Transfer event for bob (withdraw)', async () => { + await expect(transferTx) + .to.emit(comet, 'Transfer') + .withArgs(bob.address, ZERO_ADDRESS, presentValueSupply(baseSupplyIndex, SUPPLY_AMOUNT)); + }); + + it('emits Transfer event for alice (supply)', async () => { + await expect(transferTx) + .to.emit(comet, 'Transfer') + .withArgs(ZERO_ADDRESS, alice.address, presentValueSupply(baseSupplyIndex, SUPPLY_AMOUNT + BORROW_AMOUNT)); + + await snapshot.restore(); + }); + }); + }); }); - it('transfers max base balance (including accrued) from sender if the asset is base', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(comet.address, 100e6); - await setTotalsBasic(comet, { - totalSupplyBase: 100e6, - totalBorrowBase: 50e6, // non-zero borrow to accrue interest - }); - await comet.setBasePrincipal(bob.address, 100e6); - const cometAsB = comet.connect(bob); - - // Fast forward to accrue some interest - await fastForward(86400); - await ethers.provider.send('evm_mine', []); - - const t0 = await comet.totalsBasic(); - const a0 = await portfolio(protocol, alice.address); - const b0 = await portfolio(protocol, bob.address); - const bobAccruedBalance = (await comet.callStatic.balanceOf(bob.address)).toBigInt(); - const s0 = await wait(cometAsB.transferAsset(alice.address, USDC.address, ethers.constants.MaxUint256)); - const t1 = await comet.totalsBasic(); - const a1 = await portfolio(protocol, alice.address); - const b1 = await portfolio(protocol, bob.address); - - // additional 1 wei burned, amount to clear bob gets alice to same balance - 1 - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: ethers.constants.AddressZero, - amount: bobAccruedBalance, - } + describe('collateral', function () { + const TRANSFER_AMOUNT:bigint = exp(1, 18); + const SKIP_TIME: number = 60 * 60; // 1 hr + let collateral: FaucetToken; + + before(async () => { + collateral = collaterals['COMP'] as FaucetToken; + await collateral.allocateTo(alice.address, TRANSFER_AMOUNT); + await collateral.connect(alice).approve(comet.address, TRANSFER_AMOUNT); + await comet.connect(alice).supply(collateral.address, TRANSFER_AMOUNT); }); - expect(event(s0, 1)).to.be.deep.equal({ - Transfer: { - from: ethers.constants.AddressZero, - to: alice.address, - amount: bobAccruedBalance - 1n, - } + + describe('revert on', function () { + it('self-transfer', async () => { + await expect(comet.connect(alice).transferAsset( + alice.address, + collateral.address, + TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'NoSelfTransfer'); + }); + + it('global transfer pause', async () => { + await comet.connect(pauseGuardian).pause(false, true, false, false, false); + + await expect(comet.connect(alice).transferAsset( + bob.address, + collateral.address, + TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'Paused'); + + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('collaterals transfers pause', async () => { + await comet.connect(pauseGuardian).pauseCollateralTransfer(true); + + await expect(comet.connect(alice).transferAsset( + bob.address, + collateral.address, + TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'CollateralTransferPaused'); + + await comet.connect(pauseGuardian).pauseCollateralTransfer(false); + }); + + it('specific collateral asset transfer pause', async () => { + await comet.connect(pauseGuardian).pauseCollateralAssetTransfer(0, true); + + await expect(comet.connect(alice).transferAsset( + bob.address, + collateral.address, + TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused'); + + await comet.connect(pauseGuardian).pauseCollateralAssetTransfer(0, false); + }); + + it('unsupported asset & amount > 0', async () => { + // Overflow/underflow panic error + // This happens because user can not have unsupported token balance > 0 + await expect(comet.connect(alice).transferAsset(bob.address, unsupportedToken.address, TRANSFER_AMOUNT)).to.be.revertedWithPanic('0x11'); + }); + + it('unsupported asset & amount = 0', async () => { + await expect(comet.connect(alice).transferAsset(bob.address, unsupportedToken.address, 0n)).to.be.revertedWithCustomError(comet, 'BadAsset'); + }); + + it('amount > balance', async () => { + const balance = await comet.collateralBalanceOf(alice.address, collateral.address); + + // 0x11: Arithmetic operation overflowed outside of an unchecked block + await expect(comet.connect(alice).transferAsset(bob.address, collateral.address, balance.add(1))).to.be.revertedWithPanic('0x11'); + }); + + describe('not collateralized', function () { + const BORROW_AMOUNT:bigint = exp(50, baseTokenDecimals); + const TRANSFER_AMOUNT:bigint = exp(0.8, 18); + let snapshot: SnapshotRestorer; + + before(async () => snapshot = await takeSnapshot()); + + it('alice withdraw base asset to become borrower', async () => { + await comet.connect(alice).withdraw(baseToken.address, BORROW_AMOUNT); + }); + + it('alice principal is negative (borrow position)', async () => { + expect((await comet.userBasic(alice.address)).principal).to.be.lessThan(0n); + }); + + // Reproduce calculation performed in isLiquidatable function + // to check that alice is not collateralized to transfer such amount + it('final liquidity is negative', async () => { + const principal = (await comet.userBasic(alice.address)).principal; + const totalsBasic = await comet.totalsBasic(); + const basePrice = await comet.getPrice(await comet.baseTokenPriceFeed()); + const baseScale = await comet.baseScale(); + const baseLiquidity = mulPrice( + presentValue(principal, totalsBasic.baseSupplyIndex, totalsBasic.baseBorrowIndex), + basePrice, + baseScale + ); + + // Calculate liquidity for collateral + const assetInfo = await comet.getAssetInfoByAddress(collateral.address); + const collateralAmount = (await comet.collateralBalanceOf(alice.address, collateral.address)).sub(TRANSFER_AMOUNT).toBigInt(); + const collateralPrice = await comet.getPrice(assetInfo.priceFeed); + const collateralLiquidity = mulPrice(collateralAmount, collateralPrice, exp(1, 18)); + const finalLiquidity = baseLiquidity + mulFactor(collateralLiquidity, assetInfo.borrowCollateralFactor.toBigInt()); + + expect(finalLiquidity).to.be.lessThan(0n); + }); + + it('transfer is reverted with NotCollateralized error', async () => { + await expect(comet.connect(alice).transferAsset( + bob.address, + collateral.address, + TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + await snapshot.restore(); + }); + }); + }); + + describe('transfer asset: happy path & no borrow', function () { + let transferTx: ContractTransaction; + let totalsCollateralBefore: BigNumber; + let aliceCollateralBalanceBefore: BigNumber; + let transferTimestamp: BigNumber; + let cometBorrowIndexBefore: BigNumber; + let trackingSupplyIndexBefore: BigNumber; + let trackingBorrowIndexBefore: BigNumber; + let aliceBaseTrackingIndexBefore: BigNumber; + let aliceBaseTrackingAccruedBefore: BigNumber; + let baseTrackingSupplySpeedVal: BigNumber; + let trackingIndexScaleVal: BigNumber; + let borrowRateBefore: BigNumber; + let utilizationBefore: BigNumber; + let totalSupplyBefore: BigNumber; + let cometSupplyIndexBefore: BigNumber; + let cometSupplyRateBefore: BigNumber; + let alicePrincipalBefore: BigNumber; + let aliceDisplayBalanceBefore: BigNumber; + + let cometUpdatedTimeBefore: number; + let daveBaseTrackingAccruedBefore: BigNumber; + + before(async () => { + // Accrue state before transfer + await comet.accrueAccount(ethers.constants.AddressZero); + + const totals = await comet.totalsBasic(); + totalSupplyBefore = totals.totalSupplyBase; + cometBorrowIndexBefore = totals.baseBorrowIndex; + trackingSupplyIndexBefore = totals.trackingSupplyIndex; + trackingBorrowIndexBefore = totals.trackingBorrowIndex; + cometUpdatedTimeBefore = totals.lastAccrualTime; + cometSupplyIndexBefore = totals.baseSupplyIndex; + aliceBaseTrackingIndexBefore = (await comet.userBasic(alice.address)).baseTrackingIndex; + aliceBaseTrackingAccruedBefore = (await comet.userBasic(alice.address)).baseTrackingAccrued; + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal; + aliceDisplayBalanceBefore = await comet.balanceOf(alice.address); + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + trackingIndexScaleVal = await comet.trackingIndexScale(); + utilizationBefore = await comet.getUtilization(); + borrowRateBefore = await comet.getBorrowRate(utilizationBefore); + cometSupplyRateBefore = await comet.getSupplyRate(utilizationBefore); + const aliceBasic = await comet.userBasic(alice.address); + aliceBaseTrackingIndexBefore = aliceBasic.baseTrackingIndex; + aliceBaseTrackingAccruedBefore = aliceBasic.baseTrackingAccrued; + const daveBasic = await comet.userBasic(dave.address); + daveBaseTrackingAccruedBefore = daveBasic.baseTrackingAccrued; + + // wait for a while to have impact from accrual + await ethers.provider.send('evm_increaseTime', [SKIP_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('total collateral amount equals alice balance', async () => { + totalsCollateralBefore = (await comet.totalsCollateral(collateral.address)).totalSupplyAsset; + expect(totalsCollateralBefore).to.equal(TRANSFER_AMOUNT); + }); + + it('alice collateral balance equals transfer amount', async () => { + aliceCollateralBalanceBefore = await comet.collateralBalanceOf(alice.address, collateral.address); + expect(aliceCollateralBalanceBefore).to.equal(TRANSFER_AMOUNT); + }); + + it('dave collateral balance = 0', async () => { + expect(await comet.collateralBalanceOf(dave.address, collateral.address)).to.equal(0n); + }); + + it('alice assetsIn has only one asset and collateral is the only asset', async () => { + const assetsInList = await comet.getAssetList(alice.address); + expect(assetsInList).to.include(collateral.address); + expect((await comet.userBasic(alice.address)).assetsIn).to.equal(1); + }); + + it('dave assetsIn = 0', async () => { + const assetsInList = await comet.getAssetList(dave.address); + expect(assetsInList).to.be.empty; + expect((await comet.userBasic(dave.address)).assetsIn).to.equal(0); + }); + + it('alice is not a borrower', async () => { + // We should check that alice is not a borrower + // In case when alice is a borrower, she need to make additional check for collateralization + expect((await comet.userBasic(alice.address)).principal).to.equal(0n); + }); + + it('transfer is successful', async () => { + transferTx = await comet.connect(alice).transferAsset(dave.address, collateral.address, TRANSFER_AMOUNT); + await expect(transferTx).to.not.be.reverted; + transferTimestamp = BigNumber.from( + (await ethers.provider.getBlock((await transferTx.wait()).blockNumber)).timestamp + ); + }); + + it('should accrue state during collateral supply', async () => { + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + + expect(lastUpdated - cometUpdatedTimeBefore).to.be.approximately(SKIP_TIME, 2); // 2 seconds tolerance + expect(lastUpdated).to.equal(transferTimestamp); + }); + + it('TransferCollateral event is emitted', async () => { + await expect(transferTx) + .to.emit(comet, 'TransferCollateral') + .withArgs(alice.address, dave.address, collateral.address, TRANSFER_AMOUNT); + }); + + it('alice collateral balance decreased by transfer amount', async () => { + expect(await comet.collateralBalanceOf(alice.address, collateral.address)).to.equal(aliceCollateralBalanceBefore.sub(TRANSFER_AMOUNT)); + }); + + it('dave collateral balance increased by transfer amount', async () => { + expect(await comet.collateralBalanceOf(dave.address, collateral.address)).to.equal(TRANSFER_AMOUNT); + }); + + it('alice assetsIn becomes zero and asset is removed from the list', async () => { + // We expect that transfer amount is the whole alice balance + // So alice assetsIn is updated + const assetsInList = await comet.getAssetList(alice.address); + expect(assetsInList).to.be.empty; + expect((await comet.userBasic(alice.address)).assetsIn).to.equal(0); + }); + + it('dave assetsIn increases and collateral is the only asset', async () => { + const assetsInList = await comet.getAssetList(dave.address); + expect(assetsInList).to.include(collateral.address); + expect((await comet.userBasic(dave.address)).assetsIn).to.equal(1); + }); + + it('total collateral amount is not changed', async () => { + expect((await comet.totalsCollateral(collateral.address)).totalSupplyAsset).to.equal(totalsCollateralBefore); + }); + + it('should have correct display of alice principal', async () => { + const timeElapsed = transferTimestamp.sub(cometUpdatedTimeBefore); + const accruedIndex = cometSupplyIndexBefore.add(cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18))); + + // healthcheck than current index is re-calculated correctly + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const newBalanceFromPrincipal = alicePrincipalBefore.mul(accruedIndex).div(exp(1, 15)); + + // current balance + const newBalance = await comet.balanceOf(alice.address); + + expect(newBalance).to.equal(newBalanceFromPrincipal); + // check the invariant that lender's balance can only grow + expect(newBalance).to.be.eq(aliceDisplayBalanceBefore); + }); + + it("should change comet's total supply correctly after accrual (no collateral effect on supply)", async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalSupplyBefore); + }); + + it('should have correct display of total supply', async () => { + // current displayed supply + const newSupply = await comet.totalSupply(); + + // check the invariant that lender's balance can only grow + expect(newSupply).to.be.equal(totalSupplyBefore); + }); + + it('trackingSupplyIndex grows correctly during collateral supply accrual', async () => { + // accrueInternal() updates trackingSupplyIndex when totalSupplyBase >= baseMinForRewards: + // trackingSupplyIndex += divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase) + // = baseTrackingSupplySpeed * timeElapsed * baseScale / totalSupplyBase + // baseScale = 1e6 for USDC; trackingSupplyIndex is independent of the interest rate + // Example: speed=1e15, elapsed~3600, totalSupplyBase~3e15 (3e9 USDC principal) + // → delta = 1e15 * 3600 * 1e6 / 3e15 = 1200 + const timeElapsed = transferTimestamp.sub(cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const expectedTrackingSupplyIndex = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + expect((await comet.totalsBasic()).trackingSupplyIndex).to.equal(expectedTrackingSupplyIndex); + }); + + it('trackingBorrowIndex is unchanged when totalBorrowBase is zero', async () => { + // sanity check that totalBorrowBase < baseMinForRewards + expect((await comet.totalsBasic()).totalBorrowBase).to.be.lessThan(await comet.baseMinForRewards()); + + // accrueInternal() only updates trackingBorrowIndex if totalBorrowBase >= baseMinForRewards + // With no active borrows, totalBorrowBase = 0 and the condition is not satisfied + expect((await comet.totalsBasic()).trackingBorrowIndex).to.equal(trackingBorrowIndexBefore); + }); + + it('baseSupplyIndex accrues correctly during collateral supply', async () => { + // baseSupplyIndex += mulFactor(baseSupplyIndex, supplyRate * timeElapsed) + // = baseSupplyIndex + baseSupplyIndex * supplyRate * timeElapsed / 1e18 + // With utilization = 0 (no borrows), supplyRate = 0 and the index is unchanged + const timeElapsed = transferTimestamp.sub(cometUpdatedTimeBefore); + const expectedBaseSupplyIndex = cometSupplyIndexBefore.add( + cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(expectedBaseSupplyIndex); + }); + + it('baseBorrowIndex accrues correctly during collateral supply', async () => { + // baseBorrowIndex += mulFactor(baseBorrowIndex, borrowRate * timeElapsed) + // = baseBorrowIndex + baseBorrowIndex * borrowRate * timeElapsed / 1e18 + // With no borrows, getBorrowRate returns 0 and the borrow index is unchanged + const timeElapsed = transferTimestamp.sub(cometUpdatedTimeBefore); + const expectedBaseBorrowIndex = cometBorrowIndexBefore.add( + cometBorrowIndexBefore.mul(borrowRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(expectedBaseBorrowIndex); + }); + + it('alice baseTrackingAccrued increases via supply tracking during collateral supply', async () => { + // accrueAccountInternal(alice) calls updateBasePrincipal, accumulating rewards since her last sync. + // alice.principal >= 0 so supply tracking applies: + // indexDelta = trackingSupplyIndex_new - alice.baseTrackingIndex_before + // baseTrackingAccrued += principal * indexDelta / trackingIndexScale / accrualDescaleFactor + // accrualDescaleFactor = baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6 = 1 for USDC + // trackingIndexScale = 1e15 (default) + const timeElapsed = transferTimestamp.sub(cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const trackingSupplyIndexNew = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + // indexDelta spans from alice's last synced tracking index to the new global index + const indexDelta = trackingSupplyIndexNew.sub(aliceBaseTrackingIndexBefore); + // accrualDescaleFactor = 1 for USDC (baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6) + const expectedAccrued = aliceBaseTrackingAccruedBefore.add( + alicePrincipalBefore.mul(indexDelta).div(trackingIndexScaleVal) + ); + expect((await comet.userBasic(alice.address)).baseTrackingAccrued).to.equal(expectedAccrued); + }); + + it('utilization is zero after collateral supply when there are no borrows', async () => { + // Supplying collateral does not change totalSupplyBase or totalBorrowBase (principals unchanged) + // With totalBorrowBase = 0, getUtilization() returns 0 + expect(await comet.getUtilization()).to.equal(0); + expect(await comet.getUtilization()).to.equal(utilizationBefore); + }); + + it('dave baseTrackingAccrued is unchanged when dst principal is zero', async () => { + // accrueAccountInternal(dave) [dst] is called during transferCollateral(alice, dave, ...). + // dave.principal = 0 → updateBasePrincipal accrues 0 * indexDelta = 0 → no reward for dst + const daveBasicAfter = await comet.userBasic(dave.address); + expect(daveBasicAfter.baseTrackingAccrued).to.equal(daveBaseTrackingAccruedBefore); + }); + + it('dave baseTrackingIndex is synced to trackingSupplyIndex after transfer', async () => { + // After updateBasePrincipal(dave, ...) with dave.principal = 0 >= 0, the supply path runs: + // dave.baseTrackingIndex = trackingSupplyIndex_new + // This confirms that even zero-principal dst accounts have their tracking state synced. + const timeElapsed = transferTimestamp.sub(cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const trackingSupplyIndexNew = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + expect((await comet.userBasic(dave.address)).baseTrackingIndex).to.equal(trackingSupplyIndexNew); + }); }); - // Hitting the rounding down behavior in this specific case (which is favorable to the protocol) - expect(a0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.internal).to.be.deep.equal({ USDC: bobAccruedBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.internal).to.be.deep.equal({ USDC: bobAccruedBalance - 1n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase.sub(1)); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(105000); + describe('transfer asset: happy path & with borrow', function () { + const BORROW_AMOUNT:bigint = exp(20, baseTokenDecimals); + const PARTIAL_TRANSFER_AMOUNT:bigint = exp(0.2, 18); + let transferTx: ContractTransaction; + let totalsCollateralBefore: BigNumber; + let daveCollateralBalanceBefore: BigNumber; + let transferTimestamp: number; + let cometBorrowIndexBefore: BigNumber; + let trackingSupplyIndexBefore: BigNumber; + let trackingBorrowIndexBefore: BigNumber; + let daveBaseTrackingIndexBefore: BigNumber; + let daveBaseTrackingAccruedBefore: BigNumber; + let trackingIndexScaleVal: BigNumber; + let borrowRateBefore: BigNumber; + let utilizationBefore: BigNumber; + let totalSupplyBefore: BigNumber; + let totalBorrowBefore: BigNumber; + let cometSupplyIndexBefore: BigNumber; + let cometSupplyRateBefore: BigNumber; + let davePrincipalBefore: BigNumber; + let baseTrackingSupplySpeedVal: BigNumber; + let baseTrackingBorrowSpeedVal: BigNumber; + let aliceBaseTrackingAccruedBefore: BigNumber; + + let cometUpdatedTimeBefore: number; + + // Dave already has base balance (SUPPLY_AMOUNT) from previous "transfer max base balance" describe. + // Make Dave a borrower by withdrawing base asset + before(async () => { + await comet.connect(dave).withdraw(baseToken.address, BORROW_AMOUNT); + // Accrue state before transfer + await comet.accrueAccount(ethers.constants.AddressZero); + + const totals = await comet.totalsBasic(); + totalSupplyBefore = totals.totalSupplyBase; + totalBorrowBefore = totals.totalBorrowBase; + cometBorrowIndexBefore = totals.baseBorrowIndex; + trackingSupplyIndexBefore = totals.trackingSupplyIndex; + trackingBorrowIndexBefore = totals.trackingBorrowIndex; + cometUpdatedTimeBefore = totals.lastAccrualTime; + cometSupplyIndexBefore = totals.baseSupplyIndex; + daveBaseTrackingIndexBefore = (await comet.userBasic(dave.address)).baseTrackingIndex; + daveBaseTrackingAccruedBefore = (await comet.userBasic(dave.address)).baseTrackingAccrued; + davePrincipalBefore = (await comet.userBasic(dave.address)).principal; + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + trackingIndexScaleVal = await comet.trackingIndexScale(); + utilizationBefore = await comet.getUtilization(); + borrowRateBefore = await comet.getBorrowRate(utilizationBefore); + cometSupplyRateBefore = await comet.getSupplyRate(utilizationBefore); + const daveBasic = await comet.userBasic(dave.address); + daveBaseTrackingIndexBefore = daveBasic.baseTrackingIndex; + daveBaseTrackingAccruedBefore = daveBasic.baseTrackingAccrued; + const aliceBasic = await comet.userBasic(alice.address); + aliceBaseTrackingAccruedBefore = aliceBasic.baseTrackingAccrued; + + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + baseTrackingBorrowSpeedVal = await comet.baseTrackingBorrowSpeed(); + + // wait for a while to have impact from accrual + await ethers.provider.send('evm_increaseTime', [SKIP_TIME]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('total collateral amount equals dave balance', async () => { + totalsCollateralBefore = (await comet.totalsCollateral(collateral.address)).totalSupplyAsset; + expect(totalsCollateralBefore).to.equal(TRANSFER_AMOUNT); + }); + + it('dave collateral balance equals transfer amount', async () => { + daveCollateralBalanceBefore = await comet.collateralBalanceOf(dave.address, collateral.address); + expect(daveCollateralBalanceBefore).to.equal(TRANSFER_AMOUNT); + }); + + it('dave assetsIn has only one asset and collateral is the only asset', async () => { + const assetsInList = await comet.getAssetList(dave.address); + expect(assetsInList).to.include(collateral.address); + expect((await comet.userBasic(dave.address)).assetsIn).to.equal(1); + }); + + it('dave is a borrower', async () => { + expect((await comet.userBasic(dave.address)).principal).to.be.lessThan(0n); + }); + + it('dave is collateralized for transfer amount', async () => { + const principal = (await comet.userBasic(dave.address)).principal; + const totalsBasic = await comet.totalsBasic(); + const basePrice = await comet.getPrice(await comet.baseTokenPriceFeed()); + const baseScale = await comet.baseScale(); + const baseLiquidity = mulPrice( + presentValue(principal, totalsBasic.baseSupplyIndex, totalsBasic.baseBorrowIndex), + basePrice, + baseScale + ); + + // Calculate liquidity for collateral + const assetInfo = await comet.getAssetInfoByAddress(collateral.address); + const collateralAmount = (await comet.collateralBalanceOf(dave.address, collateral.address)).sub(PARTIAL_TRANSFER_AMOUNT).toBigInt(); + const collateralPrice = await comet.getPrice(assetInfo.priceFeed); + const collateralLiquidity = mulPrice(collateralAmount, collateralPrice, exp(1, 18)); + const finalLiquidity = baseLiquidity + mulFactor(collateralLiquidity, assetInfo.borrowCollateralFactor.toBigInt()); + + expect(finalLiquidity).to.be.greaterThan(0n); + }); + + it('transfer is successful', async () => { + transferTx = await comet.connect(dave).transferAsset(alice.address, collateral.address, PARTIAL_TRANSFER_AMOUNT); + await expect(transferTx).to.not.be.reverted; + transferTimestamp = + (await ethers.provider.getBlock((await transferTx.wait()).blockNumber)).timestamp; + }); + + it('TransferCollateral event is emitted', async () => { + await expect(transferTx) + .to.emit(comet, 'TransferCollateral') + .withArgs(dave.address, alice.address, collateral.address, PARTIAL_TRANSFER_AMOUNT); + }); + + it('dave collateral balance decreased by transfer amount', async () => { + expect(await comet.collateralBalanceOf(dave.address, collateral.address)).to.equal(daveCollateralBalanceBefore.sub(PARTIAL_TRANSFER_AMOUNT)); + }); + + it('alice collateral balance increased by transfer amount', async () => { + expect(await comet.collateralBalanceOf(alice.address, collateral.address)).to.equal(PARTIAL_TRANSFER_AMOUNT); + }); + + it('dave assetsIn is not changed', async () => { + const assetsInList = await comet.getAssetList(dave.address); + expect(assetsInList).to.include(collateral.address); + expect((await comet.userBasic(dave.address)).assetsIn).to.equal(1); + }); + + it('alice assetsIn increases and collateral is the only asset', async () => { + const assetsInList = await comet.getAssetList(dave.address); + expect(assetsInList).to.include(collateral.address); + expect((await comet.userBasic(dave.address)).assetsIn).to.equal(1); + }); + + it('total collateral amount is not changed', async () => { + expect((await comet.totalsCollateral(collateral.address)).totalSupplyAsset).to.equal(totalsCollateralBefore); + }); + + it('baseSupplyIndex grows when supply rate is non-zero', async () => { + // baseSupplyIndex += mulFactor(baseSupplyIndex, supplyRate * timeElapsed) + // = baseSupplyIndex + baseSupplyIndex * supplyRate * timeElapsed / 1e18 + // supplyRate > 0 because utilization > 0 (alice's 400 USDC borrow) + // Unlike the zero-borrow case above, this index now actually grows + const timeElapsed = transferTimestamp - cometUpdatedTimeBefore; + const expectedIndex = cometSupplyIndexBefore.add( + cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(expectedIndex); + }); + + it('baseBorrowIndex grows when borrow rate is non-zero', async () => { + // baseBorrowIndex += mulFactor(baseBorrowIndex, borrowRate * timeElapsed) + // = baseBorrowIndex + baseBorrowIndex * borrowRate * timeElapsed / 1e18 + // borrowRate > 0 because totalBorrowBase > 0 and utilization > 0 + const timeElapsed = BigNumber.from(transferTimestamp - cometUpdatedTimeBefore); + const expectedIndex = cometBorrowIndexBefore.add( + cometBorrowIndexBefore.mul(borrowRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(expectedIndex); + }); + + it('trackingBorrowIndex grows when totalBorrowBase exceeds baseMinForRewards', async () => { + // trackingBorrowIndex += divBaseWei(baseTrackingBorrowSpeed * timeElapsed, totalBorrowBase) + // = baseTrackingBorrowSpeed * timeElapsed * baseScale / totalBorrowBase + const timeElapsed = transferTimestamp - cometUpdatedTimeBefore; + const baseScale = exp(1, 6); + const expectedIndex = trackingBorrowIndexBefore.add( + baseTrackingBorrowSpeedVal.mul(timeElapsed).mul(baseScale).div(totalBorrowBefore) + ); + expect((await comet.totalsBasic()).trackingBorrowIndex).to.equal(expectedIndex); + }); + + it('trackingSupplyIndex also grows when totalSupplyBase exceeds baseMinForRewards', async () => { + // trackingSupplyIndex += divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase) + // = baseTrackingSupplySpeed * timeElapsed * baseScale / totalSupplyBase + const timeElapsed = transferTimestamp - cometUpdatedTimeBefore; + const baseScale = exp(1, 6); + const expectedIndex = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + expect((await comet.totalsBasic()).trackingSupplyIndex).to.equal(expectedIndex); + }); + + it('alice baseTrackingAccrued accumulates borrow rewards via trackingBorrowIndex', async () => { + // alice.principal < 0 (net borrower), so updateBasePrincipal uses borrow tracking: + // indexDelta = trackingBorrowIndex_new - alice.baseTrackingIndex_before + // baseTrackingAccrued += |principal| * indexDelta / trackingIndexScale / accrualDescaleFactor + // alice.baseTrackingIndex was set to trackingBorrowIndex at withdrawal time (same block as capture), + // so indexDelta = trackingBorrowIndex_new - trackingBorrowIndexBefore + // accrualDescaleFactor = baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6 = 1 for USDC + const timeElapsed = transferTimestamp - cometUpdatedTimeBefore; + const baseScale = exp(1, 6); + const trackingBorrowIndexNew = trackingBorrowIndexBefore.add( + baseTrackingBorrowSpeedVal.mul(timeElapsed).mul(baseScale).div(totalBorrowBefore) + ); + // indexDelta spans from alice's last synced borrow tracking index to the new global value + const indexDelta = trackingBorrowIndexNew.sub(daveBaseTrackingIndexBefore); + // accrualDescaleFactor = 1 for USDC (baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6) + const expectedAccrued = daveBaseTrackingAccruedBefore.add( + davePrincipalBefore.abs().mul(indexDelta).div(trackingIndexScaleVal) + ); + expect((await comet.userBasic(dave.address)).baseTrackingAccrued).to.equal(expectedAccrued); + }); + + it('utilization is greater than zero after collateral supply accrual', async () => { + // Active borrow (alice's 400 USDC net position) keeps utilization above zero. + // Supplying collateral does not change totalSupplyBase or totalBorrowBase principals. + expect(await comet.getUtilization()).to.be.greaterThan(0); + }); + + it('utilization after supply collateral matches exact calculation from accrued indices', async () => { + // getUtilization() = presentValue(borrow) * FACTOR_SCALE / presentValue(supply) + // = totalBorrowBase * baseBorrowIndex_new / 1e15 * 1e18 / (totalSupplyBase * baseSupplyIndex_new / 1e15) + const totals = await comet.totalsBasic(); + const totalBorrowPresent = totals.totalBorrowBase.mul(totals.baseBorrowIndex).div(exp(1, 15)); + const totalSupplyPresent = totals.totalSupplyBase.mul(totals.baseSupplyIndex).div(exp(1, 15)); + const expectedUtilization = totalBorrowPresent.mul(exp(1, 18)).div(totalSupplyPresent); + expect(await comet.getUtilization()).to.equal(expectedUtilization); + }); + + it('alice baseTrackingAccrued is unchanged when dst principal is zero', async () => { + // accrueAccountInternal(alice) [dst] is called during transferCollateral(dave, alice, ...). + // alice.principal = 0 → updateBasePrincipal accrues 0 * indexDelta = 0 → no reward for dst + const aliceBasicAfter = await comet.userBasic(alice.address); + expect(aliceBasicAfter.baseTrackingAccrued).to.equal(aliceBaseTrackingAccruedBefore); + }); + + it('alice baseTrackingIndex is synced to trackingSupplyIndex after transfer', async () => { + // After updateBasePrincipal(alice, ...) with alice.principal = 0 >= 0, the supply path runs: + // alice.baseTrackingIndex = trackingSupplyIndex_new + // This confirms dst account tracking state is updated even when no rewards accrue. + // trackingSupplyIndex += baseTrackingSupplySpeed * timeElapsed * baseScale / totalSupplyBase + const timeElapsed = BigNumber.from(transferTimestamp - cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const trackingSupplyIndexNew = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBefore) + ); + expect((await comet.userBasic(alice.address)).baseTrackingIndex).to.equal(trackingSupplyIndexNew); + }); + }); }); + + /** + * Note: tests assume, that transferFrom(), transferAssetFrom() are clones of + * transfer(), transferAsset(), thus only key cases are checked + */ + describe('transferFrom variations', function () { + const BASE_TRANSFER_AMOUNT: bigint = exp(10, baseTokenDecimals); + const COLLATERAL_TRANSFER_AMOUNT: bigint = exp(1, 18); + + let operator: SignerWithAddress; + let holder: SignerWithAddress; + let receiver: SignerWithAddress; + + before(async function () { + operator = users[10]; + holder = users[11]; + receiver = users[12]; + }); + + describe('transferFrom (base asset)', function () { + before(async function () { + await baseToken.allocateTo(holder.address, BASE_TRANSFER_AMOUNT); + await baseToken.connect(holder).approve(comet.address, BASE_TRANSFER_AMOUNT); + await comet.connect(holder).supply(baseToken.address, BASE_TRANSFER_AMOUNT); + + await comet.connect(holder).approve(operator.address, ethers.constants.MaxUint256); + + // wait for a while to have impact from accrual + await ethers.provider.send('evm_increaseTime', [60 * 60]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + describe('revert on', function () { + let principal: bigint; + let baseSupplyIndex: bigint; + let baseBorrowIndex: bigint; + + before(async () => { + principal = (await comet.userBasic(holder.address)).principal.toBigInt(); + const totalsBasic = await comet.totalsBasic(); + baseSupplyIndex = totalsBasic.baseSupplyIndex.toBigInt(); + baseBorrowIndex = totalsBasic.baseBorrowIndex.toBigInt(); + }); + + it('pause', async () => { + await comet.connect(pauseGuardian).pause(false, true, false, false, false); + + await expect(comet.connect(operator).transferFrom( + holder.address, + receiver.address, + BASE_TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'Paused'); + + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('operator has no permission from holder', async () => { + await comet.connect(holder).approve(operator.address, 0); + + await expect(comet.connect(operator).transferFrom( + holder.address, + receiver.address, + BASE_TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'Unauthorized'); + + await comet.connect(holder).approve(operator.address, ethers.constants.MaxUint256); + }); + + it('src == dst', async () => { + await expect(comet.connect(operator).transferFrom( + holder.address, + holder.address, + BASE_TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'NoSelfTransfer'); + }); + + it('exceeds balance (no collateral supplied & newSrcBalance < baseBorrowMin)', async () => { + const amountToTransfer = BASE_TRANSFER_AMOUNT + 10n; + const srcBalance = presentValue(principal, baseSupplyIndex, baseBorrowIndex) - amountToTransfer; + + // Ensure -srcBalance < baseBorrowMin + expect(baseBorrowMin).to.be.greaterThan(-srcBalance); + + await expect(comet.connect(operator).transferFrom(holder.address,receiver.address, amountToTransfer)).to.be.revertedWithCustomError(comet, 'BorrowTooSmall'); + }); + + it('exceeds balance (no collateral supplied & newSrcBalance >= baseBorrowMin)', async () => { + const amountToTransfer = BASE_TRANSFER_AMOUNT + baseBorrowMin + 10n; + const srcBalance = presentValue(principal, baseSupplyIndex, baseBorrowIndex) - amountToTransfer; + + // Ensure -srcBalance >= baseBorrowMin + expect(baseBorrowMin).to.lessThanOrEqual(-srcBalance); + + await expect(comet.connect(operator).transferFrom(holder.address,receiver.address, amountToTransfer)).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + }); + }); + + describe('happy cases', function () { + it('should accrue state (same as transfer())', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + await comet.connect(operator).transferFrom(holder.address, receiver.address, BASE_TRANSFER_AMOUNT); + expect((await comet.totalsBasic()).lastAccrualTime).to.equal((await ethers.provider.getBlock('latest')).timestamp); + + await snapshot.restore(); + }); + + it('should transfer base from holder to receiver', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const holderBalanceBeforeTx = await comet.balanceOf(holder.address); + const receiverBalanceBeforeTx = await comet.balanceOf(receiver.address); + + await comet.connect(operator).transferFrom(holder.address, receiver.address, BASE_TRANSFER_AMOUNT); + + expect(holderBalanceBeforeTx.sub(await comet.balanceOf(holder.address))).to.be.approximately(BASE_TRANSFER_AMOUNT, 1n); + expect((await comet.balanceOf(receiver.address)).sub(receiverBalanceBeforeTx)).to.be.approximately(BASE_TRANSFER_AMOUNT, 1n); + + await snapshot.restore(); + }); + + it('should transfer base when receiver == operator', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const holderBalanceBeforeTx = await comet.balanceOf(holder.address); + const operatorBalanceBeforeTx = await comet.balanceOf(operator.address); + + await comet.connect(operator).transferFrom(holder.address, operator.address, BASE_TRANSFER_AMOUNT); + + expect(holderBalanceBeforeTx.sub(await comet.balanceOf(holder.address))).to.be.approximately(BASE_TRANSFER_AMOUNT, 1n); + expect((await comet.balanceOf(operator.address)).sub(operatorBalanceBeforeTx)).to.be.approximately(BASE_TRANSFER_AMOUNT, 1n); + + await snapshot.restore(); + }); + + it('should transfer base when operator == holder', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const holderBalanceBeforeTx = await comet.balanceOf(holder.address); + const receiverBalanceBeforeTx = await comet.balanceOf(receiver.address); + + await comet.connect(holder).transferFrom(holder.address, receiver.address, BASE_TRANSFER_AMOUNT); + + expect(holderBalanceBeforeTx.sub(await comet.balanceOf(holder.address))).to.be.approximately(BASE_TRANSFER_AMOUNT, 1n); + expect((await comet.balanceOf(receiver.address)).sub(receiverBalanceBeforeTx)).to.be.approximately(BASE_TRANSFER_AMOUNT, 1n); + + await snapshot.restore(); + }); + + it('should emit Transfer events', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + const baseSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex; + + const tx = await comet.connect(operator).transferFrom(holder.address, receiver.address, BASE_TRANSFER_AMOUNT); + + // Get all Transfer events from the transaction receipt + const receipt = await tx.wait(); + const transferEvents = receipt.events?.filter((x) => x.event === 'Transfer') || []; + + // From src to zero address + let transferEvent = transferEvents[0]; + expect(transferEvent).to.not.be.undefined; + let transferFrom = transferEvent?.args?.from; + let transferTo = transferEvent?.args?.to; + let transferAmount = transferEvent?.args?.amount; + expect(transferFrom).to.be.equal(holder.address); + expect(transferTo).to.be.equal(ZERO_ADDRESS); + expect(transferAmount).to.be.approximately(presentValueSupply(baseSupplyIndex, BASE_TRANSFER_AMOUNT), 12); + + // From zero address to dst + transferEvent = transferEvents[1]; + expect(transferEvent).to.not.be.undefined; + transferFrom = transferEvent?.args?.from; + transferTo = transferEvent?.args?.to; + transferAmount = transferEvent?.args?.amount; + expect(transferFrom).to.be.equal(ZERO_ADDRESS); + expect(transferTo).to.be.equal(receiver.address); + expect(transferAmount).to.be.approximately(presentValueSupply(baseSupplyIndex, BASE_TRANSFER_AMOUNT), 12); + + await snapshot.restore(); + }); + }); + }); - it('transfer max base should transfer 0 if user has a borrow position', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC, WETH } = tokens; - - await comet.setBasePrincipal(bob.address, -100e6); - await comet.setCollateralBalance(bob.address, WETH.address, exp(1, 18)); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const a0 = await portfolio(protocol, alice.address); - const b0 = await portfolio(protocol, bob.address); - const s0 = await wait(cometAsB.transferAsset(alice.address, USDC.address, ethers.constants.MaxUint256)); - const t1 = await comet.totalsBasic(); - const a1 = await portfolio(protocol, alice.address); - const b1 = await portfolio(protocol, bob.address); - - expect(s0.receipt['events'].length).to.be.equal(0); - expect(a0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.internal).to.be.deep.equal({ USDC: exp(-100, 6), COMP: 0n, WETH: exp(1, 18), WBTC: 0n }); - expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.internal).to.be.deep.equal({ USDC: exp(-100, 6), COMP: 0n, WETH: exp(1, 18), WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(105000); + describe('transferAssetFrom (collateral)', function () { + const PARTIAL_COLLATERAL_AMOUNT = exp(0.5, 18); + + before(async function () { + // Withdraw all base balance from holder + await comet.connect(holder).withdraw(baseToken.address, ethers.constants.MaxUint256); + // Holder already has base supplied from transferFrom (base asset) describe + await collaterals['COMP'].allocateTo(holder.address, COLLATERAL_TRANSFER_AMOUNT); + await collaterals['COMP'].connect(holder).approve(comet.address, COLLATERAL_TRANSFER_AMOUNT); + await comet.connect(holder).supply(collaterals['COMP'].address, COLLATERAL_TRANSFER_AMOUNT); + + await comet.connect(holder).approve(operator.address, ethers.constants.MaxUint256); + }); + + describe('revert on', function () { + it('pause', async () => { + await comet.connect(pauseGuardian).pause(false, true, false, false, false); + + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + collaterals['COMP'].address, + PARTIAL_COLLATERAL_AMOUNT + )).to.be.revertedWithCustomError(comet, 'Paused'); + + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('operator has no permission from holder', async () => { + await comet.connect(holder).approve(operator.address, 0); + + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + collaterals['COMP'].address, + PARTIAL_COLLATERAL_AMOUNT + )).to.be.revertedWithCustomError(comet, 'Unauthorized'); + + await comet.connect(holder).approve(operator.address, ethers.constants.MaxUint256); + }); + + it('src == dst', async () => { + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + holder.address, + collaterals['COMP'].address, + PARTIAL_COLLATERAL_AMOUNT + )).to.be.revertedWithCustomError(comet, 'NoSelfTransfer'); + }); + + it('unsupported asset & amount = 0', async () => { + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + unsupportedToken.address, + 0n + )).to.be.revertedWithCustomError(comet, 'BadAsset'); + }); + + it('unsupported asset & amount > 0', async () => { + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + unsupportedToken.address, + COLLATERAL_TRANSFER_AMOUNT + )).to.be.revertedWithPanic('0x11'); + }); + + it('amount > balance', async () => { + const balance = await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address); + + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + collaterals['COMP'].address, + balance.add(1) + )).to.be.revertedWithPanic('0x11'); + }); + + it('not collateralized', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + const BORROW_AMOUNT = exp(50, baseTokenDecimals); + await baseToken.allocateTo(comet.address, BORROW_AMOUNT); + await comet.connect(holder).withdraw(baseToken.address, BORROW_AMOUNT); + + await expect(comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + collaterals['COMP'].address, + COLLATERAL_TRANSFER_AMOUNT + )).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + + await snapshot.restore(); + }); + }); + + describe('happy cases', function () { + it('should transfer collateral from holder to receiver', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const holderCollateralBeforeTx = (await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address)); + const receiverCollateralBeforeTx = (await comet.collateralBalanceOf(receiver.address, collaterals['COMP'].address)); + const totalsCollateralBefore = (await comet.totalsCollateral(collaterals['COMP'].address)).totalSupplyAsset; + + const tx = await comet.connect(operator).transferAssetFrom( + holder.address, + receiver.address, + collaterals['COMP'].address, + PARTIAL_COLLATERAL_AMOUNT + ); + + // holder's collateral balance decreases + expect(await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address)).to.equal(holderCollateralBeforeTx.sub(PARTIAL_COLLATERAL_AMOUNT)); + // receiver's collateral balance grows + expect(await comet.collateralBalanceOf(receiver.address, collaterals['COMP'].address)).to.equal(receiverCollateralBeforeTx.add(PARTIAL_COLLATERAL_AMOUNT)); + // total collateral amount is unchanged (internal transfer) + expect((await comet.totalsCollateral(collaterals['COMP'].address)).totalSupplyAsset).to.equal(totalsCollateralBefore); + await expect(tx) + .to.emit(comet, 'TransferCollateral') + .withArgs(holder.address, receiver.address, collaterals['COMP'].address, PARTIAL_COLLATERAL_AMOUNT); + + await snapshot.restore(); + }); + + it('should transfer collateral when receiver == operator', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const holderCollateralBeforeTx = (await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address)); + const operatorCollateralBeforeTx = (await comet.collateralBalanceOf(operator.address, collaterals['COMP'].address)); + + await comet.connect(operator).transferAssetFrom( + holder.address, + operator.address, + collaterals['COMP'].address, + PARTIAL_COLLATERAL_AMOUNT + ); + + // holder's collateral balance decreases + expect(await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address)).to.equal(holderCollateralBeforeTx.sub(PARTIAL_COLLATERAL_AMOUNT)); + // operator (as receiver) collateral balance grows + expect(await comet.collateralBalanceOf(operator.address, collaterals['COMP'].address)).to.equal(operatorCollateralBeforeTx.add(PARTIAL_COLLATERAL_AMOUNT)); + + await snapshot.restore(); + }); + + it('should transfer collateral when operator == holder', async () => { + const snapshot: SnapshotRestorer = await takeSnapshot(); + + const holderCollateralBeforeTx = (await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address)); + const receiverCollateralBeforeTx = (await comet.collateralBalanceOf(receiver.address, collaterals['COMP'].address)); + + await comet.connect(holder).transferAssetFrom( + holder.address, + receiver.address, + collaterals['COMP'].address, + PARTIAL_COLLATERAL_AMOUNT + ); + + // holder's collateral balance decreases + expect(await comet.collateralBalanceOf(holder.address, collaterals['COMP'].address)).to.equal(holderCollateralBeforeTx.sub(PARTIAL_COLLATERAL_AMOUNT)); + // receiver's collateral balance grows (same as transferAsset()) + expect(await comet.collateralBalanceOf(receiver.address, collaterals['COMP'].address)).to.equal(receiverCollateralBeforeTx.add(PARTIAL_COLLATERAL_AMOUNT)); + + await snapshot.restore(); + }); + }); + }); }); - it('transfers collateral from sender if the asset is collateral', async () => { - const protocol = await makeProtocol(); - const { - comet, - tokens, - users: [alice, bob], - } = protocol; - const { COMP } = tokens; - - const _i0 = await comet.setCollateralBalance(bob.address, COMP.address, 8e8); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsCollateral(COMP.address); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const s0 = await wait(cometAsB.transferAsset(alice.address, COMP.address, 8e8)); - const t1 = await comet.totalsCollateral(COMP.address); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - TransferCollateral: { - from: bob.address, - to: alice.address, - asset: COMP.address, - amount: BigInt(8e8), + describe('transfer with 24 collaterals', function () { + const TRANSFER_AMOUNT: bigint = exp(1, 18); + + let comet: CometHarnessInterfaceExtendedAssetList; + let collaterals: { [symbol: string]: FaucetToken } = {}; + + let transferTxs: ContractTransaction[] = []; + + let alice: SignerWithAddress; + let bob: SignerWithAddress; + before(async () => { + // Setup protocol with MAX_ASSETS collaterals + const cometCollaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, { + decimals: 18, + initialPrice: 1, + }]) + ); + const protocol = await makeProtocol({ + base: 'USDC', + assets: { + USDC: {decimals: baseTokenDecimals, initialPrice: 1}, + ...cometCollaterals + }, + }); + + comet = protocol.cometWithExtendedAssetList; + for (let asset in protocol.tokens) { + if (asset === 'USDC') continue; + collaterals[asset] = protocol.tokens[asset] as FaucetToken; } + + [alice, bob] = protocol.users; + }); + + describe('pause can be set for each collateral', function () { + it('setup: alice supply each of collaterals', async () => { + for (const asset in collaterals) { + await collaterals[asset].allocateTo(alice.address, TRANSFER_AMOUNT); + await collaterals[asset].connect(alice).approve(comet.address, TRANSFER_AMOUNT); + await comet.connect(alice).supply(collaterals[asset].address, TRANSFER_AMOUNT); + } + }); + + it('should allow to pause each collateral transfers', async () => { + for(let i = 0; i < MAX_ASSETS; i++) { + await comet.connect(pauseGuardian).pauseCollateralAssetTransfer(i, true); + expect(await comet.isCollateralAssetTransferPaused(i)).to.be.true; + } + }); + + it('should revert when transferring collateral asset that is paused', async () => { + for (const asset in collaterals) { + await expect(comet.connect(alice).transferAsset(bob.address, collaterals[asset].address, TRANSFER_AMOUNT)).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused'); + } + }); + + it('should allow to unpause each collateral transfers', async () => { + for(let i = 0; i < MAX_ASSETS; i++) { + await comet.connect(pauseGuardian).pauseCollateralAssetTransfer(i, false); + expect(await comet.isCollateralAssetTransferPaused(i)).to.be.false; + } + }); }); - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: exp(8, 8), WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: exp(8, 8), WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyAsset).to.be.equal(t0.totalSupplyAsset); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(95000); + describe('transfer collateral works for each collateral', function () { + it('each collateral balance is equal to supply amount', async () => { + for (const asset in collaterals) { + expect(await comet.collateralBalanceOf(alice.address, collaterals[asset].address)).to.be.equal(TRANSFER_AMOUNT); + } + }); + + it('each collateral bob balance is equal to 0', async () => { + for (const asset in collaterals) { + expect(await comet.collateralBalanceOf(bob.address, collaterals[asset].address)).to.equal(0); + } + }); + + it('transfer is successful for each collateral', async () => { + for (const asset in collaterals) { + const tx = await comet.connect(alice).transferAsset(bob.address, collaterals[asset].address, TRANSFER_AMOUNT); + await expect(tx).to.not.be.reverted; + transferTxs.push(tx); + } + }); + + it('for each collateral emits TransferCollateral event', async () => { + for (let i = 0; i < MAX_ASSETS; i++) { + await expect(transferTxs[i]) + .to.emit(comet, 'TransferCollateral') + .withArgs(alice.address, bob.address, collaterals[`ASSET${i}`].address, TRANSFER_AMOUNT); + } + }); + + it('each collateral alice balance is equal to 0', async () => { + for (const asset in collaterals) { + expect(await comet.collateralBalanceOf(alice.address, collaterals[asset].address)).to.equal(0); + } + }); + + it('each collateral bob balance is equal to transfer amount', async () => { + for (const asset in collaterals) { + expect(await comet.collateralBalanceOf(bob.address, collaterals[asset].address)).to.equal(TRANSFER_AMOUNT); + } + }); + }); }); - it('calculates base principal correctly', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; + describe('non-standard tokens', function () { + describe('USDT-like token', function () { + let comet: CometHarnessInterfaceExtendedAssetList; + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let usdt: NonStandardFaucetFeeToken; + let nonStdCollateral: NonStandardFaucetFeeToken; + const USDT_AMOUNT = exp(1, 6); + const NON_STD_COLLATERAL_AMOUNT = exp(1, 18); + + before(async function () { + const assets = defaultAssets(); + assets['USDT'] = { + initial: 1e6, + decimals: 6, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + assets['NonStdCollateral'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + comet = protocol.cometWithExtendedAssetList; + const tokens = protocol.tokens; + [alice, bob] = protocol.users; + + usdt = tokens['USDT'] as NonStandardFaucetFeeToken; + nonStdCollateral = tokens['NonStdCollateral'] as NonStandardFaucetFeeToken; + }); + + it('can transfer base token - non-standard ERC20 (without return interface) e.g. USDT', async () => { + await usdt.allocateTo(alice.address, USDT_AMOUNT); + + await usdt.connect(alice).approve(comet.address, USDT_AMOUNT); + await comet.connect(alice).supply(usdt.address, USDT_AMOUNT); + + // as per the initial test case, 1st deposit will end with the same principal + expect((await comet.userBasic(alice.address)).principal).to.equal(USDT_AMOUNT); + + await expect(comet.connect(alice).transfer(bob.address, USDT_AMOUNT)).to.not.be.reverted; + + // bob's principal should be equal to the transferred amount + expect((await comet.userBasic(bob.address)).principal).to.equal(USDT_AMOUNT); + }); + + it('can transfer collateral - non-standard ERC20 (without return interface) e.g. USDT', async () => { + await nonStdCollateral.allocateTo(alice.address, NON_STD_COLLATERAL_AMOUNT); + + await nonStdCollateral.connect(alice).approve(comet.address, NON_STD_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(nonStdCollateral.address, NON_STD_COLLATERAL_AMOUNT); + + expect((await comet.userCollateral(alice.address, nonStdCollateral.address)).balance).to.equal(NON_STD_COLLATERAL_AMOUNT); + + await expect(comet.connect(alice).transferAsset(bob.address, nonStdCollateral.address, NON_STD_COLLATERAL_AMOUNT)).to.not.be.reverted; + + // bob's collateral balance should be equal to the transferred amount + expect((await comet.userCollateral(bob.address, nonStdCollateral.address)).balance).to.equal(NON_STD_COLLATERAL_AMOUNT); + }); + }); - await comet.setBasePrincipal(bob.address, 50e6); // 100e6 in present value - const cometAsB = comet.connect(bob); + describe('fee-on-transfer token has no impact on transfer', function () { + const BASE_TOKEN_AMOUNT = exp(1, 6); + const COLLATERAL_TOKEN_AMOUNT = exp(0.5, 18); + const NUMERATOR = 10; + const DENOMINATOR = 10000; + let feeComet: CometHarnessInterfaceExtendedAssetList; + let feeBaseToken: NonStandardFaucetFeeToken; + let feeCollateral: NonStandardFaucetFeeToken; + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let transferFeeTx: ContractTransaction; + let baseAmountWithoutFee: BigNumber; + let collateralAmountWithoutFee: BigNumber; + + before(async function () { + const assets = defaultAssets(); + assets['USDT'] = { + initial: 1e6, + decimals: 6, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + assets['FeeCollateral'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + + feeComet = protocol.cometWithExtendedAssetList; + feeBaseToken = protocol.tokens['USDT'] as NonStandardFaucetFeeToken; + feeCollateral = protocol.tokens['FeeCollateral'] as NonStandardFaucetFeeToken; + [alice, bob] = protocol.users; + + // Allocate tokens to Alice + await feeCollateral.allocateTo(alice.address, COLLATERAL_TOKEN_AMOUNT); + await feeBaseToken.allocateTo(alice.address, BASE_TOKEN_AMOUNT); + + // Set fee to 0.1% + await feeBaseToken.setParams(10, exp(100, 18)); + await feeCollateral.setParams(10, exp(100, 18)); + + // Base token preparation + // We supply the amount with fee to check that it's work even on supply phase + const baseAmountDeposited = BigNumber.from(BASE_TOKEN_AMOUNT); + const baseFee = baseAmountDeposited.mul(NUMERATOR).div(DENOMINATOR); + baseAmountWithoutFee = baseAmountDeposited.sub(baseFee); + await feeBaseToken.connect(alice).approve(feeComet.address, BASE_TOKEN_AMOUNT); + await feeComet.connect(alice).supply(feeBaseToken.address, BASE_TOKEN_AMOUNT); + + // Collateral token preparation + // We supply the amount with fee to check that it's work even on supply phase + const collateralAmountDeposited = BigNumber.from(COLLATERAL_TOKEN_AMOUNT); + const collateralFee = collateralAmountDeposited.mul(NUMERATOR).div(DENOMINATOR); + collateralAmountWithoutFee = collateralAmountDeposited.sub(collateralFee); + await feeCollateral.connect(alice).approve(feeComet.address, COLLATERAL_TOKEN_AMOUNT); + await feeComet.connect(alice).supply(feeCollateral.address, COLLATERAL_TOKEN_AMOUNT); + + // we are checking that the (amount - fee) is considered as deposit + expect((await feeComet.userBasic(alice.address)).principal).to.equal(baseAmountWithoutFee); + expect((await feeComet.userCollateral(alice.address, feeCollateral.address)).balance).to.equal(collateralAmountWithoutFee); + }); + + it('no fee is charged for transfer base token - fee-on-transfer token', async () => { + const feeBalanceBefore = await feeBaseToken.balanceOf(feeBaseToken.address); + + transferFeeTx = await feeComet.connect(alice).transfer(bob.address, baseAmountWithoutFee); + await expect(transferFeeTx).to.not.be.reverted; + + // bob's principal should be equal to the transferred amount (no fee is charged) + expect((await feeComet.userBasic(bob.address)).principal).to.equal(baseAmountWithoutFee); + + const feeBalanceAfter = await feeBaseToken.balanceOf(feeBaseToken.address); + + // no fee is charged + expect(feeBalanceAfter.sub(feeBalanceBefore)).to.equal(0); + }); + + it('correct amount in the Transfer event (withdraw) - fee-on-transfer token', async () => { + // event should contain amount without fee + await expect(transferFeeTx).to.emit(feeComet, 'Transfer').withArgs(alice.address, ZERO_ADDRESS, baseAmountWithoutFee); + }); + + it('correct amount in the Transfer event (supply) - fee-on-transfer token', async () => { + // event should contain amount without fee + await expect(transferFeeTx).to.emit(feeComet, 'Transfer').withArgs(ZERO_ADDRESS, bob.address, baseAmountWithoutFee); + }); + + it('no fee is charged for transfer collateral token - fee-on-transfer token', async () => { + const feeBalanceBefore = await feeCollateral.balanceOf(feeCollateral.address); + + transferFeeTx = await feeComet.connect(alice).transferAsset(bob.address, feeCollateral.address, collateralAmountWithoutFee); + await expect(transferFeeTx).to.not.be.reverted; + + const feeBalanceAfter = await feeCollateral.balanceOf(feeCollateral.address); + + // no fee is charged + expect(feeBalanceAfter.sub(feeBalanceBefore)).to.equal(0); + + // bob's collateral balance should be equal to the transferred amount + expect((await feeComet.userCollateral(bob.address, feeCollateral.address)).balance).to.equal(collateralAmountWithoutFee); + }); + + it('correct amount in the TransferCollateral event - fee-on-transfer token', async () => { + // event should contain amount without fee - the actual received on the contract + await expect(transferFeeTx).to.emit(feeComet, 'TransferCollateral').withArgs(alice.address, bob.address, feeCollateral.address, collateralAmountWithoutFee); + }); + }); + }); - const totals0 = await setTotalsBasic(comet, { - baseSupplyIndex: 2e15, + /*////////////////////////////////////////////////////////////// + DEACTIVATE COLLATERAL FEATURE + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Transfer path behavior when collateral is deactivated and reactivated. + * @dev + * While a collateral is deactivated, `transferAsset` of that collateral reverts + * with `CollateralAssetTransferPaused(index)`, and a base `transfer` from a + * borrower holding that collateral reverts with + * `TokenIsDeactivated(collateralToken)` because the collateral no longer counts + * in `isBorrowCollateralized`. After reactivation, both `transferAsset` and + * borrower base `transfer` work again and update `userCollateral` / `userBasic` + * as usual. The MAX_ASSETS loop asserts the same deactivate-revert / + * reactivate-succeed behavior for every asset index in a full + * `cometWith24Collaterals` configuration. + * + * Context: in the wUSDM / deUSD incident scenario, deactivation must freeze + * movement of the affected collateral and any borrow-dependent base transfers + * until governance reactivates it. + */ + describe('deactivated collateral transfer flow', function () { + before(async function () { + await snapshot.restore(); + + await baseToken.allocateTo(bob.address, baseTokenSupplyAmount); + await collateralToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await baseToken.allocateTo(dave.address, baseTokenSupplyAmount); + await collateralToken.allocateTo(dave.address, collateralTokenSupplyAmount); + // Allocate some additional base tokens to the comet for borrowing + await baseToken.allocateTo(comet.address, baseTokenSupplyAmount * 5n); + + await collateralToken.connect(bob).approve(comet.address, collateralTokenSupplyAmount); + await comet.connect(bob).supply(collateralToken.address, collateralTokenSupplyAmount); + + await baseToken.connect(bob).approve(comet.address, baseTokenSupplyAmount); + await comet.connect(bob).supply(baseToken.address, baseTokenSupplyAmount); + + await collateralToken.connect(dave).approve(comet.address, collateralTokenSupplyAmount); + await comet.connect(dave).supply(collateralToken.address, collateralTokenSupplyAmount); + + await comet.connect(dave).withdraw(baseToken.address, exp(1, 6)); + + aliceBasicBefore = await comet.userBasic(alice.address); + aliceCollateralBefore = await comet.userCollateral(alice.address, collateralToken.address); + daveCollateralBefore = await comet.userCollateral(dave.address, collateralToken.address); + daveBasicBefore = await comet.userBasic(dave.address); + + // Allow alice to act on behalf of bob for transferFrom calls + await comet.connect(dave).allow(alice.address, true); + await cometWith24Collaterals.connect(bob).allow(alice.address, true); + + snapshot = await takeSnapshot(); }); - const alice0 = await portfolio(protocol, alice.address); - const bob0 = await portfolio(protocol, bob.address); + it('allows pause guardian to deactivate a token', async function () { + await expect(comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await wait(cometAsB.transferAsset(alice.address, USDC.address, 100e6)); - const totals1 = await comet.totalsBasic(); - const alice1 = await portfolio(protocol, alice.address); - const bob1 = await portfolio(protocol, bob.address); + it('asset transfer call reverts', async function () { + await expect( + comet.connect(dave).transferAsset(alice.address, collateralToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(deactivatedCollateralIndex); + }); - expect(alice0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob0.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice1.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(totals1.totalSupplyBase).to.be.equal(totals0.totalSupplyBase); - expect(totals1.totalBorrowBase).to.be.equal(totals0.totalBorrowBase); - }); + it('base token transfer reverts when user has deactivated collateral and borrow position', async function () { + expect((await comet.userBasic(dave.address)).principal).to.be.lessThan(0); - it('reverts if the asset is neither collateral nor base', async () => { - const protocol = await makeProtocol(); - const { - comet, - users: [alice, bob], - unsupportedToken: USUP, - } = protocol; + await expect( + comet.connect(dave).transfer(alice.address, baseTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); + }); - const cometAsB = comet.connect(bob); + it('allows governor to activate a token', async function () { + await expect(comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await expect(cometAsB.transferAsset(alice.address, USUP.address, 1)).to.be.reverted; - }); + it('allows to transfer activated collateral', async function () { + await comet.connect(dave).transferAsset(alice.address, collateralToken.address, collateralTokenTransferAmount); + }); - it('reverts if transfer is paused', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, pauseGuardian, users: [alice, bob] } = protocol; - const { USDC } = tokens; + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); + const aliceCollateralAfter = await comet.userCollateral(alice.address, collateralToken.address); - const cometAsB = comet.connect(bob); + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenTransferAmount); + expect(aliceCollateralAfter.balance.sub(aliceCollateralBefore.balance)).to.eq(collateralTokenTransferAmount); + }); - // Pause transfer - await wait(comet.connect(pauseGuardian).pause(false, true, false, false, false)); - expect(await comet.isTransferPaused()).to.be.true; + it('allows to transfer base token', async function () { + await comet.connect(dave).transfer(alice.address, baseTokenSupplyAmount); + }); - await expect(cometAsB.transferAsset(alice.address, USDC.address, 1)).to.be.revertedWith("custom error 'Paused()'"); - }); + it('updates users principals', async function () { + const aliceBasicAfter = await comet.userBasic(alice.address); + const daveBasicAfter = await comet.userBasic(dave.address); - it('reverts if transfer max for a collateral asset', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; + expect(aliceBasicAfter.principal.sub(aliceBasicBefore.principal)).to.be.closeTo(baseTokenSupplyAmount, 1); + expect(daveBasicAfter.principal.sub(daveBasicBefore.principal)).to.be.closeTo(-baseTokenSupplyAmount, 1); + }); - await COMP.allocateTo(bob.address, 100e6); - const cometAsB = comet.connect(bob); + for (let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; - await expect(cometAsB.transferAsset(alice.address, COMP.address, ethers.constants.MaxUint256)).to.be.revertedWith("custom error 'InvalidUInt128()'"); - }); + it(`reverts on deactivated collateral transfer with index ${i}`, async () => { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - it('borrows base if collateralized', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol(); - const { WETH, USDC } = tokens; + // Supply the asset first + await assetToken.allocateTo(dave.address, collateralTokenSupplyAmount); + await assetToken.connect(dave).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(dave).supply(assetToken.address, collateralTokenSupplyAmount); - await comet.setCollateralBalance(alice.address, WETH.address, exp(1, 18)); + // Pause specific collateral asset transfer at index assetIndex + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); - let t0 = await comet.totalsBasic(); - await setTotalsBasic(comet, { - baseBorrowIndex: t0.baseBorrowIndex.mul(2), - }); + await expect( + cometWith24Collaterals.connect(dave).transferAsset(alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(cometWith24Collaterals, 'CollateralAssetTransferPaused').withArgs(assetIndex); + }); - await comet.connect(alice).transferAsset(bob.address, USDC.address, 100e6); + it(`allows to transfer re-activated collateral with index ${i}`, async () => { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - expect(await baseBalanceOf(comet, alice.address)).to.eq(BigInt(-100e6)); + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + await expect( + cometWith24Collaterals.connect(dave).transferAsset(alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + + expect((await cometWith24Collaterals.userCollateral(alice.address, assetToken.address)).balance).to.be.equal(collateralTokenSupplyAmount); + expect((await cometWith24Collaterals.userCollateral(dave.address, assetToken.address)).balance).to.be.equal(0n); + }); + } }); - it('cant borrow less than the minimum', async () => { - const protocol = await makeProtocol(); - const { - comet, - tokens, - users: [alice, bob], - } = protocol; - const { USDC } = tokens; + describe('deactivated collateral transferFrom flow', function () { + it('allows pause guardian to deactivate a token', async function () { + await snapshot.restore(); - const cometAsB = comet.connect(bob); + await expect(comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - const amount = (await comet.baseBorrowMin()).sub(1); - await expect(cometAsB.transferAsset(alice.address, USDC.address, amount)).to.be.revertedWith( - "custom error 'BorrowTooSmall()'" - ); - }); + it('asset transferFrom call reverts', async function () { + await expect( + comet.connect(alice).transferAssetFrom(dave.address, alice.address, collateralToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(deactivatedCollateralIndex); + }); - it('reverts on self-transfer of base token', async () => { - const { - comet, - tokens, - users: [alice], - } = await makeProtocol({ base: 'USDC' }); - const { USDC } = tokens; - - await expect( - comet.connect(alice).transferAsset(alice.address, USDC.address, 100) - ).to.be.revertedWith("custom error 'NoSelfTransfer()'"); - }); + it('base token transferFrom reverts when user has deactivated collateral and borrow position', async function () { + expect((await comet.userBasic(dave.address)).principal).to.be.lessThan(0); - it('reverts on self-transfer of collateral', async () => { - const { - comet, - tokens, - users: [alice], - } = await makeProtocol(); - const { COMP } = tokens; - - await expect( - comet.connect(alice).transferAsset(alice.address, COMP.address, 100) - ).to.be.revertedWith("custom error 'NoSelfTransfer()'"); - }); + await expect( + comet.connect(alice).transferFrom(dave.address, alice.address, baseTokenSupplyAmount) + ).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); + }); - it('reverts if transferring base results in an under collateralized borrow', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol(); - const { USDC } = tokens; + it('allows governor to activate a token', async function () { + await expect(comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await expect( - comet.connect(alice).transferAsset(bob.address, USDC.address, 100e6) - ).to.be.revertedWith("custom error 'NotCollateralized()'"); - }); + it('allows to transferFrom activated collateral', async function () { + await comet.connect(alice).transferAssetFrom(dave.address, alice.address, collateralToken.address, collateralTokenTransferAmount); + }); - it('reverts if transferring collateral results in an under collateralized borrow', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol(); - const { WETH } = tokens; + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); + const aliceCollateralAfter = await comet.userCollateral(alice.address, collateralToken.address); - // user has a borrow, but with collateral to cover - await comet.setBasePrincipal(alice.address, -100e6); - await comet.setCollateralBalance(alice.address, WETH.address, exp(1, 18)); + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenTransferAmount); + expect(aliceCollateralAfter.balance.sub(aliceCollateralBefore.balance)).to.eq(collateralTokenTransferAmount); + }); - // reverts if transfer would leave the borrow uncollateralized - await expect( - comet.connect(alice).transferAsset(bob.address, WETH.address, exp(1, 18)) - ).to.be.revertedWith("custom error 'NotCollateralized()'"); - }); -}); + it('allows to transferFrom base token', async function () { + await comet.connect(alice).transferFrom(dave.address, alice.address, baseTokenSupplyAmount); + }); -describe('transferFrom', function () { - it('transfers from src if specified and sender has permission', async () => { - const protocol = await makeProtocol(); - const { - comet, - tokens, - users: [alice, bob, charlie], - } = protocol; - const { COMP } = tokens; - - const _i0 = await comet.setCollateralBalance(bob.address, COMP.address, 7); - const cometAsB = comet.connect(bob); - const cometAsC = comet.connect(charlie); - - const _a1 = await wait(cometAsB.allow(charlie.address, true)); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _s0 = await wait(cometAsC.transferAssetFrom(bob.address, alice.address, COMP.address, 7)); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 7n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 7n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - }); + it('updates users principals', async function () { + const aliceBasicAfter = await comet.userBasic(alice.address); + const daveBasicAfter = await comet.userBasic(dave.address); - it('reverts if src is specified and sender does not have permission', async () => { - const protocol = await makeProtocol(); - const { - comet, - tokens, - users: [alice, bob, charlie], - } = protocol; - const { COMP } = tokens; - - const _i0 = await comet.setCollateralBalance(bob.address, COMP.address, 7); - const cometAsC = comet.connect(charlie); - - await expect( - cometAsC.transferAssetFrom(bob.address, alice.address, COMP.address, 7) - ).to.be.revertedWith("custom error 'Unauthorized()'"); - }); + expect(aliceBasicAfter.principal.sub(aliceBasicBefore.principal)).to.be.closeTo(baseTokenSupplyAmount, 1); + expect(daveBasicAfter.principal.sub(daveBasicBefore.principal)).to.be.closeTo(-baseTokenSupplyAmount, 1); + }); - it('reverts on transfer of base token from address to itself', async () => { - const { - comet, - tokens, - users: [alice, bob], - } = await makeProtocol({ base: 'USDC' }); - const { USDC } = tokens; + for (let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; - await comet.connect(bob).allow(alice.address, true); + it(`reverts on deactivated collateral transferFrom with index ${i}`, async () => { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - await expect( - comet.connect(alice).transferAssetFrom(bob.address, bob.address, USDC.address, 100) - ).to.be.revertedWith("custom error 'NoSelfTransfer()'"); - }); + // Supply the asset first + await assetToken.allocateTo(dave.address, collateralTokenSupplyAmount); + await assetToken.connect(dave).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(dave).supply(assetToken.address, collateralTokenSupplyAmount); - it('reverts on transfer of collateral from address to itself', async () => { - const { - comet, - tokens, - users: [alice, bob], - } = await makeProtocol(); - const { COMP } = tokens; + await cometWith24Collaterals.connect(dave).allow(alice.address, true); - await comet.connect(bob).allow(alice.address, true); + // Pause specific collateral asset transfer at index assetIndex + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); - await expect( - comet.connect(alice).transferAssetFrom(bob.address, bob.address, COMP.address, 100) - ).to.be.revertedWith("custom error 'NoSelfTransfer()'"); - }); + await expect( + cometWith24Collaterals.connect(alice).transferAssetFrom(dave.address, alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError(cometWith24Collaterals, 'CollateralAssetTransferPaused').withArgs(assetIndex); + }); - it('reverts if transfer is paused', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, pauseGuardian, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; + it(`allows to transferFrom re-activated collateral with index ${i}`, async () => { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - await comet.setCollateralBalance(bob.address, COMP.address, 7); - const cometAsB = comet.connect(bob); - const cometAsC = comet.connect(charlie); + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); - // Pause transfer - await wait(comet.connect(pauseGuardian).pause(false, true, false, false, false)); - expect(await comet.isTransferPaused()).to.be.true; + await expect( + cometWith24Collaterals.connect(alice).transferAssetFrom(dave.address, alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; - await wait(cometAsB.allow(charlie.address, true)); - await expect(cometAsC.transferAssetFrom(bob.address, alice.address, COMP.address, 7)).to.be.revertedWith("custom error 'Paused()'"); + expect((await cometWith24Collaterals.userCollateral(dave.address, assetToken.address)).balance).to.be.equal(0n); + expect((await cometWith24Collaterals.userCollateral(alice.address, assetToken.address)).balance).to.be.equal(collateralTokenSupplyAmount); + }); + } }); }); diff --git a/test/upgrades/extended-pause-upgrade-test.ts b/test/upgrades/extended-pause-upgrade-test.ts new file mode 100644 index 000000000..3a8e4e7fb --- /dev/null +++ b/test/upgrades/extended-pause-upgrade-test.ts @@ -0,0 +1,279 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { setupFork, impersonateAccount, setBalance, SnapshotRestorer, takeSnapshot} from '../helpers'; +import { + CometExtAssetList__factory, + CometFactoryWithExtendedAssetList__factory, + CometProxyAdmin, + CometWithExtendedAssetList, + Configurator, + CometExtAssetList, +} from 'build/types'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { BigNumber, ContractTransaction } from 'ethers'; +import { TotalsBasicStructOutput } from 'build/types/CometExtAssetList'; + +describe('extended pause upgrade test', function () { + // Constants + const FORK_BLOCK_NUMBER = 23655019; + const COMET_ADDRESS = '0xc3d688B66703497DAA19211EEdff47f25384cdc3'; + const CONFIGURATOR_ADDRESS = '0x316f9708bB98af7dA9c68C1C3b5e79039cD336E3'; + const GOVERNOR_ADDRESS = '0x6d903f6003cca6255d85cca4d3b5e5146dc33925'; + const ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103'; + + // Contracts + let comet: CometWithExtendedAssetList; + let cometExt: CometExtAssetList; + let configurator: Configurator; + let proxyAdmin: CometProxyAdmin; + let newCometExt: CometExtAssetList; + + // Signers + let governor: SignerWithAddress; + let pauseGuardian: SignerWithAddress; + + // Variables + let assetListFactoryAddress: string; + let name32: string; + let symbol32: string; + let originalImpl: string; + let newImpl: string; + + // Extension delegate storage snapshot + let assetListFactoryBefore: string; + let maxAssetsBefore: number; + let versionBefore: string; + let nameBefore: string; + let symbolBefore: string; + let baseAccrualScaleBefore: BigNumber; + let baseIndexScaleBefore: BigNumber; + let factorScaleBefore: BigNumber; + let priceScaleBefore: BigNumber; + + // Immutable or constants snapshot + let governorBefore: string; + let pauseGuardianBefore: string; + let baseTokenBefore: string; + let baseTokenPriceFeedBefore: string; + let supplyKinkBefore: BigNumber; + + // Totals basic snapshot + let totalsBasicBefore: TotalsBasicStructOutput; + + // Upgrade transaction + let upgradeTx: ContractTransaction; + + // Snapshot + let snapshot: SnapshotRestorer; + + before(async function () { + // Setup mainnet fork + await setupFork(FORK_BLOCK_NUMBER); + + // Get contracts + comet = (await ethers.getContractAt( + 'CometWithExtendedAssetList', + COMET_ADDRESS + )) as CometWithExtendedAssetList; + + configurator = (await ethers.getContractAt( + 'Configurator', + CONFIGURATOR_ADDRESS + )) as Configurator; + + // Get proxy admin + const adminAddress = await ethers.provider.getStorageAt( + COMET_ADDRESS, + ADMIN_SLOT + ); + const proxyAdminAddress = ethers.utils.getAddress( + '0x' + adminAddress.slice(26) + ); + proxyAdmin = (await ethers.getContractAt( + 'CometProxyAdmin', + proxyAdminAddress + )) as CometProxyAdmin; + + // Impersonate governor + await impersonateAccount(GOVERNOR_ADDRESS); + governor = await ethers.getSigner(GOVERNOR_ADDRESS); + await setBalance(GOVERNOR_ADDRESS, ethers.utils.parseEther('10000')); + + // Get current extension delegate and its assetListFactory + const currentExtensionDelegate = await comet.extensionDelegate(); + const CometExtAssetListInterface = await ethers.getContractAt( + 'IAssetListFactoryHolder', + currentExtensionDelegate + ); + assetListFactoryAddress = + await CometExtAssetListInterface.assetListFactory(); + + // Get name and symbol from current extension delegate + const ExtInterface = await ethers.getContractAt( + 'CometExtInterface', + currentExtensionDelegate + ); + name32 = ethers.utils.formatBytes32String(await ExtInterface.name()); + symbol32 = ethers.utils.formatBytes32String(await ExtInterface.symbol()); + + // Get current implementation + originalImpl = await proxyAdmin.getProxyImplementation(COMET_ADDRESS); + + // Deploy new version of CometExtAssetList (with extended pause functionality) + const CometExtAssetList = (await ethers.getContractFactory( + 'CometExtAssetList' + )) as CometExtAssetList__factory; + newCometExt = await CometExtAssetList.deploy( + { name32, symbol32 }, + assetListFactoryAddress + ); + + // Deploy CometFactoryWithExtendedAssetList + const CometFactoryWithExtendedAssetList = (await ethers.getContractFactory( + 'CometFactoryWithExtendedAssetList' + )) as CometFactoryWithExtendedAssetList__factory; + const newFactory = await CometFactoryWithExtendedAssetList.deploy(); + + // Step 1: Set the new extension delegate in configurator + await configurator + .connect(governor) + .setExtensionDelegate(COMET_ADDRESS, newCometExt.address); + + // Step 2: Set the new factory in the configurator + await configurator + .connect(governor) + .setFactory(COMET_ADDRESS, newFactory.address); + + // Deploy new implementation using configurator + const deployTx = await configurator.connect(governor).deploy(COMET_ADDRESS); + const deployReceipt = await deployTx.wait(); + const deployEvent = deployReceipt.events.find((e) => e.event === 'CometDeployed'); + newImpl = deployEvent.args.newComet; + expect(newImpl).to.not.equal(ethers.constants.AddressZero); + expect(newImpl).to.not.equal(originalImpl); + + cometExt = await ethers.getContractAt('CometExtAssetList', COMET_ADDRESS) as CometExtAssetList; + + // Extension delegate storage snapshot + assetListFactoryBefore = await cometExt.assetListFactory(); + maxAssetsBefore = await cometExt.maxAssets(); + versionBefore = await cometExt.version(); + nameBefore = await cometExt.name(); + symbolBefore = await cometExt.symbol(); + baseAccrualScaleBefore = await cometExt.baseAccrualScale(); + baseIndexScaleBefore = await cometExt.baseIndexScale(); + factorScaleBefore = await cometExt.factorScale(); + priceScaleBefore = await cometExt.priceScale(); + + // Immutable or constants snapshot + governorBefore = await comet.governor(); + pauseGuardianBefore = await comet.pauseGuardian(); + baseTokenBefore = await comet.baseToken(); + baseTokenPriceFeedBefore = await comet.baseTokenPriceFeed(); + supplyKinkBefore = await comet.supplyKink(); + + // Totals basic snapshot + totalsBasicBefore = await cometExt.totalsBasic(); + + // Impersonate governor + await impersonateAccount(pauseGuardianBefore); + pauseGuardian = await ethers.getSigner(pauseGuardianBefore); + await setBalance(pauseGuardianBefore, ethers.utils.parseEther('10000')); + + upgradeTx = await proxyAdmin.connect(governor).upgrade(COMET_ADDRESS, newImpl); + + snapshot = await takeSnapshot(); + }); + + it('should upgrade proxy to new implementation by governor', async function () { + await upgradeTx.wait(); + }); + + it('should update comet and comet extension delegate implementations', async function () { + expect(await comet.extensionDelegate()).to.equal(newCometExt.address); + expect(await proxyAdmin.getProxyImplementation(COMET_ADDRESS)).to.equal(newImpl); + }); + + it('should save comet extension storage safely after upgrade', async function () { + expect(await cometExt.assetListFactory()).to.equal(assetListFactoryBefore); + expect(await cometExt.maxAssets()).to.equal(maxAssetsBefore); + expect(await cometExt.version()).to.equal(versionBefore); + expect(await cometExt.name()).to.equal(nameBefore); + expect(await cometExt.symbol()).to.equal(symbolBefore); + expect(await cometExt.baseAccrualScale()).to.equal(baseAccrualScaleBefore); + expect(await cometExt.baseIndexScale()).to.equal(baseIndexScaleBefore); + expect(await cometExt.factorScale()).to.equal(factorScaleBefore); + expect(await cometExt.priceScale()).to.equal(priceScaleBefore); + }); + + it('should save comet storage safely after upgrade', async function () { + expect(await comet.governor()).to.equal(governorBefore); + expect(await comet.pauseGuardian()).to.equal(pauseGuardianBefore); + expect(await comet.baseToken()).to.equal(baseTokenBefore); + expect(await comet.baseTokenPriceFeed()).to.equal(baseTokenPriceFeedBefore); + expect(await comet.extensionDelegate()).to.equal(newCometExt.address); + expect(await comet.supplyKink()).to.equal(supplyKinkBefore); + expect(await cometExt.totalsBasic()).to.deep.equal(totalsBasicBefore); + }); + + it('should allow to call extended pause functions after upgrade', async function () { + // Call extended pause functions + await cometExt.connect(governor).pauseLendersWithdraw(true); + await cometExt.connect(governor).pauseBorrowersWithdraw(true); + await cometExt.connect(governor).pauseCollateralSupply(true); + await cometExt.connect(governor).pauseBaseSupply(true); + await cometExt.connect(governor).pauseCollateralAssetSupply(0, true); + await cometExt.connect(governor).pauseLendersTransfer(true); + await cometExt.connect(governor).pauseBorrowersTransfer(true); + await cometExt.connect(governor).pauseCollateralTransfer(true); + await cometExt.connect(governor).pauseCollateralAssetTransfer(0, true); + }); + + it('should update pause flags in comet storage', async function () { + expect(await comet.isLendersWithdrawPaused()).to.be.true; + expect(await comet.isBorrowersWithdrawPaused()).to.be.true; + expect(await comet.isCollateralSupplyPaused()).to.be.true; + expect(await comet.isBaseSupplyPaused()).to.be.true; + expect(await comet.isCollateralAssetSupplyPaused(0)).to.be.true; + expect(await comet.isLendersTransferPaused()).to.be.true; + expect(await comet.isBorrowersTransferPaused()).to.be.true; + expect(await comet.isCollateralTransferPaused()).to.be.true; + expect(await comet.isCollateralAssetTransferPaused(0)).to.be.true; + + await snapshot.restore(); + }); + + it('should allow to call deactivateCollateral function by pause guardian', async function () { + await cometExt.connect(pauseGuardian).deactivateCollateral(0); + }); + + it('should set collateral as deactivated in comet', async function () { + expect(await comet.isCollateralDeactivated(0)).to.be.true; + }); + + it('should update deactivated collaterals flag in comet storage', async function () { + expect(await comet.deactivatedCollaterals()).to.equal(1); + }); + + it('should update pause flags for deactivated collateral', async function () { + expect(await comet.isCollateralAssetSupplyPaused(0)).to.be.true; + expect(await comet.isCollateralAssetTransferPaused(0)).to.be.true; + }); + + it('should allow to call activateCollateral function by governor', async function () { + await cometExt.connect(governor).activateCollateral(0); + }); + + it('should set collateral as activated in comet', async function () { + expect(await comet.isCollateralDeactivated(0)).to.be.false; + }); + + it('should update deactivated collaterals flag in comet storage', async function () { + expect(await comet.deactivatedCollaterals()).to.equal(0); + }); + + it('should update pause flags for activated collateral', async function () { + expect(await comet.isCollateralAssetSupplyPaused(0)).to.be.false; + expect(await comet.isCollateralAssetTransferPaused(0)).to.be.false; + }); +}); diff --git a/test/withdraw-test.ts b/test/withdraw-test.ts index 7fe2c0f71..5ee8165b2 100644 --- a/test/withdraw-test.ts +++ b/test/withdraw-test.ts @@ -1,635 +1,2426 @@ -import { EvilToken, EvilToken__factory, FaucetToken } from '../build/types'; -import { baseBalanceOf, ethers, event, expect, exp, makeProtocol, portfolio, ReentryAttack, setTotalsBasic, wait, fastForward } from './helpers'; +import { ethers, expect, exp, makeProtocol, defaultAssets, ReentryAttack, fastForward, baseBalanceOf, takeSnapshot, SnapshotRestorer, MAX_ASSETS, UserCollateral } from './helpers'; +import { EvilToken, EvilToken__factory, NonStandardFaucetFeeToken__factory, NonStandardFaucetFeeToken, CometHarnessInterface, FaucetToken, CometHarnessInterfaceExtendedAssetList, SimplePriceFeed } from '../build/types'; +import { BigNumber, ContractTransaction } from 'ethers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { TotalsCollateralStruct } from 'build/types/CometHarnessInterfaceExtendedAssetList'; -describe('withdrawTo', function () { - it('withdraws base from sender if the asset is base', async () => { +describe('withdraw', function () { + const baseTokenDecimals = 6; + + let comet: CometHarnessInterfaceExtendedAssetList; + let baseToken: FaucetToken; + let collaterals: { [symbol: string]: FaucetToken }; + let priceFeeds: { [symbol: string]: SimplePriceFeed }; + let unsupportedToken: FaucetToken; + let collateralToken: FaucetToken; + + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let dave: SignerWithAddress; + let pauseGuardian: SignerWithAddress; + let governor: SignerWithAddress; + + /*////////////////////////////////////////////////////////////// + 24 COLLATERALS COMET SETUP + //////////////////////////////////////////////////////////////*/ + + let cometWith24Collaterals: CometHarnessInterfaceExtendedAssetList; + let tokensWith24Collaterals: { [symbol: string]: FaucetToken } = {}; + let baseTokenWith24Collaterals: FaucetToken; + + let baseSnapshot: SnapshotRestorer; + + const borrowAmount = exp(10, 6); + const collateralTokenSupplyAmount = exp(5, 18); + const baseTokenSupplyAmount = exp(100, 6); + let deactivatedCollateralIndex: number; + let daveCollateralBefore: UserCollateral; + let totalsCollateralBefore: TotalsCollateralStruct; + + before(async function () { const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - const _i0 = await USDC.allocateTo(comet.address, 100e6); - await setTotalsBasic(comet, { - totalSupplyBase: 100e6, + comet = protocol.cometWithExtendedAssetList; + baseToken = protocol.tokens[protocol.base] as FaucetToken; + collaterals = Object.fromEntries( + Object.entries(protocol.tokens).filter(([_symbol, token]) => token.address !== baseToken.address) + ) as { [symbol: string]: FaucetToken }; + priceFeeds = protocol.priceFeeds; + pauseGuardian = protocol.pauseGuardian; + unsupportedToken = protocol.unsupportedToken; + collateralToken = protocol.tokens['COMP'] as FaucetToken; + + governor = protocol.governor; + alice = protocol.users[0]; + bob = protocol.users[1]; + dave = protocol.users[2]; + + await baseToken.allocateTo(alice.address, exp(1e10, baseTokenDecimals)); + await baseToken.allocateTo(bob.address, exp(1e10, baseTokenDecimals)); + + /*////////////////////////////////////////////////////////////// + 24 COLLATERALS COMET SETUP + //////////////////////////////////////////////////////////////*/ + + const collaterals24Assets = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, { + initialPrice: 100, + decimals: 18, + }]) + ); + const protocolWith24Collaterals = await makeProtocol({ + assets: { USDC: {initialPrice: 1, decimals: 6 }, ...collaterals24Assets, }, + }); + cometWith24Collaterals = protocolWith24Collaterals.cometWithExtendedAssetList; + baseTokenWith24Collaterals = protocolWith24Collaterals.tokens[protocolWith24Collaterals.base] as FaucetToken; + for (const asset in protocolWith24Collaterals.tokens) { + if (asset === 'USDC') continue; + tokensWith24Collaterals[asset] = protocolWith24Collaterals.tokens[asset] as FaucetToken; + } + + const collateralAssetInfo = await comet.getAssetInfoByAddress(collateralToken.address); + deactivatedCollateralIndex = collateralAssetInfo.offset; + + baseSnapshot = await takeSnapshot(); + }); + + describe('withdraw base asset', function () { + describe('reverts', function () { + const COLLATERAL_AMOUNT = exp(100, 6); + const SUPPLY_AMOUNT = exp(100, 6); + const BORROW_AMOUNT = exp(80, 6); + const COLLATERAL_SUPPLY = exp(1, 18); + + it('reverts if withdraw is paused', async () => { + await comet.connect(pauseGuardian).pause(false, false, true, false, false); + expect(await comet.isWithdrawPaused()).to.be.true; + + await expect(comet.connect(alice).withdraw(baseToken.address, 1)).to.be.revertedWithCustomError(comet, 'Paused'); + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if withdrawing more than available liquidity', async () => { + const snapshot = await takeSnapshot(); + + await baseToken.connect(alice).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(alice).supply(baseToken.address, SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(bob.address, COLLATERAL_SUPPLY); + await collaterals['WETH'].connect(bob).approve(comet.address, COLLATERAL_SUPPLY); + await comet.connect(bob).supply(collaterals['WETH'].address, COLLATERAL_SUPPLY); + await comet.connect(bob).withdraw(baseToken.address, BORROW_AMOUNT); + + await expect( + comet.connect(alice).withdraw(baseToken.address, SUPPLY_AMOUNT) + ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); + + await snapshot.restore(); + }); + + it('reverts if withdraw max for a collateral asset', async () => { + const snapshot = await takeSnapshot(); + + const collateral = collaterals['COMP']; + await collateral.allocateTo(bob.address, COLLATERAL_AMOUNT); + + await expect( + comet.connect(bob).withdraw(collateral.address, ethers.constants.MaxUint256) + ).to.be.revertedWithCustomError(comet, 'InvalidUInt128'); + + await snapshot.restore(); + }); + + it('reverts if asset is neither collateral nor base (arithmetic underflow)', async () => { + await expect( + comet.connect(alice).withdraw(unsupportedToken.address, 1) + ).to.be.revertedWithPanic(0x11); // Arithmetic underflow + }); + + it('reverts if borrow amount exceeds collateral backing', async () => { + await expect( + comet.connect(alice).withdraw(baseToken.address, exp(1000, baseTokenDecimals)) + ).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + }); + + it('reverts if lender withdraw is paused (extended pause)', async () => { + const snapshot = await takeSnapshot(); + + await baseToken.connect(bob).approve(comet.address, exp(100, baseTokenDecimals)); + await comet.connect(bob).supply(baseToken.address, exp(100, baseTokenDecimals)); + + await comet.connect(pauseGuardian).pauseLendersWithdraw(true); + expect(await comet.isLendersWithdrawPaused()).to.be.true; + + await expect( + comet.connect(bob).withdraw(baseToken.address, exp(50, baseTokenDecimals)) + ).to.be.revertedWithCustomError(comet, 'LendersWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseLendersWithdraw(false); + await snapshot.restore(); + }); + }); + + describe('withdraw base: happy path', function () { + const SUPPLY_AMOUNT: bigint = exp(100, baseTokenDecimals); + let withdrawTx: ContractTransaction; + let bobTokenBalanceBefore: bigint; + let bobCometBalanceBefore: bigint; + let totalSupplyBaseBefore: bigint; + + before(async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + bobTokenBalanceBefore = (await baseToken.balanceOf(bob.address)).toBigInt(); + bobCometBalanceBefore = (await comet.balanceOf(bob.address)).toBigInt(); + totalSupplyBaseBefore = (await comet.totalsBasic()).totalSupplyBase.toBigInt(); + + withdrawTx = await comet.connect(bob).withdraw(baseToken.address, SUPPLY_AMOUNT); + }); + + it('bob comet balance before withdraw equals supply amount', async () => { + expect(bobCometBalanceBefore).to.equal(SUPPLY_AMOUNT); + }); + + it('total supply base before withdraw equals supply amount', async () => { + expect(totalSupplyBaseBefore).to.equal(SUPPLY_AMOUNT); + }); + + it('withdraw tx does not revert', async () => { + await expect(withdrawTx).to.not.be.reverted; + }); + + it('emits Transfer event (ERC20)', async () => { + await expect(withdrawTx) + .to.emit(baseToken, 'Transfer') + .withArgs(comet.address, bob.address, SUPPLY_AMOUNT); + }); + + it('emits Withdraw event', async () => { + await expect(withdrawTx) + .to.emit(comet, 'Withdraw') + .withArgs(bob.address, bob.address, SUPPLY_AMOUNT); + }); + + it('emits Transfer event (Comet burn)', async () => { + await expect(withdrawTx) + .to.emit(comet, 'Transfer') + .withArgs(bob.address, ethers.constants.AddressZero, SUPPLY_AMOUNT); + }); + + it('bob comet balance is zero after full withdrawal', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(0); + }); + + it('bob receives withdrawn tokens', async () => { + expect(await baseToken.balanceOf(bob.address)).to.equal(bobTokenBalanceBefore + SUPPLY_AMOUNT); + }); + + it('total supply base is zero after full withdrawal', async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(0n); + }); + + it('total borrow base is zero', async () => { + expect((await comet.totalsBasic()).totalBorrowBase).to.equal(0n); + }); + + it('gas used is within limit', async () => { + const receipt = await withdrawTx.wait(); + expect(Number(receipt.gasUsed)).to.be.lessThan(106000); + }); + }); + + describe('max withdraw + full accrued balance', function () { + const BOB_SUPPLY_AMOUNT = exp(100, 6); + const ALICE_COLLATERAL_AMOUNT = exp(10, 18); + const ALICE_BORROW_AMOUNT = exp(50, 6); + const TIME_FORWARD_SECONDS = 86400; // 24 hours + + let accrualSnapshot: SnapshotRestorer; + + before(async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).withdraw(baseToken.address, ALICE_BORROW_AMOUNT); + + accrualSnapshot = await takeSnapshot(); + }); + + describe('withdraw max base with accrued interest', function () { + let withdrawTx: ContractTransaction; + let bobAccruedBalance: bigint; + let aliceBalanceBefore: bigint; + + before(async () => { + await accrualSnapshot.restore(); + + await baseToken.allocateTo(comet.address, exp(60, 6)); + + await fastForward(TIME_FORWARD_SECONDS); + await ethers.provider.send('evm_mine', []); + + bobAccruedBalance = (await comet.callStatic.balanceOf(bob.address)).toBigInt(); + aliceBalanceBefore = (await baseToken.balanceOf(alice.address)).toBigInt(); + + withdrawTx = await comet.connect(bob).withdrawTo(alice.address, baseToken.address, ethers.constants.MaxUint256); + }); + + it('bob balance after accrual is greater than supplied amount', async () => { + expect(bobAccruedBalance).to.be.gt(BOB_SUPPLY_AMOUNT); + }); + + it('withdraw tx does not revert', async () => { + await expect(withdrawTx).to.not.be.reverted; + }); + + it('bob comet balance is zero after max withdrawal', async () => { + expect(await comet.balanceOf(bob.address)).to.equal(0); + }); + + it('alice receives full accrued balance', async () => { + expect(await baseToken.balanceOf(alice.address)).to.equal(aliceBalanceBefore + bobAccruedBalance); + }); + }); + + describe('user can withdraw full accrued balance (interest test)', function () { + let balanceAfterAccrual: bigint; + + before(async () => { + await accrualSnapshot.restore(); + + await fastForward(TIME_FORWARD_SECONDS); + await ethers.provider.send('evm_mine', []); + + balanceAfterAccrual = (await comet.callStatic.balanceOf(bob.address)).toBigInt(); + + await baseToken.allocateTo(alice.address, exp(60, 6)); + await baseToken.connect(alice).approve(comet.address, exp(60, 6)); + await comet.connect(alice).supply(baseToken.address, exp(60, 6)); + + await comet.connect(bob).withdraw(baseToken.address, balanceAfterAccrual); + }); + + it('balance after accrual is >= supplied amount', async () => { + expect(balanceAfterAccrual).to.be.gte(BOB_SUPPLY_AMOUNT); + }); + + it('bob final comet balance is zero', async () => { + const finalBalance = await comet.callStatic.balanceOf(bob.address); + expect(finalBalance).to.be.equal(0); + }); + }); + + describe('withdraw to different recipient after interest accrual', function () { + let balanceAfterAccrual: bigint; + + before(async () => { + await accrualSnapshot.restore(); + + await fastForward(TIME_FORWARD_SECONDS); + await ethers.provider.send('evm_mine', []); + + balanceAfterAccrual = (await comet.callStatic.balanceOf(bob.address)).toBigInt(); + + await baseToken.allocateTo(alice.address, exp(60, 6)); + await baseToken.connect(alice).approve(comet.address, exp(60, 6)); + await comet.connect(alice).supply(baseToken.address, exp(60, 6)); + }); + + it('bob accrued balance is >= supplied amount', async () => { + expect(balanceAfterAccrual).to.be.gte(BOB_SUPPLY_AMOUNT); + }); + + it('alice receives full accrued balance and bob comet balance is zero', async () => { + const aliceBalanceBefore = await baseToken.balanceOf(alice.address); + await comet.connect(bob).withdrawTo(alice.address, baseToken.address, balanceAfterAccrual); + + expect(await baseToken.balanceOf(alice.address)).to.equal(aliceBalanceBefore.add(balanceAfterAccrual)); + expect(await comet.balanceOf(bob.address)).to.equal(0); + }); + }); + }); + + describe('withdraw max base with borrow position (edge case)', function () { + const ALICE_SUPPLY_AMOUNT = exp(200, 6); + const BOB_COLLATERAL_AMOUNT = exp(1, 18); + const BOB_BORROW_AMOUNT = exp(100, 6); + + let withdrawTx: ContractTransaction; + let aliceBalanceBefore: bigint; + + before(async () => { + await baseSnapshot.restore(); + + await baseToken.connect(alice).approve(comet.address, ALICE_SUPPLY_AMOUNT); + await comet.connect(alice).supply(baseToken.address, ALICE_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(bob.address, BOB_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(bob).approve(comet.address, BOB_COLLATERAL_AMOUNT); + await comet.connect(bob).supply(collaterals['WETH'].address, BOB_COLLATERAL_AMOUNT); + await comet.connect(bob).withdraw(baseToken.address, BOB_BORROW_AMOUNT); + + aliceBalanceBefore = (await baseToken.balanceOf(alice.address)).toBigInt(); + + withdrawTx = await comet.connect(bob).withdrawTo(alice.address, baseToken.address, ethers.constants.MaxUint256); + }); + + it('emits Transfer event with 0 amount (no tokens transferred)', async () => { + await expect(withdrawTx) + .to.emit(baseToken, 'Transfer') + .withArgs(comet.address, alice.address, 0); + }); + + it('emits Withdraw event with 0 amount', async () => { + await expect(withdrawTx) + .to.emit(comet, 'Withdraw') + .withArgs(bob.address, alice.address, 0); + }); + + it('alice balance unchanged', async () => { + expect(await baseToken.balanceOf(alice.address)).to.equal(aliceBalanceBefore); + }); + + it('gas used is within limit', async () => { + const receipt = await withdrawTx.wait(); + expect(Number(receipt.gasUsed)).to.be.lessThan(121000); + }); + }); + + describe('edge cases', function () { + describe('borrow without base supply (no Transfer burn event)', function () { + const ALICE_SUPPLY_AMOUNT = exp(110, 6); + const BOB_COLLATERAL_AMOUNT = exp(1, 18); + const BORROW_AMOUNT = exp(1, 6); + + let withdrawTx: ContractTransaction; + + before(async () => { + await baseSnapshot.restore(); + + await baseToken.connect(alice).approve(comet.address, ALICE_SUPPLY_AMOUNT); + await comet.connect(alice).supply(baseToken.address, ALICE_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(bob.address, BOB_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(bob).approve(comet.address, BOB_COLLATERAL_AMOUNT); + await comet.connect(bob).supply(collaterals['WETH'].address, BOB_COLLATERAL_AMOUNT); + + withdrawTx = await comet.connect(bob).withdrawTo(alice.address, baseToken.address, BORROW_AMOUNT); + }); + + it('emits exactly 2 events (no Transfer burn)', async () => { + const receipt = await withdrawTx.wait(); + expect(receipt.events.length).to.be.equal(2); + }); + + it('emits Transfer event (ERC20)', async () => { + await expect(withdrawTx) + .to.emit(baseToken, 'Transfer') + .withArgs(comet.address, alice.address, BORROW_AMOUNT); + }); + + it('emits Withdraw event', async () => { + await expect(withdrawTx) + .to.emit(comet, 'Withdraw') + .withArgs(bob.address, alice.address, BORROW_AMOUNT); + }); + }); + + describe('withdraw 0 with collateral only position', function () { + const COLLATERAL_AMOUNT = exp(1, 18); + + it('withdraws 0 base with only collateral position (no base supplied)', async () => { + await baseSnapshot.restore(); + + await collaterals['WETH'].allocateTo(alice.address, COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, COLLATERAL_AMOUNT); + + const tx = await comet.connect(alice).withdraw(baseToken.address, 0); + + await expect(tx) + .to.emit(baseToken, 'Transfer') + .withArgs(comet.address, alice.address, 0); + }); + }); + }); + }); + + describe('withdraw collateral', function () { + before(async () => { + await baseSnapshot.restore(); + }); + + describe('reverts', function () { + const BOB_SUPPLY_AMOUNT = exp(200, 6); + const ALICE_COLLATERAL_AMOUNT = exp(1, 18); + const BORROW_AMOUNT = exp(100, 6); + const COLLATERAL_SUPPLY = exp(1, 18); + + it('reverts if withdraw is paused', async () => { + await comet.connect(pauseGuardian).pause(false, false, true, false, false); + expect(await comet.isWithdrawPaused()).to.be.true; + + await expect(comet.connect(alice).withdraw(collaterals['COMP'].address, 1)).to.be.revertedWithCustomError(comet, 'Paused'); + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if collateral withdraw is paused (extended pause)', async () => { + await comet.connect(pauseGuardian).pauseCollateralWithdraw(true); + expect(await comet.isCollateralWithdrawPaused()).to.be.true; + + await expect( + comet.connect(alice).withdraw(collaterals['COMP'].address, 1) + ).to.be.revertedWithCustomError(comet, 'CollateralWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseCollateralWithdraw(false); + }); + + it('reverts if withdrawing more collateral than supplied', async () => { + await baseSnapshot.restore(); + + await collaterals['WETH'].allocateTo(alice.address, COLLATERAL_SUPPLY); + await collaterals['WETH'].connect(alice).approve(comet.address, COLLATERAL_SUPPLY); + await comet.connect(alice).supply(collaterals['WETH'].address, COLLATERAL_SUPPLY); + await expect( + comet.connect(alice).withdraw(collaterals['WETH'].address, COLLATERAL_SUPPLY + 1n) + ).to.be.revertedWithPanic(0x11); + }); + + it('reverts if collateral withdraw amount is not collateralized', async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).withdraw(baseToken.address, BORROW_AMOUNT); + + // alice has 1 WETH as collateral and borrowed 100 USDC + // Withdrawing all WETH leaves 0 weighted collateral, but debt = 100 USDC ($100) + // 0 < 100 → NotCollateralized + await expect( + comet.connect(alice).withdraw(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + }); + + describe('oracle reverts (with borrow position)', function () { + const ALICE_WETH_SUPPLY = exp(2, 18); + let oracleSnapshot: SnapshotRestorer; + + before(async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_WETH_SUPPLY); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_WETH_SUPPLY); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_WETH_SUPPLY); + await comet.connect(alice).withdraw(baseToken.address, BORROW_AMOUNT); + + oracleSnapshot = await takeSnapshot(); + }); + + it('reverts collateral withdraw if collateral oracle returns 0', async () => { + await priceFeeds.WETH.setRoundData(1, 0, 0, 0, 1); + + await expect( + comet.connect(alice).withdraw(collaterals['WETH'].address, exp(1, 18)) + ).to.be.revertedWithCustomError(comet, 'BadPrice'); + }); + + it('reverts collateral withdraw if base oracle returns 0', async () => { + await oracleSnapshot.restore(); + + await priceFeeds.USDC.setRoundData(1, 0, 0, 0, 1); + + await expect( + comet.connect(alice).withdraw(collaterals['WETH'].address, exp(1, 18)) + ).to.be.revertedWithCustomError(comet, 'BadPrice'); + }); + }); + }); + + describe('withdraw collateral: happy path', function () { + const COLLATERAL_SUPPLY_AMOUNT: bigint = exp(8, 8); + // Alice supplies base so totalSupplyBase > baseMinForRewards, enabling trackingSupplyIndex growth + const ALICE_BASE_SUPPLY: bigint = exp(10000, 6); + const SKIP_TIME: number = 60 * 60; // 1 hr + + let collateral: FaucetToken; + let withdrawTx: ContractTransaction; + let aliceBalanceBefore: typeof ethers.BigNumber.prototype; + let totalSupplyBefore: typeof ethers.BigNumber.prototype; + let totalCollateralSupplyBefore: BigNumber; + let totalSupplyBaseBefore: BigNumber; + let alicePrincipalBefore: BigNumber; + let aliceDisplayBalanceBefore: BigNumber; + let cometSupplyIndexBefore: BigNumber; + let cometSupplyRateBefore: BigNumber; + let cometUpdatedTimeBefore: number; + let cometBorrowIndexBefore: BigNumber; + let trackingSupplyIndexBefore: BigNumber; + let trackingBorrowIndexBefore: BigNumber; + let bobBaseTrackingAccruedBefore: BigNumber; + let baseTrackingSupplySpeedVal: BigNumber; + let bobCollateralBalanceBefore: BigNumber; + let borrowRateBefore: BigNumber; + let utilizationBefore: BigNumber; + let withdrawTimestamp: BigNumber; + + before(async () => { + await baseSnapshot.restore(); + + // Supply base tokens so totalSupplyBase >= baseMinForRewards, enabling trackingSupplyIndex growth + await baseToken.connect(alice).approve(comet.address, ALICE_BASE_SUPPLY); + await comet.connect(alice).supply(baseToken.address, ALICE_BASE_SUPPLY); + + collateral = collaterals['COMP']; + await collateral.allocateTo(bob.address, COLLATERAL_SUPPLY_AMOUNT); + await collateral.connect(bob).approve(comet.address, COLLATERAL_SUPPLY_AMOUNT); + await comet.connect(bob).supply(collateral.address, COLLATERAL_SUPPLY_AMOUNT); + + aliceBalanceBefore = await collateral.balanceOf(alice.address); + totalSupplyBefore = (await comet.totalsCollateral(collateral.address)).totalSupplyAsset; + bobCollateralBalanceBefore = (await comet.userCollateral(bob.address, collateral.address)).balance; + const totals = await comet.totalsBasic(); + totalCollateralSupplyBefore = (await comet.totalsCollateral(collateral.address)).totalSupplyAsset; + totalSupplyBaseBefore = totals.totalSupplyBase; + alicePrincipalBefore = (await comet.userBasic(alice.address)).principal; + aliceDisplayBalanceBefore = await comet.balanceOf(alice.address); + cometSupplyIndexBefore = totals.baseSupplyIndex; + cometSupplyRateBefore = await comet.getSupplyRate(await comet.getUtilization()); + cometUpdatedTimeBefore = totals.lastAccrualTime; + + cometBorrowIndexBefore = totals.baseBorrowIndex; + trackingSupplyIndexBefore = totals.trackingSupplyIndex; + trackingBorrowIndexBefore = totals.trackingBorrowIndex; + utilizationBefore = await comet.getUtilization(); + borrowRateBefore = await comet.getBorrowRate(utilizationBefore); + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + const bobBasic = await comet.userBasic(bob.address); + bobBaseTrackingAccruedBefore = bobBasic.baseTrackingAccrued; + + // Advance time to verify accrual during withdrawal + await ethers.provider.send('evm_increaseTime', [60 * 60]); // 1 hr + await ethers.provider.send('evm_mine', []); + }); + + it('alice has no collateral registered before withdrawal', async () => { + const userData = await comet.userBasic(alice.address); + expect(userData.assetsIn).to.equal(0); + }); + + it('bob collateral balance before withdraw equals supply amount', async () => { + expect((await comet.userCollateral(bob.address, collateral.address)).balance).to.equal(COLLATERAL_SUPPLY_AMOUNT); + }); + + it('total supply before withdraw equals supply amount', async () => { + expect(totalSupplyBefore).to.equal(COLLATERAL_SUPPLY_AMOUNT); + }); + + it('withdraw collateral does not revert', async () => { + withdrawTx = await comet.connect(bob).withdrawTo(alice.address, collateral.address, COLLATERAL_SUPPLY_AMOUNT); + expect(withdrawTx).to.not.be.reverted; + }); + + it('emits Transfer event (ERC20)', async () => { + await expect(withdrawTx) + .to.emit(collateral, 'Transfer') + .withArgs(comet.address, alice.address, COLLATERAL_SUPPLY_AMOUNT); + }); + + it('emits WithdrawCollateral event', async () => { + await expect(withdrawTx) + .to.emit(comet, 'WithdrawCollateral') + .withArgs(bob.address, alice.address, collateral.address, COLLATERAL_SUPPLY_AMOUNT); + }); + + it('accrues state during collateral withdrawal', async () => { + const lastUpdated = (await comet.totalsBasic()).lastAccrualTime; + const withdrawalTimestamp = BigNumber.from( + (await ethers.provider.getBlock((await withdrawTx.wait()).blockNumber)).timestamp + ); + expect(lastUpdated - cometUpdatedTimeBefore).to.be.approximately(SKIP_TIME, 2); // 2 seconds tolerance + expect(lastUpdated).to.equal(withdrawalTimestamp); + }); + + it('supply index is updated correctly after accrual', async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add( + cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + }); + + it('recipient balance increases by withdrawn amount', async () => { + expect(await collateral.balanceOf(alice.address)).to.equal(aliceBalanceBefore.add(COLLATERAL_SUPPLY_AMOUNT)); + }); + + it('bob collateral balance is zero after full withdrawal', async () => { + expect((await comet.userCollateral(bob.address, collateral.address)).balance).to.equal(0); + }); + + it('total supply is zero after full withdrawal', async () => { + const totalsCollateral = await comet.totalsCollateral(collateral.address); + expect(totalsCollateral.totalSupplyAsset).to.equal(0); + }); + + it('total collateral supply decreases by withdraw amount', async () => { + const totalCollateralSupplyAfter = (await comet.totalsCollateral(collateral.address)).totalSupplyAsset; + + expect(totalCollateralSupplyBefore.sub(totalCollateralSupplyAfter)).to.equal(COLLATERAL_SUPPLY_AMOUNT); + }); + + it('assetsIn is cleared when collateral balance goes to zero', async () => { + const collateralIndex = (await comet.getAssetInfoByAddress(collateral.address)).offset; + const userData = await comet.userBasic(alice.address); + const offset = 1 << collateralIndex; + + expect(userData.assetsIn & offset).to.equal(0); + }); + + it('alice principal is not changed after collateral withdrawal', async () => { + expect((await comet.userBasic(alice.address)).principal).to.equal(alicePrincipalBefore); + }); + + it('alice displayed base balance is correct after accrual', async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add( + cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + + const index = (await comet.totalsBasic()).baseSupplyIndex; + expect(index).to.equal(accruedIndex); + + const newBalanceFromPrincipal = alicePrincipalBefore.mul(accruedIndex).div(exp(1, 15)); + const newBalance = await comet.balanceOf(alice.address); + + expect(newBalance).to.equal(newBalanceFromPrincipal); + expect(newBalance).to.be.eq(aliceDisplayBalanceBefore); + }); + + it("comet's total supply base is not changed by collateral withdrawal", async () => { + expect((await comet.totalsBasic()).totalSupplyBase).to.equal(totalSupplyBaseBefore); + }); + + it("comet's displayed total supply is correct after accrual", async () => { + const curTime = (await ethers.provider.getBlock('latest')).timestamp; + const timeElapsed = curTime - cometUpdatedTimeBefore; + const accruedIndex = cometSupplyIndexBefore.add( + cometSupplyIndexBefore.mul(cometSupplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + + const displayedTotalSupply = await comet.totalSupply(); + const expectedTotalSupply = totalSupplyBaseBefore.mul(accruedIndex).div(exp(1, 15)); + + expect(displayedTotalSupply).to.equal(expectedTotalSupply); + }); + + it("bob's collateral balance is decreased by withdrawal", async () => { + expect( + (await comet.userCollateral(bob.address, collateral.address)).balance + ).to.equal(bobCollateralBalanceBefore.sub(COLLATERAL_SUPPLY_AMOUNT)); + }); + + it('accrual time is updated after collateral withdrawal', async () => { + const receipt = await withdrawTx.wait(); + const block = await ethers.provider.getBlock(receipt.blockNumber); + withdrawTimestamp = BigNumber.from(block.timestamp); + expect((await comet.totalsBasic()).lastAccrualTime).to.equal(withdrawTimestamp.toNumber()); + expect(withdrawTimestamp.toNumber()).to.be.greaterThan(cometUpdatedTimeBefore); + }); + + it('trackingSupplyIndex grows correctly during collateral withdrawal accrual', async () => { + // accrueInternal() updates trackingSupplyIndex when totalSupplyBase >= baseMinForRewards: + // trackingSupplyIndex += divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase) + // = baseTrackingSupplySpeed * timeElapsed * baseScale / totalSupplyBase + const timeElapsed = withdrawTimestamp.sub(cometUpdatedTimeBefore); + const baseScale = exp(1, 6); + const expectedTrackingSupplyIndex = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBaseBefore) + ); + expect((await comet.totalsBasic()).trackingSupplyIndex).to.equal(expectedTrackingSupplyIndex); + }); + + it('trackingBorrowIndex is unchanged when totalBorrowBase is zero', async () => { + // accrueInternal() only updates trackingBorrowIndex if totalBorrowBase >= baseMinForRewards + // With no active borrows, totalBorrowBase = 0 and the condition is not satisfied + expect((await comet.totalsBasic()).totalBorrowBase).to.be.lessThan(await comet.baseMinForRewards()); + expect((await comet.totalsBasic()).trackingBorrowIndex).to.equal(trackingBorrowIndexBefore); + }); + + it('baseBorrowIndex accrues correctly during collateral withdrawal', async () => { + // baseBorrowIndex += mulFactor(baseBorrowIndex, borrowRate * timeElapsed) + // = baseBorrowIndex + baseBorrowIndex * borrowRate * timeElapsed / 1e18 + // With no borrows, getBorrowRate returns 0 and the borrow index is unchanged + const timeElapsed = withdrawTimestamp.sub(cometUpdatedTimeBefore); + const expectedBaseBorrowIndex = cometBorrowIndexBefore.add( + cometBorrowIndexBefore.mul(borrowRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(expectedBaseBorrowIndex); + }); + + it('bob baseTrackingAccrued is unchanged when principal is zero', async () => { + // accrueAccountInternal(bob) calls updateBasePrincipal(bob, basic, basic.principal). + // bob.principal = 0 → indexDelta * 0 = 0 → no reward accrual, baseTrackingAccrued stays the same + const bobBasicAfter = await comet.userBasic(bob.address); + expect(bobBasicAfter.baseTrackingAccrued).to.equal(bobBaseTrackingAccruedBefore); + }); + + it('utilization after collateral withdrawal matches exact calculation from accrued indices', async () => { + // getUtilization() = presentValue(borrow) * FACTOR_SCALE / presentValue(supply) + // = totalBorrowBase * baseBorrowIndex_new / 1e15 * 1e18 / (totalSupplyBase * baseSupplyIndex_new / 1e15) + const totals = await comet.totalsBasic(); + const totalBorrowPresent = totals.totalBorrowBase.mul(totals.baseBorrowIndex).div(exp(1, 15)); + const totalSupplyPresent = totals.totalSupplyBase.mul(totals.baseSupplyIndex).div(exp(1, 15)); + const expectedUtilization = totalBorrowPresent.mul(exp(1, 18)).div(totalSupplyPresent); + expect(await comet.getUtilization()).to.equal(expectedUtilization); + }); + }); + + // Tests accrueAccountInternal(bob) when bob has a negative principal (active borrow). + // Focuses on what differs from zero-borrow happy path: non-zero rates, growing borrow index, + // and borrow reward accrual via trackingBorrowIndex. + describe('withdraw collateral: accrual with active borrow (non-zero utilization)', function () { + const SKIP_TIME = 3600; + // COMP has 18 decimals; alice supplied 10,000 USDC in happy path → totalSupplyBase = 1e10 + // 10 COMP at $175 = $1750 collateral, borrow $100 USDC → 1% utilization → non-zero rates + const BOB_COMP_SUPPLY: bigint = exp(10, 18); // 10 COMP (18-decimal token) + const BOB_BORROW_AMOUNT: bigint = exp(100, 6); // 100 USDC + const BOB_COMP_WITHDRAW: bigint = exp(1, 18); // withdraw 1 COMP, keep 9 as collateral + + let baseSupplyIndexBefore: BigNumber; + let baseBorrowIndexBefore: BigNumber; + let trackingSupplyIndexBefore: BigNumber; + let trackingBorrowIndexBefore: BigNumber; + let totalSupplyBaseBefore: BigNumber; + let totalBorrowBaseBefore: BigNumber; + let lastAccrualTimeBefore: number; + let bobPrincipalBefore: BigNumber; + let bobBaseTrackingIndexBefore: BigNumber; + let bobBaseTrackingAccruedBefore: BigNumber; + let baseTrackingBorrowSpeedVal: BigNumber; + let baseTrackingSupplySpeedVal: BigNumber; + let trackingIndexScaleVal: BigNumber; + let supplyRateBefore: BigNumber; + let borrowRateBefore: BigNumber; + let utilizationBefore: BigNumber; + let withdrawCollateralTx: ContractTransaction; + let withdrawTimestamp: BigNumber; + + before(async function () { + // Build on state from previous describe: alice has 10,000 USDC in comet, totalBorrowBase = 0 + const compCollateral = collaterals['COMP']; + await compCollateral.allocateTo(bob.address, BOB_COMP_SUPPLY); + await compCollateral.connect(bob).approve(comet.address, BOB_COMP_SUPPLY); + await comet.connect(bob).supply(compCollateral.address, BOB_COMP_SUPPLY); + + // Bob borrows base, making his principal negative and creating non-zero utilization + await comet.connect(bob).withdraw(baseToken.address, BOB_BORROW_AMOUNT); + + const totals = await comet.totalsBasic(); + baseSupplyIndexBefore = totals.baseSupplyIndex; + baseBorrowIndexBefore = totals.baseBorrowIndex; + trackingSupplyIndexBefore = totals.trackingSupplyIndex; + trackingBorrowIndexBefore = totals.trackingBorrowIndex; + totalSupplyBaseBefore = totals.totalSupplyBase; + totalBorrowBaseBefore = totals.totalBorrowBase; + lastAccrualTimeBefore = totals.lastAccrualTime; + + const bobBasic = await comet.userBasic(bob.address); + bobPrincipalBefore = bobBasic.principal; + bobBaseTrackingIndexBefore = bobBasic.baseTrackingIndex; + bobBaseTrackingAccruedBefore = bobBasic.baseTrackingAccrued; + + utilizationBefore = await comet.getUtilization(); + supplyRateBefore = await comet.getSupplyRate(utilizationBefore); + borrowRateBefore = await comet.getBorrowRate(utilizationBefore); + baseTrackingSupplySpeedVal = await comet.baseTrackingSupplySpeed(); + baseTrackingBorrowSpeedVal = await comet.baseTrackingBorrowSpeed(); + trackingIndexScaleVal = await comet.trackingIndexScale(); + + await ethers.provider.send('evm_increaseTime', [SKIP_TIME]); + await ethers.provider.send('evm_mine', []); + }); + + it('bob principal is negative (active borrow)', async () => { + expect(bobPrincipalBefore).to.be.lessThan(0); + }); + + it('totalBorrowBase exceeds baseMinForRewards', async () => { + expect(totalBorrowBaseBefore).to.be.greaterThanOrEqual(await comet.baseMinForRewards()); + }); + + it('utilization is greater than zero before withdrawal', async () => { + expect(utilizationBefore).to.be.greaterThan(0); + }); + + it('bob withdraws COMP collateral, triggering accrueAccountInternal', async () => { + withdrawCollateralTx = await comet.connect(bob).withdraw(collaterals['COMP'].address, BOB_COMP_WITHDRAW); + await expect(withdrawCollateralTx).to.not.be.reverted; + }); + + it('accrual time matches the withdrawal block timestamp', async () => { + withdrawTimestamp = BigNumber.from( + (await ethers.provider.getBlock((await withdrawCollateralTx.wait()).blockNumber)).timestamp + ); + expect((await comet.totalsBasic()).lastAccrualTime).to.equal(withdrawTimestamp.toNumber()); + }); + + it('baseSupplyIndex grows when supply rate is non-zero', async () => { + // supplyRate > 0 due to positive utilization (borrows exist) + // baseSupplyIndex += mulFactor(baseSupplyIndex, supplyRate * timeElapsed) + const timeElapsed = withdrawTimestamp.sub(lastAccrualTimeBefore); + const expectedBaseSupplyIndex = baseSupplyIndexBefore.add( + baseSupplyIndexBefore.mul(supplyRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseSupplyIndex).to.equal(expectedBaseSupplyIndex); + }); + + it('baseBorrowIndex grows when borrow rate is non-zero', async () => { + // borrowRate > 0 due to positive utilization + // baseBorrowIndex += mulFactor(baseBorrowIndex, borrowRate * timeElapsed) + const timeElapsed = withdrawTimestamp.sub(lastAccrualTimeBefore); + const expectedBaseBorrowIndex = baseBorrowIndexBefore.add( + baseBorrowIndexBefore.mul(borrowRateBefore).mul(timeElapsed).div(exp(1, 18)) + ); + expect((await comet.totalsBasic()).baseBorrowIndex).to.equal(expectedBaseBorrowIndex); + }); + + it('trackingBorrowIndex grows when totalBorrowBase exceeds baseMinForRewards', async () => { + // trackingBorrowIndex += divBaseWei(baseTrackingBorrowSpeed * timeElapsed, totalBorrowBase) + // = baseTrackingBorrowSpeed * timeElapsed * baseScale / totalBorrowBase + const timeElapsed = withdrawTimestamp.sub(lastAccrualTimeBefore); + const baseScale = exp(1, 6); + const expectedTrackingBorrowIndex = trackingBorrowIndexBefore.add( + baseTrackingBorrowSpeedVal.mul(timeElapsed).mul(baseScale).div(totalBorrowBaseBefore) + ); + expect((await comet.totalsBasic()).trackingBorrowIndex).to.equal(expectedTrackingBorrowIndex); + }); + + it('trackingSupplyIndex also grows with non-zero total supply', async () => { + // trackingSupplyIndex += divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase) + const timeElapsed = withdrawTimestamp.sub(lastAccrualTimeBefore); + const baseScale = exp(1, 6); + const expectedTrackingSupplyIndex = trackingSupplyIndexBefore.add( + baseTrackingSupplySpeedVal.mul(timeElapsed).mul(baseScale).div(totalSupplyBaseBefore) + ); + expect((await comet.totalsBasic()).trackingSupplyIndex).to.equal(expectedTrackingSupplyIndex); + }); + + it('bob baseTrackingAccrued accumulates borrow rewards via trackingBorrowIndex', async () => { + // bob.principal < 0 → borrow tracking applies in updateBasePrincipal: + // indexDelta = trackingBorrowIndex_new - bob.baseTrackingIndex_before + // baseTrackingAccrued += |principal| * indexDelta / trackingIndexScale / accrualDescaleFactor + // accrualDescaleFactor = baseScale / BASE_ACCRUAL_SCALE = 1e6 / 1e6 = 1 for USDC + const timeElapsed = withdrawTimestamp.sub(lastAccrualTimeBefore); + const baseScale = exp(1, 6); + const trackingBorrowIndexNew = trackingBorrowIndexBefore.add( + baseTrackingBorrowSpeedVal.mul(timeElapsed).mul(baseScale).div(totalBorrowBaseBefore) + ); + const indexDelta = trackingBorrowIndexNew.sub(bobBaseTrackingIndexBefore); + const expectedAccrued = bobBaseTrackingAccruedBefore.add( + bobPrincipalBefore.abs().mul(indexDelta).div(trackingIndexScaleVal) + ); + expect((await comet.userBasic(bob.address)).baseTrackingAccrued).to.equal(expectedAccrued); + }); + + it('utilization is greater than zero after collateral withdrawal', async () => { + // Collateral withdrawal does not affect totalBorrowBase or totalSupplyBase + expect(await comet.getUtilization()).to.be.greaterThan(0); + }); + + it('utilization after collateral withdrawal matches exact calculation from accrued indices', async () => { + // getUtilization() = presentValue(borrow) * FACTOR_SCALE / presentValue(supply) + // = totalBorrowBase * baseBorrowIndex_new / 1e15 * 1e18 / (totalSupplyBase * baseSupplyIndex_new / 1e15) + const totals = await comet.totalsBasic(); + const totalBorrowPresent = totals.totalBorrowBase.mul(totals.baseBorrowIndex).div(exp(1, 15)); + const totalSupplyPresent = totals.totalSupplyBase.mul(totals.baseSupplyIndex).div(exp(1, 15)); + const expectedUtilization = totalBorrowPresent.mul(exp(1, 18)).div(totalSupplyPresent); + expect(await comet.getUtilization()).to.equal(expectedUtilization); + }); + }); + + describe('edge cases', function () { + const COLLATERAL_AMOUNT = exp(1, 8); + const SUPPLY_AMOUNT = exp(100, 6); + const WITHDRAW_AMOUNT = exp(25, 6); + + it('withdraws 0 collateral successfully', async () => { + await baseSnapshot.restore(); + + await collaterals['COMP'].allocateTo(alice.address, COLLATERAL_AMOUNT); + await collaterals['COMP'].connect(alice).approve(comet.address, COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['COMP'].address, COLLATERAL_AMOUNT); + + const balanceBefore = (await comet.userCollateral(alice.address, collaterals['COMP'].address)).balance; + const tx = await comet.connect(alice).withdraw(collaterals['COMP'].address, 0); + + await expect(tx) + .to.emit(comet, 'WithdrawCollateral') + .withArgs(alice.address, alice.address, collaterals['COMP'].address, 0); + + expect((await comet.userCollateral(alice.address, collaterals['COMP'].address)).balance).to.equal(balanceBefore); + }); + + it('multiple consecutive withdraws in same block', async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + await comet.connect(bob).withdraw(baseToken.address, WITHDRAW_AMOUNT); + expect(await comet.balanceOf(bob.address)).to.equal(exp(75, 6)); + await comet.connect(bob).withdraw(baseToken.address, WITHDRAW_AMOUNT); + expect(await comet.balanceOf(bob.address)).to.equal(exp(50, 6)); + + await comet.connect(bob).withdraw(baseToken.address, WITHDRAW_AMOUNT); + expect(await comet.balanceOf(bob.address)).to.equal(exp(25, 6)); + + + await comet.connect(bob).withdraw(baseToken.address, WITHDRAW_AMOUNT); + expect(await comet.balanceOf(bob.address)).to.equal(0); + }); + + it('withdrawTo zero address sends tokens to zero address (tokens burned)', async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + const zeroAddressBalanceBefore = await baseToken.balanceOf(ethers.constants.AddressZero); + + const tx = await comet.connect(bob).withdrawTo(ethers.constants.AddressZero, baseToken.address, SUPPLY_AMOUNT); + + await expect(tx) + .to.emit(comet, 'Withdraw') + .withArgs(bob.address, ethers.constants.AddressZero, SUPPLY_AMOUNT); + + expect(await baseToken.balanceOf(ethers.constants.AddressZero)).to.equal(zeroAddressBalanceBefore.add(SUPPLY_AMOUNT)); + expect(await comet.balanceOf(bob.address)).to.equal(0); + }); + }); + }); + + describe('borrow (withdraw without supply)', function () { + before(async () => { + await baseSnapshot.restore(); + }); + + describe('reverts', function () { + const BOB_SUPPLY_AMOUNT = exp(100, 6); + const BOB_LARGE_SUPPLY_AMOUNT = exp(100000, 6); + const ALICE_COLLATERAL_AMOUNT = exp(1, 18); + const SMALL_BORROW_AMOUNT = exp(1, 6); + const LARGE_BORROW_AMOUNT = exp(10000, 6); + + it("can't borrow if there is no collateral supplied", async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + + await expect( + comet.connect(alice).withdraw(baseToken.address, SMALL_BORROW_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + }); + + it("can't borrow if there is not enough collateral", async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, BOB_LARGE_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_LARGE_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + + const collateralValueUsd = Number(ALICE_COLLATERAL_AMOUNT) / 1e18 * 3000; + const borrowValueUsd = Number(LARGE_BORROW_AMOUNT) / 1e6; + expect(borrowValueUsd).to.be.gt(collateralValueUsd); + + await expect( + comet.connect(alice).withdraw(baseToken.address, LARGE_BORROW_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'NotCollateralized'); + }); + + describe('reverts with collateral supplied', function () { + let borrowRevertSnapshot: SnapshotRestorer; + + before(async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + + borrowRevertSnapshot = await takeSnapshot(); + }); + + it("can't borrow less than minBorrow", async () => { + + const borrowAmount = exp(0.5, 6); + const baseBorrowMin = await comet.baseBorrowMin(); + expect(borrowAmount).to.be.lt(baseBorrowMin); + + await expect( + comet.connect(alice).withdraw(baseToken.address, borrowAmount) + ).to.be.revertedWithCustomError(comet, 'BorrowTooSmall'); + }); + + it('reverts if borrower withdraw is paused (extended pause)', async () => { + const snapshot = await takeSnapshot(); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + + await comet.connect(pauseGuardian).pauseBorrowersWithdraw(true); + expect(await comet.isBorrowersWithdrawPaused()).to.be.true; + + await expect( + comet.connect(alice).withdraw(baseToken.address, SMALL_BORROW_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'BorrowersWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseBorrowersWithdraw(false); + await snapshot.restore(); + }); + + it('reverts borrow if collateral oracle returns 0', async () => { + await borrowRevertSnapshot.restore(); + + await priceFeeds.WETH.setRoundData(1, 0, 0, 0, 1); + + await expect( + comet.connect(alice).withdraw(baseToken.address, SMALL_BORROW_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'BadPrice'); + }); + + it('reverts borrow if base oracle returns 0', async () => { + await borrowRevertSnapshot.restore(); + + await priceFeeds.USDC.setRoundData(1, 0, 0, 0, 1); + + await expect( + comet.connect(alice).withdraw(baseToken.address, SMALL_BORROW_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'BadPrice'); + }); + }); + }); + + describe('borrow: happy path', function () { + const BOB_SUPPLY_AMOUNT = exp(100, 6); + const ALICE_COLLATERAL_AMOUNT = exp(1, 18); + const BORROW_AMOUNT = exp(10, 6); + + before(async () => { + await baseSnapshot.restore(); + }); + + it('principal from the 1st borrow equals to the requested amount', async () => { + const collateralValueUsd = Number(ALICE_COLLATERAL_AMOUNT) / 1e18 * 3000; + const borrowValueUsd = Number(BORROW_AMOUNT) / 1e6; + expect(collateralValueUsd).to.be.gt(borrowValueUsd); + + await baseToken.connect(bob).approve(comet.address, BOB_SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, BOB_SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + + await comet.connect(alice).withdraw(baseToken.address, BORROW_AMOUNT); + + const aliceBalance = await baseBalanceOf(comet, alice.address); + expect(aliceBalance).to.equal(-BORROW_AMOUNT); + }); + + it('borrow balance increases with interest over time (consecutive borrows)', async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, exp(1000, 6)); + await comet.connect(bob).supply(baseToken.address, exp(1000, 6)); + + await collaterals['WETH'].allocateTo(alice.address, exp(10, 18)); + await collaterals['WETH'].connect(alice).approve(comet.address, exp(10, 18)); + await comet.connect(alice).supply(collaterals['WETH'].address, exp(10, 18)); + + const borrowAmount1 = exp(100, 6); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount1); + const balance1 = await baseBalanceOf(comet, alice.address); + expect(balance1).to.equal(-borrowAmount1); + + await fastForward(86400); + await ethers.provider.send('evm_mine', []); + + const balanceAfterTime = await baseBalanceOf(comet, alice.address); + expect(balanceAfterTime).to.be.lte(balance1); + + const borrowAmount2 = exp(50, 6); + await comet.connect(alice).withdraw(baseToken.address, borrowAmount2); + + const finalBalance = await baseBalanceOf(comet, alice.address); + expect(finalBalance).to.be.lte(-(borrowAmount1 + borrowAmount2)); + }); + + it('borrows to withdraw if necessary/possible', async () => { + await baseSnapshot.restore(); + + const SMALL_SUPPLY = exp(10, 6); + const SMALL_BORROW = exp(1, 6); + + await baseToken.connect(bob).approve(comet.address, SMALL_SUPPLY); + await comet.connect(bob).supply(baseToken.address, SMALL_SUPPLY); + + await collaterals['WETH'].allocateTo(alice.address, ALICE_COLLATERAL_AMOUNT); + await collaterals['WETH'].connect(alice).approve(comet.address, ALICE_COLLATERAL_AMOUNT); + await comet.connect(alice).supply(collaterals['WETH'].address, ALICE_COLLATERAL_AMOUNT); + + const bobUsdcBefore = await baseToken.balanceOf(bob.address); + await comet.connect(alice).withdrawTo(bob.address, baseToken.address, SMALL_BORROW); + + expect(await baseBalanceOf(comet, alice.address)).to.eq(-SMALL_BORROW); + expect(await baseToken.balanceOf(bob.address)).to.eq(bobUsdcBefore.add(SMALL_BORROW)); + }); + }); + }); + + describe('withdrawTo', function () { + const SUPPLY_AMOUNT = exp(100, 6); + + before(async () => { + await baseSnapshot.restore(); + }); + + it('withdraws to sender by default', async () => { + await baseToken.connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + const bobUsdcBefore = await baseToken.balanceOf(bob.address); + expect(await comet.balanceOf(bob.address)).to.equal(SUPPLY_AMOUNT); + + await comet.connect(bob).withdraw(baseToken.address, SUPPLY_AMOUNT); + + expect(await comet.balanceOf(bob.address)).to.equal(0); + expect(await baseToken.balanceOf(bob.address)).to.equal(bobUsdcBefore.add(SUPPLY_AMOUNT)); + }); + + it('reverts if collateral withdraw is paused (extended pause)', async () => { + await baseSnapshot.restore(); + + await comet.connect(pauseGuardian).pauseCollateralWithdraw(true); + expect(await comet.isCollateralWithdrawPaused()).to.be.true; + + await expect( + comet.connect(bob).withdrawTo(alice.address, collaterals['COMP'].address, 1) + ).to.be.revertedWithCustomError(comet, 'CollateralWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseCollateralWithdraw(false); + }); + + it('reverts if lender withdraw is paused (extended pause)', async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + await comet.connect(pauseGuardian).pauseLendersWithdraw(true); + expect(await comet.isLendersWithdrawPaused()).to.be.true; + + await expect( + comet.connect(bob).withdrawTo(alice.address, baseToken.address, exp(50, baseTokenDecimals)) + ).to.be.revertedWithCustomError(comet, 'LendersWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseLendersWithdraw(false); + }); + + it('reverts if borrower withdraw is paused (extended pause)', async () => { + await baseSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(baseToken.address, SUPPLY_AMOUNT); + + await collaterals['WETH'].allocateTo(alice.address, exp(1, 18)); + await collaterals['WETH'].connect(alice).approve(comet.address, exp(1, 18)); + await comet.connect(alice).supply(collaterals['WETH'].address, exp(1, 18)); + + await comet.connect(pauseGuardian).pauseBorrowersWithdraw(true); + expect(await comet.isBorrowersWithdrawPaused()).to.be.true; + + await expect( + comet.connect(alice).withdrawTo(bob.address, baseToken.address, exp(10, baseTokenDecimals)) + ).to.be.revertedWithCustomError(comet, 'BorrowersWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseBorrowersWithdraw(false); + }); + }); + + describe('withdrawFrom', function () { + const SUPPLY_AMOUNT = exp(1, 8); + let charlie: SignerWithAddress; + let withdrawFromSnapshot: SnapshotRestorer; + + before(async () => { + await baseSnapshot.restore(); + charlie = (await ethers.getSigners())[4]; + + await collaterals['COMP'].allocateTo(bob.address, SUPPLY_AMOUNT); + await collaterals['COMP'].connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(collaterals['COMP'].address, SUPPLY_AMOUNT); + + withdrawFromSnapshot = await takeSnapshot(); + }); + + it('withdraws from src if specified and sender has permission', async () => { + const aliceBalanceBefore = await collaterals['COMP'].balanceOf(alice.address); + expect((await comet.userCollateral(bob.address, collaterals['COMP'].address)).balance).to.equal(SUPPLY_AMOUNT); + + await comet.connect(bob).allow(charlie.address, true); + await comet.connect(charlie).withdrawFrom(bob.address, alice.address, collaterals['COMP'].address, SUPPLY_AMOUNT); + + expect((await comet.userCollateral(bob.address, collaterals['COMP'].address)).balance).to.equal(0); + expect(await collaterals['COMP'].balanceOf(alice.address)).to.equal(aliceBalanceBefore.add(SUPPLY_AMOUNT)); + }); + + it('reverts if src is specified and sender does not have permission', async () => { + await withdrawFromSnapshot.restore(); + + await expect( + comet.connect(charlie).withdrawFrom(bob.address, alice.address, collaterals['COMP'].address, SUPPLY_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'Unauthorized'); + }); + + it('reverts if withdraw is paused', async () => { + await withdrawFromSnapshot.restore(); + + await comet.connect(pauseGuardian).pause(false, false, true, false, false); + expect(await comet.isWithdrawPaused()).to.be.true; + + await comet.connect(bob).allow(charlie.address, true); + await expect( + comet.connect(charlie).withdrawFrom(bob.address, alice.address, collaterals['COMP'].address, SUPPLY_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'Paused'); + + await comet.connect(pauseGuardian).pause(false, false, false, false, false); + }); + + it('reverts if collateral withdraw is paused (extended pause)', async () => { + await withdrawFromSnapshot.restore(); + + await comet.connect(bob).allow(charlie.address, true); + await collaterals['COMP'].allocateTo(bob.address, SUPPLY_AMOUNT); + await collaterals['COMP'].connect(bob).approve(comet.address, SUPPLY_AMOUNT); + await comet.connect(bob).supply(collaterals['COMP'].address, SUPPLY_AMOUNT); + + await comet.connect(pauseGuardian).pauseCollateralWithdraw(true); + expect(await comet.isCollateralWithdrawPaused()).to.be.true; + + await expect( + comet.connect(charlie).withdrawFrom(bob.address, alice.address, collaterals['COMP'].address, SUPPLY_AMOUNT) + ).to.be.revertedWithCustomError(comet, 'CollateralWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseCollateralWithdraw(false); + }); + + it('reverts if lender withdraw is paused (extended pause)', async () => { + await withdrawFromSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, exp(100, baseTokenDecimals)); + await comet.connect(bob).supply(baseToken.address, exp(100, baseTokenDecimals)); + await comet.connect(bob).allow(charlie.address, true); + + await comet.connect(pauseGuardian).pauseLendersWithdraw(true); + expect(await comet.isLendersWithdrawPaused()).to.be.true; + + await expect( + comet.connect(charlie).withdrawFrom(bob.address, alice.address, baseToken.address, exp(50, baseTokenDecimals)) + ).to.be.revertedWithCustomError(comet, 'LendersWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseLendersWithdraw(false); + }); + + it('reverts if borrower withdraw is paused (extended pause)', async () => { + await withdrawFromSnapshot.restore(); + + await baseToken.connect(bob).approve(comet.address, exp(100, baseTokenDecimals)); + await comet.connect(bob).supply(baseToken.address, exp(100, baseTokenDecimals)); + + await collaterals['WETH'].allocateTo(alice.address, exp(1, 18)); + await collaterals['WETH'].connect(alice).approve(comet.address, exp(1, 18)); + await comet.connect(alice).supply(collaterals['WETH'].address, exp(1, 18)); + await comet.connect(alice).allow(charlie.address, true); + + await comet.connect(pauseGuardian).pauseBorrowersWithdraw(true); + expect(await comet.isBorrowersWithdrawPaused()).to.be.true; + + await expect( + comet.connect(charlie).withdrawFrom(alice.address, bob.address, baseToken.address, exp(10, baseTokenDecimals)) + ).to.be.revertedWithCustomError(comet, 'BorrowersWithdrawPaused'); + + await comet.connect(pauseGuardian).pauseBorrowersWithdraw(false); + }); + }); + + describe('reentrancy protection', function () { + const USDC_LIQUIDITY = exp(100, 6); + const ATTACK_AMOUNT = exp(1, 6); + const COLLATERAL_SUPPLY = exp(100, 6); + const ALICE_COLLATERAL_BALANCE = exp(1, 6); + + let evilComet: CometHarnessInterface; + let USDC: FaucetToken; + let EVIL: EvilToken; + let evilAlice: SignerWithAddress; + let evilBob: SignerWithAddress; + let reentrancySnapshot: SnapshotRestorer; + + before(async () => { + const { comet, tokens, users } = await makeProtocol({ + assets: { + USDC: { decimals: 6 }, + EVIL: { + decimals: 6, + initialPrice: 2, + factory: await ethers.getContractFactory('EvilToken') as EvilToken__factory, + } + } + }); + evilComet = comet; + USDC = tokens.USDC as FaucetToken; + EVIL = tokens.EVIL as EvilToken; + [evilAlice, evilBob] = users; + + await USDC.allocateTo(evilComet.address, USDC_LIQUIDITY); + + // Harness: EvilToken can't be supplied normally - it's malicious and triggers reentrancy + const totalsCollateral = Object.assign({}, await evilComet.totalsCollateral(EVIL.address), { + totalSupplyAsset: COLLATERAL_SUPPLY, + }); + await evilComet.setTotalsCollateral(EVIL.address, totalsCollateral); + await evilComet.setCollateralBalance(evilAlice.address, EVIL.address, ALICE_COLLATERAL_BALANCE); + await evilComet.connect(evilAlice).allow(EVIL.address, true); + + reentrancySnapshot = await takeSnapshot(); + }); + + it('blocks malicious reentrant transferFrom', async () => { + const attack = Object.assign({}, await EVIL.getAttack(), { + attackType: ReentryAttack.TransferFrom, + destination: evilBob.address, + asset: USDC.address, + amount: ATTACK_AMOUNT + }); + await EVIL.setAttack(attack); + + await expect( + evilComet.connect(evilAlice).withdraw(EVIL.address, ATTACK_AMOUNT) + ).to.be.revertedWithCustomError(evilComet, 'ReentrantCallBlocked'); + + expect(await USDC.balanceOf(evilComet.address)).to.eq(USDC_LIQUIDITY); + expect(await baseBalanceOf(evilComet, evilAlice.address)).to.eq(0n); + expect(await USDC.balanceOf(evilBob.address)).to.eq(0); }); - const _i1 = await comet.setBasePrincipal(bob.address, 100e6); - const cometAsB = comet.connect(bob); + it('blocks malicious reentrant withdrawFrom', async () => { + await reentrancySnapshot.restore(); + + const attack = Object.assign({}, await EVIL.getAttack(), { + attackType: ReentryAttack.WithdrawFrom, + destination: evilBob.address, + asset: USDC.address, + amount: ATTACK_AMOUNT + }); + await EVIL.setAttack(attack); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const s0 = await wait(cometAsB.withdrawTo(alice.address, USDC.address, 100e6)); - const t1 = await comet.totalsBasic(); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); + await expect( + evilComet.connect(evilAlice).withdraw(EVIL.address, ATTACK_AMOUNT) + ).to.be.revertedWithCustomError(evilComet, 'ReentrantCallBlocked'); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: comet.address, - to: alice.address, - amount: BigInt(100e6), - } - }); - expect(event(s0, 1)).to.be.deep.equal({ - Withdraw: { - src: bob.address, - to: alice.address, - amount: BigInt(100e6), - } + expect(await USDC.balanceOf(evilComet.address)).to.eq(USDC_LIQUIDITY); + expect(await baseBalanceOf(evilComet, evilAlice.address)).to.eq(0n); + expect(await USDC.balanceOf(evilBob.address)).to.eq(0); }); - expect(event(s0, 2)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: ethers.constants.AddressZero, - amount: BigInt(100e6), - } + }); + + describe('non-standard tokens', function () { + describe('USDT-like token (no return value)', function () { + let nstComet: CometHarnessInterface; + let alice: SignerWithAddress; + let bob: SignerWithAddress; + let usdt: NonStandardFaucetFeeToken; + let nonStdCollateral: NonStandardFaucetFeeToken; + const USDT_AMOUNT = exp(100, 6); + const NON_STD_COLLATERAL_AMOUNT = exp(1, 18); + + before(async function () { + const assets = defaultAssets(); + assets['USDT'] = { + initial: 1e6, + decimals: 6, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + assets['NonStdCollateral'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + nstComet = protocol.comet; + [alice, bob] = protocol.users; + + const tokens = protocol.tokens; + usdt = tokens['USDT'] as NonStandardFaucetFeeToken; + nonStdCollateral = tokens['NonStdCollateral'] as NonStandardFaucetFeeToken; + + await usdt.allocateTo(bob.address, USDT_AMOUNT); + await usdt.connect(bob).approve(nstComet.address, USDT_AMOUNT); + await nstComet.connect(bob).supply(usdt.address, USDT_AMOUNT); + + await nonStdCollateral.allocateTo(alice.address, NON_STD_COLLATERAL_AMOUNT); + await nonStdCollateral.connect(alice).approve(nstComet.address, NON_STD_COLLATERAL_AMOUNT); + await nstComet.connect(alice).supply(nonStdCollateral.address, NON_STD_COLLATERAL_AMOUNT); + }); + + it('can withdraw base token - non-standard ERC20 (without return interface)', async () => { + const bobBalanceBefore = await usdt.balanceOf(bob.address); + + await nstComet.connect(bob).withdraw(usdt.address, USDT_AMOUNT); + + expect(await usdt.balanceOf(bob.address)).to.equal(bobBalanceBefore.add(USDT_AMOUNT)); + expect(await nstComet.balanceOf(bob.address)).to.equal(0); + }); + + it('can withdraw collateral - non-standard ERC20 (without return interface)', async () => { + const aliceBalanceBefore = await nonStdCollateral.balanceOf(alice.address); + + await nstComet.connect(alice).withdraw(nonStdCollateral.address, NON_STD_COLLATERAL_AMOUNT); + + expect(await nonStdCollateral.balanceOf(alice.address)).to.equal(aliceBalanceBefore.add(NON_STD_COLLATERAL_AMOUNT)); + expect((await nstComet.userCollateral(alice.address, nonStdCollateral.address)).balance).to.equal(0); + }); }); - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(0n); - expect(t1.totalBorrowBase).to.be.equal(0n); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(106000); + describe('fee-on-transfer token', function () { + const BASE_TOKEN_AMOUNT = exp(100, 6); + const COLLATERAL_TOKEN_AMOUNT = exp(1, 18); + const NUMERATOR = 10; + const DENOMINATOR = 10000; + + let feeComet: CometHarnessInterface; + let feeBaseToken: NonStandardFaucetFeeToken; + let feeCollateral: NonStandardFaucetFeeToken; + let alice: SignerWithAddress; + let bob: SignerWithAddress; + + before(async function () { + const assets = defaultAssets(); + assets['USDT'] = { + initial: 1e6, + decimals: 6, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + assets['FeeCollateral'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + feeComet = protocol.comet; + feeBaseToken = protocol.tokens['USDT'] as NonStandardFaucetFeeToken; + feeCollateral = protocol.tokens['FeeCollateral'] as NonStandardFaucetFeeToken; + [alice, bob] = protocol.users; + + await feeBaseToken.setParams(NUMERATOR, exp(100, 18)); + await feeCollateral.setParams(NUMERATOR, exp(100, 18)); + + await feeBaseToken.allocateTo(bob.address, BASE_TOKEN_AMOUNT); + await feeBaseToken.connect(bob).approve(feeComet.address, BASE_TOKEN_AMOUNT); + await feeComet.connect(bob).supply(feeBaseToken.address, BASE_TOKEN_AMOUNT); + + await feeCollateral.allocateTo(alice.address, COLLATERAL_TOKEN_AMOUNT); + await feeCollateral.connect(alice).approve(feeComet.address, COLLATERAL_TOKEN_AMOUNT); + await feeComet.connect(alice).supply(feeCollateral.address, COLLATERAL_TOKEN_AMOUNT); + }); + + it('withdraws base token with fee-on-transfer (fee deducted on transfer out)', async () => { + const bobPrincipal = (await feeComet.userBasic(bob.address)).principal; + const bobBalanceBefore = await feeBaseToken.balanceOf(bob.address); + + const withdrawTx = await feeComet.connect(bob).withdraw(feeBaseToken.address, bobPrincipal); + expect(withdrawTx).to.not.be.reverted; + + const fee = BigNumber.from(bobPrincipal).mul(NUMERATOR).div(DENOMINATOR); + const expectedReceived = BigNumber.from(bobPrincipal).sub(fee); + + expect(await feeBaseToken.balanceOf(bob.address)).to.equal(bobBalanceBefore.add(expectedReceived)); + }); + + it('withdraws collateral with fee-on-transfer (fee deducted on transfer out)', async () => { + const aliceCollateral = (await feeComet.userCollateral(alice.address, feeCollateral.address)).balance; + const aliceBalanceBefore = await feeCollateral.balanceOf(alice.address); + + const withdrawTx = await feeComet.connect(alice).withdraw(feeCollateral.address, aliceCollateral); + expect(withdrawTx).to.not.be.reverted; + + const fee = BigNumber.from(aliceCollateral).mul(NUMERATOR).div(DENOMINATOR); + const expectedReceived = BigNumber.from(aliceCollateral).sub(fee); + + expect(await feeCollateral.balanceOf(alice.address)).to.equal(aliceBalanceBefore.add(expectedReceived)); + + expect((await feeComet.userCollateral(alice.address, feeCollateral.address)).balance).to.equal(0); + }); + }); }); - it('does not emit Transfer for 0 burn', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC, WETH } = tokens; - - await USDC.allocateTo(comet.address, 110e6); - await setTotalsBasic(comet, { - totalSupplyBase: 100e6, - }); - await comet.setCollateralBalance(bob.address, WETH.address, exp(1, 18)); - const cometAsB = comet.connect(bob); - - const s0 = await wait(cometAsB.withdrawTo(alice.address, USDC.address, exp(1, 6))); - expect(s0.receipt['events'].length).to.be.equal(2); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: comet.address, - to: alice.address, - amount: exp(1, 6), + describe('withdraw 24 collaterals', function () { + const SUPPLY_COLLATERAL_AMOUNT: bigint = exp(1, 18); + let withdrawTxs: ContractTransaction[] = []; + let alicePrincipalBefore: BigNumber; + let snapshot: SnapshotRestorer; + + before(async () => { + await baseTokenWith24Collaterals.allocateTo(bob.address, exp(100000, 6)); + await baseTokenWith24Collaterals.connect(bob).approve(cometWith24Collaterals.address, exp(100000, 6)); + await cometWith24Collaterals.connect(bob).supply(baseTokenWith24Collaterals.address, exp(100000, 6)); + + for (let i = 0; i < MAX_ASSETS; i++) { + const assetToken = tokensWith24Collaterals[`ASSET${i}`]; + await assetToken.allocateTo(alice.address, SUPPLY_COLLATERAL_AMOUNT); + await assetToken.connect(alice).approve(cometWith24Collaterals.address, SUPPLY_COLLATERAL_AMOUNT); + await cometWith24Collaterals.connect(alice).supply(assetToken.address, SUPPLY_COLLATERAL_AMOUNT); + + await assetToken.allocateTo(dave.address, SUPPLY_COLLATERAL_AMOUNT); + await assetToken.connect(dave).approve(cometWith24Collaterals.address, SUPPLY_COLLATERAL_AMOUNT); + await cometWith24Collaterals.connect(dave).supply(assetToken.address, SUPPLY_COLLATERAL_AMOUNT); } + + alicePrincipalBefore = (await cometWith24Collaterals.userBasic(alice.address)).principal; + + snapshot = await takeSnapshot(); }); - expect(event(s0, 1)).to.be.deep.equal({ - Withdraw: { - src: bob.address, - to: alice.address, - amount: exp(1, 6), - } + + describe('withdraw', function () { + this.afterAll(async () => snapshot.restore()); + + it('each collateral withdraw is successful', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + const balanceBefore = await asset.balanceOf(alice.address); + const withdrawTx = await cometWith24Collaterals.connect(alice).withdraw(asset.address, SUPPLY_COLLATERAL_AMOUNT); + await expect(withdrawTx).to.not.be.reverted; + expect(await asset.balanceOf(alice.address)).to.equal(balanceBefore.add(SUPPLY_COLLATERAL_AMOUNT)); + withdrawTxs.push(withdrawTx); + } + }); + + it('WithdrawCollateral event is emitted for each collateral', async () => { + const assets = Object.values(tokensWith24Collaterals); + for (let i = 0; i < assets.length; i++) { + await expect(withdrawTxs[i]) + .to.emit(cometWith24Collaterals, 'WithdrawCollateral') + .withArgs(alice.address, alice.address, assets[i].address, SUPPLY_COLLATERAL_AMOUNT); + } + withdrawTxs = []; + }); + + it('each collateral balance is zero after withdrawal', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(await cometWith24Collaterals.collateralBalanceOf(alice.address, asset.address)).to.be.equal(0); + } + }); + + it('alice asset list is empty after all withdrawals', async () => { + const assetList = await cometWith24Collaterals.getAssetList(alice.address); + expect(assetList.length).to.equal(0); + }); + + it('each collateral comet total supplied collateral amount decreased by alice withdrawal', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect((await cometWith24Collaterals.totalsCollateral(asset.address)).totalSupplyAsset).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice principal is not changed', async () => { + expect((await cometWith24Collaterals.userBasic(alice.address)).principal).to.be.equal(alicePrincipalBefore); + }); + }); + + describe('withdrawTo', function () { + before(async () => { + await cometWith24Collaterals.connect(alice).allow(dave.address, true); + }); + + this.afterAll(async () => snapshot.restore()); + + it('each collateral withdrawTo is successful', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + const balanceBefore = await asset.balanceOf(dave.address); + const withdrawToTx = await cometWith24Collaterals.connect(alice).withdrawTo(dave.address, asset.address, SUPPLY_COLLATERAL_AMOUNT); + expect(withdrawToTx).to.not.be.reverted; + expect(await asset.balanceOf(dave.address)).to.equal(balanceBefore.add(SUPPLY_COLLATERAL_AMOUNT)); + withdrawTxs.push(withdrawToTx); + } + }); + + it('WithdrawCollateral event is emitted for each collateral', async () => { + const assets = Object.values(tokensWith24Collaterals); + for (let i = 0; i < assets.length; i++) { + await expect(withdrawTxs[i]) + .to.emit(cometWith24Collaterals, 'WithdrawCollateral') + .withArgs(alice.address, dave.address, assets[i].address, SUPPLY_COLLATERAL_AMOUNT); + } + withdrawTxs = []; + }); + + it('each collateral balance for alice is zero', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(await cometWith24Collaterals.collateralBalanceOf(alice.address, asset.address)).to.be.equal(0); + } + }); + + it('alice asset list is empty after all withdrawals', async () => { + const assetList = await cometWith24Collaterals.getAssetList(alice.address); + expect(assetList.length).to.equal(0); + }); + + it('each collateral comet total supplied collateral amount decreased by alice withdrawal', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect((await cometWith24Collaterals.totalsCollateral(asset.address)).totalSupplyAsset).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice principal is not changed', async () => { + expect((await cometWith24Collaterals.userBasic(alice.address)).principal).to.be.equal(alicePrincipalBefore); + }); + }); + + describe('withdrawFrom', function () { + before(async () => { + await cometWith24Collaterals.connect(alice).allow(dave.address, true); + }); + + this.afterAll(async () => snapshot.restore()); + + it('each collateral withdrawFrom is successful', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + const balanceBefore = await asset.balanceOf(alice.address); + const withdrawFromTx = await cometWith24Collaterals.connect(dave).withdrawFrom(alice.address, alice.address, asset.address, SUPPLY_COLLATERAL_AMOUNT); + expect(withdrawFromTx).to.not.be.reverted; + expect(await asset.balanceOf(alice.address)).to.equal(balanceBefore.add(SUPPLY_COLLATERAL_AMOUNT)); + withdrawTxs.push(withdrawFromTx); + } + }); + + it('WithdrawCollateral event is emitted for each collateral', async () => { + const assets = Object.values(tokensWith24Collaterals); + for (let i = 0; i < assets.length; i++) { + await expect(withdrawTxs[i]) + .to.emit(cometWith24Collaterals, 'WithdrawCollateral') + .withArgs(alice.address, alice.address, assets[i].address, SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('each collateral balance for alice is zero', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect(await cometWith24Collaterals.collateralBalanceOf(alice.address, asset.address)).to.be.equal(0); + } + }); + + it('alice asset list is empty after all withdrawals', async () => { + const assetList = await cometWith24Collaterals.getAssetList(alice.address); + expect(assetList.length).to.equal(0); + }); + + it('each collateral comet total supplied collateral amount decreased by alice withdrawal', async () => { + for (const asset of Object.values(tokensWith24Collaterals)) { + expect((await cometWith24Collaterals.totalsCollateral(asset.address)).totalSupplyAsset).to.be.equal(SUPPLY_COLLATERAL_AMOUNT); + } + }); + + it('alice principal is not changed', async () => { + expect((await cometWith24Collaterals.userBasic(alice.address)).principal).to.be.equal(alicePrincipalBefore); + }); + }); + + describe('borrow with 24 collaterals', function () { + before(async () => { + await snapshot.restore(); + }); + + it('can borrow when user has 24 different collateral types', async () => { + const assetList = await cometWith24Collaterals.getAssetList(alice.address); + expect(assetList.length).to.equal(MAX_ASSETS); + + const borrowAmount = exp(100, 6); + const aliceBalanceBefore = await baseTokenWith24Collaterals.balanceOf(alice.address); + + await cometWith24Collaterals.connect(alice).withdraw(baseTokenWith24Collaterals.address, borrowAmount); + + expect(await baseTokenWith24Collaterals.balanceOf(alice.address)).to.equal(aliceBalanceBefore.add(borrowAmount)); + expect(await baseBalanceOf(cometWith24Collaterals as unknown as CometHarnessInterface, alice.address)).to.equal(BigInt(-borrowAmount)); + }); }); }); - it('withdraws max base balance (including accrued) from sender if the asset is base', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(comet.address, 110e6); - await setTotalsBasic(comet, { - totalSupplyBase: 100e6, - totalBorrowBase: 50e6, // non-zero borrow to accrue interest - }); - await comet.setBasePrincipal(bob.address, 100e6); - const cometAsB = comet.connect(bob); - - // Fast forward to accrue some interest - await fastForward(86400); - await ethers.provider.send('evm_mine', []); - - const a0 = await portfolio(protocol, alice.address); - const b0 = await portfolio(protocol, bob.address); - const bobAccruedBalance = (await comet.callStatic.balanceOf(bob.address)).toBigInt(); - const s0 = await wait(cometAsB.withdrawTo(alice.address, USDC.address, ethers.constants.MaxUint256)); - const t1 = await comet.totalsBasic(); - const a1 = await portfolio(protocol, alice.address); - const b1 = await portfolio(protocol, bob.address); - - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: comet.address, - to: alice.address, - amount: bobAccruedBalance, - } + describe('per-asset collateral pause (24 assets)', function () { + let cometExtendedMaxAssets: CometHarnessInterfaceExtendedAssetList; + let extTokensWithMaxAssets: { [symbol: string]: FaucetToken }; + let extAlice: SignerWithAddress; + let extBob: SignerWithAddress; + let extPauseGuardian: SignerWithAddress; + let extSnapshot: SnapshotRestorer; + + const collateralTokenSupplyAmount = exp(5, 18); + + before(async () => { + const maxAssetsCollaterals = Object.fromEntries( + Array.from({ length: MAX_ASSETS }, (_, j) => [`ASSET${j}`, {}]) + ); + const protocolMaxAssets = await makeProtocol({ + assets: { USDC: {}, ...maxAssetsCollaterals }, + }); + cometExtendedMaxAssets = protocolMaxAssets.cometWithExtendedAssetList; + extTokensWithMaxAssets = protocolMaxAssets.tokens as { [symbol: string]: FaucetToken }; + extPauseGuardian = protocolMaxAssets.pauseGuardian; + [extAlice, extBob] = protocolMaxAssets.users; + + await cometExtendedMaxAssets.connect(extBob).allow(extAlice.address, true); + + extSnapshot = await takeSnapshot(); }); - expect(event(s0, 1)).to.be.deep.equal({ - Withdraw: { - src: bob.address, - to: alice.address, - amount: bobAccruedBalance, + + describe('withdraw', function () { + this.afterAll(async () => extSnapshot.restore()); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`withdraw reverts if collateral asset ${i} withdraw is paused`, async () => { + const assetIndex = i - 1; + const assetToken = extTokensWithMaxAssets[`ASSET${assetIndex}`]; + + await assetToken.allocateTo(extBob.address, collateralTokenSupplyAmount); + await assetToken + .connect(extBob) + .approve(cometExtendedMaxAssets.address, collateralTokenSupplyAmount); + await cometExtendedMaxAssets + .connect(extBob) + .supply(assetToken.address, collateralTokenSupplyAmount); + + expect( + await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address) + ).to.be.equal(collateralTokenSupplyAmount); + + await cometExtendedMaxAssets + .connect(extPauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, true); + + await expect( + cometExtendedMaxAssets + .connect(extBob) + .withdraw(assetToken.address, collateralTokenSupplyAmount) + ).to.be.revertedWithCustomError( + cometExtendedMaxAssets, + 'CollateralAssetWithdrawPaused' + ); + }); } - }); - expect(event(s0, 2)).to.be.deep.equal({ - Transfer: { - from: bob.address, - to: ethers.constants.AddressZero, - amount: bobAccruedBalance, + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to withdraw collateral asset ${i} when asset becomes unpaused`, async () => { + const assetIndex = i - 1; + const assetToken = extTokensWithMaxAssets[`ASSET${assetIndex}`]; + const collateralBalance = await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address); + const tokenBalance = await assetToken.balanceOf(extBob.address); + + await cometExtendedMaxAssets + .connect(extPauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, false); + + await cometExtendedMaxAssets.connect(extBob).withdraw(assetToken.address, collateralTokenSupplyAmount); + + const collateralBalanceAfter = await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(extBob.address); + + expect(collateralBalanceAfter).to.be.equal(collateralBalance.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalance.add(collateralTokenSupplyAmount)); + }); } }); - expect(a0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.internal).to.be.deep.equal({ USDC: bobAccruedBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.external).to.be.deep.equal({ USDC: bobAccruedBalance, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(0n); - expect(t1.totalBorrowBase).to.be.equal(exp(50, 6)); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(115000); - }); + describe('withdrawTo', function () { + this.afterAll(async () => extSnapshot.restore()); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`withdrawTo reverts if collateral asset ${i} withdraw is paused`, async () => { + const assetIndex = i - 1; + const assetToken = extTokensWithMaxAssets[`ASSET${assetIndex}`]; + + await assetToken.allocateTo(extBob.address, collateralTokenSupplyAmount); + await assetToken + .connect(extBob) + .approve(cometExtendedMaxAssets.address, collateralTokenSupplyAmount); + await cometExtendedMaxAssets + .connect(extBob) + .supply(assetToken.address, collateralTokenSupplyAmount); + + expect( + await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address) + ).to.be.equal(collateralTokenSupplyAmount); + + await cometExtendedMaxAssets + .connect(extPauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, true); + + await expect( + cometExtendedMaxAssets + .connect(extBob) + .withdrawTo( + extAlice.address, + assetToken.address, + collateralTokenSupplyAmount + ) + ).to.be.revertedWithCustomError( + cometExtendedMaxAssets, + 'CollateralAssetWithdrawPaused' + ); + }); + } - it('withdraw max base should withdraw 0 if user has a borrow position', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC, WETH } = tokens; - - await comet.setBasePrincipal(bob.address, -100e6); - await comet.setCollateralBalance(bob.address, WETH.address, exp(1, 18)); - const cometAsB = comet.connect(bob); - - const t0 = await comet.totalsBasic(); - const a0 = await portfolio(protocol, alice.address); - const b0 = await portfolio(protocol, bob.address); - const s0 = await wait(cometAsB.withdrawTo(alice.address, USDC.address, ethers.constants.MaxUint256)); - const t1 = await comet.totalsBasic(); - const a1 = await portfolio(protocol, alice.address); - const b1 = await portfolio(protocol, bob.address); - - expect(s0.receipt['events'].length).to.be.equal(2); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: comet.address, - to: alice.address, - amount: 0n, + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to withdrawTo collateral asset ${i} when asset becomes unpaused`, async () => { + const assetIndex = i - 1; + const assetToken = extTokensWithMaxAssets[`ASSET${assetIndex}`]; + const collateralBalanceBob = await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address); + const collateralBalanceAlice = await cometExtendedMaxAssets.collateralBalanceOf(extAlice.address, assetToken.address); + const tokenBalanceBob = await assetToken.balanceOf(extBob.address); + const tokenBalanceAlice = await assetToken.balanceOf(extAlice.address); + + await cometExtendedMaxAssets + .connect(extPauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, false); + + await cometExtendedMaxAssets + .connect(extBob) + .withdrawTo(extAlice.address, assetToken.address, collateralTokenSupplyAmount); + + const collateralBalanceBobAfter = await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address); + const collateralBalanceAliceAfter = await cometExtendedMaxAssets.collateralBalanceOf(extAlice.address, assetToken.address); + const tokenBalanceBobAfter = await assetToken.balanceOf(extBob.address); + const tokenBalanceAliceAfter = await assetToken.balanceOf(extAlice.address); + + expect(collateralBalanceBobAfter).to.be.equal(collateralBalanceBob.sub(collateralTokenSupplyAmount)); + expect(collateralBalanceAliceAfter).to.be.equal(collateralBalanceAlice); + expect(tokenBalanceBobAfter).to.be.equal(tokenBalanceBob); + expect(tokenBalanceAliceAfter).to.be.equal(tokenBalanceAlice.add(collateralTokenSupplyAmount)); + }); } }); - expect(event(s0, 1)).to.be.deep.equal({ - Withdraw: { - src: bob.address, - to: alice.address, - amount: 0n, + + describe('withdrawFrom', function () { + this.afterAll(async () => extSnapshot.restore()); + + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`withdrawFrom reverts if collateral asset ${i} withdraw is paused`, async () => { + const assetIndex = i - 1; + const assetToken = extTokensWithMaxAssets[`ASSET${assetIndex}`]; + + await assetToken.allocateTo(extBob.address, collateralTokenSupplyAmount); + await assetToken + .connect(extBob) + .approve(cometExtendedMaxAssets.address, collateralTokenSupplyAmount); + await cometExtendedMaxAssets + .connect(extBob) + .supply(assetToken.address, collateralTokenSupplyAmount); + + expect( + await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address) + ).to.be.equal(collateralTokenSupplyAmount); + + await cometExtendedMaxAssets + .connect(extPauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, true); + + await expect( + cometExtendedMaxAssets + .connect(extAlice) + .withdrawFrom( + extBob.address, + extAlice.address, + assetToken.address, + collateralTokenSupplyAmount + ) + ).to.be.revertedWithCustomError( + cometExtendedMaxAssets, + 'CollateralAssetWithdrawPaused' + ); + }); } - }); - expect(a0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b0.internal).to.be.deep.equal({ USDC: exp(-100, 6), COMP: 0n, WETH: exp(1, 18), WBTC: 0n }); - expect(b0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(a1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(b1.internal).to.be.deep.equal({ USDC: exp(-100, 6), COMP: 0n, WETH: exp(1, 18), WBTC: 0n }); - expect(b1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase); - expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(121000); + for (let i = 1; i <= MAX_ASSETS; i++) { + it(`allows to withdrawFrom collateral asset ${i} when asset becomes unpaused`, async () => { + const assetIndex = i - 1; + const assetToken = extTokensWithMaxAssets[`ASSET${assetIndex}`]; + const collateralBalanceBob = await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address); + const collateralBalanceAlice = await cometExtendedMaxAssets.collateralBalanceOf(extAlice.address, assetToken.address); + const tokenBalanceBob = await assetToken.balanceOf(extBob.address); + const tokenBalanceAlice = await assetToken.balanceOf(extAlice.address); + + await cometExtendedMaxAssets + .connect(extPauseGuardian) + .pauseCollateralAssetWithdraw(assetIndex, false); + + await cometExtendedMaxAssets + .connect(extAlice) + .withdrawFrom(extBob.address, extAlice.address, assetToken.address, collateralTokenSupplyAmount); + + const collateralBalanceBobAfter = await cometExtendedMaxAssets.collateralBalanceOf(extBob.address, assetToken.address); + const collateralBalanceAliceAfter = await cometExtendedMaxAssets.collateralBalanceOf(extAlice.address, assetToken.address); + const tokenBalanceBobAfter = await assetToken.balanceOf(extBob.address); + const tokenBalanceAliceAfter = await assetToken.balanceOf(extAlice.address); + + expect(collateralBalanceBobAfter).to.be.equal(collateralBalanceBob.sub(collateralTokenSupplyAmount)); + expect(collateralBalanceAliceAfter).to.be.equal(collateralBalanceAlice); + expect(tokenBalanceBobAfter).to.be.equal(tokenBalanceBob); + expect(tokenBalanceAliceAfter).to.be.equal(tokenBalanceAlice.add(collateralTokenSupplyAmount)); + }); + } + }); }); - // This demonstrates a weird quirk of the present value/principal value rounding down math. - it('withdraws 0 but Comet Transfer event amount is 1', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice] } = protocol; - const { USDC } = tokens; + /*////////////////////////////////////////////////////////////// + DEACTIVATE COLLATERAL FEATURE + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Withdraw path behavior when collateral is deactivated and reactivated. + * @dev + * While a collateral is deactivated, borrowing against it (base `withdraw` that + * opens/increases debt) reverts with `TokenIsDeactivated(collateralToken)`, but + * lenders can still withdraw base and collateral holders can still withdraw the + * deactivated asset — deactivation must never trap users. After the `governor` + * reactivates it, borrowing against the collateral works again. The MAX_ASSETS + * loop asserts the same withdraw-always-succeeds behavior for every asset index + * in a full `cometWith24Collaterals` configuration, both while deactivated and + * after reactivation. + * + * Context: in the wUSDM / deUSD incident scenario, deactivation must block new + * borrow exposure to the affected collateral without preventing exits. + */ + describe('deactivated collateral withdraw flow', function () { + before(async () => { + await baseSnapshot.restore(); + + await baseToken.allocateTo(bob.address, baseTokenSupplyAmount); + await collateralToken.allocateTo(bob.address, collateralTokenSupplyAmount); + // Allocate some additional base tokens to the comet for borrowing + await baseToken.allocateTo(comet.address, baseTokenSupplyAmount * 5n); + + await collateralToken.allocateTo(dave.address, collateralTokenSupplyAmount); + + await collateralToken.connect(bob).approve(comet.address, collateralTokenSupplyAmount); + await comet.connect(bob).supply(collateralToken.address, collateralTokenSupplyAmount); + + await baseToken.connect(bob).approve(comet.address, baseTokenSupplyAmount); + await comet.connect(bob).supply(baseToken.address, baseTokenSupplyAmount); + + await collateralToken.connect(dave).approve(comet.address, collateralTokenSupplyAmount); + await comet.connect(dave).supply(collateralToken.address, collateralTokenSupplyAmount); + + daveCollateralBefore = await comet.userCollateral(dave.address, collateralToken.address); + totalsCollateralBefore = await comet.totalsCollateral(collateralToken.address); + + baseSnapshot = await takeSnapshot(); + }); + + it('allows pause guardian to deactivate collateral', async function () { + await expect(await comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await comet.setBasePrincipal(alice.address, 99999992291226); - await setTotalsBasic(comet, { - totalSupplyBase: 699999944771920, - baseSupplyIndex: 1000000131467072, + it('reverts if borrow', async function () { + await expect( + comet.connect(dave).withdraw(baseToken.address, borrowAmount) + ).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); }); - const s0 = await wait(comet.connect(alice).withdraw(USDC.address, 0)); + it('should not revert when withdrawing base token if base token is lending and user has deactivated collateral', async function() { + const bobBaseBalanceBefore = await comet.balanceOf(bob.address); - expect(s0.receipt['events'].length).to.be.equal(3); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: comet.address, - to: alice.address, - amount: 0n, - } + expect((await comet.userBasic(bob.address)).principal).to.be.greaterThanOrEqual(0); + expect((await comet.userCollateral(bob.address, collateralToken.address)).balance).to.be.greaterThan(0); + expect(bobBaseBalanceBefore).to.be.greaterThan(0); + + await expect(comet.connect(bob).withdraw(baseToken.address, borrowAmount)).to.not.be.reverted; + + const bobBaseBalanceAfter = await comet.balanceOf(bob.address); + expect(bobBaseBalanceBefore.sub(bobBaseBalanceAfter)).to.be.closeTo(borrowAmount, 1); }); - expect(event(s0, 1)).to.be.deep.equal({ - Withdraw: { - src: alice.address, - to: alice.address, - amount: 0n, - } + + it('allows to withdraw collateral', async function () { + await comet.connect(dave).withdraw(collateralToken.address, collateralTokenSupplyAmount/2n); }); - // Weird quirk of round down behavior where `withdrawAmount` is 1 even though - // `amount` is 0. So no base leaves Comet (which is expected) - expect(event(s0, 2)).to.be.deep.equal({ - Transfer: { - from: alice.address, - to: ethers.constants.AddressZero, - amount: 1n, - } + + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); + + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenSupplyAmount/2n); }); - }); - it('withdraws collateral from sender if the asset is collateral', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; + it('updates totals collateral', async function () { + const totalsCollateralAfter = await comet.totalsCollateral(collateralToken.address); + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).sub(collateralTokenSupplyAmount/2n); + + expect(totalsCollateralAfter.totalSupplyAsset).to.eq(expectedTotalSupplyAsset); + }); - const _i0 = await COMP.allocateTo(comet.address, 8e8); - const t0 = Object.assign({}, await comet.totalsCollateral(COMP.address), { - totalSupplyAsset: 8e8, + it('allows governor to activate collateral', async function () { + await expect(await comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; }); - const _b0 = await wait(comet.setTotalsCollateral(COMP.address, t0)); - const _i1 = await comet.setCollateralBalance(bob.address, COMP.address, 8e8); - const cometAsB = comet.connect(bob); + it('allows to withdraw activated collateral', async function () { + await comet.connect(dave).withdraw(collateralToken.address, collateralTokenSupplyAmount/4n); + }); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const s0 = await wait(cometAsB.withdrawTo(alice.address, COMP.address, 8e8)); - const t1 = await comet.totalsCollateral(COMP.address); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); - expect(event(s0, 0)).to.be.deep.equal({ - Transfer: { - from: comet.address, - to: alice.address, - amount: BigInt(8e8), - } + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenSupplyAmount * 3n / 4n); }); - expect(event(s0, 1)).to.be.deep.equal({ - WithdrawCollateral: { - src: bob.address, - to: alice.address, - asset: COMP.address, - amount: BigInt(8e8), - } + + it('updates totals collateral', async function () { + const totalsCollateralAfter = await comet.totalsCollateral(collateralToken.address); + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).sub(collateralTokenSupplyAmount * 3n / 4n); + + expect(totalsCollateralAfter.totalSupplyAsset).to.eq(expectedTotalSupplyAsset); }); - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: exp(8, 8), WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: exp(8, 8), WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(t1.totalSupplyAsset).to.be.equal(0n); - expect(Number(s0.receipt.gasUsed)).to.be.lessThan(85000); - }); + it('allows to borrow base token', async function () { + await comet.connect(dave).withdraw(baseToken.address, borrowAmount); - it('calculates base principal correctly', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; - - await USDC.allocateTo(comet.address, 100e6); - const _totals0 = await setTotalsBasic(comet, { - baseSupplyIndex: 2e15, - totalSupplyBase: 50e6, // 100e6 in present value - }); - - await comet.setBasePrincipal(bob.address, 50e6); // 100e6 in present value - const cometAsB = comet.connect(bob); - - const alice0 = await portfolio(protocol, alice.address); - const bob0 = await portfolio(protocol, bob.address); - - await wait(cometAsB.withdrawTo(alice.address, USDC.address, 100e6)); - const totals1 = await comet.totalsBasic(); - const alice1 = await portfolio(protocol, alice.address); - const bob1 = await portfolio(protocol, bob.address); - - expect(alice0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob0.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(alice1.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(bob1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(totals1.totalSupplyBase).to.be.equal(0n); - expect(totals1.totalBorrowBase).to.be.equal(0n); - }); + // Check that caller becomes borrower after borrowing + expect((await comet.userBasic(dave.address)).principal).to.be.lessThan(0); + }); - it('reverts if withdrawing base exceeds the total supply', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { USDC } = tokens; + for(let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; - const _i0 = await USDC.allocateTo(comet.address, 100e6); - const _i1 = await comet.setBasePrincipal(bob.address, 100e6); - const cometAsB = comet.connect(bob); + it(`should not revert when withdrawing deactivated collateral asset with index ${i}`, async function () { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - await expect(cometAsB.withdrawTo(alice.address, USDC.address, 100e6)).to.be.reverted; - }); + await assetToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await assetToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).supply(assetToken.address, collateralTokenSupplyAmount); - it('reverts if withdrawing collateral exceeds the total supply', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); - const _i0 = await COMP.allocateTo(comet.address, 8e8); - const _i1 = await comet.setCollateralBalance(bob.address, COMP.address, 8e8); - const cometAsB = comet.connect(bob); + const collateralBalanceBefore = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceBefore = await assetToken.balanceOf(bob.address); - await expect(cometAsB.withdrawTo(alice.address, COMP.address, 8e8)).to.be.reverted; - }); + await expect( + cometWith24Collaterals.connect(bob).withdraw(assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; - it('reverts if the asset is neither collateral nor base', async () => { - const protocol = await makeProtocol(); - const { comet, users: [alice, bob], unsupportedToken: USUP } = protocol; + const collateralBalanceAfter = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(bob.address); - const _i0 = await USUP.allocateTo(comet.address, 1); - const cometAsB = comet.connect(bob); + expect(collateralBalanceAfter).to.be.equal(collateralBalanceBefore.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalanceBefore.add(collateralTokenSupplyAmount)); + }); - await expect(cometAsB.withdrawTo(alice.address, USUP.address, 1)).to.be.reverted; - }); + it(`allows to withdraw re-activated collateral with index ${i}`, async function () { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - it('reverts if withdraw is paused', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, pauseGuardian, users: [alice, bob] } = protocol; - const { USDC } = tokens; + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + await assetToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await assetToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).supply(assetToken.address, collateralTokenSupplyAmount); - await USDC.allocateTo(comet.address, 1); - const cometAsB = comet.connect(bob); + const collateralBalanceBefore = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceBefore = await assetToken.balanceOf(bob.address); - // Pause withdraw - await wait(comet.connect(pauseGuardian).pause(false, false, true, false, false)); - expect(await comet.isWithdrawPaused()).to.be.true; + await expect( + cometWith24Collaterals.connect(bob).withdraw(assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; - await expect(cometAsB.withdrawTo(alice.address, USDC.address, 1)).to.be.revertedWith("custom error 'Paused()'"); + const collateralBalanceAfter = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(bob.address); + + expect(collateralBalanceAfter).to.be.equal(collateralBalanceBefore.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalanceBefore.add(collateralTokenSupplyAmount)); + }); + } }); - it('reverts if withdraw max for a collateral asset', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [alice, bob] } = protocol; - const { COMP } = tokens; + describe('deactivated collateral withdrawTo flow', function () { + it('allows pause guardian to deactivate collateral', async function () { + await baseSnapshot.restore(); - await COMP.allocateTo(bob.address, 100e6); - const cometAsB = comet.connect(bob); + await expect(await comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await expect(cometAsB.withdrawTo(alice.address, COMP.address, ethers.constants.MaxUint256)).to.be.revertedWith("custom error 'InvalidUInt128()'"); - }); + it('reverts if borrow', async function () { + await expect( + comet.connect(dave).withdrawTo(alice.address, baseToken.address, borrowAmount) + ).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); + }); + + it('should not revert when withdrawing base token if base token is lending and user has deactivated collateral', async function() { + const bobBaseBalanceBefore = await comet.balanceOf(bob.address); - it('borrows to withdraw if necessary/possible', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol(); - const { WETH, USDC } = tokens; + expect((await comet.userBasic(bob.address)).principal).to.be.greaterThanOrEqual(0); + expect((await comet.userCollateral(bob.address, collateralToken.address)).balance).to.be.greaterThan(0); + expect(bobBaseBalanceBefore).to.be.greaterThan(0); - await USDC.allocateTo(comet.address, 1e6); - await comet.setCollateralBalance(alice.address, WETH.address, exp(1, 18)); + await expect( + comet.connect(bob).withdrawTo(alice.address, baseToken.address, borrowAmount) + ).to.not.be.reverted; - let t0 = await comet.totalsBasic(); - await setTotalsBasic(comet, { - baseBorrowIndex: t0.baseBorrowIndex.mul(2), + const bobBaseBalanceAfter = await comet.balanceOf(bob.address); + expect(bobBaseBalanceBefore.sub(bobBaseBalanceAfter)).to.be.closeTo(borrowAmount, 1); }); - await comet.connect(alice).withdrawTo(bob.address, USDC.address, 1e6); + it('allows to withdraw collateral', async function () { + await comet.connect(dave).withdrawTo(alice.address, collateralToken.address, collateralTokenSupplyAmount/2n); + }); - expect(await baseBalanceOf(comet, alice.address)).to.eq(BigInt(-1e6)); - expect(await USDC.balanceOf(bob.address)).to.eq(1e6); - }); -}); + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); -describe('withdraw', function () { - it('withdraws to sender by default', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, users: [bob] } = protocol; - const { USDC } = tokens; + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenSupplyAmount/2n); + }); + + it('updates totals collateral', async function () { + const totalsCollateralAfter = await comet.totalsCollateral(collateralToken.address); + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).sub(collateralTokenSupplyAmount/2n); - const _i0 = await USDC.allocateTo(comet.address, 100e6); - const _t0 = await setTotalsBasic(comet, { - totalSupplyBase: 100e6, + expect(totalsCollateralAfter.totalSupplyAsset).to.eq(expectedTotalSupplyAsset); }); - const _i1 = await comet.setBasePrincipal(bob.address, 100e6); - const cometAsB = comet.connect(bob); + it('allows governor to activate collateral', async function () { + await expect(await comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - const q0 = await portfolio(protocol, bob.address); - const _s0 = await wait(cometAsB.withdraw(USDC.address, 100e6)); - const _t1 = await comet.totalsBasic(); - const q1 = await portfolio(protocol, bob.address); + it('allows to withdraw activated collateral', async function () { + await comet.connect(dave).withdrawTo(alice.address, collateralToken.address, collateralTokenSupplyAmount/4n); + }); - expect(q0.internal).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: exp(100, 6), COMP: 0n, WETH: 0n, WBTC: 0n }); - }); + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); - it('reverts if withdraw is paused', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { comet, tokens, pauseGuardian, users: [bob] } = protocol; - const { USDC } = tokens; + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenSupplyAmount * 3n / 4n); + }); - await USDC.allocateTo(comet.address, 100e6); - const cometAsB = comet.connect(bob); + it('updates totals collateral', async function () { + const totalsCollateralAfter = await comet.totalsCollateral(collateralToken.address); + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).sub(collateralTokenSupplyAmount * 3n / 4n); - // Pause withdraw - await wait(comet.connect(pauseGuardian).pause(false, false, true, false, false)); - expect(await comet.isWithdrawPaused()).to.be.true; + expect(totalsCollateralAfter.totalSupplyAsset).to.eq(expectedTotalSupplyAsset); + }); - await expect(cometAsB.withdraw(USDC.address, 100e6)).to.be.revertedWith("custom error 'Paused()'"); - }); + it('allows to borrow base token', async function () { + await comet.connect(dave).withdrawTo(alice.address, baseToken.address, borrowAmount); - it('reverts if withdraw amount is less than baseBorrowMin', async () => { - const { comet, tokens, users: [alice] } = await makeProtocol({ - baseBorrowMin: exp(1, 6) + // Check that caller becomes borrower after borrowing + expect((await comet.userBasic(dave.address)).principal).to.be.lessThan(0); }); - const { USDC } = tokens; - await expect( - comet.connect(alice).withdraw(USDC.address, exp(.5, 6)) - ).to.be.revertedWith("custom error 'BorrowTooSmall()'"); - }); + for(let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; - it('reverts if base withdraw amount is not collateralzed', async () => { - const { comet, tokens, users: [alice] } = await makeProtocol(); - const { USDC } = tokens; + it(`should not revert when withdrawing deactivated collateral asset with index ${i}`, async function () { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - await expect( - comet.connect(alice).withdraw(USDC.address, exp(1, 6)) - ).to.be.revertedWith("custom error 'NotCollateralized()'"); - }); + await assetToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await assetToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).supply(assetToken.address, collateralTokenSupplyAmount); - it('reverts if collateral withdraw amount is not collateralized', async () => { - const { comet, tokens, users: [alice] } = await makeProtocol(); - const { WETH } = tokens; + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); - const totalsCollateral = Object.assign({}, await comet.totalsCollateral(WETH.address), { - totalSupplyAsset: exp(1, 18), - }); - await wait(comet.setTotalsCollateral(WETH.address, totalsCollateral)); + const collateralBalanceBefore = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceBefore = await assetToken.balanceOf(alice.address); - // user has a borrow, but with collateral to cover - await comet.setBasePrincipal(alice.address, -100e6); - await comet.setCollateralBalance(alice.address, WETH.address, exp(1, 18)); + await expect( + cometWith24Collaterals.connect(bob).withdrawTo(alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; - // reverts if withdraw would leave borrow uncollateralized - await expect( - comet.connect(alice).withdraw(WETH.address, exp(1, 18)) - ).to.be.revertedWith("custom error 'NotCollateralized()'"); - }); + const collateralBalanceAfter = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(alice.address); - describe('reentrancy', function () { - it('blocks malicious reentrant transferFrom', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol({ - assets: { - USDC: { - decimals: 6 - }, - EVIL: { - decimals: 6, - initialPrice: 2, - factory: await ethers.getContractFactory('EvilToken') as EvilToken__factory, - } - } + expect(collateralBalanceAfter).to.be.equal(collateralBalanceBefore.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalanceBefore.add(collateralTokenSupplyAmount)); }); - const { USDC, EVIL } = <{ USDC: FaucetToken, EVIL: EvilToken }>tokens; - await USDC.allocateTo(comet.address, 100e6); + it(`allows to withdrawTo re-activated collateral with index ${i}`, async function () { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; - const attack = Object.assign({}, await EVIL.getAttack(), { - attackType: ReentryAttack.TransferFrom, - destination: bob.address, - asset: USDC.address, - amount: 1e6 - }); - await EVIL.setAttack(attack); + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + await assetToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await assetToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).supply(assetToken.address, collateralTokenSupplyAmount); + + const collateralBalanceBefore = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceBefore = await assetToken.balanceOf(alice.address); - const totalsCollateral = Object.assign({}, await comet.totalsCollateral(EVIL.address), { - totalSupplyAsset: 100e6, + await expect( + cometWith24Collaterals.connect(bob).withdrawTo(alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + + const collateralBalanceAfter = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(alice.address); + + expect(collateralBalanceAfter).to.be.equal(collateralBalanceBefore.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalanceBefore.add(collateralTokenSupplyAmount)); }); - await comet.setTotalsCollateral(EVIL.address, totalsCollateral); + } + }); + + describe('deactivated collateral withdrawFrom flow', function () { + it('allows pause guardian to deactivate collateral', async function () { + await baseSnapshot.restore(); - await comet.setCollateralBalance(alice.address, EVIL.address, exp(1, 6)); - await comet.connect(alice).allow(EVIL.address, true); + await expect(await comet.connect(pauseGuardian).deactivateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); + + it('reverts if borrow', async function () { + await comet.connect(dave).allow(alice.address, true); + await expect( + comet.connect(alice).withdrawFrom(dave.address, alice.address, baseToken.address, borrowAmount) + ).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); + }); - // In callback, EVIL token calls transferFrom(alice.address, bob.address, 1e6) + it('should not revert when withdrawing base token if base token is lending and user has deactivated collateral', async function() { + const bobBaseBalanceBefore = await comet.balanceOf(bob.address); + + expect((await comet.userBasic(bob.address)).principal).to.be.greaterThanOrEqual(0); + expect((await comet.userCollateral(bob.address, collateralToken.address)).balance).to.be.greaterThan(0); + expect(bobBaseBalanceBefore).to.be.greaterThan(0); + await comet.connect(bob).allow(alice.address, true); await expect( - comet.connect(alice).withdraw(EVIL.address, 1e6) - ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); + comet.connect(alice).withdrawFrom(bob.address, alice.address, baseToken.address, borrowAmount) + ).to.not.be.reverted; - // no USDC transferred - expect(await USDC.balanceOf(comet.address)).to.eq(100e6); - expect(await baseBalanceOf(comet, alice.address)).to.eq(0n); - expect(await USDC.balanceOf(alice.address)).to.eq(0); - expect(await baseBalanceOf(comet, bob.address)).to.eq(0n); - expect(await USDC.balanceOf(bob.address)).to.eq(0); + const bobBaseBalanceAfter = await comet.balanceOf(bob.address); + expect(bobBaseBalanceBefore.sub(bobBaseBalanceAfter)).to.be.closeTo(borrowAmount, 1); }); - it('blocks malicious reentrant withdrawFrom', async () => { - const { comet, tokens, users: [alice, bob] } = await makeProtocol({ - assets: { - USDC: { - decimals: 6 - }, - EVIL: { - decimals: 6, - initialPrice: 2, - factory: await ethers.getContractFactory('EvilToken') as EvilToken__factory, - } - } - }); - const { USDC, EVIL } = <{ USDC: FaucetToken, EVIL: EvilToken }>tokens; + it('allows to withdraw collateral', async function () { + await comet.connect(alice).withdrawFrom(dave.address, alice.address, collateralToken.address, collateralTokenSupplyAmount/2n); + }); - await USDC.allocateTo(comet.address, 100e6); + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); - const attack = Object.assign({}, await EVIL.getAttack(), { - attackType: ReentryAttack.WithdrawFrom, - destination: bob.address, - asset: USDC.address, - amount: 1e6 - }); - await EVIL.setAttack(attack); + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenSupplyAmount/2n); + }); - const totalsCollateral = Object.assign({}, await comet.totalsCollateral(EVIL.address), { - totalSupplyAsset: 100e6, - }); - await comet.setTotalsCollateral(EVIL.address, totalsCollateral); + it('updates totals collateral', async function () { + const totalsCollateralAfter = await comet.totalsCollateral(collateralToken.address); + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).sub(collateralTokenSupplyAmount/2n); + + expect(totalsCollateralAfter.totalSupplyAsset).to.eq(expectedTotalSupplyAsset); + }); - await comet.setCollateralBalance(alice.address, EVIL.address, exp(1, 6)); + it('allows governor to activate collateral', async function () { + await expect(await comet.connect(governor).activateCollateral(deactivatedCollateralIndex)).to.not.be.reverted; + }); - await comet.connect(alice).allow(EVIL.address, true); + it('allows to withdraw activated collateral', async function () { + await comet.connect(alice).withdrawFrom(dave.address, alice.address, collateralToken.address, collateralTokenSupplyAmount/4n); + }); - // in callback, EvilToken attempts to withdraw USDC to bob's address - await expect( - comet.connect(alice).withdraw(EVIL.address, 1e6) - ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); + it('updates users collateral balances', async function () { + const daveCollateralAfter = await comet.userCollateral(dave.address, collateralToken.address); - // no USDC transferred - expect(await USDC.balanceOf(comet.address)).to.eq(100e6); - expect(await baseBalanceOf(comet, alice.address)).to.eq(0n); - expect(await USDC.balanceOf(alice.address)).to.eq(0); - expect(await baseBalanceOf(comet, bob.address)).to.eq(0n); - expect(await USDC.balanceOf(bob.address)).to.eq(0); + expect(daveCollateralBefore.balance.sub(daveCollateralAfter.balance)).to.eq(collateralTokenSupplyAmount * 3n / 4n); }); - }); -}); + it('updates totals collateral', async function () { + const totalsCollateralAfter = await comet.totalsCollateral(collateralToken.address); + const expectedTotalSupplyAsset = BigNumber.from(totalsCollateralBefore.totalSupplyAsset).sub(collateralTokenSupplyAmount * 3n / 4n); -describe('withdrawFrom', function () { - it('withdraws from src if specified and sender has permission', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; - - const _i0 = await COMP.allocateTo(comet.address, 7); - const t0 = Object.assign({}, await comet.totalsCollateral(COMP.address), { - totalSupplyAsset: 7, - }); - const _b0 = await wait(comet.setTotalsCollateral(COMP.address, t0)); - - const _i1 = await comet.setCollateralBalance(bob.address, COMP.address, 7); - - const cometAsB = comet.connect(bob); - const cometAsC = comet.connect(charlie); - - const _a1 = await wait(cometAsB.allow(charlie.address, true)); - const p0 = await portfolio(protocol, alice.address); - const q0 = await portfolio(protocol, bob.address); - const _s0 = await wait(cometAsC.withdrawFrom(bob.address, alice.address, COMP.address, 7)); - const p1 = await portfolio(protocol, alice.address); - const q1 = await portfolio(protocol, bob.address); - - expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 7n, WETH: 0n, WBTC: 0n }); - expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 7n, WETH: 0n, WBTC: 0n }); - expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n }); - }); + expect(totalsCollateralAfter.totalSupplyAsset).to.eq(expectedTotalSupplyAsset); + }); + + it('allows to borrow base token', async function () { + await comet.connect(alice).withdrawFrom(dave.address, alice.address, baseToken.address, borrowAmount); - it('reverts if src is specified and sender does not have permission', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; + // Check that caller becomes borrower after borrowing + expect((await comet.userBasic(dave.address)).principal).to.be.lessThan(0); + }); + + it('bob gives allowance to alice on 24 collateral comet', async function () { + await cometWith24Collaterals.connect(bob).allow(alice.address, true); + }); - const cometAsC = comet.connect(charlie); + for(let i = 1; i <= MAX_ASSETS; i++) { + const assetIndex = i - 1; - await expect(cometAsC.withdrawFrom(bob.address, alice.address, COMP.address, 7)) - .to.be.revertedWith("custom error 'Unauthorized()'"); - }); + it(`should not revert when withdrawing deactivated collateral asset with index ${i}`, async function () { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + + await assetToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await assetToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).supply(assetToken.address, collateralTokenSupplyAmount); + + await cometWith24Collaterals.connect(pauseGuardian).deactivateCollateral(assetIndex); + + const collateralBalanceBefore = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceBefore = await assetToken.balanceOf(alice.address); + + await expect( + cometWith24Collaterals.connect(alice).withdrawFrom(bob.address, alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; - it('reverts if withdraw is paused', async () => { - const protocol = await makeProtocol(); - const { comet, tokens, pauseGuardian, users: [alice, bob, charlie] } = protocol; - const { COMP } = tokens; + const collateralBalanceAfter = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(alice.address); - await COMP.allocateTo(comet.address, 7); - const cometAsB = comet.connect(bob); - const cometAsC = comet.connect(charlie); + expect(collateralBalanceAfter).to.be.equal(collateralBalanceBefore.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalanceBefore.add(collateralTokenSupplyAmount)); + }); + + it(`allows to withdrawFrom re-activated collateral with index ${i}`, async function () { + const assetToken = tokensWith24Collaterals[`ASSET${assetIndex}`]; + + await cometWith24Collaterals.connect(governor).activateCollateral(assetIndex); + + await assetToken.allocateTo(bob.address, collateralTokenSupplyAmount); + await assetToken.connect(bob).approve(cometWith24Collaterals.address, collateralTokenSupplyAmount); + await cometWith24Collaterals.connect(bob).supply(assetToken.address, collateralTokenSupplyAmount); - // Pause withdraw - await wait(comet.connect(pauseGuardian).pause(false, false, true, false, false)); - expect(await comet.isWithdrawPaused()).to.be.true; + const collateralBalanceBefore = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceBefore = await assetToken.balanceOf(alice.address); - await wait(cometAsB.allow(charlie.address, true)); - await expect(cometAsC.withdrawFrom(bob.address, alice.address, COMP.address, 7)).to.be.revertedWith("custom error 'Paused()'"); + await expect( + cometWith24Collaterals.connect(alice).withdrawFrom(bob.address, alice.address, assetToken.address, collateralTokenSupplyAmount) + ).to.not.be.reverted; + + const collateralBalanceAfter = await cometWith24Collaterals.collateralBalanceOf(bob.address, assetToken.address); + const tokenBalanceAfter = await assetToken.balanceOf(alice.address); + + expect(collateralBalanceAfter).to.be.equal(collateralBalanceBefore.sub(collateralTokenSupplyAmount)); + expect(tokenBalanceAfter).to.be.equal(tokenBalanceBefore.add(collateralTokenSupplyAmount)); + }); + } }); -}); \ No newline at end of file +});