diff --git a/.github/workflows/deploy-market.yaml b/.github/workflows/deploy-market.yaml index a9758642e..7115fc0d5 100644 --- a/.github/workflows/deploy-market.yaml +++ b/.github/workflows/deploy-market.yaml @@ -51,13 +51,14 @@ jobs: _TENDERLY_KEY_RONIN: ${{ secrets._TENDERLY_KEY_RONIN }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} steps: - name: Seacrest uses: hayesgm/seacrest@1578d5b4cf165e97e11d3debf039db09a497211f 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}\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://scroll.drpc.org\"}')[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}\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"${SCROLL_QUICKNODE_LINK}\"}')[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 9975d4781..6f9382d81 100644 --- a/.github/workflows/enact-migration.yaml +++ b/.github/workflows/enact-migration.yaml @@ -74,6 +74,7 @@ jobs: _TENDERLY_KEY_POLYGON: ${{ secrets._TENDERLY_KEY_POLYGON }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} GOV_NETWORK: ${{ vars.GOV_NETWORK }} steps: - name: Get governance network @@ -99,7 +100,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}\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://scroll.drpc.org\"}')[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}\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"${SCROLL_QUICKNODE_LINK}\"}')[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 088d643d0..1a6fad41e 100644 --- a/.github/workflows/prepare-migration.yaml +++ b/.github/workflows/prepare-migration.yaml @@ -54,6 +54,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} GOV_NETWORK: ${{ vars.GOV_NETWORK }} steps: - name: Get governance network @@ -69,7 +70,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}\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"https://scroll.drpc.org\"}')[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}\",\"mainnet\":\"${MAINNET_QUICKNODE_LINK}\",\"polygon\":\"${POLYGON_QUICKNODE_LINK}\",\"arbitrum\":\"${ARBITRUM_QUICKNODE_LINK}\",\"base\":\"${BASE_QUICKNODE_LINK}\",\"scroll\":\"${SCROLL_QUICKNODE_LINK}\"}')[github.event.inputs.network] }}" port: 8585 if: github.event.inputs.eth_pk == '' diff --git a/.github/workflows/run-contract-linter.yaml b/.github/workflows/run-contract-linter.yaml index 7acd61cce..640af6586 100644 --- a/.github/workflows/run-contract-linter.yaml +++ b/.github/workflows/run-contract-linter.yaml @@ -28,6 +28,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/run-coverage.yaml b/.github/workflows/run-coverage.yaml index e6e883174..c38f43f96 100644 --- a/.github/workflows/run-coverage.yaml +++ b/.github/workflows/run-coverage.yaml @@ -30,6 +30,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/run-eslint.yaml b/.github/workflows/run-eslint.yaml index a3cc60e00..b2bf8f0da 100644 --- a/.github/workflows/run-eslint.yaml +++ b/.github/workflows/run-eslint.yaml @@ -28,6 +28,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/run-forge-tests.yaml b/.github/workflows/run-forge-tests.yaml index b75007ac6..a878c80e7 100644 --- a/.github/workflows/run-forge-tests.yaml +++ b/.github/workflows/run-forge-tests.yaml @@ -42,6 +42,7 @@ jobs: SCROLL_RPC_URL: ${{ secrets.SCROLL_RPC_URL }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} RONIN_QUICKNODE_LINK: ${{ secrets.RONIN_QUICKNODE_LINK }} ARBITRUM_QUICKNODE_LINK: ${{ secrets.ARBITRUM_QUICKNODE_LINK }} POLYGON_QUICKNODE_LINK: ${{ secrets.POLYGON_QUICKNODE_LINK }} diff --git a/.github/workflows/run-gas-profiler.yaml b/.github/workflows/run-gas-profiler.yaml index 4aacef1a8..9575c3140 100644 --- a/.github/workflows/run-gas-profiler.yaml +++ b/.github/workflows/run-gas-profiler.yaml @@ -29,6 +29,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/run-scenarios.yaml b/.github/workflows/run-scenarios.yaml index 75c337ba1..2bedfa6b9 100644 --- a/.github/workflows/run-scenarios.yaml +++ b/.github/workflows/run-scenarios.yaml @@ -29,6 +29,7 @@ env: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} jobs: prepare: diff --git a/.github/workflows/run-semgrep.yaml b/.github/workflows/run-semgrep.yaml index 077f45fa5..c92e59d66 100644 --- a/.github/workflows/run-semgrep.yaml +++ b/.github/workflows/run-semgrep.yaml @@ -35,6 +35,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} container: # A Docker image with Semgrep installed. Do not change this. image: returntocorp/semgrep diff --git a/.github/workflows/run-unit-tests.yaml b/.github/workflows/run-unit-tests.yaml index 5c006b4dd..58979e4ec 100644 --- a/.github/workflows/run-unit-tests.yaml +++ b/.github/workflows/run-unit-tests.yaml @@ -32,6 +32,7 @@ jobs: SCROLLSCAN_KEY: ${{ secrets.SCROLLSCAN_KEY }} UNICHAIN_QUICKNODE_LINK: ${{ secrets.UNICHAIN_QUICKNODE_LINK }} LINEA_QUICKNODE_LINK: ${{ secrets.LINEA_QUICKNODE_LINK }} + SCROLL_QUICKNODE_LINK: ${{ secrets.SCROLL_QUICKNODE_LINK }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/audits/Service Patch Audit.pdf b/audits/Service Patch Audit.pdf new file mode 100644 index 000000000..4cb3d9fbe Binary files /dev/null and b/audits/Service Patch Audit.pdf differ diff --git a/contracts/AssetList.sol b/contracts/AssetList.sol index c4dcb6800..5d078aae5 100644 --- a/contracts/AssetList.sol +++ b/contracts/AssetList.sol @@ -137,9 +137,17 @@ 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(); + // Sanity checks for factors ordering: BCF < LCF; LCF <= MAX; LF <= MAX + if (assetConfig.borrowCollateralFactor >= assetConfig.liquidateCollateralFactor && assetConfig.borrowCollateralFactor != 0) + revert CometMainInterface.BorrowCFTooLarge(); if (assetConfig.liquidateCollateralFactor > MAX_COLLATERAL_FACTOR) revert CometMainInterface.LiquidateCFTooLarge(); + if (assetConfig.liquidationFactor > MAX_COLLATERAL_FACTOR) revert CometMainInterface.LiqPenaltyTooHigh(); + + // Valid collateral factor configurations: + // 1. Both BCF and LCF are 0 => collateral is 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) unchecked { // Keep 4 decimals for each factor @@ -148,8 +156,8 @@ 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(); + // safety check duplicate sanity check on original values to ensure no values skewing after descaling and type conversion + if (borrowCollateralFactor >= liquidateCollateralFactor && borrowCollateralFactor != 0) 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..1eb0345ad 100644 --- a/contracts/CometMainInterface.sol +++ b/contracts/CometMainInterface.sol @@ -20,6 +20,7 @@ abstract contract CometMainInterface is CometCore { error BorrowCFTooLarge(); error InsufficientReserves(); error LiquidateCFTooLarge(); + error LiqPenaltyTooHigh(); error NoSelfTransfer(); error NotCollateralized(); error NotForSale(); @@ -34,6 +35,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 3490e984d..668f21c0c 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,7 @@ contract CometWithExtendedAssetList is CometMainInterface { baseSupplyIndex_ += safe64(mulFactor(baseSupplyIndex_, supplyRate * timeElapsed)); baseBorrowIndex_ += safe64(mulFactor(baseBorrowIndex_, borrowRate * timeElapsed)); } + return (baseSupplyIndex_, baseBorrowIndex_); } @@ -283,21 +289,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 +344,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 +421,36 @@ contract CometWithExtendedAssetList is CometMainInterface { uint64(baseScale) ); - for (uint8 i = 0; i < numAssets; ) { + AssetInfo memory asset; + uint256 newAmount; + for (uint8 i; i < numAssets; ++i) { 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); + + // Mechanism to skip assets with no borrowing power. It avoids getPrice() call price feed, + // so in case if excluded asset's oracle reverts (e.g. stale, broken, decommissioned), + // it won't block the entire collateralization check, and won't paralyze borrows and transfers. + // the asset has zero influence on their borrow capacity. + if (asset.borrowCollateralFactor == 0) { + continue; + } + + newAmount = mulPrice( userCollateral[account][asset.asset].balance, getPrice(asset.priceFeed), asset.scale @@ -404,7 +460,6 @@ contract CometWithExtendedAssetList is CometMainInterface { asset.borrowCollateralFactor )); } - unchecked { i++; } } return liquidity >= 0; @@ -414,43 +469,68 @@ 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; ++i) { if (isInAsset(assetsIn, i, _reserved)) { - if (liquidity >= 0) { - return false; + if (liquidity >= 0) return (false, basePrice, assetPrices); + + asset = getAssetInfo(i); + + // Skip assets that do not count toward the liquidation threshold. It avoids getPrice() call for price feed + // so in case if excluded asset's oracle reverts (e.g. stale, broken, decommissioned), + // it won't block the entire liquidation check, and won't paralyze liquidations of accounts which hold it. + if (asset.liquidateCollateralFactor == 0) { + continue; } - AssetInfo memory asset = getAssetInfo(i); - uint newAmount = mulPrice( + assetPrices[i] = getPrice(asset.priceFeed); + + 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 +628,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 +898,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 +908,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 +919,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 +947,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 +972,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 +1054,7 @@ contract CometWithExtendedAssetList is CometMainInterface { } return transferBase(src, dst, amount); } else { + if (isCollateralTransferPaused()) revert CollateralTransferPaused(); return transferCollateral(src, dst, asset, safe128(amount)); } } @@ -887,8 +1086,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 +1126,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 +1183,7 @@ contract CometWithExtendedAssetList is CometMainInterface { } return withdrawBase(src, to, amount); } else { + if (isCollateralWithdrawPaused()) revert CollateralWithdrawPaused(); return withdrawCollateral(src, to, asset, safe128(amount)); } } @@ -989,8 +1207,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 +1228,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 +1252,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 +1293,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,23 +1318,39 @@ 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); } - unchecked { i++; } + unchecked { ++i; } } uint256 deltaBalance = divPrice(deltaValue, basePrice, uint64(baseScale)); @@ -1148,15 +1420,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/hardhat/dai/deploy.ts b/deployments/hardhat/dai/deploy.ts index 61bc1407b..a20bc4263 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 { Configurator, FaucetToken, SimplePriceFeed } from '../../../build/types'; -import { DeploySpec, cloneGov, deployComet, exp, sameAddress, wait } from '../../../src/deploy'; +import { FaucetToken, SimplePriceFeed } from '../../../build/types'; +import { DeploySpec, cloneGov, deployComet, exp, wait } from '../../../src/deploy'; // Fixed test addresses for the dev market-admin role (impersonated by scenarios). const MARKET_ADMIN = '0x1111111111111111111111111111111111111111'; @@ -29,11 +29,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'); @@ -129,7 +128,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/linea/usdc/migrations/1783599230_update_comet_version.ts b/deployments/linea/usdc/migrations/1783599230_update_comet_version.ts new file mode 100644 index 000000000..39f3db3ac --- /dev/null +++ b/deployments/linea/usdc/migrations/1783599230_update_comet_version.ts @@ -0,0 +1,231 @@ +import { expect } from 'chai'; +import { Contract, utils } from 'ethers'; +import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; +import { migration } from '../../../../plugins/deployment_manager/Migration'; +import { exp, proposal, calldata } from '../../../../src/deploy'; +import { forkedHreForBase } from '../../../../plugins/scenario/utils/hreForBase'; + +const USDC_COMET_LINEA = '0x8D38A3d6B3c3B7d96D6536DA7Eef94A9d7dbC991'; +const WETH_COMET_LINEA = '0x60F2058379716A64a7A5d29219397e79bC552194'; + +//// + +const USDC_COMET_SCROLL = '0xB2f97c1Bd3bf02f5e74d13f02E3e26F93D77CE44'; + +export default migration('1783599230_update_comet_version', { + async prepare() { + return {}; + }, + + async enact(deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager) { + const trace = deploymentManager.tracer(); + + const { + bridgeReceiver: lineaBridgeReceiver, + cometAdmin: lineaCometAdmin, + configurator: lineaConfigurator, + cometFactory: lineaCometFactory + } = await deploymentManager.getContracts(); + + const { + lineaMessageService, + scrollMessenger, + governor, + } = await govDeploymentManager.getContracts(); + + const cometFactoryV2Linea = new Contract( + lineaCometFactory.address, + [ + 'function setVersion(((uint64,uint64,uint64),string))', + ], + await deploymentManager.getSigner() + ); + + const setVersionCalldataLinea = await calldata( + cometFactoryV2Linea.populateTransaction.setVersion( + [[1, 2, 1], ''] + ) + ); + + const deployAndUpgradeToCalldataUsdcLinea = utils.defaultAbiCoder.encode( + ['address', 'address'], + [lineaConfigurator.address, USDC_COMET_LINEA] + ); + const deployAndUpgradeToCalldataWethLinea = utils.defaultAbiCoder.encode( + ['address', 'address'], + [lineaConfigurator.address, WETH_COMET_LINEA] + ); + + const l2ProposalDataLinea = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [ + lineaCometFactory.address, + lineaCometAdmin.address, + lineaCometAdmin.address + ], + [ + 0, + 0, + 0 + ], + [ + 'setVersion(((uint64,uint64,uint64),string))', + 'deployAndUpgradeTo(address,address)', + 'deployAndUpgradeTo(address,address)', + ], + [ + setVersionCalldataLinea, + deployAndUpgradeToCalldataUsdcLinea, + deployAndUpgradeToCalldataWethLinea + ] + ] + ); + + // Scroll + const scrollHre = await forkedHreForBase({ name: 'scroll-usdc', network: 'scroll', deployment: 'usdc' }); + const scrollDm = await govDeploymentManager.addBridgedDeploymentManager('scroll', 'usdc', scrollHre); + const { + bridgeReceiver: scrollBridgeReceiver, + configurator: scrollConfigurator, + cometFactory: scrollCometFactory, + cometAdmin: scrollCometAdmin, + } = await scrollDm.getContracts(); + + const cometFactoryV2Scroll = new Contract( + scrollCometFactory.address, + [ + 'function setVersion(((uint64,uint64,uint64),string))', + ], + await scrollDm.getSigner() + ); + + const setVersionCalldataScroll = await calldata( + cometFactoryV2Scroll.populateTransaction.setVersion( + [[1, 2, 1], ''] + ) + ); + + const deployAndUpgradeToCalldataUsdcScroll = utils.defaultAbiCoder.encode( + ['address', 'address'], + [scrollConfigurator.address, USDC_COMET_SCROLL] + ); + + const l2ProposalDataScroll = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [ + scrollCometFactory.address, scrollCometAdmin.address, + ], + [ + 0, 0, + ], + [ + 'setVersion(((uint64,uint64,uint64),string))', + 'deployAndUpgradeTo(address,address)', + ], + [ + setVersionCalldataScroll, deployAndUpgradeToCalldataUsdcScroll, + ] + ] + ); + + const mainnetActions = [ + // 1. Update USDC and WETH Comet on Linea to the service patch version + { + contract: lineaMessageService, + signature: 'sendMessage(address,uint256,bytes)', + args: [lineaBridgeReceiver.address, 0, l2ProposalDataLinea], + }, + // 2. Update USDC Comet to the service patch version + { + contract: scrollMessenger, + signature: 'sendMessage(address,uint256,bytes,uint256)', + args: [scrollBridgeReceiver.address, 0, l2ProposalDataScroll, 1_000_000], + value: exp(0.05, 18) + }, + ]; + + const description = `# Update Comet version on Linea and Scroll + +## Proposal summary + +This proposal upgrades the Compound III USDC and WETH markets on Linea and the USDC market on Scroll to a new Comet version that changes how the base supply index is capped in markets that have lenders but no borrowers. + +Previously, \`accruedInterestIndices()\` applied a post-accrual clamp: whenever \`totalBorrowBase == 0\`, if \`presentValueSupply(totalSupplyBase)\` exceeded the Comet's base token balance, \`baseSupplyIndex\` was forced down to \`(balance * BASE_INDEX_SCALE) / totalSupplyBase\`. This protected lender withdrawals in reserve-funded markets with no borrowers, but only corrected the index after it had already been pushed too far. + +The new version enforces the same cap earlier, in \`getSupplyRate()\`: when utilization is \`0\` and \`presentValueSupply(totalSupplyBase) >= balanceOf(this)\`, the supply rate returns \`0\`, so accrual stops before the index overshoots rather than being corrected after the fact. Because a single accrual step still applies one rate over the full \`timeElapsed\`, a long gap between accruals can still overshoot the cap by a few wei; after that, \`getSupplyRate()\` returns \`0\` and the supply index no longer increases. + +Further detailed information can be found in the corresponding [pull request](https://github.com/compound-finance/comet/pull/1139). + +## Audit + +The new Comet version has been audited by [Certora](https://certora.cdn.prismic.io/certora/o_tSg8jfh3YhErQ7_Woof-CometPRs-FinalReport.pdf) and no issues were found. + +## Proposal Actions + +The first action updates the Comet implementation version in V2 Factory on Linea and deploys and upgrades the USDC and WETH Comets to the new implementation. + +The second action updates the Comet implementation version in V2 Factory on Scroll and deploys and upgrades the USDC Comet to the new implementation.`; + + const txn = await deploymentManager.retry(async () => + trace( + await governor.propose(...(await proposal(mainnetActions, description))) + ), 0, 300_000 + ); + + const event = txn.events.find( + (event: { event: string }) => event.event === 'ProposalCreated' + ); + const [proposalId] = event.args; + trace(`Created proposal ${proposalId}.`); + }, + + async enacted(): Promise { + return false; + }, + + async verify(deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager): Promise { + const { + cometFactory: lineaCometFactory, + configurator: lineaConfigurator, + } = await deploymentManager.getContracts(); + + expect(await lineaConfigurator.factory(USDC_COMET_LINEA)).to.equal(lineaCometFactory.address); + expect(await lineaConfigurator.factory(WETH_COMET_LINEA)).to.equal(lineaCometFactory.address); + + const cometFactoryV2Linea = new Contract( + lineaCometFactory.address, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + await deploymentManager.getSigner() + ); + + const [version, alternative] = await cometFactoryV2Linea.version(); + expect(version).to.deep.equal([1, 2, 1]); + expect(alternative).to.equal(''); + + // Scroll + const scrollDm = govDeploymentManager.bridgedDeploymentManagers.get('scroll:usdc') as DeploymentManager; + const { + configurator: scrollConfigurator, + cometFactory: scrollCometFactory, + } = await scrollDm.getContracts(); + + expect(await scrollConfigurator.factory(USDC_COMET_SCROLL)).to.equal(scrollCometFactory.address); + const scrollSigner = await scrollDm.getSigner(); + + const cometFactoryV2Scroll = new Contract( + scrollCometFactory.address, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + scrollSigner + ); + + const [versionScroll, alternativeScroll] = await cometFactoryV2Scroll.version(); + expect(versionScroll).to.deep.equal([1, 2, 1]); + expect(alternativeScroll).to.equal(''); + }, +}); diff --git a/deployments/linea/weth/migrations/1783599230_update_comet_version.ts b/deployments/linea/weth/migrations/1783599230_update_comet_version.ts new file mode 100644 index 000000000..15c64ca71 --- /dev/null +++ b/deployments/linea/weth/migrations/1783599230_update_comet_version.ts @@ -0,0 +1,128 @@ +import { expect } from 'chai'; +import { Contract, utils } from 'ethers'; +import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; +import { migration } from '../../../../plugins/deployment_manager/Migration'; +import { proposal, calldata } from '../../../../src/deploy'; + +const USDC_COMET_LINEA = '0x8D38A3d6B3c3B7d96D6536DA7Eef94A9d7dbC991'; +const WETH_COMET_LINEA = '0x60F2058379716A64a7A5d29219397e79bC552194'; + +export default migration('1783599230_update_comet_version', { + async prepare() { + return {}; + }, + + async enact(deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager) { + const trace = deploymentManager.tracer(); + + const { + bridgeReceiver: lineaBridgeReceiver, + cometAdmin: lineaCometAdmin, + configurator: lineaConfigurator, + cometFactory: lineaCometFactory + } = await deploymentManager.getContracts(); + + const { + lineaMessageService, + governor, + } = await govDeploymentManager.getContracts(); + + const cometFactoryV2Linea = new Contract( + lineaCometFactory.address, + [ + 'function setVersion(((uint64,uint64,uint64),string))', + ], + await deploymentManager.getSigner() + ); + + const setVersionCalldataLinea = await calldata( + cometFactoryV2Linea.populateTransaction.setVersion( + [[1, 2, 1], ''] + ) + ); + + const deployAndUpgradeToCalldataUsdcLinea = utils.defaultAbiCoder.encode( + ['address', 'address'], + [lineaConfigurator.address, USDC_COMET_LINEA] + ); + const deployAndUpgradeToCalldataWethLinea = utils.defaultAbiCoder.encode( + ['address', 'address'], + [lineaConfigurator.address, WETH_COMET_LINEA] + ); + + const l2ProposalDataLinea = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [ + lineaCometFactory.address, + lineaCometAdmin.address, + lineaCometAdmin.address + ], + [ + 0, + 0, + 0 + ], + [ + 'setVersion(((uint64,uint64,uint64),string))', + 'deployAndUpgradeTo(address,address)', + 'deployAndUpgradeTo(address,address)', + ], + [ + setVersionCalldataLinea, + deployAndUpgradeToCalldataUsdcLinea, + deployAndUpgradeToCalldataWethLinea + ] + ] + ); + + const mainnetActions = [ + // 1. Update USDC and WETH Comet on Linea to the service patch version + { + contract: lineaMessageService, + signature: 'sendMessage(address,uint256,bytes)', + args: [lineaBridgeReceiver.address, 0, l2ProposalDataLinea], + }, + ]; + + const description = `DESCRIPTION`; + + const txn = await deploymentManager.retry(async () => + trace( + await governor.propose(...(await proposal(mainnetActions, description))) + ), 0, 300_000 + ); + + const event = txn.events.find( + (event: { event: string }) => event.event === 'ProposalCreated' + ); + const [proposalId] = event.args; + trace(`Created proposal ${proposalId}.`); + }, + + async enacted(): Promise { + return false; + }, + + async verify(deploymentManager: DeploymentManager): Promise { + const { + cometFactory: lineaCometFactory, + configurator: lineaConfigurator, + } = await deploymentManager.getContracts(); + + expect(await lineaConfigurator.factory(USDC_COMET_LINEA)).to.equal(lineaCometFactory.address); + expect(await lineaConfigurator.factory(WETH_COMET_LINEA)).to.equal(lineaCometFactory.address); + + const cometFactoryV2Linea = new Contract( + lineaCometFactory.address, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + await deploymentManager.getSigner() + ); + + const [version, alternative] = await cometFactoryV2Linea.version(); + expect(version).to.deep.equal([1, 2, 1]); + expect(alternative).to.equal(''); + }, +}); diff --git a/deployments/linea/weth/roots.json b/deployments/linea/weth/roots.json index c774096d0..64540d726 100644 --- a/deployments/linea/weth/roots.json +++ b/deployments/linea/weth/roots.json @@ -2,7 +2,6 @@ "comet": "0x60F2058379716A64a7A5d29219397e79bC552194", "configurator": "0x970FfD8E335B8fa4cd5c869c7caC3a90671d5Dc3", "rewards": "0x2c7118c4C88B9841FCF839074c26Ae8f035f2921", - "cometFactory": "0xaeB318360f27748Acb200CE616E389A6C9409a07", "bridgeReceiver": "0x1F71901daf98d70B4BAF40DE080321e5C2676856", "l2MessageService": "0x508Ca82Df566dCD1B0DE8296e70a96332cD644ec", "l2StandardBridge": "0x353012dc4a9A6cF55c941bADC267f82004A8ceB9", diff --git a/deployments/relations.ts b/deployments/relations.ts index 0c676f9a7..f5c71c0a7 100644 --- a/deployments/relations.ts +++ b/deployments/relations.ts @@ -56,13 +56,14 @@ const relationConfigMap: RelationConfigMap = { // This is a workaround for contracts that don't work in Hardhat fork // invalid opcode when calling symbol() + // Arbitrum-specific known contract mappings if (address === '0xd09acb80c1e8f2291862c4978a008791c9167003') { return 'tETH'; } if (address === '0x5a7facb970d094b6c7ff1df0ea68d99e6e73cbff') { return 'weETH'; } - if (address.toLowerCase() === '0x87eee96d50fb761ad85b1c982d28a042169d61b1') { + if (address === '0x87eee96d50fb761ad85b1c982d28a042169d61b1') { return 'wrsETH'; } // Mantle-specific known contract mapping diff --git a/deployments/scroll/usdc/migrations/1783599218_update_comet_version.ts b/deployments/scroll/usdc/migrations/1783599218_update_comet_version.ts new file mode 100644 index 000000000..4fe493a3d --- /dev/null +++ b/deployments/scroll/usdc/migrations/1783599218_update_comet_version.ts @@ -0,0 +1,110 @@ +import { expect } from 'chai'; +import { Contract, utils } from 'ethers'; +import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; +import { migration } from '../../../../plugins/deployment_manager/Migration'; +import { exp, proposal, calldata } from '../../../../src/deploy'; + +const USDC_COMET_SCROLL = '0xB2f97c1Bd3bf02f5e74d13f02E3e26F93D77CE44'; + +export default migration('1783599218_update_comet_version', { + async prepare() { + return {}; + }, + + async enact(deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager) { + const trace = deploymentManager.tracer(); + + const { + bridgeReceiver, + cometAdmin, + configurator, + cometFactory + } = await deploymentManager.getContracts(); + + const { + scrollMessenger, + governor, + } = await govDeploymentManager.getContracts(); + + const cometFactoryV2Scroll = new Contract( + cometFactory.address, + [ + 'function setVersion(((uint64,uint64,uint64),string))', + ], + await deploymentManager.getSigner() + ); + + const setVersionCalldataUsdc = await calldata( + cometFactoryV2Scroll.populateTransaction.setVersion( + [[1, 2, 1], ''] + ) + ); + const deployAndUpgradeToCalldataUsdc = utils.defaultAbiCoder.encode( + ['address', 'address'], + [configurator.address, USDC_COMET_SCROLL] + ); + + const l2ProposalData = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [ + cometFactory.address, cometAdmin.address, + ], + [ + 0, 0, + ], + [ + 'setVersion(((uint64,uint64,uint64),string))', + 'deployAndUpgradeTo(address,address)', + ], + [ + setVersionCalldataUsdc, deployAndUpgradeToCalldataUsdc, + ] + ] + ); + + const mainnetActions = [ + // 1. Update USDC Comet to the service patch version + { + contract: scrollMessenger, + signature: 'sendMessage(address,uint256,bytes,uint256)', + args: [bridgeReceiver.address, 0, l2ProposalData, 1_000_000], + value: exp(0.05, 18) + }, + ]; + + const description = `DESCRIPTION`; + const txn = await deploymentManager.retry(async () => + trace( + await governor.propose(...(await proposal(mainnetActions, description))) + ), 0, 300_000 + ); + + const event = txn.events.find( + (event: { event: string }) => event.event === 'ProposalCreated' + ); + const [proposalId] = event.args; + trace(`Created proposal ${proposalId}.`); + }, + + async enacted(): Promise { + return false; + }, + + async verify(deploymentManager: DeploymentManager) { + const { cometFactory } = await deploymentManager.getContracts(); + const signer = await deploymentManager.getSigner(); + + const cometFactoryV2Scroll = new Contract( + cometFactory.address, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + signer + ); + + const [version, alternative] = await cometFactoryV2Scroll.version(); + expect(version).to.deep.equal([1, 2, 1]); + expect(alternative).to.equal(''); + }, +}); 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/BridgeHelper.sol b/forge/script/marketupdates/helpers/BridgeHelper.sol index 24dd951a1..5e95ef60d 100644 --- a/forge/script/marketupdates/helpers/BridgeHelper.sol +++ b/forge/script/marketupdates/helpers/BridgeHelper.sol @@ -47,11 +47,11 @@ library BridgeHelper { // Common setup for Optimism and Base address crossDomainMessenger = 0x4200000000000000000000000000000000000007; vm.prank(crossDomainMessenger); - address crossDomainMessengerImpl = 0xC0d3c0d3c0D3c0D3C0d3C0D3C0D3c0d3c0d30007; - // Mock message sender + // Mock message sender directly on the proxy address so this doesn't break + // if the underlying L2CrossDomainMessenger implementation is upgraded on-chain vm.mockCall( - crossDomainMessengerImpl, + crossDomainMessenger, abi.encodeWithSelector(IOvmL2CrossDomainMessengerInterface.xDomainMessageSender.selector), abi.encode(messageSender) ); diff --git a/foundry.toml b/foundry.toml index 8fd39aa92..aece3f13e 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 2b01f6c95..5b8e31bb3 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -70,6 +70,7 @@ const { ARBITRUM_QUICKNODE_LINK, UNICHAIN_QUICKNODE_LINK = '', LINEA_QUICKNODE_LINK = '', + SCROLL_QUICKNODE_LINK, _TENDERLY_KEY_RONIN, _TENDERLY_KEY_POLYGON, MNEMONIC = 'myth like woof scare over problem client lizard pioneer submit female collect', @@ -183,7 +184,7 @@ export const networkConfigs: NetworkConfig[] = [ { network: 'scroll', chainId: 534352, - url: 'https://scroll-mainnet.gateway.tenderly.co', + url: `${SCROLL_QUICKNODE_LINK}`, }, ]; @@ -352,7 +353,7 @@ const config: HardhatUserConfig = { mantle: ETHERSCAN_KEY, unichain: ETHERSCAN_KEY, // Scroll - 'scroll': ETHERSCAN_KEY, + scroll: ETHERSCAN_KEY, linea: ETHERSCAN_KEY_FOR_LINEA, }, customChains: [ diff --git a/plugins/scenario/utils/hreForBase.ts b/plugins/scenario/utils/hreForBase.ts index 00f2c28f9..b3d7d139d 100644 --- a/plugins/scenario/utils/hreForBase.ts +++ b/plugins/scenario/utils/hreForBase.ts @@ -9,6 +9,7 @@ import { Environment } from 'hardhat/internal/core/runtime-environment'; import { ForkSpec } from '../World'; import { HttpNetworkUserConfig } from 'hardhat/types'; import { EthereumProvider } from 'hardhat/types/provider'; +import { networkConfigs } from '../../../hardhat.config'; /* mimics https://github.com/nomiclabs/hardhat/blob/master/packages/hardhat-core/src/internal/lib/hardhat-lib.ts @@ -89,6 +90,12 @@ function getBlockRollback(base: ForkSpec) { return 25; } +let activeMigration = false; + +export function migrationStarted() { + activeMigration = true; +} + export async function forkedHreForBase(base: ForkSpec): Promise { const ctx: HardhatContext = HardhatContext.getHardhatContext(); @@ -101,16 +108,21 @@ export async function forkedHreForBase(base: ForkSpec): Promise { + if (activeMigration){ + return networkConfigs.find(c => c.network === base.network)?.url; + } + return baseNetwork.url; + })(); + const provider = new ethers.providers.JsonRpcProvider(providerUrl); + if(providerUrl) console.log(`Forking from network: ${base.network} at block number: ${await provider.getBlockNumber() - (getBlockRollback(base) || 0)}`); // noNetwork otherwise - if (!base.blockNumber && baseNetwork.url && getBlockRollback(base) !== undefined) + if (!base.blockNumber && providerUrl && getBlockRollback(base) !== undefined) base.blockNumber = await provider.getBlockNumber() - getBlockRollback(base); // arbitrary number of blocks to go back if (getBlockRollback(base) === 0) { - const provider = new ethers.providers.JsonRpcProvider(baseNetwork.url); const block = await provider.getBlockNumber(); base.blockNumber = block - 1; } @@ -124,7 +136,7 @@ export async function forkedHreForBase(base: ForkSpec): Promise 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 be64732a7..3cf88dbf2 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'; @@ -212,7 +212,11 @@ scenario( { filter: async (ctx) => !matchesDeployment(ctx, [ { network: 'base', deployment: 'usds' }, - { network: 'ronin', deployment: 'weth' }, + { network: 'ronin' }, + // asset0's liquidationFactor (0.8) is below its liquidateCollateralFactor (0.85), so + // seizing 100% of collateral at the liquidation threshold always undershoots the debt — + // a minted supply overshoot can never occur on this market. + { network: 'scroll', deployment: 'usdc' }, ]), tokenBalances: async (ctx) => ( { @@ -300,4 +304,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 c481702a0..3c2f033cd 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 @@ -707,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 0039b9601..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(); @@ -536,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 15a6c4cf5..5bf66ff7d 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 36956771c..dbd7bb5e2 100644 --- a/scenario/constraints/ProposalConstraint.ts +++ b/scenario/constraints/ProposalConstraint.ts @@ -3,7 +3,7 @@ import { IGovernorBravo, ProposalState, OpenProposal } from '../context/Gov'; import { CometContext } from '../context/CometContext'; import { fetchLogs } from '../utils'; import { DeploymentManager } from '../../plugins/deployment_manager'; -import { isBridgedDeployment, executeOpenProposal, voteForOpenProposal, executeOpenProposalAndRelay } from '../utils'; +import { isBridgedDeployment, voteForOpenProposal, executeOpenProposalAndRelay } from '../utils'; import { getOpenBridgedProposals, executeBridgedProposal } from '../utils/bridgeProposal'; export async function getOpenProposals(deploymentManager: DeploymentManager, governor: IGovernorBravo): Promise { @@ -78,24 +78,20 @@ export class ProposalConstraint implements StaticConstra ); } - // temporary hack to skip proposal 519 - if (proposal.id.eq(519)) { - console.log('Skipping proposal 519'); + // 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; } try { // Execute the proposal debug(`${label} Processing pending proposal ${proposal.id}`); - if (isBridged) { - await executeOpenProposalAndRelay( - governanceDeploymentManager, - ctx.world.deploymentManager, - proposal - ); - } else { - await executeOpenProposal(governanceDeploymentManager, proposal); - } + await executeOpenProposalAndRelay( + governanceDeploymentManager, + ctx.world.deploymentManager, + proposal + ); debug(`${label} Open proposal ${proposal.id} was executed`); } catch (err) { debug(`${label} Failed to execute proposal ${proposal.id}`, err.message); diff --git a/scenario/context/CometActor.ts b/scenario/context/CometActor.ts index 411d2a976..2b2543fdb 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 1d024b16b..1657aa29e 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 { @@ -395,17 +397,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 cd3f8a5ad..352fca7aa 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']); @@ -18,4 +20,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 4562f920b..cb08b4f01 100644 --- a/scenario/utils/index.ts +++ b/scenario/utils/index.ts @@ -12,10 +12,10 @@ import { utils, } from 'ethers'; import { execSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, unlinkSync } from 'fs'; import { CometContext } from '../context/CometContext'; import CometAsset from '../context/CometAsset'; -import { exp } from '../../test/helpers'; +import { ethers, exp } from '../../test/helpers'; import { DeploymentManager } from '../../plugins/deployment_manager'; import { impersonateAddress } from '../../plugins/scenario/utils'; import { ProposalState, OpenProposal } from '../context/Gov'; @@ -37,6 +37,8 @@ export { mineBlocks, setEtherBalance, setNextBaseFeeToZero, setNextBlockTimestam import { readFileSync } from 'fs'; import path from 'path'; +export * from './hreUtils'; + export const MAX_ASSETS = 24; export const UINT256_MAX = 2n ** 256n - 1n; @@ -358,6 +360,10 @@ export async function isValidAssetIndex( if (assetNum >= MAX_ASSETS) return false; // Asset info checks. If any of these are false, the asset is invalid. This means that the asset is deprecated. const comet = await ctx.getComet(); + + const numAssets = await comet.numAssets(); + if (assetNum >= numAssets) return false; + const assetInfo = await comet.getAssetInfo(assetNum); if (assetInfo.borrowCollateralFactor.toBigInt() == 0n) return false; if (assetInfo.supplyCap.toBigInt() == 0n) return false; @@ -367,6 +373,15 @@ export async function isValidAssetIndex( return true; } +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, @@ -437,10 +452,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, @@ -928,8 +991,6 @@ export async function tenderlyExecute( }, ]; - const chainId2 = bdm.hre.ethers.provider.network.chainId; - console.log(`\n========================== TENDERLY ==========================\n`); console.log(`\nExecuting Tenderly simulation for proposal ${id}...`); @@ -939,54 +1000,93 @@ export async function tenderlyExecute( const exec1 = bundle[bundle.length - 1].simulation; - console.log(` >>> PROPOSAL EXECUTED ${id} \n`); + console.log(` >>> PROPOSAL EXECUTED ${id}`); console.log(`Simulation ${exec1.id} done, status: ${exec1.status}`); - console.log(`Link: https://www.tdly.co/shared/simulation/${exec1.id}`); - 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(); - const relayMessages = loadCachedRelayMessages(); - const latestL2 = await bdm.hre.ethers.provider.getBlock('latest'); - const maxEta = Math.max(...proposals.map(p => Number(p.eta || 0))) + delay.toNumber(); - const T0L2 = BigInt(Math.max(latestL2.timestamp, maxEta + 1)); - 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.messenger, - block_number: Number(block), - block_header: { - timestamp: bdm.hre.ethers.utils.hexlify(Number(timestamp)) - }, - input: msg.callData, - save: true, - save_if_fails: true, - gas_price: 0, - gas_limit: 16_777_215, - }; - }); + console.log(`Link: https://www.tdly.co/shared/simulation/${exec1.id} \n`); + + const bdms = [bdm]; + for (const dm of gdm.bridgedDeploymentManagers.values()) { + if (!bdms.includes(dm)) { + bdms.push(dm); + } + } - if (simsL2.length > 0) { - const bundle2 = await simulateBundle(bdm, simsL2, Number(B0L2)); - console.log(` >>> PROPOSAL RELAYED ${id} \n`); - const sim = bundle2[bundle2.length - 1]; - await shareSimulation(bdm, sim.simulation.id); - console.log(`Simulation ${sim.simulation.id} done, status: ${sim.simulation.status}`); - console.log(`Link: https://www.tdly.co/shared/simulation/${sim.simulation.id}`); + // make bdm contain only 1 dm per network + const uniqueBdms = new Map(); + for (const dm of bdms) { + const chainId = dm.hre.ethers.provider.network.chainId; + if (!uniqueBdms.has(chainId)) { + uniqueBdms.set(chainId, dm); + } + } + bdms.length = 0; + bdms.push(...uniqueBdms.values()); + + + for (const currentBdm of bdms) { + const chainId2 = currentBdm.hre.ethers.provider.network.chainId; + let proposals; + if (chainId1 !== chainId2) { + const relayPath = path.resolve(__dirname, '../../cache/relay.json'); + if (existsSync(relayPath)) unlinkSync(relayPath); + + proposals = await relayMessage(gdm, currentBdm, parseFloat(B0.toString()), bundle[bundle.length - 1].transaction.transaction_info.logs); + + debug(`Proposals relayed to ${currentBdm.network}: ${proposals?.length ?? 0}`); + + if (proposals && proposals.length > 0) { + const timelockL2 = await currentBdm.getContractOrThrow('timelock'); + const delay = await timelockL2.delay(); + const relayMessages = loadCachedRelayMessages(); + const executeProposalSig = ethers.utils.id('executeProposal(uint256)').substring(0, 10); + + const latestL2 = await currentBdm.hre.ethers.provider.getBlock('latest'); + const maxEta = Math.max(...proposals.map(p => Number(p.eta || 0))) + delay.toNumber(); + const T0L2 = Math.max(latestL2.timestamp, maxEta + 1); + const B0L2 = Number(latestL2.number) + 1; + + let previousBlock = latestL2.number; + let previousTimestamp = T0L2; + const simsL2 = relayMessages.map((msg) => { + let block = previousBlock; + let timestamp = previousTimestamp; + + if (msg.callData.startsWith(executeProposalSig) && !msg.eta) { + block = block + 1; + timestamp = timestamp + delay.toNumber() + 1; + } + + previousBlock = block; + previousTimestamp = timestamp; + + return { + network_id: chainId2.toString(), + from: msg.signer, + to: msg.messenger, + block_number: Number(block), + block_header: { + timestamp: currentBdm.hre.ethers.utils.hexlify(Number(timestamp)) + }, + input: msg.callData, + save: true, + save_if_fails: true, + gas_price: 0, + }; + }); + + if (simsL2.length > 0) { + const bundle2 = await simulateBundle(currentBdm, simsL2, Number(B0L2)); + + // filter from bundle every entry with simulation.input that starts with 0x0d61b519 i.e. executeProposal(uint256) + const filteredBundle = bundle2.filter(entry => entry.simulation.input.startsWith(executeProposalSig)); + for (const sim of filteredBundle) { + await shareSimulation(currentBdm, sim.simulation.id); + console.log(`\nRelayed to ${currentBdm.network}`); + console.log(`Simulation ${sim.simulation.id} done, status: ${sim.simulation.status}`); + console.log(`Link: https://www.tdly.co/shared/simulation/${sim.simulation.id} \n`); + } + } + } } } @@ -1002,7 +1102,9 @@ async function simulateBundle( const results = []; for (const sim of simulations) { - const { username, project, accessKey } = (dm.hre.config as any).tenderly; + const project = 'comet'; + const username = process.env.TENDERLY_USERNAME || ''; + const accessKey = process.env.TENDERLY_ACCESS_KEY || ''; // Merge rolling state changes with simulation's own state_objects const stateObjects = sim.state_objects @@ -1060,7 +1162,9 @@ async function simulateBundle( } async function shareSimulation(dm: DeploymentManager, simulationId: string) { - const { username, project, accessKey } = (dm.hre.config as any).tenderly; + const project = 'comet'; + const username = process.env.TENDERLY_USERNAME || ''; + const accessKey = process.env.TENDERLY_ACCESS_KEY || ''; return axios.post( `https://api.tenderly.co/api/v1/account/${username}/project/${project}/simulations/${simulationId}/share`, {}, @@ -1522,25 +1626,26 @@ export async function executeOpenProposalAndRelay( await governanceDeploymentManager.hre.ethers.provider.getBlockNumber(); await executeOpenProposal(governanceDeploymentManager, openProposal); console.log(`Executed proposal ${openProposal.id} on ${governanceDeploymentManager.network}, checking if relay to ${bridgeDeploymentManager.network} is needed...`); - await mockAllRedstoneOracles(bridgeDeploymentManager); console.log(`All Redstone oracles on ${bridgeDeploymentManager.network} are mocked`); - if ( - await isBridgeProposal( - governanceDeploymentManager, - bridgeDeploymentManager, - openProposal - ) - ) { - await relayMessage( - governanceDeploymentManager, - bridgeDeploymentManager, - startingBlockNumber - ); - } else { - console.log( - `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Proposal ${openProposal.id} doesn't target bridge; not relaying` - ); - return; + const bridgeManagers = await isBridgeProposal( + governanceDeploymentManager, + bridgeDeploymentManager, + openProposal + ); + for (const bridgeManager of bridgeManagers) { + await mockAllRedstoneOracles(bridgeManager); + if (bridgeManager) { + await relayMessage( + governanceDeploymentManager, + bridgeManager, + startingBlockNumber + ); + } else { + console.log( + `[${governanceDeploymentManager.network} -> ${bridgeManager.network}] Proposal ${openProposal.id} doesn't target bridge; not relaying` + ); + return; + } } } @@ -1619,6 +1724,42 @@ export function isTenderlyLog(log: any): log is { raw: { topics: string[], data: 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(); @@ -1642,4 +1783,4 @@ export async function supportsMarketAdminPermissionChecker(ctx: CometContext): P } catch (e) { return false; } -} \ No newline at end of file +} diff --git a/scenario/utils/isBridgeProposal.ts b/scenario/utils/isBridgeProposal.ts index f2d4ac362..63957eccf 100644 --- a/scenario/utils/isBridgeProposal.ts +++ b/scenario/utils/isBridgeProposal.ts @@ -1,142 +1,155 @@ import { DeploymentManager } from '../../plugins/deployment_manager'; +import { getRoots } from '../../plugins/deployment_manager/Roots'; import { OpenProposal } from '../context/Gov'; +import { utils } from 'ethers'; +import { forkedHreForBase } from '../../plugins/scenario/utils/hreForBase'; + +const EXCLUDED_ROOTS = ['comptrollerV2', 'comet', 'configurator', 'rewards', 'bulker', 'cometFactory']; + +const CCTP_DOMAIN_TO_NETWORK: Record = { + 0: 'mainnet', + 1: 'avalanche', + 2: 'optimism', + 3: 'arbitrum', + 6: 'base', + 7: 'polygon', +}; + +const ROOT_TO_NETWORK: Record = { + fxRoot: 'polygon', + arbitrumInbox: 'arbitrum', + arbitrumL1GatewayRouter: 'arbitrum', + baseL1CrossDomainMessenger: 'base', + baseL1StandardBridge: 'base', + baseL1USDSBridge: 'base', + opL1CrossDomainMessenger: 'optimism', + opL1StandardBridge: 'optimism', + mantleL1CrossDomainMessenger: 'mantle', + mantleL1StandardBridge: 'mantle', + unichainL1CrossDomainMessenger: 'unichain', + unichainL1StandardBridge: 'unichain', + scrollMessenger: 'scroll', + scrollL1USDCGateway: 'scroll', + lineaMessageService: 'linea', + lineaL1TokenBridge: 'linea', + lineaL1USDCBridge: 'linea', + l1CCIPRouter: 'ronin', + l1TokenAdminRegistry: 'ronin', + roninl1CCIPOnRamp: 'ronin', + roninl1NativeBridge: 'ronin', +}; + +function parseCCTPNetworks(openProposal: OpenProposal, cctpAddress: string): string[] { + const networks: string[] = []; + const cctpLower = cctpAddress.toLowerCase(); + + for (let i = 0; i < openProposal.targets.length; i++) { + if (openProposal.targets[i].toLowerCase() !== cctpLower) continue; + const sig = openProposal.signatures[i]; + if (!sig.startsWith('depositForBurn(')) continue; + + const calldata = openProposal.calldatas[i]; + // destinationDomain is the second parameter (uint32) in all depositForBurn variants + const decoded = utils.defaultAbiCoder.decode(['uint256', 'uint32'], utils.hexDataSlice(calldata, 0, 64)); + const domain = decoded[1]; + const network = CCTP_DOMAIN_TO_NETWORK[domain]; + if (network) networks.push(network); + } + return networks; +} + +export async function getProposalBridgeNetworks( + governanceDeploymentManager: DeploymentManager, + openProposal: OpenProposal +): Promise { + const roots = await getRoots(governanceDeploymentManager.cache); + const targets = openProposal.targets.map(t => t.toLowerCase()); + + const networks = new Set(); + for (const [alias, address] of roots) { + if (EXCLUDED_ROOTS.includes(alias)) continue; + + if (alias === 'CCTPTokenMessenger' && targets.includes(address.toLowerCase())) { + for (const net of parseCCTPNetworks(openProposal, address)) { + networks.add(net); + } + continue; + } + + const network = ROOT_TO_NETWORK[alias]; + if (network && targets.includes(address.toLowerCase())) { + networks.add(network); + } + } + return [...new Set(networks)]; +} + +const existingBridgeManagers: Record = {}; export async function isBridgeProposal( governanceDeploymentManager: DeploymentManager, bridgeDeploymentManager: DeploymentManager, openProposal: OpenProposal ) { - const bridgeNetwork = bridgeDeploymentManager.network; - console.log(`Checking if proposal ${openProposal.id} is a bridge proposal on ${bridgeNetwork}`); - switch (bridgeNetwork) { - case 'arbitrum': { - const inbox = await governanceDeploymentManager.getContractOrThrow('arbitrumInbox'); - const l1GatewayRouter = await governanceDeploymentManager.getContractOrThrow( - 'arbitrumL1GatewayRouter' - ); - const targets = openProposal.targets; - return targets.includes(inbox.address) || targets.includes(l1GatewayRouter.address); - } - case 'polygon': { - const { - fxRoot, - RootChainManager - } = await governanceDeploymentManager.getContracts(); - const bridgeAddresses = [fxRoot, RootChainManager] - .filter(x => x) - .map(x => x.address.toLowerCase()); - const targets = openProposal.targets; - return targets.some(t => bridgeAddresses.includes(t.toLowerCase())); - } - case 'base': { - const baseL1CrossDomainMessenger = await governanceDeploymentManager.getContractOrThrow( - 'baseL1CrossDomainMessenger' - ); - const baseL1StandardBridge = await governanceDeploymentManager.getContractOrThrow( - 'baseL1StandardBridge' - ); - const baseL1USDSBridge = await governanceDeploymentManager.getContractOrThrow( - 'baseL1USDSBridge' - ); - const targets = openProposal.targets; - const bridgeContracts = [baseL1CrossDomainMessenger.address, baseL1StandardBridge.address, baseL1USDSBridge.address]; - - return targets.some(t => bridgeContracts.includes(t)); - } - case 'linea': { - const lineaMessageService = await governanceDeploymentManager.getContractOrThrow( - 'lineaMessageService' - ); - const lineaL1USDCBridge = await governanceDeploymentManager.getContractOrThrow( - 'lineaL1USDCBridge' - ); - const lineaL1TokenBridge = await governanceDeploymentManager.getContractOrThrow( - 'lineaL1TokenBridge' - ); - const bridgeContracts = [ - lineaMessageService.address, - lineaL1USDCBridge.address, - lineaL1TokenBridge.address - ]; - const targets = openProposal.targets; - return targets.some(t => bridgeContracts.includes(t)); - } - // case 'linea': { - // const governor = await governanceDeploymentManager.getContractOrThrow('governor'); - // const lineaMessageService = await governanceDeploymentManager.getContractOrThrow( - // 'lineaMessageService' - // ); - // const { targets } = await governor.getActions(openProposal.id); - // return targets.includes(lineaMessageService.address); - // } - case 'optimism': { - const opL1CrossDomainMessenger = await governanceDeploymentManager.getContractOrThrow( - 'opL1CrossDomainMessenger' - ); - const opL1StandardBridge = await governanceDeploymentManager.getContractOrThrow( - 'opL1StandardBridge' - ); - const targets = openProposal.targets; - const bridgeContracts = [opL1CrossDomainMessenger.address, opL1StandardBridge.address]; - return targets.some(t => bridgeContracts.includes(t)); - } - case 'mantle': { - const mantleL1CrossDomainMessenger = await governanceDeploymentManager.getContractOrThrow( - 'mantleL1CrossDomainMessenger' - ); - const mantleL1StandardBridge = await governanceDeploymentManager.getContractOrThrow( - 'mantleL1StandardBridge' - ); - const targets = openProposal.targets; - const bridgeContracts = [ - mantleL1CrossDomainMessenger.address, - mantleL1StandardBridge.address - ]; - return targets.some(t => bridgeContracts.includes(t)); - } - case 'unichain': { - const unichainL1CrossDomainMessenger = await governanceDeploymentManager.getContractOrThrow( - 'unichainL1CrossDomainMessenger' - ); - const unichainL1StandardBridge = await governanceDeploymentManager.getContractOrThrow( - 'unichainL1StandardBridge' - ); - const targets = openProposal.targets; - const bridgeContracts = [ - unichainL1CrossDomainMessenger.address, - unichainL1StandardBridge.address - ]; - return targets.some(t => bridgeContracts.includes(t)); - } - case 'scroll': { - const scrollMessenger = await governanceDeploymentManager.getContractOrThrow( - 'scrollMessenger' - ); - const targets = openProposal.targets; - return targets.includes(scrollMessenger.address); + const bridgeNetworks = await getProposalBridgeNetworks(governanceDeploymentManager, openProposal); + const otherBridgeNetworks = bridgeNetworks.filter(n => n !== bridgeDeploymentManager.network); + const bridgeManagers = [bridgeDeploymentManager]; + if (!existingBridgeManagers[bridgeDeploymentManager.network]) { + existingBridgeManagers[bridgeDeploymentManager.network] = bridgeDeploymentManager; + } + if (!existingBridgeManagers[governanceDeploymentManager.network]) { + existingBridgeManagers[governanceDeploymentManager.network] = governanceDeploymentManager; + } + for(const bridgeNetwork of otherBridgeNetworks) { + if (existingBridgeManagers[bridgeNetwork]) { + bridgeManagers.push(existingBridgeManagers[bridgeNetwork]); + continue; } - case 'ronin': { - const governor = await governanceDeploymentManager.getContractOrThrow('governor'); - const l1CCIPRouter = await governanceDeploymentManager.getContractOrThrow( - 'l1CCIPRouter' - ); - const roninl1NativeBridge = await governanceDeploymentManager.getContractOrThrow( - 'roninl1NativeBridge' - ); - const roninL1OnRamp = await governanceDeploymentManager.getContractOrThrow( - 'roninl1CCIPOnRamp' - ); - const { targets } = await governor.proposalDetails(openProposal.id); - const bridgeContracts = [ - roninl1NativeBridge.address, - l1CCIPRouter.address, - roninL1OnRamp.address - ]; - return targets.some(t => bridgeContracts.includes(t)); + + let deploymentToken: string; + + let dm: DeploymentManager; + let existingBridgedDm: DeploymentManager | undefined; + for (const cachedDm of governanceDeploymentManager.bridgedDeploymentManagers.values()) { + if (cachedDm.network === bridgeNetwork) { + existingBridgedDm = cachedDm; + break; + } } - default: { - const tag = `[${bridgeNetwork} -> ${governanceDeploymentManager.network}]`; - throw new Error(`${tag} Unable to determine whether to relay Proposal ${openProposal.id}`); + + if (existingBridgedDm) { + dm = existingBridgedDm; + } else { + // default deployment token is USDC for all networks except Ronin (WETH) and Mantle (USDE) + switch (bridgeNetwork) { + case 'arbitrum': + case 'polygon': + case 'base': + case 'linea': + case 'optimism': + case 'unichain': + case 'scroll': + deploymentToken = 'usdc'; + break; + case 'mantle': + deploymentToken = 'usde'; + break; + case 'ronin': + deploymentToken = 'weth'; + break; + default: { + const tag = `[${governanceDeploymentManager.network} -> ${bridgeNetwork}]`; + throw new Error(`${tag} Unable to determine whether to relay Proposal ${openProposal.id}`); + } + } + + const hre = await forkedHreForBase({ name: '', network: bridgeNetwork, deployment: '' }); + dm = await governanceDeploymentManager.addBridgedDeploymentManager(bridgeNetwork, deploymentToken, hre); } + + existingBridgeManagers[bridgeNetwork] = dm; + bridgeManagers.push(dm); } + return bridgeManagers; } + diff --git a/scenario/utils/relayArbitrumMessage.ts b/scenario/utils/relayArbitrumMessage.ts index 550979448..3c19598a7 100644 --- a/scenario/utils/relayArbitrumMessage.ts +++ b/scenario/utils/relayArbitrumMessage.ts @@ -1,7 +1,7 @@ import { DeploymentManager } from '../../plugins/deployment_manager'; import { impersonateAddress } from '../../plugins/scenario/utils'; import { setNextBaseFeeToZero, setNextBlockTimestamp } from './hreUtils'; -import { utils, BigNumber } from 'ethers'; +import { utils, BigNumber, Contract, constants } from 'ethers'; import { Log } from '@ethersproject/abstract-provider'; import { sourceTokens } from '../../plugins/scenario/utils/TokenSourcer'; import { OpenBridgedProposal } from '../context/Gov'; @@ -84,18 +84,18 @@ export async function relayArbitrumMessage( const header = '0x'; const headerLength = header.length; const wordLength = 2 * 32; - const innnerData = header + data.slice(headerLength + (11 * wordLength)); + const innerData = header + data.slice(headerLength + (11 * wordLength)); const toValue = data.slice(headerLength + (2 * wordLength), headerLength + (3 * wordLength)); let toAddress = BigNumber.from(`0x${toValue}`).toHexString(); - // if lenght of toAddress is less than 42, then it is padded with 0s and we need to add them after 0x + // if length of toAddress is less than 42, then it is padded with 0s and we need to add them after 0x if(toAddress.length < 42) { toAddress = `0x${toAddress.slice(2).padStart(40, '0')}`; } const messageNum = topics[1]; return { - data: innnerData, + data: innerData, toAddress, messageNum }; @@ -232,6 +232,18 @@ export async function relayArbitrumMessage( await signer.getAddress() ); } else { + // Mock ArbSys precompile (0x64) — Arbitrum precompiles don't exist in Hardhat's EVM, + // but the L2 gateways call ArbSys.sendTxToL1 internally during outboundTransfer. + // Bytecode 0x60206000f3 disassembles to: PUSH1 0x20 | PUSH1 0x00 | RETURN + // which returns 32 zero bytes from uninitialized memory for any call. + await bridgeDeploymentManager.hre.network.provider.request({ + method: 'hardhat_setCode', + params: [ + '0x0000000000000000000000000000000000000064', + '0x60206000f3', + ], + }); + await bridgeReceiver.executeProposal(id, { gasPrice: 0 }); } openBridgedProposals.push({ @@ -244,6 +256,189 @@ export async function relayArbitrumMessage( return openBridgedProposals; } +export async function simulateL2ToL1TokenBridging( + governanceDeploymentManager: DeploymentManager, + bridgeDeploymentManager: DeploymentManager, + tenderlyLogs?: any[], + proposalId?: BigNumber +) { + if(tenderlyLogs) { + return; + } + console.log('Simulating L2→L1 token bridging for any executed Arbitrum proposals...'); + + // L2 contracts + const bridgeReceiver = await bridgeDeploymentManager.getContractOrThrow('bridgeReceiver'); + + // Parse recent ProposalCreated events to find actions that bridge tokens from L2 to L1 + // ProposalCreated(address indexed rootMessageSender, uint256 id, address[] targets, uint256[] values, string[] signatures, bytes[] calldatas, uint256 eta) + console.log('Fetching recent ProposalCreated events from BridgeReceiver...'); + const latestBlockNumber = await bridgeDeploymentManager.hre.ethers.provider.getBlockNumber(); + const proposalCreatedEvents = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ + fromBlock: latestBlockNumber - 1000, // look back 1000 blocks for ProposalCreated events, which should be sufficient to cover any recent proposals given typical block times on Arbitrum + toBlock: 'latest', + address: bridgeReceiver.address, + topics: [utils.id('ProposalCreated(address,uint256,address[],uint256[],string[],bytes[],uint256)')] + }); + const outboundTransferSignature = 'outboundTransfer(address,address,uint256,bytes)'; + const outboundTransfer2Signature = 'outboundTransfer(address,address,uint256,uint256,uint256,bytes)'; + const depositForBurnSignature = 'depositForBurn(uint256,uint32,bytes32,address,bytes32,uint256,uint32)'; + const ARBITRUM_GATEWAY_ROUTER = '0x5288c571Fd7aD117beA99bF60FE0846C4E84F933'; + const ARBITRUM_BRIDGE = '0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a'; + const ARBITRUM_OUTBOX = '0x667e23ABd27E623c11d4CC00ca3EC4d0bD63337a'; + const MAINNET_WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + + for (const event of proposalCreatedEvents) { + const decodedEvent = bridgeReceiver.interface.parseLog(event); + const { id, targets, signatures, calldatas } = decodedEvent.args; + + if (proposalId && id.toString() !== proposalId.toString()) { + continue; + } + + for (let i = 0; i < signatures.length; i++) { + // Look for L2→L1 outboundTransfer calls (standard Arbitrum gateway bridge) + if (signatures[i] === outboundTransferSignature || signatures[i] === outboundTransfer2Signature) { + const [l1Token, to, amount] = (() => { + if (signatures[i] === outboundTransferSignature) { + return utils.defaultAbiCoder.decode( + ['address', 'address', 'uint256', 'bytes'], + calldatas[i] + ); + } else if (signatures[i] === outboundTransfer2Signature) { + return utils.defaultAbiCoder.decode( + ['address', 'address', 'uint256', 'uint256', 'uint256', 'bytes'], + calldatas[i] + ); + } + })(); + console.log(`Simulating L2→L1 token bridging: ${amount.toString()} of ${l1Token} to ${to}`); + + const gatewayAddress = await (async () => { + if(targets[i].toLowerCase() === ARBITRUM_GATEWAY_ROUTER.toLowerCase()) { // Arbitrum WETH gateway + const router = new Contract( + ARBITRUM_GATEWAY_ROUTER, + ['function l1TokenToGateway(address l1Token) view returns (address)'], + await governanceDeploymentManager.getSigner() + ); + return await router.l1TokenToGateway(l1Token); + } + return targets[i]; + })(); + const l2Gateway = new Contract( + gatewayAddress, + ['function counterpartGateway() view returns (address)'], + await bridgeDeploymentManager.getSigner() + ); + const l1GatewayAddress = await l2Gateway.counterpartGateway(); + + const l1Gateway = new Contract( + l1GatewayAddress, + [ + 'function finalizeInboundTransfer(address _token, address _from, address _to, uint256 _amount, bytes calldata _data)', + 'function inbox() view returns (address)' + ], + await governanceDeploymentManager.getSigner() + ); + // override 0x4 slot in outbox to L2 gateway + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + ARBITRUM_OUTBOX, + '0x4', + utils.hexZeroPad(gatewayAddress, 32) + ]); + + // impersonate outbox to call finalizeInboundTransfer, as if the message came from L2 gateway + const outboxSigner = await impersonateAddress( + governanceDeploymentManager, + ARBITRUM_OUTBOX + ); + + await governanceDeploymentManager.hre.network.provider.send('hardhat_setBalance', [ + outboxSigner.address, + '0x1000000000000000000', + ]); + + const arbitrumBridge = new Contract( + ARBITRUM_BRIDGE, + ['function executeCall(address to, uint256 value, bytes calldata data)'], + outboxSigner + ); + + const data = l1Gateway.interface.encodeFunctionData( + 'finalizeInboundTransfer', + [ + l1Token, + ARBITRUM_GATEWAY_ROUTER, + to, amount, + utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, '0x']) + ]); + console.log(`Relaying message to L1 gateway at ${l1GatewayAddress} with data: ${data}`); + const bridgeTx = await arbitrumBridge.connect(outboxSigner).executeCall( + l1Gateway.address, + l1Token.toLowerCase() === MAINNET_WETH.toLowerCase() ? amount : 0, + data, + ); + await (bridgeTx).wait(); + // stop impersonation after the call + await governanceDeploymentManager.hre.network.provider.send('hardhat_stopImpersonatingAccount', [ + outboxSigner.address + ]); + // override 0x4 slot in outbox to L2 gateway + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + ARBITRUM_OUTBOX, + '0x4', + utils.hexZeroPad(constants.AddressZero, 32) + ]); + } + + // Look for L2→L1 CCTP depositForBurn calls (Circle CCTP bridge, e.g. native USDC) + if (signatures[i] === depositForBurnSignature) { + const [amount, , mintRecipientBytes32, burnToken] = utils.defaultAbiCoder.decode( + ['uint256', 'uint32', 'bytes32', 'address', 'bytes32', 'uint256', 'uint32'], + calldatas[i] + ); + + const mintRecipient = utils.getAddress('0x' + utils.hexlify(mintRecipientBytes32).slice(-40)); + + try { + // L2 + const l2CCTPTokenMessenger = await bridgeDeploymentManager.getContractOrThrow('CCTPMessageTransmitter'); + // Resolve L1 token via CCTP TokenMinter: burnToken (L2) → localToken (L1) + const l1CCTPTokenMessenger = await governanceDeploymentManager.getContractOrThrow('CCTPTokenMessenger'); + const tokenMinterAddress = await l1CCTPTokenMessenger.localMinter(); + const L1TokenMinter = new Contract( + tokenMinterAddress, + ['function mint(uint32 sourceDomain, bytes32 burnToken, address recipientOne, address recipientTwo, uint256 amountOne, uint256 amountTwo) returns (address)'], + await governanceDeploymentManager.getSigner() + ); + const l1CCTPTokenMessengerSigner = await impersonateAddress( + governanceDeploymentManager, + l1CCTPTokenMessenger.address + ); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setBalance', [ + l1CCTPTokenMessengerSigner.address, + '0x1000000000000000000', + ]); + const sourceDomain = await l2CCTPTokenMessenger.localDomain(); + const mintTx = await L1TokenMinter.connect(l1CCTPTokenMessengerSigner).mint( + sourceDomain, + utils.hexZeroPad(burnToken, 32), + mintRecipient, + L1TokenMinter.address, // mint to the token minter first, since some tokens (e.g. USDC) have a cap on max amount per mint, and the token minter can then transfer to the recipient + amount, + 1 + ); + console.log('Simulated CCTP mint transaction:', mintTx.hash); + await mintTx.wait(); + } catch (e) { + console.log(`Warning: Could not simulate CCTP L2→L1 bridging for depositForBurn: ${e.message}`); + } + } + await governanceDeploymentManager.hre.network.provider.send('evm_mine'); + } + } +} + export async function relayArbitrumCCTPMint( governanceDeploymentManager: DeploymentManager, bridgeDeploymentManager: DeploymentManager, diff --git a/scenario/utils/relayBaseMessage.ts b/scenario/utils/relayBaseMessage.ts index e357eceb4..027e0d03f 100644 --- a/scenario/utils/relayBaseMessage.ts +++ b/scenario/utils/relayBaseMessage.ts @@ -1,7 +1,7 @@ import { DeploymentManager } from '../../plugins/deployment_manager'; import { impersonateAddress } from '../../plugins/scenario/utils'; import { setNextBaseFeeToZero, setNextBlockTimestamp } from './hreUtils'; -import { BigNumber, ethers } from 'ethers'; +import { BigNumber, ethers, utils } from 'ethers'; import { Log } from '@ethersproject/abstract-provider'; import { OpenBridgedProposal } from '../context/Gov'; import { applyL1ToL2Alias, isTenderlyLog } from './index'; @@ -154,9 +154,9 @@ export default async function relayBaseMessage( openBridgedProposals.push({ id, eta }); } } else { - throw new Error( - `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message` - ); + // throw error only on last relay message and no proposal created event found + if(sentMessageEvents.indexOf(sentMessageEvent) === sentMessageEvents.length - 1 && openBridgedProposals.length === 0) + throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); } } @@ -207,4 +207,116 @@ export default async function relayBaseMessage( } return openBridgedProposals; -} \ No newline at end of file +} + +export async function simulateL2ToL1TokenBridging( + governanceDeploymentManager: DeploymentManager, + bridgeDeploymentManager: DeploymentManager, + tenderlyLogs?: any[], + proposalId?: BigNumber +) { + if(tenderlyLogs) { + return; + } + console.log('Simulating L2→L1 token bridging for any executed Base proposals...'); + + // L2 contracts + const bridgeReceiver = await bridgeDeploymentManager.getContractOrThrow('bridgeReceiver'); + const baseL2Bridge = await bridgeDeploymentManager.getContractOrThrow('l2StandardBridge'); + const l2CrossDomainMessenger = await bridgeDeploymentManager.getContractOrThrow('l2CrossDomainMessenger'); + + // L1 contracts + const baseL1CrossDomainMessenger = await governanceDeploymentManager.getContractOrThrow('baseL1CrossDomainMessenger'); + const baseL1Bridge = await governanceDeploymentManager.getContractOrThrow('baseL1StandardBridge'); + const BASE_L1_PORTAL = '0x49048044D57e1C92A77f79988d21Fa8fAF74E97e'; + + // Parse recent ProposalCreated events to find actions that bridge tokens from L2 to L1 + // ProposalCreated(address indexed rootMessageSender, uint256 id, address[] targets, uint256[] values, string[] signatures, bytes[] calldatas, uint256 eta) + console.log('Fetching recent ProposalCreated events from BridgeReceiver...'); + const latestBlockNumber = await bridgeDeploymentManager.hre.ethers.provider.getBlockNumber(); + const proposalCreatedEvents = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ + fromBlock: latestBlockNumber - 1000, // look back 1000 blocks for ProposalCreated events, which should be sufficient to cover any recent proposals given typical block times on Base + toBlock: 'latest', + address: bridgeReceiver.address, + topics: [utils.id('ProposalCreated(address,uint256,address[],uint256[],string[],bytes[],uint256)')] + }); + + const bridgeERC20ToSignature = 'bridgeERC20To(address,address,address,uint256,uint32,bytes)'; + + for (const event of proposalCreatedEvents) { + const decodedEvent = bridgeReceiver.interface.parseLog(event); + const { id, signatures, calldatas } = decodedEvent.args; + + if (proposalId && id.toString() !== proposalId.toString()) { + continue; + } + + for (let i = 0; i < signatures.length; i++) { + if (signatures[i] === bridgeERC20ToSignature) { + const [localToken, remoteToken, to, amount, , extraData] = utils.defaultAbiCoder.decode( + ['address', 'address', 'address', 'uint256', 'uint32', 'bytes'], + calldatas[i] + ); + + console.log(`Simulating L2→L1 bridgeERC20To: ${amount.toString()} of ${remoteToken} to ${to}`); + + console.log('Setting up L1 state to simulate finalizeBridgeERC20...'); + console.log('Base L1 Portal address:', BASE_L1_PORTAL); + console.log('Overriding slot', utils.hexZeroPad('0x32', 32)); + console.log('l2CrossDomainMessenger:', utils.hexZeroPad(l2CrossDomainMessenger.address, 32)); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + BASE_L1_PORTAL, + utils.hexZeroPad('0x32', 32), + utils.hexZeroPad(l2CrossDomainMessenger.address, 32) + ]); + + // Set deposits[_localToken][_remoteToken] on L1StandardBridge so finalizeBridgeERC20 won't underflow + // deposits mapping is at base slot 2 in L1StandardBridge storage layout + // In finalizeBridgeERC20 context: _localToken = remoteToken (L1), _remoteToken = localToken (L2) + const depositsBaseSlot = 2; + const innerSlot = utils.keccak256( + utils.defaultAbiCoder.encode(['address', 'uint256'], [remoteToken, depositsBaseSlot]) + ); + const depositsSlot = utils.keccak256( + utils.defaultAbiCoder.encode(['address', 'bytes32'], [localToken, innerSlot]) + ); + + console.log(`Setting deposits[${remoteToken}][${localToken}] to ${amount.toString()} at slot ${depositsSlot} on ${baseL1Bridge.address}`); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + baseL1Bridge.address, + depositsSlot, + utils.hexZeroPad(amount.toHexString(), 32) + ]); + + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + baseL1CrossDomainMessenger.address, + '0xcc', + utils.hexZeroPad(baseL2Bridge.address, 32) + ]); + + const domainMessengerSigner = await impersonateAddress( + governanceDeploymentManager, + baseL1CrossDomainMessenger.address + ); + + await governanceDeploymentManager.hre.network.provider.send('hardhat_setBalance', [ + domainMessengerSigner.address, + ethers.utils.hexStripZeros(ethers.utils.parseEther('1').toHexString()), + ]); + + await ( + await baseL1Bridge.connect(domainMessengerSigner).finalizeBridgeERC20( + remoteToken, localToken, bridgeReceiver.address, to, amount, extraData, + { gasPrice: 0, gasLimit: 2_500_000 } + ) + ).wait(); + + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + BASE_L1_PORTAL, + utils.hexZeroPad('0x32', 32), + utils.hexZeroPad('0xdead', 32) + ]); + } + } + } +} diff --git a/scenario/utils/relayLineaMessage.ts b/scenario/utils/relayLineaMessage.ts index 78f2b0f14..ace9028fb 100644 --- a/scenario/utils/relayLineaMessage.ts +++ b/scenario/utils/relayLineaMessage.ts @@ -6,7 +6,7 @@ import { OpenBridgedProposal } from '../context/Gov'; import { impersonateAddress } from '../../plugins/scenario/utils'; import { isTenderlyLog } from './index'; -const LINEA_SETTER_ROLE_ACCOUNT = '0xc1C6B09D1eB6fCA0fF3cA11027E5Bc4AeDb47F67'; +const LINEA_SETTER_ROLE_ACCOUNT = '0x2b0F9C76970975aec03784EFd763623757EF7652'; export default async function relayLineaMessage( governanceDeploymentManager: DeploymentManager, @@ -245,9 +245,9 @@ export default async function relayLineaMessage( // Add the proposal to the list of open bridged proposals to be executed after all the messages have been relayed openBridgedProposals.push({ id, eta }); } else { - throw new Error( - `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message` - ); + // throw error only on last relay message and no proposal created event found + if(messageSentEvents.indexOf(messageSentEvent) === messageSentEvents.length - 1 && openBridgedProposals.length === 0) + throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); } } diff --git a/scenario/utils/relayMantleMessage.ts b/scenario/utils/relayMantleMessage.ts index 812255b75..2e1b97d76 100644 --- a/scenario/utils/relayMantleMessage.ts +++ b/scenario/utils/relayMantleMessage.ts @@ -127,7 +127,9 @@ export default async function relayMantleMessage( // Add the proposal to the list of open bridged proposals to be executed after all the messages have been relayed openBridgedProposals.push({ id, eta }); } else { - throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); + // throw error only on last relay message and no proposal created event found + if(sentMessageEvents.indexOf(sentMessageEvent) === sentMessageEvents.length - 1 && openBridgedProposals.length === 0) + throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); } } diff --git a/scenario/utils/relayMessage.ts b/scenario/utils/relayMessage.ts index fa0523665..52c7d7fca 100644 --- a/scenario/utils/relayMessage.ts +++ b/scenario/utils/relayMessage.ts @@ -1,9 +1,9 @@ import { DeploymentManager } from '../../plugins/deployment_manager'; import relayPolygonMessage from './relayPolygonMessage'; -import { relayArbitrumMessage, relayArbitrumCCTPMint } from './relayArbitrumMessage'; +import { relayArbitrumMessage, relayArbitrumCCTPMint, simulateL2ToL1TokenBridging } from './relayArbitrumMessage'; import relayBaseMessage from './relayBaseMessage'; import relayLineaMessage from './relayLineaMessage'; -import relayOptimismMessage from './relayOptimismMessage'; +import relayOptimismMessage, { simulateL2ToL1TokenBridging as simulateOptimismL2ToL1TokenBridging } from './relayOptimismMessage'; import relayMantleMessage from './relayMantleMessage'; import { relayUnichainMessage, relayUnichainCCTPMint } from './relayUnichainMessage'; import relayScrollMessage from './relayScrollMessage'; @@ -16,23 +16,36 @@ export default async function relayMessage( tenderlyLogs?: any[] ) { const bridgeNetwork = bridgeDeploymentManager.network; + if(bridgeNetwork === governanceDeploymentManager.network) return; // no need to relay if the proposal is on the same network console.log(`Relaying messages from ${governanceDeploymentManager.network} -> ${bridgeNetwork}`); let proposal; switch (bridgeNetwork) { case 'base': - return await relayBaseMessage( + proposal = await relayBaseMessage( governanceDeploymentManager, bridgeDeploymentManager, startingBlockNumber, tenderlyLogs ); + await simulateOptimismL2ToL1TokenBridging( + governanceDeploymentManager, + bridgeDeploymentManager, + tenderlyLogs + ); + return proposal; case 'optimism': - return await relayOptimismMessage( + proposal = await relayOptimismMessage( governanceDeploymentManager, bridgeDeploymentManager, startingBlockNumber, tenderlyLogs ); + await simulateOptimismL2ToL1TokenBridging( + governanceDeploymentManager, + bridgeDeploymentManager, + tenderlyLogs + ); + return proposal; case 'mantle': return await relayMantleMessage( governanceDeploymentManager, @@ -74,6 +87,11 @@ export default async function relayMessage( startingBlockNumber, tenderlyLogs ); + await simulateL2ToL1TokenBridging( + governanceDeploymentManager, + bridgeDeploymentManager, + tenderlyLogs + ); return proposal; case 'linea': return await relayLineaMessage( diff --git a/scenario/utils/relayOptimismMessage.ts b/scenario/utils/relayOptimismMessage.ts index 5ce1d076d..f575bc663 100644 --- a/scenario/utils/relayOptimismMessage.ts +++ b/scenario/utils/relayOptimismMessage.ts @@ -1,7 +1,7 @@ import { DeploymentManager } from '../../plugins/deployment_manager'; import { impersonateAddress } from '../../plugins/scenario/utils'; import { setNextBaseFeeToZero, setNextBlockTimestamp } from './hreUtils'; -import { BigNumber, ethers } from 'ethers'; +import { BigNumber, ethers, utils } from 'ethers'; import { Log } from '@ethersproject/abstract-provider'; import { OpenBridgedProposal } from '../context/Gov'; import { applyL1ToL2Alias, isTenderlyLog } from './index'; @@ -144,7 +144,9 @@ export default async function relayOptimismMessage( throw e; } } else { - throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); + // throw error only on last relay message and no proposal created event found + if(sentMessageEvents.indexOf(sentMessageEvent) === sentMessageEvents.length - 1 && openBridgedProposals.length === 0) + throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); } // Execute open bridged proposals now that all messages have been bridged @@ -176,3 +178,113 @@ export default async function relayOptimismMessage( return openBridgedProposals; } } + +export async function simulateL2ToL1TokenBridging( + governanceDeploymentManager: DeploymentManager, + bridgeDeploymentManager: DeploymentManager, + tenderlyLogs?: any[], + proposalId?: BigNumber +) { + if(tenderlyLogs) { + return; + } + console.log('Simulating L2→L1 token bridging for any executed Optimism proposals...'); + + // L2 contracts + const bridgeReceiver = await bridgeDeploymentManager.getContractOrThrow('bridgeReceiver'); + const optimismL2Bridge = await bridgeDeploymentManager.getContractOrThrow('l2StandardBridge'); + const l2CrossDomainMessenger = await bridgeDeploymentManager.getContractOrThrow('l2CrossDomainMessenger'); + + // L1 contracts + const opL1CrossDomainMessenger = await governanceDeploymentManager.getContractOrThrow('opL1CrossDomainMessenger'); + const optimismL1Bridge = await governanceDeploymentManager.getContractOrThrow('opL1StandardBridge'); + const OPTIMISM_L1_PORTAL = '0xbEb5Fc579115071764c7423A4f12eDde41f106Ed'; + + // Parse recent ProposalCreated events to find actions that bridge tokens from L2 to L1 + // ProposalCreated(address indexed rootMessageSender, uint256 id, address[] targets, uint256[] values, string[] signatures, bytes[] calldatas, uint256 eta) + console.log('Fetching recent ProposalCreated events from BridgeReceiver...'); + const latestBlockNumber = await bridgeDeploymentManager.hre.ethers.provider.getBlockNumber(); + const proposalCreatedEvents = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ + fromBlock: latestBlockNumber - 1000, // look back 1000 blocks for ProposalCreated events, which should be sufficient to cover any recent proposals given typical block times on Optimism + toBlock: 'latest', + address: bridgeReceiver.address, + topics: [utils.id('ProposalCreated(address,uint256,address[],uint256[],string[],bytes[],uint256)')] + }); + + const bridgeERC20ToSignature = 'bridgeERC20To(address,address,address,uint256,uint32,bytes)'; + + for (const event of proposalCreatedEvents) { + const decodedEvent = bridgeReceiver.interface.parseLog(event); + const { id, signatures, calldatas } = decodedEvent.args; + + if (proposalId && id.toString() !== proposalId.toString()) { + continue; + } + + for (let i = 0; i < signatures.length; i++) { + if (signatures[i] === bridgeERC20ToSignature) { + const [localToken, remoteToken, to, amount, , extraData] = utils.defaultAbiCoder.decode( + ['address', 'address', 'address', 'uint256', 'uint32', 'bytes'], + calldatas[i] + ); + + console.log(`Simulating L2→L1 bridgeERC20To: ${amount.toString()} of ${remoteToken} to ${to}`); + + console.log('Setting up L1 state to simulate finalizeBridgeERC20...'); + console.log('Optimism L1 Portal address:', OPTIMISM_L1_PORTAL); + console.log('Overriding slot', utils.hexZeroPad('0x32', 32)); + console.log('l2CrossDomainMessenger:', utils.hexZeroPad(l2CrossDomainMessenger.address, 32)); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + OPTIMISM_L1_PORTAL, + utils.hexZeroPad('0x32', 32), + utils.hexZeroPad(l2CrossDomainMessenger.address, 32) + ]); + + // Set deposits[_localToken][_remoteToken] on L1StandardBridge so finalizeBridgeERC20 won't underflow + // deposits mapping is at base slot 2 in L1StandardBridge storage layout + // In finalizeBridgeERC20 context: _localToken = remoteToken (L1), _remoteToken = localToken (L2) + const depositsBaseSlot = 2; + const innerSlot = utils.keccak256( + utils.defaultAbiCoder.encode(['address', 'uint256'], [remoteToken, depositsBaseSlot]) + ); + const depositsSlot = utils.keccak256( + utils.defaultAbiCoder.encode(['address', 'bytes32'], [localToken, innerSlot]) + ); + + console.log(`Setting deposits[${remoteToken}][${localToken}] to ${amount.toString()} at slot ${depositsSlot} on ${optimismL1Bridge.address}`); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + optimismL1Bridge.address, + depositsSlot, + utils.hexZeroPad(amount.toHexString(), 32) + ]); + + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + opL1CrossDomainMessenger.address, + '0xcc', + utils.hexZeroPad(optimismL2Bridge.address, 32) + ]); + + const domainMessengerSigner = await impersonateAddress( + governanceDeploymentManager, + opL1CrossDomainMessenger.address + ); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setBalance', [ + domainMessengerSigner.address, + ethers.utils.hexStripZeros(ethers.utils.parseEther('1').toHexString()), + ]); + + await ( + await optimismL1Bridge.connect(domainMessengerSigner).finalizeBridgeERC20( + remoteToken, localToken, bridgeReceiver.address, to, amount, extraData, + { gasPrice: 0, gasLimit: 2_500_000 } + ) + ).wait(); + await governanceDeploymentManager.hre.network.provider.send('hardhat_setStorageAt', [ + OPTIMISM_L1_PORTAL, + utils.hexZeroPad('0x32', 32), + utils.hexZeroPad('0xdead', 32) + ]); + } + } + } +} diff --git a/scenario/utils/relayScrollMessage.ts b/scenario/utils/relayScrollMessage.ts index 686658cd0..3d6b5b7bf 100644 --- a/scenario/utils/relayScrollMessage.ts +++ b/scenario/utils/relayScrollMessage.ts @@ -167,7 +167,9 @@ export default async function relayScrollMessage( // Add the proposal to the list of open bridged proposals to be executed after all the messages have been relayed openBridgedProposals.push({ id, eta }); } else { - throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); + // throw error only on last relay message and no proposal created event found + if(messageSentEvents.indexOf(messageSentEvent) === messageSentEvents.length - 1 && openBridgedProposals.length === 0) + throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); } } diff --git a/scenario/utils/relayUnichainMessage.ts b/scenario/utils/relayUnichainMessage.ts index 5b3db9b5b..94d91debf 100644 --- a/scenario/utils/relayUnichainMessage.ts +++ b/scenario/utils/relayUnichainMessage.ts @@ -132,7 +132,9 @@ export async function relayUnichainMessage( openBridgedProposals.push({ id, eta }); } } else { - throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); + // throw error only on last relay message and no proposal created event found + if(sentMessageEvents.indexOf(sentMessageEvent) === sentMessageEvents.length - 1 && openBridgedProposals.length === 0) + throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); } } diff --git a/scenario/utils/scenarioHelper.ts b/scenario/utils/scenarioHelper.ts index 26381efc0..577f8b5b8 100644 --- a/scenario/utils/scenarioHelper.ts +++ b/scenario/utils/scenarioHelper.ts @@ -32,6 +32,7 @@ const defaultConfig = { transferCollateral: 100, supplyCollateral: 100, supplyBase: 1000, + reservesBase: 5000, }; export function getConfigForScenario(ctx: CometContext, i?: number) { diff --git a/src/deploy/Network.ts b/src/deploy/Network.ts index 685ee22dd..9e4258bb0 100644 --- a/src/deploy/Network.ts +++ b/src/deploy/Network.ts @@ -116,7 +116,7 @@ export async function deployNetworkComet( baseBorrowMin, targetReserves, assetConfigs, - rewardTokenAddress + rewardTokenAddress, } = await getConfiguration(deploymentManager, configOverrides); /* Deploy contracts */ diff --git a/src/deploy/NetworkConfiguration.ts b/src/deploy/NetworkConfiguration.ts index 116c0d815..19b9b5d79 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/tasks/deployment_manager/task.ts b/tasks/deployment_manager/task.ts index d4378cb64..47bfc06c7 100644 --- a/tasks/deployment_manager/task.ts +++ b/tasks/deployment_manager/task.ts @@ -4,7 +4,7 @@ import { writeEnacted } from '../../plugins/deployment_manager/Enacted'; import { HardhatRuntimeEnvironment, HardhatConfig } from 'hardhat/types'; import { DeploymentManager, VerifyArgs } from '../../plugins/deployment_manager'; import { impersonateAddress } from '../../plugins/scenario/utils'; -import hreForBase from '../../plugins/scenario/utils/hreForBase'; +import hreForBase, { migrationStarted} from '../../plugins/scenario/utils/hreForBase'; // TODO: Don't depend on scenario's hreForBase async function getForkEnv(env: HardhatRuntimeEnvironment, deployment: string): Promise { @@ -248,6 +248,7 @@ task('migrate', 'Runs migration') governanceDm = dm; } + migrationStarted(); if (impersonate && !simulate) { throw new Error('Cannot impersonate an address if not simulating a migration. Please specify --simulate to simulate.'); } else if (impersonate && simulate) { @@ -376,6 +377,7 @@ task('deploy_and_migrate', 'Runs deploy and migration') governanceDm = dm; } + migrationStarted(); if (impersonate && !simulate) { throw new Error('Cannot impersonate an address if not simulating a migration. Please specify --simulate to simulate.'); } else if (impersonate && simulate) { diff --git a/test/absorb-test.ts b/test/absorb-test.ts index 2e52a9a9d..0d56aeb02 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 { cometWithExtendedAssetList : comet, users: [absorber, underwater] } = await makeProtocol(); + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, priceFeeds, users: [absorber, underwater] } = protocol; + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, priceFeeds, users: [absorber, underwater1, underwater2] } = protocol; + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, tokens, priceFeeds, users: [absorber, underwater1, underwater2, underwater3] } = protocol; + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, tokens, users: [absorber, underwater], priceFeeds } = protocol; + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, users: [alice, bob] } = await makeProtocol(); + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, pauseGuardian, users: [alice, bob] } = protocol; + const { + cometWithExtendedAssetList: 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 { cometWithExtendedAssetList : comet, users: [absorber, underwater], tokens } = await makeProtocol(); + const { + cometWithExtendedAssetList: 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,905 @@ describe('absorb', function () { expect(await comet.getAssetList(underwater.address)).to.be.empty; }); -}); \ No newline at end of file + + /* + * Written after the USDM incident, where a removed Chainlink price feed caused absorb to revert + * while calculating the USD value of seized collateral, freezing liquidations. + * + * This suite covers four (LCF, LF) combinations and how each affects absorption: + * 1. LCF > 0, LF > 0 - active collateral: price fetched, collateral seized at full USD value. + * 2. LCF > 0, LF = 0 - soft de-list: price still fetched (isLiquidatable counts it), but + * absorbInternal skips seizure; full debt absorbed by reserves. + * 3. LCF = 0, LF > 0 - worthless seizure: price fetch skipped (assetPrices[i] = 0), collateral + * still seized and moved to reserves but with usdValue = 0. + * 4. LCF = 0, LF = 0 - full de-list: both price fetch and seizure skipped; asset completely + * ignored during absorption, collateral left stranded in user's account. + * + * Also covers edge cases: + * - mixed liquidation factors across multiple assets: only assets with LF > 0 are seized. + * - price feed paralysis: a reverting price feed freezes isLiquidatable, isBorrowCollateralized, + * and absorb; restoring the feed unblocks all three. Governance can also set LCF = 0 to skip + * the price fetch entirely, resolving the paralysis without replacing the broken feed. + */ + 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.cometProxyWithExtendedAssetList.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.cometProxyWithExtendedAssetList.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.cometProxyWithExtendedAssetList.address, CometExtAssetList24Assets.address); + await configuratorProxy24Assets.setFactory(configuratorAndProtocol24Assets.cometProxyWithExtendedAssetList.address, CometFactoryWithExtendedAssetList.address); + await configuratorAndProtocol24Assets.proxyAdmin.deployAndUpgradeTo(configuratorAndProtocol24Assets.configuratorProxy.address, configuratorAndProtocol24Assets.cometProxyWithExtendedAssetList.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('asset can be liquidated with positive liquidation collateral factor and liquidation factor', function () { + /* + * normal "active collateral" state. + * + * Key factor roles in absorption: + * - LCF > 0: the asset counts toward the account's liquidation threshold in isLiquidatable; + * its price is fetched and stored in assetPrices[i]. + * - LF > 0: absorbInternal seizes the collateral, reads assetPrices[i], and uses the + * USD value to offset the absorbed debt. + * - borrowCF: governs only isBorrowCollateralized (new-borrow gate); irrelevant to + * isLiquidatable and absorb. + * + * Flow: + * With LCF > 0 and LF > 0: + * - Collateral is seized: Alice's COMP collateral is transferred to protocol reserves + * - AbsorbCollateral event is emitted with the seized amount and its 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 by the USD value of the seized collateral + * - AbsorbDebt event is emitted with the base amount paid out to the absorber + * - Total borrow base is reduced by the repay amount + * - Transfer event is NOT emitted (new principal clamps to 0, no supply side created) + */ + 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('skips liquidation for asset with liquidationF = 0 and liquidateCF > 0', function () { + /* + * "soft de-list" state. + * + * Key factor roles in absorption: + * - LCF > 0: the asset still counts toward the account's liquidation threshold; + * its price is fetched and stored in assetPrices[i]. + * - LF = 0: absorbInternal skips seizure for this asset entirely — no collateral + * transfer, assetPrices[i] is not used to offset debt. + * - borrowCF: governs only isBorrowCollateralized (new-borrow gate); irrelevant to + * isLiquidatable and absorb. + * + * Flow: + * When LF = 0 and LCF still > 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) + */ + 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('asset abosorbs with zero value when liquidateCF > 0 and liquidationF is positive', function () { + /* + * the collateral is still seizable but treated as worthless. + * + * Key factor roles in absorption: + * - LCF = 0: isLiquidatableInternal skips price fetching for this asset, + * so assetPrices[i] stays 0; the asset contributes no coverage. + * - LF > 0: absorbInternal seizes the collateral but uses assetPrices[i] = 0 — + * the collateral moves to reserves with zero USD value offset. + * - borrowCF: governs only isBorrowCollateralized (new-borrow gate); irrelevant to + * isLiquidatable and absorb. + * + * Flow: + * When LCF = 0 and LF > 0: + * - Collateral IS seized: Alice's COMP collateral is transferred to protocol reserves + * - AbsorbCollateral event IS emitted but with usdValue = 0 (assetPrices[i] = 0 since + * isLiquidatableInternal skipped price fetching for this LCF = 0 asset) + * - User collateral balance is set to 0 + * - totalsCollateral.totalSupplyAsset is reduced to 0 + * - User's assetsIn is reset to 0 + * - User principal is not offset by collateral value — deltaBalance = 0, full debt remains + * - New balance is clamped to 0; debt is fully absorbed by protocol reserves + * - AbsorbDebt event is emitted (full debt absorbed by reserves) + * - Total borrow base is reduced by the repay amount + * - Transfer event is NOT emitted (new principal clamps to 0, no supply side created) + * - Comet ERC20 collateral balance is unchanged (tokens stay locked in comet) + * - Collateral reserves increase by the seized amount + */ + let cometBaseTokenBalanceBefore: BigNumber; + let cometCompBalanceBefore: BigNumber; + let cometCompReservesBefore: BigNumber; + let computedDeltaBalance: bigint; + let computedNewBalance: bigint; + let computedRepayAmount: bigint; + + before(async () => { + await snapshot.restore(); + cometBaseTokenBalanceBefore = await baseToken.balanceOf(comet.address); + cometCompBalanceBefore = await compToken.balanceOf(comet.address); + cometCompReservesBefore = await comet.getCollateralReserves(compToken.address); + }); + + it('borrowCollateralFactor and liquidateCollateralFactor updated to 0', async () => { + await configurator.updateAssetBorrowCollateralFactor(cometProxyAddress, compToken.address, 0n); + await configurator.updateAssetLiquidateCollateralFactor(cometProxyAddress, compToken.address, 0n); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('liquidateCollateralFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).liquidateCollateralFactor).to.equal(0); + }); + + it('liquidationFactor remains non-zero after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).liquidationFactor).to.be.gt(0); + }); + + it('alice is liquidatable with zero liquidateCollateralFactor', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.true; + }); + + 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 with usdValue of 0', async () => { + // assetPrices[i] stays 0 because isLiquidatableInternal skips LCF=0 assets before calling getPrice() + // value = mulPrice(seizeAmount, 0, scale) = 0 + expect(liquidationTx).to.emit(comet, 'AbsorbCollateral').withArgs(bob.address, alice.address, compToken.address, aliceCompSupply, 0n); + }); + + 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('resets 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('deltaBalance is 0 when deltaValue is 0', () => { + // assetPrices[i] = 0 for LCF = 0 assets → deltaValue = 0 → deltaBalance = divPrice(0, price, scale) = 0 + computedDeltaBalance = divPrice(0n, basePrice, baseScale); + expect(computedDeltaBalance).to.equal(0n); + }); + + it('new balance is clamped to 0 from the negative old borrow balance', () => { + // oldBalance < 0, deltaBalance = 0 → unclamped = oldBalance (still negative) → clamped to 0 + const unclamped = oldBalance + computedDeltaBalance; + expect(unclamped < 0).to.be.true; + computedNewBalance = 0n; + }); + + it('new principal is 0 and matches stored principal', async () => { + const totalsBasic = await cometExt.totalsBasic(); + newPrincipal = principalValue(computedNewBalance, totalsBasic.baseSupplyIndex, totalsBasic.baseBorrowIndex); + expect(newPrincipal).to.equal(0n); + expect((await comet.userBasic(alice.address)).principal).to.equal(0n); + }); + + it('repay amount equals the absorbed borrow', () => { + // repayAmount = newPrincipal - oldPrincipal = 0 - oldPrincipal = -oldPrincipal > 0 + computedRepayAmount = newPrincipal - oldPrincipal; + expect(computedRepayAmount > 0n).to.be.true; + }); + + it('supply amount is 0 since new principal does not exceed 0', () => { + // supplyAmount = 0 when newPrincipal <= 0 (see repayAndSupplyAmount) + expect(newPrincipal <= 0n).to.be.true; + }); + + it('totalSupplyBase is unchanged after absorption', async () => { + const current = await cometExt.totalsBasic(); + expect(current.totalSupplyBase).to.equal(totalSupplyBase); + }); + + it('totalBorrowBase is reduced by repay amount after absorption', async () => { + const current = await cometExt.totalsBasic(); + expect(current.totalBorrowBase).to.equal(totalBorrowBase.toBigInt() - computedRepayAmount); + }); + + it('emits AbsorbDebt event', async () => { + // basePaidOut = computedNewBalance - oldBalance = 0 - oldBalance = -oldBalance (full debt absorbed by reserves) + const basePaidOut = computedNewBalance - 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 () => { + expect(liquidationTx).to.not.emit(comet, 'Transfer'); + }); + + it('comet base token ERC20 balance is unchanged after absorption', async () => { + // absorb does not transfer base tokens; debt absorption is an accounting change only + expect(await baseToken.balanceOf(comet.address)).to.equal(cometBaseTokenBalanceBefore); + }); + + it('comet collateral token ERC20 balance is unchanged after absorption', async () => { + // seized tokens remain locked in the comet contract; they are reclassified to reserves, not transferred out + expect(await compToken.balanceOf(comet.address)).to.equal(cometCompBalanceBefore); + }); + + it('comet collateral reserves increase by the seized amount', async () => { + // getCollateralReserves = balanceOf(comet) - totalsCollateral.totalSupplyAsset + // after seizure: totalSupplyAsset = 0, balanceOf unchanged → reserves grow by aliceCompSupply + expect(await comet.getCollateralReserves(compToken.address)).to.equal(cometCompReservesBefore.add(aliceCompSupply)); + }); + }); + + describe('asset ignored during absorption when liquidateCF = 0 and liquidationF = 0', function () { + /* + * Full de-listing: both liquidateCF and liquidationFactor are zero. + * + * Key factor roles in absorption (borrowCollateralFactor plays no role here): + * - LCF = 0: the asset contributes no coverage; isLiquidatableInternal skips price + * fetching, so assetPrices[i] stays 0. + * - LF = 0: absorbInternal skips seizure entirely — no price lookup, no collateral + * transfer; the balance remains in the user's protocol account. + * - borrowCF: governs only isBorrowCollateralized (new-borrow gate); irrelevant to + * isLiquidatable and absorb. + * + * Flow: + * When LCF = 0 and LF = 0: + * - Collateral is NOT seized: Alice's COMP collateral remains in the user's protocol account + * - AbsorbCollateral event is NOT emitted (asset is skipped entirely during absorption) + * - User collateral balance remains unchanged (same as before absorption) + * - totalsCollateral.totalSupplyAsset remains unchanged + * - User's assetsIn is reset to 0 even though collateral was not seized + * - User principal is not offset by collateral value — deltaBalance = 0, full debt remains + * - New balance is clamped to 0; debt is fully absorbed by protocol reserves + * - AbsorbDebt event is emitted (full debt absorbed by reserves) + * - Total borrow base is reduced by the repay amount + * - Transfer event is NOT emitted (new principal clamps to 0, no supply side created) + * - Comet ERC20 collateral balance is unchanged (tokens stay locked in comet) + * - Collateral reserves unchanged (no seizure occurred) + */ + let cometBaseTokenBalanceBefore: BigNumber; + let cometCompBalanceBefore: BigNumber; + let cometCompReservesBefore: BigNumber; + let computedDeltaBalance: bigint; + let computedNewBalance: bigint; + let computedRepayAmount: bigint; + + before(async () => { + await snapshot.restore(); + cometBaseTokenBalanceBefore = await baseToken.balanceOf(comet.address); + cometCompBalanceBefore = await compToken.balanceOf(comet.address); + cometCompReservesBefore = await comet.getCollateralReserves(compToken.address); + }); + + it('borrowCollateralFactor, liquidateCollateralFactor and liquidationFactor can be updated to 0', async () => { + await configurator.updateAssetBorrowCollateralFactor(cometProxyAddress, compToken.address, 0n); + await configurator.updateAssetLiquidateCollateralFactor(cometProxyAddress, compToken.address, 0n); + await configurator.updateAssetLiquidationFactor(cometProxyAddress, compToken.address, 0n); + await proxyAdmin.deployAndUpgradeTo(configuratorProxy.address, cometProxyAddress); + }); + + it('liquidateCollateralFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).liquidateCollateralFactor).to.equal(0); + }); + + it('liquidationFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(compToken.address)).liquidationFactor).to.equal(0); + }); + + it('alice is liquidatable with zero liquidateCollateralFactor and zero liquidationFactor', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.true; + }); + + it('absorbs is successful', 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('resets user assetsIn to 0 even though collateral was not seized', async () => { + // absorbInternal always resets assetsIn regardless of liquidationFactor + expect((await comet.userBasic(alice.address)).assetsIn).to.equal(0); + expect((await comet.userBasic(alice.address))._reserved).to.equal(0); + }); + + it('deltaBalance is 0 when deltaValue is 0', () => { + // asset skipped in absorbInternal (LF = 0) → deltaValue = 0 → deltaBalance = divPrice(0, price, scale) = 0 + computedDeltaBalance = divPrice(0n, basePrice, baseScale); + expect(computedDeltaBalance).to.equal(0n); + }); + + it('new balance is clamped to 0 from the negative old borrow balance', () => { + // oldBalance < 0, deltaBalance = 0 → unclamped = oldBalance (still negative) → clamped to 0 + const unclamped = oldBalance + computedDeltaBalance; + expect(unclamped < 0).to.be.true; + computedNewBalance = 0n; + }); + + it('new principal is 0 and matches stored principal', async () => { + const totalsBasic = await cometExt.totalsBasic(); + newPrincipal = principalValue(computedNewBalance, totalsBasic.baseSupplyIndex, totalsBasic.baseBorrowIndex); + expect(newPrincipal).to.equal(0n); + expect((await comet.userBasic(alice.address)).principal).to.equal(0n); + }); + + it('repay amount equals the absorbed borrow', () => { + // repayAmount = newPrincipal - oldPrincipal = 0 - oldPrincipal = -oldPrincipal > 0 + computedRepayAmount = newPrincipal - oldPrincipal; + expect(computedRepayAmount > 0n).to.be.true; + }); + + it('supply amount is 0 since new principal does not exceed 0', () => { + // supplyAmount = 0 when newPrincipal <= 0 (see repayAndSupplyAmount) + expect(newPrincipal <= 0n).to.be.true; + }); + + it('totalSupplyBase is unchanged after absorption', async () => { + const current = await cometExt.totalsBasic(); + expect(current.totalSupplyBase).to.equal(totalSupplyBase); + }); + + it('totalBorrowBase is reduced by repay amount after absorption', async () => { + const current = await cometExt.totalsBasic(); + expect(current.totalBorrowBase).to.equal(totalBorrowBase.toBigInt() - computedRepayAmount); + }); + + it('emits AbsorbDebt event', async () => { + // basePaidOut = computedNewBalance - oldBalance = 0 - oldBalance = -oldBalance (full debt absorbed by reserves) + const basePaidOut = computedNewBalance - 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 () => { + expect(liquidationTx).to.not.emit(comet, 'Transfer'); + }); + + it('comet base token ERC20 balance is unchanged after absorption', async () => { + // absorb does not transfer base tokens; debt absorption is an accounting change only + expect(await baseToken.balanceOf(comet.address)).to.equal(cometBaseTokenBalanceBefore); + }); + + it('comet collateral token ERC20 balance is unchanged after absorption', async () => { + // collateral was not seized; tokens remain in the protocol account, still locked in comet + expect(await compToken.balanceOf(comet.address)).to.equal(cometCompBalanceBefore); + }); + + it('comet collateral reserves are unchanged after absorption', async () => { + // no seizure occurred; getCollateralReserves = balanceOf(comet) - totalSupplyAsset remains the same + expect(await comet.getCollateralReserves(compToken.address)).to.equal(cometCompReservesBefore); + }); + }); + + 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/accrue-test.ts b/test/accrue-test.ts index 997aa9499..927f5d1a8 100644 --- a/test/accrue-test.ts +++ b/test/accrue-test.ts @@ -209,11 +209,13 @@ describe('accrue', function () { const t2 = Object.assign({}, t0, { baseBorrowIndex: 2n ** 64n - 1n, + totalSupplyBase: 80000, + totalBorrowBase: 20000, }); await fastForward(998); const _s1 = await wait(comet.setTotalsBasic(t2)); await fastForward(2); - await expect(wait(comet.accrue())).to.be.revertedWith('code 0x11 (Arithmetic operation underflowed or overflowed outside of an unchecked block)'); + await expect(comet.accrue()).to.be.revertedWith('code 0x11 (Arithmetic operation underflowed or overflowed outside of an unchecked block)'); }); it('supports up to the maximum timestamp then breaks', async () => { diff --git a/test/asset-info-test-asset-list-comet.ts b/test/asset-info-test-asset-list-comet.ts index 5650f0203..546f3bd44 100644 --- a/test/asset-info-test-asset-list-comet.ts +++ b/test/asset-info-test-asset-list-comet.ts @@ -1,4 +1,6 @@ -import { expect, exp, makeConfigurator, ONE, makeProtocol } from './helpers'; +import { AssetList, AssetList__factory, AssetListFactory, AssetListFactory__factory, FaucetToken, FaucetToken__factory, SimplePriceFeed, SimplePriceFeed__factory } from 'build/types'; +import { expect, exp, makeConfigurator, ONE, makeProtocol, ethers } from './helpers'; +import { AssetInfoStructOutput } from 'build/types/AssetList'; describe('asset info', function () { it('initializes protocol', async () => { @@ -72,30 +74,330 @@ describe('asset info', function () { await expect(cometWithExtendedAssetList.getAssetInfo(3)).to.be.revertedWith("custom error 'BadAsset()'"); }); - it('reverts if collateral factors are out of range', async () => { - await expect(makeConfigurator({ - assets: { - USDC: {}, - ASSET1: {borrowCF: exp(0.9, 18), liquidateCF: exp(0.9, 18)}, - ASSET2: {}, - }, - })).to.be.revertedWith("custom error 'BorrowCFTooLarge()'"); + describe('factors validation', function () { + // FACTOR_SCALE / 1e4 — the precision unit factors are truncated to when packed into AssetList storage + const DESCALE = exp(1, 14); - // check descaled factors - await expect(makeConfigurator({ - assets: { - USDC: {}, - ASSET1: {borrowCF: exp(0.9, 18), liquidateCF: exp(0.9, 18) + 1n}, - ASSET2: {}, - }, - })).to.be.revertedWith("custom error 'BorrowCFTooLarge()'"); + let assetList: AssetList; + let assetListFactory: AssetListFactory; + let faucetToken: FaucetToken; + let priceFeed: SimplePriceFeed; - await expect(makeConfigurator({ - assets: { - USDC: {}, - ASSET1: {borrowCF: exp(0.99, 18), liquidateCF: exp(1.1, 18)}, - ASSET2: {}, - }, - })).to.be.revertedWith("custom error 'LiquidateCFTooLarge()'"); + // Base valid config; each test spreads this and overrides only the field(s) under test. + let collateralAssetConfig: { + asset: string; + priceFeed: string; + decimals: number; + borrowCollateralFactor: bigint; + liquidateCollateralFactor: bigint; + liquidationFactor: bigint; + supplyCap: bigint; + }; + + before(async () => { + assetListFactory = await (await ethers.getContractFactory('AssetListFactory') as AssetListFactory__factory).deploy(); + await assetListFactory.deployed(); + + faucetToken = await (await ethers.getContractFactory('FaucetToken') as FaucetToken__factory).deploy(10n ** 24n, 'Test Token', 18, 'TEST'); + await faucetToken.deployed(); + + priceFeed = await (await ethers.getContractFactory('SimplePriceFeed') as SimplePriceFeed__factory).deploy(exp(1, 8), 8); + await priceFeed.deployed(); + + collateralAssetConfig = { + asset: faucetToken.address, + priceFeed: priceFeed.address, + decimals: 18, + borrowCollateralFactor: exp(0.75, 18), + liquidateCollateralFactor: exp(0.8, 18), + liquidationFactor: exp(0.9, 18), + supplyCap: 10n ** 24n, + }; + + assetList = await (await ethers.getContractFactory('AssetList') as AssetList__factory).deploy([collateralAssetConfig]); + await assetList.deployed(); + }); + + describe('happy cases', function () { + it('borrowCF > 0, liquidateCF > 0 and borrowCF < liquidateCF', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig }])).to.not.be.reverted; + }); + + it('borrowCF = 0, liquidateCF = 0', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, borrowCollateralFactor: 0n, liquidateCollateralFactor: 0n }])).to.not.be.reverted; + }); + + it('borrowCF > liquidateF and borrowCF < liquidateCF', async () => { + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.95, 18), + liquidateCollateralFactor: exp(0.98, 18), + liquidationFactor: exp(0.9, 18), + }])).to.not.be.reverted; + }); + + it('borrowCF < liquidateCF and liquidateCF > liquidateF', async () => { + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.7, 18), + liquidateCollateralFactor: exp(0.95, 18), + liquidationFactor: exp(0.9, 18), + }])).to.not.be.reverted; + }); + + it('borrowCF < liquidateCF < liquidateF', async () => { + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.7, 18), + liquidateCollateralFactor: exp(0.8, 18), + liquidationFactor: exp(0.95, 18), + }])).to.not.be.reverted; + }); + + it('borrowCF = 0 and liquidateCF > 0', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, borrowCollateralFactor: 0n }])).to.not.be.reverted; + }); + + it('borrowCF = 0, liquidateF = 0, liquidateCF > 0', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, borrowCollateralFactor: 0n, liquidationFactor: 0n }])).to.not.be.reverted; + }); + + it('liquidateCF < MAX_COLLATERAL_FACTOR', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, liquidateCollateralFactor: ONE - DESCALE }])).to.not.be.reverted; + }); + + it('liquidateCF = MAX_COLLATERAL_FACTOR', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, liquidateCollateralFactor: ONE }])).to.not.be.reverted; + }); + + it('liquidateF < MAX_COLLATERAL_FACTOR', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, liquidationFactor: ONE - DESCALE }])).to.not.be.reverted; + }); + + it('liquidateF = MAX_COLLATERAL_FACTOR', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, liquidationFactor: ONE }])).to.not.be.reverted; + }); + }); + + describe('revert when', function () { + it('borrowCF > 0, liquidateCF = 0', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, liquidateCollateralFactor: 0n }])) + .to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('borrowCF > 0, liquidateCF > 0 and borrowCF > liquidateCF', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, borrowCollateralFactor: exp(0.9, 18), liquidateCollateralFactor: exp(0.8, 18) }])) + .to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('borrowCF > 0, liquidateCF > 0 and borrowCF = liquidateCF', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, borrowCollateralFactor: exp(0.8, 18), liquidateCollateralFactor: exp(0.8, 18) }])) + .to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('liquidateCF > MAX_COLLATERAL_FACTOR', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, borrowCollateralFactor: 0n, liquidateCollateralFactor: ONE + 1n }])) + .to.be.revertedWithCustomError(assetList, 'LiquidateCFTooLarge'); + }); + + it('liquidateF > MAX_COLLATERAL_FACTOR', async () => { + await expect(assetListFactory.createAssetList([{ ...collateralAssetConfig, liquidationFactor: ONE + 1n }])) + .to.be.revertedWithCustomError(assetList, 'LiqPenaltyTooHigh'); + }); + }); + + /* + * When every factor is a positive integer smaller than DESCALE (1e14), truncation zeroes them + * all out after packing. The post-descale BorrowCFTooLarge guard is conditioned on + * packedBorrowCF != 0, so it short-circuits. Only the raw pre-descale checks apply. + * State is verified by attaching to the address returned via callStatic. + */ + describe('all factors > 0 and below DESCALE — pack to zero', function () { + describe('happy cases', function () { + describe('minimum valid values: borrowCF = 1, liquidateCF = 2, liquidationFactor = 1', function () { + let assetInfo: AssetInfoStructOutput; + + before(async () => { + const config = [{ ...collateralAssetConfig, borrowCollateralFactor: 1n, liquidateCollateralFactor: 2n, liquidationFactor: 1n }]; + const address = await assetListFactory.callStatic.createAssetList(config); + await assetListFactory.createAssetList(config); + const deployedAssetList = await ethers.getContractAt('AssetList', address); + assetInfo = await deployedAssetList.getAssetInfo(0); + }); + + it('borrowCollateralFactor packs to zero', async () => { + expect(assetInfo.borrowCollateralFactor).to.equal(0n); + }); + + it('liquidateCollateralFactor packs to zero', async () => { + expect(assetInfo.liquidateCollateralFactor).to.equal(0n); + }); + + it('liquidationFactor packs to zero', async () => { + expect(assetInfo.liquidationFactor).to.equal(0n); + }); + }); + + describe('maximum sub-DESCALE values: borrowCF = 1, liquidateCF and liquidationFactor at DESCALE - 1', function () { + let assetInfo: AssetInfoStructOutput; + + before(async () => { + const config = [{ ...collateralAssetConfig, borrowCollateralFactor: 1n, liquidateCollateralFactor: DESCALE - 1n, liquidationFactor: DESCALE - 1n }]; + const address = await assetListFactory.callStatic.createAssetList(config); + await assetListFactory.createAssetList(config); + const deployedAssetList = await ethers.getContractAt('AssetList', address); + assetInfo = await deployedAssetList.getAssetInfo(0); + }); + + it('borrowCollateralFactor packs to zero', async () => { + expect(assetInfo.borrowCollateralFactor).to.equal(0n); + }); + + it('liquidateCollateralFactor packs to zero', async () => { + expect(assetInfo.liquidateCollateralFactor).to.equal(0n); + }); + + it('liquidationFactor packs to zero', async () => { + expect(assetInfo.liquidationFactor).to.equal(0n); + }); + }); + + describe('borrowCF just below liquidateCF: both at adjacent sub-DESCALE positions', function () { + let assetInfo: AssetInfoStructOutput; + + before(async () => { + const config = [{ ...collateralAssetConfig, borrowCollateralFactor: DESCALE - 2n, liquidateCollateralFactor: DESCALE - 1n, liquidationFactor: 1n }]; + const address = await assetListFactory.callStatic.createAssetList(config); + await assetListFactory.createAssetList(config); + const deployedAssetList = await ethers.getContractAt('AssetList', address); + assetInfo = await deployedAssetList.getAssetInfo(0); + }); + + it('borrowCollateralFactor packs to zero', async () => { + expect(assetInfo.borrowCollateralFactor).to.equal(0n); + }); + + it('liquidateCollateralFactor packs to zero', async () => { + expect(assetInfo.liquidateCollateralFactor).to.equal(0n); + }); + + it('liquidationFactor packs to zero', async () => { + expect(assetInfo.liquidationFactor).to.equal(0n); + }); + }); + }); + + describe('revert when', function () { + it('borrowCF > liquidateCF: pre-descale ordering check fires even though both pack to zero', async () => { + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: 2n, + liquidateCollateralFactor: 1n, + liquidationFactor: 1n, + }])).to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('borrowCF = liquidateCF: pre-descale equality check fires even though both pack to zero', async () => { + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: 1n, + liquidateCollateralFactor: 1n, + liquidationFactor: 1n, + }])).to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + }); + }); + + /* + * Factors are truncated to 4-decimal precision (DESCALE = 1e14) when packed into AssetList + * storage. A post-descale safety check re-runs the BorrowCFTooLarge guard on packed values, + * catching cases where original values pass the first check but collapse into the same bin. + */ + describe('descaled values', function () { + describe('happy cases', function () { + it('both factors are exact multiples of DESCALE with a clear gap', async () => { + // 0.9e18 → 9000 units, 0.91e18 → 9100 units after descale + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18), + liquidateCollateralFactor: exp(0.91, 18), + }])).to.not.be.reverted; + }); + + it('gap is exactly one DESCALE unit — minimum valid separation', async () => { + // 9000*DESCALE → 9001*DESCALE: packed values differ by 1 unit + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18), + liquidateCollateralFactor: exp(0.9, 18) + DESCALE, + }])).to.not.be.reverted; + }); + + it('borrowCF just below a bin boundary, liquidateCF at that boundary', async () => { + // borrowCF = 9000*DESCALE - 1 → truncates to bin 8999; liquidateCF = 9000*DESCALE → bin 9000 + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18) - 1n, + liquidateCollateralFactor: exp(0.9, 18), + }])).to.not.be.reverted; + }); + + it('borrowCF inside a bin, liquidateCF at the start of the next bin', async () => { + // borrowCF = 9000*DESCALE + 1 → bin 9000; liquidateCF = 9001*DESCALE → bin 9001 + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18) + 1n, + liquidateCollateralFactor: exp(0.9, 18) + DESCALE, + }])).to.not.be.reverted; + }); + + it('borrowCF = 0 with liquidateCF below DESCALE — descale check is skipped', async () => { + // Packed borrowCollateralFactor = 0 → the != 0 guard short-circuits the descale check + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: 0n, + liquidateCollateralFactor: 1n, + }])).to.not.be.reverted; + }); + }); + + describe('revert when', function () { + it('gap = 1: liquidateCF is in the same bin as borrowCF after truncation', async () => { + // 9000*DESCALE and 9000*DESCALE + 1 both truncate to 9000 → equal after descale + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18), + liquidateCollateralFactor: exp(0.9, 18) + 1n, + }])).to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('gap = DESCALE - 1: maximum same-bin gap — liquidateCF still truncates to the same bin', async () => { + // 9001*DESCALE - 1 truncates to 9000, same as 9000*DESCALE + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18), + liquidateCollateralFactor: exp(0.9, 18) + DESCALE - 1n, + }])).to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('both values are non-zero and inside the same bin', async () => { + // borrowCF = 9000*DESCALE + 1, liquidateCF = 9001*DESCALE - 1 → both truncate to 9000 + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.9, 18) + 1n, + liquidateCollateralFactor: exp(0.9, 18) + DESCALE - 1n, + }])).to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + + it('same-bin collision at a different factor magnitude', async () => { + // 7000*DESCALE and 7000*DESCALE + 1 both truncate to 7000 → equal after descale + await expect(assetListFactory.createAssetList([{ + ...collateralAssetConfig, + borrowCollateralFactor: exp(0.7, 18), + liquidateCollateralFactor: exp(0.7, 18) + 1n, + }])).to.be.revertedWithCustomError(assetList, 'BorrowCFTooLarge'); + }); + }); + }); }); }); 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 3679cd22b..194781c84 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -29,6 +29,7 @@ import { Configurator__factory, CometHarnessInterfaceExtendedAssetList, CometInterface, + CometMainInterface, NonStandardFaucetFeeToken, NonStandardFaucetFeeToken__factory, AssetListFactory, @@ -41,6 +42,12 @@ import { BigNumber } from 'ethers'; import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider'; import { TotalsBasicStructOutput, TotalsCollateralStructOutput } from '../build/types/CometHarnessExtendedAssetList'; +// Snapshot +export { takeSnapshot, SnapshotRestorer } from './helpers/snapshot'; + +// Network helpers +export * from './helpers/network-helpers'; + export { Comet, ethers, expect, hre }; export type Numeric = number | bigint; @@ -142,6 +149,19 @@ 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; } @@ -166,14 +186,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 { @@ -191,25 +267,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'] || {} + ), }; } @@ -217,6 +309,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; @@ -243,8 +339,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 @@ -275,7 +371,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(); } @@ -283,7 +379,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 extensionDelegateAssetList = opts.extensionDelegateAssetList; if (extensionDelegateAssetList === undefined) { @@ -322,7 +418,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; @@ -337,7 +433,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; @@ -364,7 +460,7 @@ export async function makeProtocol(opts: ProtocolOpts = {}): Promise { users, base, reward, - cometWithExtendedAssetList: await ethers.getContractAt('CometHarnessInterfaceExtendedAssetList', cometWithExtendedAssetList.address) as CometWithExtendedAssetList, + cometWithExtendedAssetList: (await ethers.getContractAt('CometHarnessInterfaceExtendedAssetList', cometWithExtendedAssetList.address)) as CometWithExtendedAssetList, assetListFactory: assetListFactory, tokens, unsupportedToken, @@ -442,7 +538,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; @@ -511,11 +607,7 @@ export async function makeConfigurator(opts: ProtocolOpts = {}): Promise { return { opts, governor, - rewards + rewards, }; } @@ -597,14 +689,16 @@ export async function makeBulker(opts: BulkerOpts): Promise { return { opts, - bulker + bulker, }; } -export async function bumpTotalsCollateral(cometWithExtendedAssetList: CometHarnessInterfaceExtendedAssetList, token: FaucetToken | NonStandardFaucetFeeToken, delta: bigint): Promise { - const t0 = await cometWithExtendedAssetList.totalsCollateral(token.address); - const t1 = Object.assign({}, t0, { totalSupplyAsset: t0.totalSupplyAsset.toBigInt() + delta }); - await token.allocateTo(cometWithExtendedAssetList.address, delta); - await wait(cometWithExtendedAssetList.setTotalsCollateral(token.address, t1)); +export async function bumpTotalsCollateral(comet: CometHarnessInterfaceExtendedAssetList, token: FaucetToken | NonStandardFaucetFeeToken, delta: bigint): Promise { + const t0 = await comet.totalsCollateral(token.address); + 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; } @@ -615,6 +709,31 @@ export async function setTotalsBasic(cometWithExtendedAssetList: CometHarnessInt 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) { @@ -643,7 +762,7 @@ type Portfolio = { external: { [symbol: string]: bigint; }; -} +}; type TotalsAndReserves = { totals: { @@ -652,7 +771,7 @@ type TotalsAndReserves = { reserves: { [symbol: string]: bigint; }; -} +}; export async function portfolio({ cometWithExtendedAssetList, base, tokens }, account): Promise { const internal = { [base]: await baseBalanceOf(cometWithExtendedAssetList, account) }; @@ -667,7 +786,9 @@ export async function portfolio({ cometWithExtendedAssetList, base, tokens }, ac } export async function totalsAndReserves({ cometWithExtendedAssetList, base, tokens }): Promise { - const totals = { [base]: BigInt((await cometWithExtendedAssetList.totalsBasic()).totalSupplyBase) }; + const totals = { + [base]: BigInt((await cometWithExtendedAssetList.totalsBasic()).totalSupplyBase), + }; const reserves = { [base]: BigInt(await cometWithExtendedAssetList.getReserves()) }; for (const symbol in tokens) { if (symbol != base) { @@ -682,9 +803,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 { @@ -694,7 +813,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))) { @@ -726,3 +846,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 672c72694..232e0db62 100644 --- a/test/interest-rate-test.ts +++ b/test/interest-rate-test.ts @@ -1,119 +1,2204 @@ -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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 () { + /** + * No-borrow reserve farming cap (CometWithExtendedAssetList / #464). + * + * Setup: two lenders supply 2×$1M base, Comet is seeded with $5 reserves, utilization stays 0. + * Lenders earn supply interest only from those reserves (base supply rate at util=0). + * + * Contract behavior: when utilization is 0 and presentValue(totalSupply) >= token balance, + * getSupplyRate() returns 0 — accrual stops instead of clamping baseSupplyIndex after the fact + * (the old post-accrual truncate in accruedInterestIndices was removed in #464). + * + * Accrual applies one supply rate over the whole timeElapsed window. If that window is large, + * a single accrue can land a few wei above the ideal cap; the important invariant is that + * getSupplyRate() then stays 0 and a follow-up accrue does not grow the index further. + * + * Assertions use market-wide totals (2 deposits + reserves), not a single lender's principal. + */ + 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 maxSupplyIndex: 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(await testComet.getUtilization()); + expect(curSupplyRate).to.equal(baseSupplyRate); + }); + + it('get expected time elapsed on which reserves spend will happen', async () => { + const { totalSupplyBase, baseSupplyIndex } = await testComet.totalsBasic(); + const baseBalance = await baseToken.balanceOf(testComet.address); + + // totalSupply() may grow only up to the actual token balance (deposits + seed reserves) + maxSupplyIndex = baseBalance.mul(exp(1, 15)).div(totalSupplyBase); + + // since utilization = 0, lenders will get only baseRate of interest + const expectedSupplyRate = baseSupplyRate; + + // accrued index = supply index + supply index * supply rate * time elapsed + // => time elapsed = (accrued index - supply index) / (supply index * supply rate / factorScale) + expectedTimeElapsed = maxSupplyIndex + .sub(baseSupplyIndex) + .div(baseSupplyIndex.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('total supply does not exceed comet balance and supply rate is cut off', async () => { + const baseBalance = await baseToken.balanceOf(testComet.address); + const totalSupply = await testComet.totalSupply(); + + // See describe-block comment: cap is rate cutoff, not index truncate; allow tiny overshoot on one long accrue. + expect(totalSupply).to.be.approximately(baseBalance, 200); + expect(await testComet.getSupplyRate(await testComet.getUtilization())).to.equal(0); + }); + + it('supply index is at most the max possible index', async () => { + const { baseSupplyIndex, totalSupplyBase } = await testComet.totalsBasic(); + const baseBalance = await baseToken.balanceOf(testComet.address); + const capIndex = baseBalance.mul(exp(1, 15)).div(totalSupplyBase); + + // capIndex = balance * BASE_INDEX_SCALE / totalSupplyBase; same tolerance as totalSupply check above. + expect(baseSupplyIndex).to.be.approximately(capIndex, 100_000); + }); + + it('accrue market does not change the supply index', async () => { + // With getSupplyRate() == 0, further accrual must be a no-op for the supply index. + 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 44f36f8a2..96b217961 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 { CometExt, 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,468 @@ 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 () { + let snapshot: SnapshotRestorer; + + // Configurator and protocol + let configurator: Configurator; + let configuratorProxyAddress: string; + let proxyAdmin: CometProxyAdmin; + let cometProxyAddress: string; + let comet: CometWithExtendedAssetList; + let priceFeedWithRevert: PriceFeedWithRevert; + + // Tokens + let baseSymbol: string; + let baseToken: FaucetToken | NonStandardFaucetFeeToken; + let collateralToken: FaucetToken | NonStandardFaucetFeeToken; + let tokens: Record; + + // Users + let alice: SignerWithAddress; + let pauseGuardian: 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.cometProxyWithExtendedAssetList.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]; + pauseGuardian = protocol.pauseGuardian; + + // 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); + + // Deploy a price feed that always reverts on latestRoundData + const PriceFeedWithRevertFactory = (await ethers.getContractFactory('PriceFeedWithRevert')) as PriceFeedWithRevert__factory; + priceFeedWithRevert = await PriceFeedWithRevertFactory.deploy(100, 8); + + 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(); + }); + } + + describe('edge cases', function () { + /* + * Tests three resolution paths for price-feed paralysis in isBorrowCollateralized: restoring + * the original feed, setting borrowCF to 0, and deactivating the collateral via the pause + * guardian. Each path proves that the Reverted error from a broken feed can be unblocked. + */ + describe('revert on price feed side', function () { + 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; + }); + + 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; + }); + }); + + /* + * Demonstrates that setting borrowCollateralFactor to 0 resolves price-feed paralysis: + * once governance zeros a reverting asset's borrowCF, isBorrowCollateralized skips that + * asset's getPrice() call entirely and returns normally instead of reverting. + */ + describe('zero borrowCF resolves price feed paralysis in isBorrowCollateralized', function () { + before(async () => { + await snapshot.restore(); + + 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); + }); + + it('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 sets borrowCollateralFactor to 0 for the affected asset', async () => { + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n); + }); + + it('borrowCollateralFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).borrowCollateralFactor).to.equal(0); + }); + + it('isBorrowCollateralized succeeds and position is undercollateralized after borrowCF is set to 0', async () => { + expect(await comet.isBorrowCollateralized(alice.address)).to.be.false; + }); + }); + + /* + * Demonstrates that deactivating a collateral via the pause guardian resolves price-feed + * paralysis: the deactivation check in isBorrowCollateralized runs before any getPrice() + * call, so the function now reverts with the protocol-controlled TokenIsDeactivated error + * instead of the uncontrolled external Reverted error from the broken price feed. + */ + describe('token deactivation resolves price feed paralysis in isBorrowCollateralized', function () { + let cometExt: CometExt; + let collateralAssetIndex: number; + + before(async () => { + await snapshot.restore(); + + 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); + + collateralAssetIndex = (await comet.getAssetInfoByAddress(collateralToken.address)).offset; + cometExt = comet.attach(cometProxyAddress) as CometExt; + }); + + it('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('pause guardian deactivates the affected collateral', async () => { + await expect(cometExt.connect(pauseGuardian).deactivateCollateral(collateralAssetIndex)).to.not.be.reverted; + }); + + it('collateral is marked as deactivated', async () => { + expect(await comet.isCollateralDeactivated(collateralAssetIndex)).to.be.true; + }); + + it('isBorrowCollateralized reverts with TokenIsDeactivated instead of Reverted', async () => { + await expect(comet.isBorrowCollateralized(alice.address)).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); + }); + }); + + /* + * Demonstrates the two-step mitigation sequence: zeroing borrowCF first stops the + * external price-feed revert in isBorrowCollateralized, then deactivating the token + * re-reverts the function but with the protocol-controlled TokenIsDeactivated error + * instead of the uncontrolled external Reverted error from the broken price feed. + */ + describe('zero borrowCF unblocks price feed paralysis, then deactivation re-reverts with TokenIsDeactivated', function () { + let cometExt: CometExt; + let collateralAssetIndex: number; + + before(async () => { + await snapshot.restore(); + + 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); + + collateralAssetIndex = (await comet.getAssetInfoByAddress(collateralToken.address)).offset; + cometExt = comet.attach(cometProxyAddress) as CometExt; + }); + + it('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('isBorrowCollateralized reverts when collateral price feed reverts', async () => { + await expect(comet.isBorrowCollateralized(alice.address)).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('governance sets borrowCollateralFactor to 0 for the affected asset', async () => { + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n); + }); + + it('borrowCollateralFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).borrowCollateralFactor).to.equal(0); + }); + + it('isBorrowCollateralized no longer reverts after borrowCF is zeroed', async () => { + expect(await comet.isBorrowCollateralized(alice.address)).to.be.false; + }); + + it('pause guardian deactivates the affected collateral', async () => { + await expect(cometExt.connect(pauseGuardian).deactivateCollateral(collateralAssetIndex)).to.not.be.reverted; + }); + + it('collateral is marked as deactivated', async () => { + expect(await comet.isCollateralDeactivated(collateralAssetIndex)).to.be.true; + }); + + it('isBorrowCollateralized reverts with TokenIsDeactivated after deactivation', async () => { + await expect(comet.isBorrowCollateralized(alice.address)).to.be.revertedWithCustomError(comet, 'TokenIsDeactivated').withArgs(collateralToken.address); + }); + }); + }); + }); }); diff --git a/test/is-liquidatable-test.ts b/test/is-liquidatable-test.ts index 2b621317b..576276a37 100644 --- a/test/is-liquidatable-test.ts +++ b/test/is-liquidatable-test.ts @@ -1,4 +1,7 @@ -import { expect, exp, makeProtocol } from './helpers'; +import { CometExt, 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,492 @@ 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 () { + let snapshot: SnapshotRestorer; + + // Configurator and protocol + let comet: CometWithExtendedAssetList; + let configurator: Configurator; + let configuratorProxyAddress: string; + let proxyAdmin: CometProxyAdmin; + let cometProxyAddress: string; + let priceFeedWithRevert: PriceFeedWithRevert; + + // Tokens + let baseSymbol: string; + let baseToken: FaucetToken | NonStandardFaucetFeeToken; + let collateralToken: FaucetToken | NonStandardFaucetFeeToken; + let tokens: Record; + + // Users + let alice: SignerWithAddress; + let governor: SignerWithAddress; + let pauseGuardian: 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.cometProxyWithExtendedAssetList.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; + pauseGuardian = protocol.pauseGuardian; + + // 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(); + + // Deploy a price feed that always reverts on latestRoundData + const PriceFeedWithRevertFactory = (await ethers.getContractFactory('PriceFeedWithRevert')) as PriceFeedWithRevert__factory; + priceFeedWithRevert = await PriceFeedWithRevertFactory.deploy(100, 8); + + 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(); + }); + } + + describe('edge cases', function () { + /* + * Tests two resolution paths for price-feed paralysis in isLiquidatable: restoring the + * original feed, and setting liquidateCF to 0. Each path proves that the Reverted error + * from a broken feed can be unblocked without restoring the feed itself. + */ + describe('revert on price feed side', function () { + 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; + }); + + 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; + }); + }); + + /* + * Demonstrates that setting liquidateCollateralFactor to 0 resolves price-feed paralysis: + * once governance zeros a reverting asset's liquidateCF, isLiquidatable skips that asset's + * getPrice() call entirely and returns normally instead of reverting. + */ + describe('zero liquidateCF resolves price feed paralysis in isLiquidatable', function () { + before(async () => { + await snapshot.restore(); + + 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); + }); + + it('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 sets liquidateCollateralFactor to 0 for the affected asset', async () => { + // set borrowCF to 0 first, as we have check in AssetList BCF < LCF + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n); + await updateAssetLiquidateCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n, governor); + }); + + it('liquidateCollateralFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).liquidateCollateralFactor).to.equal(0); + }); + + it('isLiquidatable succeeds and position is liquidatable after liquidateCF is set to 0', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.true; + }); + }); + + /* + * Demonstrates that token deactivation does NOT resolve price-feed paralysis in isLiquidatable, + * unlike isBorrowCollateralized where the deactivation check runs before getPrice(). + * In isLiquidatable, a positive liquidateCF asset still triggers a price fetch even when + * deactivated, so the external Reverted error from the broken feed continues to propagate. + */ + describe('token deactivation does not resolve price feed paralysis in isLiquidatable', function () { + let cometExt: CometExt; + let collateralAssetIndex: number; + + before(async () => { + await snapshot.restore(); + + 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); + + collateralAssetIndex = (await comet.getAssetInfoByAddress(collateralToken.address)).offset; + cometExt = comet.attach(cometProxyAddress) as unknown as CometExt; + }); + + it('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('pause guardian deactivates the affected collateral', async () => { + await expect(cometExt.connect(pauseGuardian).deactivateCollateral(collateralAssetIndex)).to.not.be.reverted; + }); + + it('collateral is marked as deactivated', async () => { + expect(await comet.isCollateralDeactivated(collateralAssetIndex)).to.be.true; + }); + + it('isLiquidatable still reverts with Reverted after deactivation', async () => { + await expect(comet.isLiquidatable(alice.address)).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + }); + + /* + * Demonstrates the full mitigation sequence when token deactivation alone is insufficient: + * after deactivation fails to stop the external Reverted error from the broken price feed, + * zeroing liquidateCF skips the price fetch entirely and resolves the paralysis. + */ + describe('zeroing liquidateCF resolves price feed paralysis where token deactivation fails', function () { + let cometExt: CometExt; + let collateralAssetIndex: number; + + before(async () => { + await snapshot.restore(); + + 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); + + collateralAssetIndex = (await comet.getAssetInfoByAddress(collateralToken.address)).offset; + cometExt = comet.attach(cometProxyAddress) as unknown as CometExt; + }); + + it('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('pause guardian deactivates the affected collateral', async () => { + await expect(cometExt.connect(pauseGuardian).deactivateCollateral(collateralAssetIndex)).to.not.be.reverted; + }); + + it('collateral is marked as deactivated', async () => { + expect(await comet.isCollateralDeactivated(collateralAssetIndex)).to.be.true; + }); + + it('isLiquidatable still reverts with Reverted after deactivation', async () => { + await expect(comet.isLiquidatable(alice.address)).to.be.revertedWithCustomError(priceFeedWithRevert, 'Reverted'); + }); + + it('governance sets liquidateCollateralFactor to 0 for the affected asset', async () => { + // zero borrowCF first — AssetList validates borrowCF < liquidateCF + await updateAssetBorrowCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n); + await updateAssetLiquidateCollateralFactor(configurator, proxyAdmin, cometProxyAddress, collateralToken.address, 0n, governor); + }); + + it('liquidateCollateralFactor is 0 after upgrade', async () => { + expect((await comet.getAssetInfoByAddress(collateralToken.address)).liquidateCollateralFactor).to.equal(0); + }); + + it('isLiquidatable no longer reverts after liquidateCF is zeroed', async () => { + expect(await comet.isLiquidatable(alice.address)).to.be.true; + }); + }); + }); + }); }); diff --git a/test/liquidation-logic/poc-absorb-nonborrower-panic.test.ts b/test/liquidation-logic/poc-absorb-nonborrower-panic.test.ts new file mode 100644 index 000000000..be75d88fe --- /dev/null +++ b/test/liquidation-logic/poc-absorb-nonborrower-panic.test.ts @@ -0,0 +1,42 @@ +import { exp, makeProtocol, expect } from '../helpers'; + +/** + * F-3 verification (wip-partial) — RESULT: NOT A BUG. + * + * F-3 claimed that absorbInternal's + * + * uint256 debtRemainingValue = mulPrice(uint256(-presentValue(accountUser.principal)), basePrice, baseScale); + * if (accountUser.principal > 0 || debtRemainingValue <= liquidity) revert NotLiquidatable(); + * + * would revert with a raw arithmetic Panic (0x11) for a supplier (principal > 0), because + * `uint256(-presentValue(+))` wraps to ~2^256. In practice this does NOT happen: a non-borrower + * absorb reverts cleanly with `NotLiquidatable`. These tests pin that correct behaviour. + * + * Covered: + * - principal > 0 via a REAL supply() (baseSupplyIndex = 1e15, principal = 1e8) → NotLiquidatable + * - principal == 0 → NotLiquidatable + */ +describe('absorb: non-borrower accounts revert NotLiquidatable (F-3 verification)', function () { + it('real supplier (principal > 0) reverts NotLiquidatable, not a panic', async () => { + const { cometWithExtendedAssetList: comet, tokens, users: [absorber, supplier] } = await makeProtocol({ base: 'USDC' }); + + const usdc = tokens['USDC']; + await usdc.allocateTo(supplier.address, exp(100, 6)); + await usdc.connect(supplier).approve(comet.address, exp(100, 6)); + await comet.connect(supplier).supply(usdc.address, exp(100, 6)); // principal becomes +1e8 + + await expect( + comet.absorb(absorber.address, [supplier.address]) + ).to.be.revertedWithCustomError(comet, 'NotLiquidatable'); + }); + + it('zero-principal account reverts NotLiquidatable', async () => { + const { cometWithExtendedAssetList: comet, users: [absorber, idle] } = await makeProtocol(); + + await comet.setBasePrincipal(idle.address, 0); + + await expect( + comet.absorb(absorber.address, [idle.address]) + ).to.be.revertedWithCustomError(comet, 'NotLiquidatable'); + }); +}); diff --git a/test/quote-collateral-test.ts b/test/quote-collateral-test.ts index f6c73e0bb..6473ca9e9 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: comet, tokens } = protocol; const { COMP } = tokens; @@ -81,7 +84,7 @@ describe('quoteCollateral', function () { initialPrice: 200, liquidationFactor: exp(0.6, 18), }, - } + }, }); const { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: comet, tokens } = protocol; const { COMP } = tokens; @@ -150,7 +153,7 @@ describe('quoteCollateral', function () { initialPrice: 200, liquidationFactor: exp(0.75, 18), }, - } + }, }); const { cometWithExtendedAssetList: 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.cometProxyWithExtendedAssetList.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 2567c7778..3245d3c4e 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 { cometWithExtendedAssetList: 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, FaucetToken, CometExtAssetList, CometHarnessInterfaceExtendedAssetList } from '../build/types'; +import { BigNumber, ContractTransaction } from 'ethers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { TotalsCollateralStruct } from 'build/types/CometHarnessExtendedAssetList'; - it('supplies max base borrow balance (including accrued) from sender if the asset is base', async () => { - const protocol = await makeProtocol({ base: 'USDC' }); - const { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: comet, tokens, users: [alice, bob] } = await makeProtocol({ - assets: { - USDC: { - decimals: 6 - }, - EVIL: { + describe('non-standard tokens', function () { + describe('USDT-like token', function () { + let comet: CometHarnessInterfaceExtendedAssetList; + 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.cometWithExtendedAssetList; + 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: CometHarnessInterfaceExtendedAssetList; + 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.cometWithExtendedAssetList; + 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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: CometHarnessInterfaceExtendedAssetList, + 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 5b849b56f..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 { - cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { - cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 d91fd8643..0ef56495e 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, 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 { cometWithExtendedAssetList: 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: CometHarnessInterfaceExtendedAssetList; + let USDC: FaucetToken; + let EVIL: EvilToken; + let evilAlice: SignerWithAddress; + let evilBob: SignerWithAddress; + let reentrancySnapshot: SnapshotRestorer; + + before(async () => { + const { cometWithExtendedAssetList: 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: CometHarnessInterfaceExtendedAssetList; + 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.cometWithExtendedAssetList; + [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: CometHarnessInterfaceExtendedAssetList; + 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.cometWithExtendedAssetList; + 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 { cometWithExtendedAssetList: 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 CometHarnessInterfaceExtendedAssetList, 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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(87000); - }); + 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 { cometWithExtendedAssetList: 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 +});