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..88be81e0d 100644 --- a/deployments/hardhat/dai/deploy.ts +++ b/deployments/hardhat/dai/deploy.ts @@ -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/mainnet/usdc/migrations/1780051404_update_usd_based_to_v2_factory.ts b/deployments/mainnet/usdc/migrations/1780051404_update_usd_based_to_v2_factory.ts new file mode 100644 index 000000000..7e658c70a --- /dev/null +++ b/deployments/mainnet/usdc/migrations/1780051404_update_usd_based_to_v2_factory.ts @@ -0,0 +1,227 @@ +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 } from '../../../../src/deploy'; + +const USDC_COMET = '0xc3d688B66703497DAA19211EEdff47f25384cdc3'; +const USDS_COMET = '0x5D409e56D886231aDAf00c8775665AD0f9897b56'; +const USDT_COMET = '0x3Afdc9BCA9213A35503b077a6072F3D0d5AB0840'; + +const COMET_FACTORY_V2 = '0x298aC0E463cEAd4aaA73fb91Df7C639A8eFBd9c4'; + +const USDC_EXT = '0xc1806c4a18ab9bD0afbeBD35cE3314A24bb129d1'; +const USDT_EXT = '0xf186a03e1200e5Ccee03E00Ef7C77218652d3b1C'; +const USDS_EXT = '0xbEf2218271f74B58ed06197903574716709c9537'; + +export default migration('1780051404_update_usd_based_to_v2_factory', { + async prepare() { + return {}; + }, + + async enact(deploymentManager: DeploymentManager) { + + const trace = deploymentManager.tracer(); + + const { + governor, + cometAdmin, + configurator, + } = await deploymentManager.getContracts(); + + const mainnetActions = [ + // 1. Update version in new Comet to the recent service patch version + { + target: COMET_FACTORY_V2, + signature: 'setVersion(((uint64,uint64,uint64),string))', + calldata: utils.defaultAbiCoder.encode( + ['tuple((uint64,uint64,uint64),string)'], + [[ + [1, 2, 1], + '', + ]] + ), + }, + // 2. Update USDC Comet factory to a new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDC_COMET, COMET_FACTORY_V2], + }, + // 3. Set service patch version of the extension delegate for the USDC Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDC_COMET, USDC_EXT], + }, + // 4. Deploy and upgrade to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDC_COMET], + }, + // 5. Update USDT Comet factory to the new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDT_COMET, COMET_FACTORY_V2], + }, + // 6. Set service patch version of the extension delegate for the USDT Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDT_COMET, USDT_EXT], + }, + // 7. Deploy and upgrade USDT Comet to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDT_COMET], + }, + // 8. Update USDS Comet factory to the new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDS_COMET, COMET_FACTORY_V2], + }, + // 9. Set service patch version of the extension delegate for the USDS Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDS_COMET, USDS_EXT], + }, + // 10. Deploy and upgrade USDS Comet to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDS_COMET], + }, + ]; + + const description = `# Update Mainnet USD based Comets to the service patch version + +## Proposal summary + +WOOF! proposes to update Mainnet cUSDCv3, cUSDTv3 and cUSDSv3 Comet markets to a new service patch version introducing several improvements and security enhancements: + +- Extended Pause Controls: collateral interactions can now be paused independently per collateral asset. +- Price Feed Patch (Post-USDM incident response): skips price feed calls for assets with zero collateral factor, preventing unnecessary reverts. +- Collateral Deactivation Mechanism: introduces a Guardian-controlled emergency mechanism to deactivate unsafe collateral assets, with reactivation requiring a governance proposal. +- Utilization Peaking Protection: caps utilization at 200%, preventing additional borrowing when post-borrow utilization exceeds this threshold, while preserving lender withdrawals. +- Borrow Index Fix (Empty Market): prevents borrow interest accrual in markets without active borrowers. +- Supply Index Fix (Empty Market): ensures supply index only accrues when lenders are present. +- Lender Illiquidity Fix in Zero-Borrow Markets: prevents reserve depletion in markets with no borrowers by capping supply rate to zero when utilization is zero and reserves are exhausted. +- Accrue Interest on Collateral Actions (Post-USDM incident response): collateral actions (supply, withdraw, transfer) now trigger interest accrual for affected accounts. +- Technical Improvements: includes removal of redundant arguments in supplyInternal() and optimized price caching in absorbInternal(), improving gas efficiency without affecting protocol behavior. + +This proposal takes the governance steps recommended and necessary to update Compound III USDS, USDT and USDS markets on Mainnet. Simulations have confirmed the market's readiness, as much as possible, using the [Comet scenario suite](https://github.com/compound-finance/comet/tree/main/scenario). + +Detailed information can be found on the corresponding [proposal pull request](https://github.com/compound-finance/comet/pull/1125). + +### Bytecode Repository + +This update is done with the use of the bytecode repository, which provides trustless and deterministic deployments. + +Further details on the deployment can be found in the [Bytecode Repository git](https://github.com/woof-software/bytecode-repository) and [forum discussion](https://www.comp.xyz/t/rfc-bytecode-repository-and-deployment-pipeline-modernization/6965). + +### Audit + +Both service patch Comet update and Bytecode Repository have been audited by Certora and full reports can be found here: + +- [Certora Comet Service Patch Audit](https://www.certora.com/reports/comet-service-patch) +- [Certora Bytecode Repository Audit](https://www.certora.com/reports/compound-bytecoderepository) + + +## Proposal Actions + +The first proposal action updates the version in the new Comet factory to the recent service patch version. + +The second proposal action updates the factory of the USDC Comet to the new V2 factory. + +The third proposal action sets the extension delegate for the USDC Comet to the new service patch version. + +The fourth proposal action deploys and upgrades the USDC Comet to the new service patch version. + +The fifth proposal action updates the factory of the USDT Comet to the new V2 factory. + +The sixth proposal action sets the extension delegate for the USDT Comet to the new service patch version. + +The seventh proposal action deploys and upgrades the USDT Comet to the new service patch version. + +The eighth proposal action updates the factory of the USDS Comet to the new V2 factory. + +The ninth proposal action sets the extension delegate for the USDS Comet to the new service patch version. + +The tenth proposal action deploys and upgrades the USDS Comet to the new service patch version. +`; + 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 { configurator } = await deploymentManager.getContracts(); + const newCometAbi = [ + 'function MAX_SUPPORTED_UTILIZATION() external view returns (uint256)', + 'function symbol() external view returns (string)', + 'function name() external view returns (string)', + 'function extensionDelegate() external view returns (address)', + ]; + + const factoryV2 = new Contract( + COMET_FACTORY_V2, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + await deploymentManager.getSigner() + ); + + const [baseVersion, baseAlternative] = await factoryV2.version(); + expect(baseVersion).to.deep.equal([1, 2, 1]); + expect(baseAlternative).to.equal(''); + + expect(await configurator.factory(USDC_COMET)).to.equal(COMET_FACTORY_V2); + expect(await configurator.factory(USDT_COMET)).to.equal(COMET_FACTORY_V2); + expect(await configurator.factory(USDS_COMET)).to.equal(COMET_FACTORY_V2); + + expect((await configurator.getConfiguration(USDC_COMET)).extensionDelegate).to.equal(USDC_EXT); + expect((await configurator.getConfiguration(USDT_COMET)).extensionDelegate).to.equal(USDT_EXT); + expect((await configurator.getConfiguration(USDS_COMET)).extensionDelegate).to.equal(USDS_EXT); + + const expectedMaxUtilization = exp(2, 18); + const signer = await deploymentManager.getSigner(); + + const newCometUsdc = new Contract(USDC_COMET, newCometAbi, signer); + + expect(await newCometUsdc.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsdc.symbol()).to.equal('cUSDCv3'); + expect(await newCometUsdc.name()).to.equal('Compound USDC'); + expect(await newCometUsdc.extensionDelegate()).to.equal(USDC_EXT); + + const newCometUsdt = new Contract(USDT_COMET, newCometAbi, signer); + + expect(await newCometUsdt.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsdt.symbol()).to.equal('cUSDTv3'); + expect(await newCometUsdt.name()).to.equal('Compound USDT'); + expect(await newCometUsdt.extensionDelegate()).to.equal(USDT_EXT); + + const newCometUsds = new Contract(USDS_COMET, newCometAbi, signer); + + expect(await newCometUsds.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsds.symbol()).to.equal('cUSDSv3'); + expect(await newCometUsds.name()).to.equal('Compound USDS'); + expect(await newCometUsds.extensionDelegate()).to.equal(USDS_EXT); + }, +}); diff --git a/deployments/mainnet/usds/migrations/1780051404_update_usd_based_to_v2_factory.ts b/deployments/mainnet/usds/migrations/1780051404_update_usd_based_to_v2_factory.ts new file mode 100644 index 000000000..7e658c70a --- /dev/null +++ b/deployments/mainnet/usds/migrations/1780051404_update_usd_based_to_v2_factory.ts @@ -0,0 +1,227 @@ +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 } from '../../../../src/deploy'; + +const USDC_COMET = '0xc3d688B66703497DAA19211EEdff47f25384cdc3'; +const USDS_COMET = '0x5D409e56D886231aDAf00c8775665AD0f9897b56'; +const USDT_COMET = '0x3Afdc9BCA9213A35503b077a6072F3D0d5AB0840'; + +const COMET_FACTORY_V2 = '0x298aC0E463cEAd4aaA73fb91Df7C639A8eFBd9c4'; + +const USDC_EXT = '0xc1806c4a18ab9bD0afbeBD35cE3314A24bb129d1'; +const USDT_EXT = '0xf186a03e1200e5Ccee03E00Ef7C77218652d3b1C'; +const USDS_EXT = '0xbEf2218271f74B58ed06197903574716709c9537'; + +export default migration('1780051404_update_usd_based_to_v2_factory', { + async prepare() { + return {}; + }, + + async enact(deploymentManager: DeploymentManager) { + + const trace = deploymentManager.tracer(); + + const { + governor, + cometAdmin, + configurator, + } = await deploymentManager.getContracts(); + + const mainnetActions = [ + // 1. Update version in new Comet to the recent service patch version + { + target: COMET_FACTORY_V2, + signature: 'setVersion(((uint64,uint64,uint64),string))', + calldata: utils.defaultAbiCoder.encode( + ['tuple((uint64,uint64,uint64),string)'], + [[ + [1, 2, 1], + '', + ]] + ), + }, + // 2. Update USDC Comet factory to a new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDC_COMET, COMET_FACTORY_V2], + }, + // 3. Set service patch version of the extension delegate for the USDC Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDC_COMET, USDC_EXT], + }, + // 4. Deploy and upgrade to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDC_COMET], + }, + // 5. Update USDT Comet factory to the new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDT_COMET, COMET_FACTORY_V2], + }, + // 6. Set service patch version of the extension delegate for the USDT Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDT_COMET, USDT_EXT], + }, + // 7. Deploy and upgrade USDT Comet to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDT_COMET], + }, + // 8. Update USDS Comet factory to the new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDS_COMET, COMET_FACTORY_V2], + }, + // 9. Set service patch version of the extension delegate for the USDS Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDS_COMET, USDS_EXT], + }, + // 10. Deploy and upgrade USDS Comet to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDS_COMET], + }, + ]; + + const description = `# Update Mainnet USD based Comets to the service patch version + +## Proposal summary + +WOOF! proposes to update Mainnet cUSDCv3, cUSDTv3 and cUSDSv3 Comet markets to a new service patch version introducing several improvements and security enhancements: + +- Extended Pause Controls: collateral interactions can now be paused independently per collateral asset. +- Price Feed Patch (Post-USDM incident response): skips price feed calls for assets with zero collateral factor, preventing unnecessary reverts. +- Collateral Deactivation Mechanism: introduces a Guardian-controlled emergency mechanism to deactivate unsafe collateral assets, with reactivation requiring a governance proposal. +- Utilization Peaking Protection: caps utilization at 200%, preventing additional borrowing when post-borrow utilization exceeds this threshold, while preserving lender withdrawals. +- Borrow Index Fix (Empty Market): prevents borrow interest accrual in markets without active borrowers. +- Supply Index Fix (Empty Market): ensures supply index only accrues when lenders are present. +- Lender Illiquidity Fix in Zero-Borrow Markets: prevents reserve depletion in markets with no borrowers by capping supply rate to zero when utilization is zero and reserves are exhausted. +- Accrue Interest on Collateral Actions (Post-USDM incident response): collateral actions (supply, withdraw, transfer) now trigger interest accrual for affected accounts. +- Technical Improvements: includes removal of redundant arguments in supplyInternal() and optimized price caching in absorbInternal(), improving gas efficiency without affecting protocol behavior. + +This proposal takes the governance steps recommended and necessary to update Compound III USDS, USDT and USDS markets on Mainnet. Simulations have confirmed the market's readiness, as much as possible, using the [Comet scenario suite](https://github.com/compound-finance/comet/tree/main/scenario). + +Detailed information can be found on the corresponding [proposal pull request](https://github.com/compound-finance/comet/pull/1125). + +### Bytecode Repository + +This update is done with the use of the bytecode repository, which provides trustless and deterministic deployments. + +Further details on the deployment can be found in the [Bytecode Repository git](https://github.com/woof-software/bytecode-repository) and [forum discussion](https://www.comp.xyz/t/rfc-bytecode-repository-and-deployment-pipeline-modernization/6965). + +### Audit + +Both service patch Comet update and Bytecode Repository have been audited by Certora and full reports can be found here: + +- [Certora Comet Service Patch Audit](https://www.certora.com/reports/comet-service-patch) +- [Certora Bytecode Repository Audit](https://www.certora.com/reports/compound-bytecoderepository) + + +## Proposal Actions + +The first proposal action updates the version in the new Comet factory to the recent service patch version. + +The second proposal action updates the factory of the USDC Comet to the new V2 factory. + +The third proposal action sets the extension delegate for the USDC Comet to the new service patch version. + +The fourth proposal action deploys and upgrades the USDC Comet to the new service patch version. + +The fifth proposal action updates the factory of the USDT Comet to the new V2 factory. + +The sixth proposal action sets the extension delegate for the USDT Comet to the new service patch version. + +The seventh proposal action deploys and upgrades the USDT Comet to the new service patch version. + +The eighth proposal action updates the factory of the USDS Comet to the new V2 factory. + +The ninth proposal action sets the extension delegate for the USDS Comet to the new service patch version. + +The tenth proposal action deploys and upgrades the USDS Comet to the new service patch version. +`; + 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 { configurator } = await deploymentManager.getContracts(); + const newCometAbi = [ + 'function MAX_SUPPORTED_UTILIZATION() external view returns (uint256)', + 'function symbol() external view returns (string)', + 'function name() external view returns (string)', + 'function extensionDelegate() external view returns (address)', + ]; + + const factoryV2 = new Contract( + COMET_FACTORY_V2, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + await deploymentManager.getSigner() + ); + + const [baseVersion, baseAlternative] = await factoryV2.version(); + expect(baseVersion).to.deep.equal([1, 2, 1]); + expect(baseAlternative).to.equal(''); + + expect(await configurator.factory(USDC_COMET)).to.equal(COMET_FACTORY_V2); + expect(await configurator.factory(USDT_COMET)).to.equal(COMET_FACTORY_V2); + expect(await configurator.factory(USDS_COMET)).to.equal(COMET_FACTORY_V2); + + expect((await configurator.getConfiguration(USDC_COMET)).extensionDelegate).to.equal(USDC_EXT); + expect((await configurator.getConfiguration(USDT_COMET)).extensionDelegate).to.equal(USDT_EXT); + expect((await configurator.getConfiguration(USDS_COMET)).extensionDelegate).to.equal(USDS_EXT); + + const expectedMaxUtilization = exp(2, 18); + const signer = await deploymentManager.getSigner(); + + const newCometUsdc = new Contract(USDC_COMET, newCometAbi, signer); + + expect(await newCometUsdc.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsdc.symbol()).to.equal('cUSDCv3'); + expect(await newCometUsdc.name()).to.equal('Compound USDC'); + expect(await newCometUsdc.extensionDelegate()).to.equal(USDC_EXT); + + const newCometUsdt = new Contract(USDT_COMET, newCometAbi, signer); + + expect(await newCometUsdt.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsdt.symbol()).to.equal('cUSDTv3'); + expect(await newCometUsdt.name()).to.equal('Compound USDT'); + expect(await newCometUsdt.extensionDelegate()).to.equal(USDT_EXT); + + const newCometUsds = new Contract(USDS_COMET, newCometAbi, signer); + + expect(await newCometUsds.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsds.symbol()).to.equal('cUSDSv3'); + expect(await newCometUsds.name()).to.equal('Compound USDS'); + expect(await newCometUsds.extensionDelegate()).to.equal(USDS_EXT); + }, +}); diff --git a/deployments/mainnet/usdt/migrations/1780051404_update_usd_based_to_v2_factory.ts b/deployments/mainnet/usdt/migrations/1780051404_update_usd_based_to_v2_factory.ts new file mode 100644 index 000000000..7e658c70a --- /dev/null +++ b/deployments/mainnet/usdt/migrations/1780051404_update_usd_based_to_v2_factory.ts @@ -0,0 +1,227 @@ +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 } from '../../../../src/deploy'; + +const USDC_COMET = '0xc3d688B66703497DAA19211EEdff47f25384cdc3'; +const USDS_COMET = '0x5D409e56D886231aDAf00c8775665AD0f9897b56'; +const USDT_COMET = '0x3Afdc9BCA9213A35503b077a6072F3D0d5AB0840'; + +const COMET_FACTORY_V2 = '0x298aC0E463cEAd4aaA73fb91Df7C639A8eFBd9c4'; + +const USDC_EXT = '0xc1806c4a18ab9bD0afbeBD35cE3314A24bb129d1'; +const USDT_EXT = '0xf186a03e1200e5Ccee03E00Ef7C77218652d3b1C'; +const USDS_EXT = '0xbEf2218271f74B58ed06197903574716709c9537'; + +export default migration('1780051404_update_usd_based_to_v2_factory', { + async prepare() { + return {}; + }, + + async enact(deploymentManager: DeploymentManager) { + + const trace = deploymentManager.tracer(); + + const { + governor, + cometAdmin, + configurator, + } = await deploymentManager.getContracts(); + + const mainnetActions = [ + // 1. Update version in new Comet to the recent service patch version + { + target: COMET_FACTORY_V2, + signature: 'setVersion(((uint64,uint64,uint64),string))', + calldata: utils.defaultAbiCoder.encode( + ['tuple((uint64,uint64,uint64),string)'], + [[ + [1, 2, 1], + '', + ]] + ), + }, + // 2. Update USDC Comet factory to a new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDC_COMET, COMET_FACTORY_V2], + }, + // 3. Set service patch version of the extension delegate for the USDC Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDC_COMET, USDC_EXT], + }, + // 4. Deploy and upgrade to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDC_COMET], + }, + // 5. Update USDT Comet factory to the new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDT_COMET, COMET_FACTORY_V2], + }, + // 6. Set service patch version of the extension delegate for the USDT Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDT_COMET, USDT_EXT], + }, + // 7. Deploy and upgrade USDT Comet to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDT_COMET], + }, + // 8. Update USDS Comet factory to the new one + { + contract: configurator, + signature: 'setFactory(address,address)', + args: [USDS_COMET, COMET_FACTORY_V2], + }, + // 9. Set service patch version of the extension delegate for the USDS Comet + { + contract: configurator, + signature: 'setExtensionDelegate(address,address)', + args: [USDS_COMET, USDS_EXT], + }, + // 10. Deploy and upgrade USDS Comet to a new version of Comet + { + contract: cometAdmin, + signature: 'deployAndUpgradeTo(address,address)', + args: [configurator.address, USDS_COMET], + }, + ]; + + const description = `# Update Mainnet USD based Comets to the service patch version + +## Proposal summary + +WOOF! proposes to update Mainnet cUSDCv3, cUSDTv3 and cUSDSv3 Comet markets to a new service patch version introducing several improvements and security enhancements: + +- Extended Pause Controls: collateral interactions can now be paused independently per collateral asset. +- Price Feed Patch (Post-USDM incident response): skips price feed calls for assets with zero collateral factor, preventing unnecessary reverts. +- Collateral Deactivation Mechanism: introduces a Guardian-controlled emergency mechanism to deactivate unsafe collateral assets, with reactivation requiring a governance proposal. +- Utilization Peaking Protection: caps utilization at 200%, preventing additional borrowing when post-borrow utilization exceeds this threshold, while preserving lender withdrawals. +- Borrow Index Fix (Empty Market): prevents borrow interest accrual in markets without active borrowers. +- Supply Index Fix (Empty Market): ensures supply index only accrues when lenders are present. +- Lender Illiquidity Fix in Zero-Borrow Markets: prevents reserve depletion in markets with no borrowers by capping supply rate to zero when utilization is zero and reserves are exhausted. +- Accrue Interest on Collateral Actions (Post-USDM incident response): collateral actions (supply, withdraw, transfer) now trigger interest accrual for affected accounts. +- Technical Improvements: includes removal of redundant arguments in supplyInternal() and optimized price caching in absorbInternal(), improving gas efficiency without affecting protocol behavior. + +This proposal takes the governance steps recommended and necessary to update Compound III USDS, USDT and USDS markets on Mainnet. Simulations have confirmed the market's readiness, as much as possible, using the [Comet scenario suite](https://github.com/compound-finance/comet/tree/main/scenario). + +Detailed information can be found on the corresponding [proposal pull request](https://github.com/compound-finance/comet/pull/1125). + +### Bytecode Repository + +This update is done with the use of the bytecode repository, which provides trustless and deterministic deployments. + +Further details on the deployment can be found in the [Bytecode Repository git](https://github.com/woof-software/bytecode-repository) and [forum discussion](https://www.comp.xyz/t/rfc-bytecode-repository-and-deployment-pipeline-modernization/6965). + +### Audit + +Both service patch Comet update and Bytecode Repository have been audited by Certora and full reports can be found here: + +- [Certora Comet Service Patch Audit](https://www.certora.com/reports/comet-service-patch) +- [Certora Bytecode Repository Audit](https://www.certora.com/reports/compound-bytecoderepository) + + +## Proposal Actions + +The first proposal action updates the version in the new Comet factory to the recent service patch version. + +The second proposal action updates the factory of the USDC Comet to the new V2 factory. + +The third proposal action sets the extension delegate for the USDC Comet to the new service patch version. + +The fourth proposal action deploys and upgrades the USDC Comet to the new service patch version. + +The fifth proposal action updates the factory of the USDT Comet to the new V2 factory. + +The sixth proposal action sets the extension delegate for the USDT Comet to the new service patch version. + +The seventh proposal action deploys and upgrades the USDT Comet to the new service patch version. + +The eighth proposal action updates the factory of the USDS Comet to the new V2 factory. + +The ninth proposal action sets the extension delegate for the USDS Comet to the new service patch version. + +The tenth proposal action deploys and upgrades the USDS Comet to the new service patch version. +`; + 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 { configurator } = await deploymentManager.getContracts(); + const newCometAbi = [ + 'function MAX_SUPPORTED_UTILIZATION() external view returns (uint256)', + 'function symbol() external view returns (string)', + 'function name() external view returns (string)', + 'function extensionDelegate() external view returns (address)', + ]; + + const factoryV2 = new Contract( + COMET_FACTORY_V2, + [ + 'function version() view returns ((uint64,uint64,uint64),string)', + ], + await deploymentManager.getSigner() + ); + + const [baseVersion, baseAlternative] = await factoryV2.version(); + expect(baseVersion).to.deep.equal([1, 2, 1]); + expect(baseAlternative).to.equal(''); + + expect(await configurator.factory(USDC_COMET)).to.equal(COMET_FACTORY_V2); + expect(await configurator.factory(USDT_COMET)).to.equal(COMET_FACTORY_V2); + expect(await configurator.factory(USDS_COMET)).to.equal(COMET_FACTORY_V2); + + expect((await configurator.getConfiguration(USDC_COMET)).extensionDelegate).to.equal(USDC_EXT); + expect((await configurator.getConfiguration(USDT_COMET)).extensionDelegate).to.equal(USDT_EXT); + expect((await configurator.getConfiguration(USDS_COMET)).extensionDelegate).to.equal(USDS_EXT); + + const expectedMaxUtilization = exp(2, 18); + const signer = await deploymentManager.getSigner(); + + const newCometUsdc = new Contract(USDC_COMET, newCometAbi, signer); + + expect(await newCometUsdc.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsdc.symbol()).to.equal('cUSDCv3'); + expect(await newCometUsdc.name()).to.equal('Compound USDC'); + expect(await newCometUsdc.extensionDelegate()).to.equal(USDC_EXT); + + const newCometUsdt = new Contract(USDT_COMET, newCometAbi, signer); + + expect(await newCometUsdt.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsdt.symbol()).to.equal('cUSDTv3'); + expect(await newCometUsdt.name()).to.equal('Compound USDT'); + expect(await newCometUsdt.extensionDelegate()).to.equal(USDT_EXT); + + const newCometUsds = new Contract(USDS_COMET, newCometAbi, signer); + + expect(await newCometUsds.MAX_SUPPORTED_UTILIZATION()).to.equal(expectedMaxUtilization); + expect(await newCometUsds.symbol()).to.equal('cUSDSv3'); + expect(await newCometUsds.name()).to.equal('Compound USDS'); + expect(await newCometUsds.extensionDelegate()).to.equal(USDS_EXT); + }, +}); diff --git a/deployments/relations.ts b/deployments/relations.ts index 0c676f9a7..c38f80ab5 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 @@ -118,7 +119,7 @@ const relationConfigMap: RelationConfigMap = { }, }, 'comet:implementation': { - artifact: 'contracts/CometWithExtendedAssetList.sol:CometWithExtendedAssetList', + artifact: 'contracts/CometInterface.sol:CometInterface', delegates: { field: async (comet) => comet.extensionDelegate(), }, 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/import/blockscout.ts b/plugins/import/blockscout.ts index 7a74d70f0..ed3b26dab 100644 --- a/plugins/import/blockscout.ts +++ b/plugins/import/blockscout.ts @@ -50,4 +50,3 @@ export async function get(url, data) { const res = (await axios.get(url, { params: data }))['data']; return res; } - diff --git a/plugins/scenario/utils/hreForBase.ts b/plugins/scenario/utils/hreForBase.ts index 00f2c28f9..1550b63d1 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,17 +108,51 @@ export async function forkedHreForBase(base: ForkSpec): Promise { + if (activeMigration){ + return networkConfigs.find(c => c.network === base.network)?.url; + } + return baseNetwork.url; + })(); + + const getBlockNumberWithRetry = async (): Promise => { + const provider = new ethers.providers.JsonRpcProvider(providerUrl); + const maxAttempts = 5; + const retryDelayMs = 5000; + const requestTimeoutMs = 10000; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await Promise.race([ + provider.getBlockNumber(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timed out fetching block number after ${requestTimeoutMs / 1000}s`)), requestTimeoutMs) + ), + ]); + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + + console.warn(`Failed to fetch block number (attempt ${attempt}/${maxAttempts}). Retrying in ${retryDelayMs / 1000}s...`); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + throw new Error('Failed to fetch block number after retries.'); + }; + + if(providerUrl){ + console.log(`Forking from network: ${base.network}`); + console.log(`At block number: ${await getBlockNumberWithRetry() - (getBlockRollback(base) || 0)}`); + } // noNetwork otherwise - if (!base.blockNumber && baseNetwork.url && getBlockRollback(base) !== undefined) - base.blockNumber = await provider.getBlockNumber() - getBlockRollback(base); // arbitrary number of blocks to go back + if (!base.blockNumber && providerUrl && getBlockRollback(base) !== undefined) + base.blockNumber = await getBlockNumberWithRetry() - 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(); + const block = await getBlockNumberWithRetry(); base.blockNumber = block - 1; } @@ -124,7 +165,7 @@ export async function forkedHreForBase(base: ForkSpec): Promise authorized manager can supplyFrom collateral on behalf of owner', { - filter: async (ctx) => await isValidAssetIndex(ctx, 1) && await isTriviallySourceable(ctx, 1, getConfigForScenario(ctx, 1).supplyCollateral), - tokenBalances: async (ctx) => ( - { - albert: { $asset1: getConfigForScenario(ctx, 1).supplyCollateral } - } - ), + filter: async (ctx) => await isValidAssetIndex(ctx, (await getActiveAsset(ctx)).assetIndex) && await isTriviallySourceable(ctx, (await getActiveAsset(ctx)).assetIndex, getConfigForScenario(ctx, (await getActiveAsset(ctx)).assetIndex).supplyCollateral), + tokenBalances: async (ctx) => { + const activeAsset = await getActiveAsset(ctx); + const assetIndex = activeAsset.assetIndex; + + return { + albert: { [`$asset${assetIndex}`]: getConfigForScenario(ctx, assetIndex).supplyCollateral } + }; + }, + supplyCaps: async (ctx) => + { + const activeAsset = await getActiveAsset(ctx); + const assetIndex = activeAsset.assetIndex; + + return { + [`$asset${assetIndex}`]: getConfigForScenario(ctx, assetIndex).bulkerAsset, + }; + }, }, async ({ comet, actors }, context, world) => { const { albert, betty } = actors; - const { asset: assetAddress, scale: scaleBN } = await comet.getAssetInfo(1); + const activeAsset = await getActiveAsset(context); + const assetIndex = activeAsset.assetIndex; + + const { asset: assetAddress, scale: scaleBN } = await comet.getAssetInfo(assetIndex); + const collateralAsset = context.getAssetByAddress(assetAddress); const scale = scaleBN.toBigInt(); - const toSupply = BigInt(getConfigForScenario(context, 1).supplyCollateral) * scale; + const toSupply = BigInt(getConfigForScenario(context, assetIndex).supplyCollateral) * scale; expect(await collateralAsset.balanceOf(albert.address)).to.be.equal(toSupply); expect(await comet.collateralBalanceOf(betty.address, collateralAsset.address)).to.be.equal(0n); diff --git a/scenario/ConfiguratorScenario.ts b/scenario/ConfiguratorScenario.ts index 2e777092a..356901393 100644 --- a/scenario/ConfiguratorScenario.ts +++ b/scenario/ConfiguratorScenario.ts @@ -1,78 +1,23 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { expect } from 'chai'; -import { BigNumber, ethers } from 'ethers'; +import { ethers } from 'ethers'; import { CometContext, scenario } from './context/CometContext'; import { exp } from '../test/helpers'; -import { expectRevertCustom, setEtherBalance, supportsMarketAdminPermissionChecker } from './utils'; +import { expectRevertCustom, setEtherBalance, supportsMarketAdminPermissionChecker, normalizeStructOutput, getActiveAsset, hasActiveAsset } from './utils'; import { MarketAdminPermissionChecker } from '../build/types'; const SECONDS_PER_YEAR = 31_536_000n; // Based on contract's internal precision: FACTOR_SCALE=1e18 with 4 decimal places -const FACTOR_SCALE = 10n ** 18n; -const MIN_FACTOR_INCREMENT = FACTOR_SCALE / 10n ** 4n; - -type ArrayMethods = keyof Omit; - -type NamedKeys = { - [K in keyof T as K extends number | `${number}` | ArrayMethods ? never : K]: T[K]; -}; - -type Normalize = T extends BigNumber - ? bigint - : T extends string | number | boolean - ? T - : [NamedKeys] extends [Record] - ? T extends (infer U)[] - ? Normalize[] - : T - : { [K in keyof NamedKeys]: Normalize[K]> }; - -type NormalizedStruct = Normalize>; - -/** - * Hybrid array-objects with both numeric and named keys are stripped to plain - * objects with native bigint values, safe to destructure, compare, and serialize. - */ -function normalizeStructOutput(value: T): NormalizedStruct { - function normalize(val: any): any { - if (BigNumber.isBigNumber(val)) { - return val.toBigInt(); - } - if (val && typeof val === 'object') { - const namedKeys = Object.keys(val).filter((key) => isNaN(Number(key))); - if (namedKeys.length > 0) { - return Object.fromEntries(namedKeys.map((key) => [key, normalize(val[key])])); - } - if (Array.isArray(val)) { - return val.map(normalize); - } - } - return val; - } - - return normalize(value) as NormalizedStruct; -} - -async function hasActiveAsset(ctx: CometContext): Promise { - const configurator = await ctx.getConfigurator(); - const cometAddress = (await ctx.getComet()).address; - const assetConfigs = normalizeStructOutput(await configurator.getConfiguration(cometAddress)).assetConfigs; - - return assetConfigs.some((asset) => asset.borrowCollateralFactor > 0n && asset.supplyCap > 0n); -} - -/// Finds the first asset with non-zero configuration values -async function getActiveAsset(context: CometContext) { - const configurator = await context.getConfigurator(); - const cometAddress = (await context.getComet()).address; - const assetConfigs = normalizeStructOutput(await configurator.getConfiguration(cometAddress)).assetConfigs; - - const assetIndex = assetConfigs.findIndex((asset) => asset.borrowCollateralFactor > 0n && asset.supplyCap > 0n); - - return { - assetIndex, - assetConfig: assetConfigs[assetIndex] - }; +// const FACTOR_SCALE = exp(1, 18); +const MIN_FACTOR_INCREMENT = exp(0.0001, 18); // FACTOR_SCALE / 10 ** 4 + +// Mirrors AssetList.sol's pack/unpack round-trip (lines 152-157, 284-287): factors are descaled +// to a uint16 (`value / descale`) when packed into AssetInfo, then rescaled back out +// (`unpacked * rescale`) with descale == rescale == FACTOR_SCALE / 1e4. Net effect is flooring to +// this 4-decimal grid, so any raw config value must go through the same floor division before +// comparing against what gets read back from AssetInfo post-upgrade. +function quantizeFactor(value: bigint): bigint { + return (value / MIN_FACTOR_INCREMENT) * MIN_FACTOR_INCREMENT; } function getMinSupplyCapIncrement(decimals: number): bigint { @@ -949,7 +894,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfigsBefore = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs; const existingAssetConfig = assetConfigsBefore.at(assetIndex); @@ -969,8 +914,8 @@ scenario( const updatedAssetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(existingAssetConfig.asset)); - expect(updatedAssetInfo.borrowCollateralFactor).to.be.equal(updatedAssetConfig.borrowCollateralFactor); - expect(updatedAssetInfo.liquidateCollateralFactor).to.be.equal(updatedAssetConfig.liquidateCollateralFactor); + expect(updatedAssetInfo.borrowCollateralFactor).to.be.equal(quantizeFactor(updatedAssetConfig.borrowCollateralFactor)); + expect(updatedAssetInfo.liquidateCollateralFactor).to.be.equal(quantizeFactor(updatedAssetConfig.liquidateCollateralFactor)); } ); @@ -980,7 +925,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -1011,7 +956,7 @@ scenario('Configurator#updateAsset reverts if called by non-governor', {}, async const { albert } = actors; const existingAssetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( - -1 + 0 ); const updatedAssetConfig = { @@ -1029,7 +974,7 @@ scenario('Configurator#updateAsset reverts if asset does not exist', {}, async ( const { admin } = actors; const existingAssetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( - -1 + 0 ); const updatedAssetConfig = { @@ -1049,7 +994,7 @@ scenario( async ({ comet, configurator, actors }, context) => { const { admin } = actors; // use the last asset in the existing configuration to ensure the asset exists - const assetIndex = -1; + const assetIndex = 0; const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).asset; const newPriceFeed = await deployPriceFeed(context, 'asset'); @@ -1069,7 +1014,7 @@ scenario( async ({ comet, configurator, actors }, context) => { const { admin } = actors; // use the last asset in the existing configuration to ensure the asset exists - const assetIndex = -1; + const assetIndex = 0; const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(assetIndex).asset; const firstNewPriceFeed = await deployPriceFeed(context, 'asset'); @@ -1099,7 +1044,7 @@ scenario( async ({ comet, configurator, actors }, context) => { const { albert } = actors; - const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(-1).asset; + const existingAsset = (await configurator.getConfiguration(comet.address)).assetConfigs.at(0).asset; const newPriceFeed = await deployPriceFeed(context, 'asset'); await expectRevertCustom( @@ -2290,7 +2235,7 @@ scenario( const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); - expect(assetInfo.borrowCollateralFactor).to.be.equal(newAssetBorrowCollateralFactor); + expect(assetInfo.borrowCollateralFactor).to.be.equal(quantizeFactor(newAssetBorrowCollateralFactor)); } ); @@ -2300,7 +2245,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2383,7 +2328,7 @@ scenario( const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); - expect(assetInfo.borrowCollateralFactor).to.be.equal(newAssetBorrowCollateralFactor); + expect(assetInfo.borrowCollateralFactor).to.be.equal(quantizeFactor(newAssetBorrowCollateralFactor)); } ); @@ -2423,7 +2368,7 @@ scenario( async ({ comet, configurator, actors }) => { const { albert } = actors; - const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(-1); + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(0); const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; const newAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; @@ -2442,7 +2387,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; // use the existing config to get a valid factor value - const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(-1); + const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at(0); const oldAssetBorrowCollateralFactor = assetConfig.borrowCollateralFactor; const newAssetBorrowCollateralFactor = oldAssetBorrowCollateralFactor + MIN_FACTOR_INCREMENT; @@ -2463,7 +2408,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2483,7 +2428,7 @@ scenario( const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); - expect(assetInfo.liquidateCollateralFactor).to.be.equal(newAssetLiquidateCollateralFactor); + expect(assetInfo.liquidateCollateralFactor).to.be.equal(quantizeFactor(newAssetLiquidateCollateralFactor)); } ); @@ -2493,7 +2438,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2530,7 +2475,7 @@ scenario( const { admin } = actors; const marketAdminSigner = await getMarketAdminSigner(context); - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2550,7 +2495,7 @@ scenario( const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); - expect(assetInfo.liquidateCollateralFactor).to.be.equal(newAssetLiquidateCollateralFactor); + expect(assetInfo.liquidateCollateralFactor).to.be.equal(quantizeFactor(newAssetLiquidateCollateralFactor)); } ); @@ -2565,7 +2510,7 @@ scenario( await expectRevertCustom( configurator .connect(albert.signer) - .updateAssetLiquidateCollateralFactor(comet.address, assetConfigs.at(-1).asset, 1n), + .updateAssetLiquidateCollateralFactor(comet.address, assetConfigs.at(0).asset, 1n), 'Unauthorized()' ); } @@ -2592,7 +2537,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2612,7 +2557,7 @@ scenario( const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); - expect(assetInfo.liquidationFactor).to.be.equal(newAssetLiquidationFactor); + expect(assetInfo.liquidationFactor).to.be.equal(quantizeFactor(newAssetLiquidationFactor)); } ); @@ -2622,7 +2567,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2659,7 +2604,7 @@ scenario( const { admin } = actors; const marketAdminSigner = await getMarketAdminSigner(context); - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2679,7 +2624,7 @@ scenario( const assetInfo = normalizeStructOutput(await comet.getAssetInfoByAddress(assetConfig.asset)); - expect(assetInfo.liquidationFactor).to.be.equal(newAssetLiquidationFactor); + expect(assetInfo.liquidationFactor).to.be.equal(quantizeFactor(newAssetLiquidationFactor)); } ); @@ -2692,7 +2637,7 @@ scenario( const assetConfigs = (await configurator.getConfiguration(comet.address)).assetConfigs; await expectRevertCustom( - configurator.connect(albert.signer).updateAssetLiquidationFactor(comet.address, assetConfigs.at(-1).asset, 1n), + configurator.connect(albert.signer).updateAssetLiquidationFactor(comet.address, assetConfigs.at(0).asset, 1n), 'Unauthorized()' ); } @@ -2719,7 +2664,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2748,7 +2693,7 @@ scenario( async ({ comet, configurator, actors }) => { const { admin } = actors; - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2810,7 +2755,7 @@ scenario( const { admin } = actors; const marketAdminSigner = await getMarketAdminSigner(context); - const assetIndex = -1; + const assetIndex = 0; const assetConfig = normalizeStructOutput(await configurator.getConfiguration(comet.address)).assetConfigs.at( assetIndex ); @@ -2870,7 +2815,7 @@ scenario( const assetConfigs = (await configurator.getConfiguration(comet.address)).assetConfigs; await expectRevertCustom( - configurator.connect(albert.signer).updateAssetSupplyCap(comet.address, assetConfigs.at(-1).asset, 1n), + configurator.connect(albert.signer).updateAssetSupplyCap(comet.address, assetConfigs.at(0).asset, 1n), 'Unauthorized()' ); } diff --git a/scenario/InterestRateScenario.ts b/scenario/InterestRateScenario.ts index 740fae238..90dd60353 100644 --- a/scenario/InterestRateScenario.ts +++ b/scenario/InterestRateScenario.ts @@ -1,17 +1,45 @@ -import { scenario } from './context/CometContext'; +import { CometContext, scenario } from './context/CometContext'; import { expect } from 'chai'; -import { annualize, defactor, exp } from '../test/helpers'; +import { annualize, defactor, exp, factorScale } from '../test/helpers'; import { BigNumber } from 'ethers'; import { FuzzType } from './constraints/Fuzzing'; +import { expectRevertCustom, supportUtilizationLimit, isFreshMarket } from './utils'; +import { getConfigForScenario } from './utils/scenarioHelper'; -function calculateInterestRate( +function calculateInterestRateSupply( utilization: BigNumber, kink: BigNumber, interestRateBase: BigNumber, interestRateSlopeLow: BigNumber, interestRateSlopeHigh: BigNumber, - factorScale = BigNumber.from(exp(1, 18)) + totalSupplyBase: BigNumber +): BigNumber { + const factorScale = BigNumber.from(exp(1, 18)); + + if (totalSupplyBase.isZero()) return BigNumber.from(0); + + if (utilization.lte(kink)) { + const interestRateWithoutBase = interestRateSlopeLow.mul(utilization).div(factorScale); + return interestRateBase.add(interestRateWithoutBase); + } else { + const rateSlopeLow = interestRateSlopeLow.mul(kink).div(factorScale); + const rateSlopeHigh = interestRateSlopeHigh.mul(utilization.sub(kink)).div(factorScale); + return interestRateBase.add(rateSlopeLow).add(rateSlopeHigh); + } +} + +function calculateInterestRateBorrow( + utilization: BigNumber, + kink: BigNumber, + interestRateBase: BigNumber, + interestRateSlopeLow: BigNumber, + interestRateSlopeHigh: BigNumber, + totalBorrowBase?: BigNumber, ): BigNumber { + const factorScale = BigNumber.from(exp(1, 18)); + + if(totalBorrowBase.isZero()) return BigNumber.from(0); + if (utilization.lte(kink)) { const interestRateWithoutBase = interestRateSlopeLow.mul(utilization).div(factorScale); return interestRateBase.add(interestRateWithoutBase); @@ -56,22 +84,26 @@ scenario( const expectedUtilization = calculateUtilization(totalSupplyBase, totalBorrowBase, baseSupplyIndex, baseBorrowIndex); expect(defactor(actualUtilization)).to.be.approximately(defactor(expectedUtilization), 0.00001); + totalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; expect(await comet.getSupplyRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateSupply( actualUtilization, supplyKink, supplyPerSecondInterestRateBase, supplyPerSecondInterestRateSlopeLow, - supplyPerSecondInterestRateSlopeHigh + supplyPerSecondInterestRateSlopeHigh, + totalSupplyBase ) ); + totalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; expect(await comet.getBorrowRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateBorrow( actualUtilization, borrowKink, borrowPerSecondInterestRateBase, borrowPerSecondInterestRateSlopeLow, - borrowPerSecondInterestRateSlopeHigh + borrowPerSecondInterestRateSlopeHigh, + totalBorrowBase ) ); } @@ -148,22 +180,26 @@ scenario( const expectedUtilization = calculateUtilization(totalSupplyBase, totalBorrowBase, baseSupplyIndex, baseBorrowIndex); expect(defactor(actualUtilization)).to.be.approximately(defactor(expectedUtilization), 0.00001); + totalSupplyBase = (await comet.totalsBasic()).totalSupplyBase; expect(await comet.getSupplyRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateSupply( actualUtilization, supplyKink, supplyPerSecondInterestRateBase, supplyPerSecondInterestRateSlopeLow, - supplyPerSecondInterestRateSlopeHigh + supplyPerSecondInterestRateSlopeHigh, + totalSupplyBase ) ); + totalBorrowBase = (await comet.totalsBasic()).totalBorrowBase; expect(await comet.getBorrowRate(actualUtilization)).to.equal( - calculateInterestRate( + calculateInterestRateBorrow( actualUtilization, borrowKink, borrowPerSecondInterestRateBase, borrowPerSecondInterestRateSlopeLow, - borrowPerSecondInterestRateSlopeHigh + borrowPerSecondInterestRateSlopeHigh, + totalBorrowBase, ) ); } @@ -179,3 +215,387 @@ scenario.skip( expect(defactor(utilization)).to.be.approximately(0.5, 0.00001); } ); + +scenario( + 'Comet#interestRate reverts for pushing utilization above 200%', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + }, + async ({ comet }, context: CometContext) => { + const { albert, betty } = context.actors; + const { asset, scale, borrowCollateralFactor, priceFeed } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(asset); + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + + // Get constants + const baseScale = (await comet.baseScale()).toBigInt(); + const collateralScale = scale.toBigInt(); + const basePrice = (await comet.getPrice(await comet.baseTokenPriceFeed())).toBigInt(); + const collateralPrice = (await comet.getPrice(priceFeed)).toBigInt(); + + // Step 1: Set up a known supply state + // Supply a fixed amount of base tokens to establish a baseline + const baseSupplyAmount = 10n * baseScale; // 10 base tokens + await context.sourceTokens(baseSupplyAmount, baseToken.address, betty.address); + await baseToken.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken.address, amount: baseSupplyAmount }); + + // Get current state after supply + let currentTotalSupply = (await comet.totalSupply()).toBigInt(); + + // Step 2: Calculate borrow amount to exceed 200% utilization + // We want to borrow enough so that: (currentTotalBorrow + borrowAmount) / currentTotalSupply > 2 + // Simplest approach: borrow 3x the current supply (which gives 300% utilization if no existing borrow) + // This ensures we definitely exceed 200% even with existing borrows + let targetBorrowAmount = 3n * currentTotalSupply; + + // Ensure we have enough base tokens available to borrow + // We need: supply + reserves >= borrowAmount + // If not, we need to supply more. If we supply more, utilization goes down, + // so we need to borrow even more. Let's supply enough to cover the borrow. + const currentReserves = (await comet.getReserves()).toBigInt(); + const availableToBorrow = currentTotalSupply + (currentReserves > 0n ? currentReserves : 0n); + + if (targetBorrowAmount > availableToBorrow) { + // Supply enough to cover the borrow + // We need: newSupply >= targetBorrowAmount + // So: additionalSupply = targetBorrowAmount - currentTotalSupply (assuming no reserves) + const additionalSupply = targetBorrowAmount - currentTotalSupply + baseScale; + await context.sourceTokens(additionalSupply, baseToken.address, betty.address); + await baseToken.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken.address, amount: additionalSupply }); + + // Recalculate: now we have more supply, so we need to borrow even more to exceed 200% + currentTotalSupply = (await comet.totalSupply()).toBigInt(); + targetBorrowAmount = 3n * currentTotalSupply; + } + + // Step 4: Calculate collateral needed for the borrow + // We need enough collateral to support the borrow based on borrowCollateralFactor + const collateralWeiPerUnitBase = (collateralScale * basePrice) / collateralPrice; + let collateralNeeded = (collateralWeiPerUnitBase * targetBorrowAmount) / baseScale; + collateralNeeded = (collateralNeeded * factorScale) / borrowCollateralFactor.toBigInt(); // adjust for borrowCollateralFactor + collateralNeeded = (collateralNeeded * 11n) / 10n; // add 10% fudge factor for safety + + // Step 5: Source collateral tokens for albert and have him supply + await context.sourceTokens(collateralNeeded, collateralAsset.address, albert.address); + await collateralAsset.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: collateralAsset.address, amount: collateralNeeded }); + + // Step 6: Try to borrow base asset, which should revert with ExceedsSupportedUtilization + // The borrow should push utilization above 200% + await expectRevertCustom( + albert.withdrawAsset({ asset: baseTokenAddress, amount: targetBorrowAmount }), + 'ExceedsSupportedUtilization()' + ); + } +); + +/** + * @notice Verifies that supply index remains unchanged when market has no supplies + * @dev `if (totalSupplyBase == 0) return 0;` + * When there are no lenders in the market, supply rate should be 0 and + * baseSupplyIndex should not accrue even after time passes. + * This prevents phantom interest accrual on an empty market. + */ +scenario( + 'Comet#interestRate > supply index does not change when there are no supplies', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + + // Get initial state + const initialTotals = await comet.totalsBasic(); + const initialSupplyIndex = initialTotals.baseSupplyIndex; + + // Verify there are no supplies (totalSupplyBase == 0) + expect(initialTotals.totalSupplyBase.toBigInt()).to.equal(0n); + + // Verify supply rate is 0 when there are no supplies + const supplyRate = await comet.getSupplyRate(0); + expect(supplyRate.toBigInt()).to.equal(0n); + + // Skip some time (1 hour) + await ethers.provider.send('evm_increaseTime', [3600]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue by calling accrueAccount + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get state after time skip + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + + // Verify baseSupplyIndex has not changed + expect(finalSupplyIndex.toBigInt()).to.equal(initialSupplyIndex.toBigInt()); + + // Verify lastAccrualTime was updated (accrual happened but index didn't change) + expect(finalTotals.lastAccrualTime).to.be.greaterThan(initialTotals.lastAccrualTime); + } +); + +/** + * @notice Verifies that supply index does not grow when there are supplies but no reserves + * @dev When lenders supply to the market but there are no reserves (or reserves are exhausted), + * the baseSupplyIndex should not increase because there are no funds to pay interest from. + * This prevents lenders from accruing interest that cannot be withdrawn (illiquidity protection). + */ +scenario( + 'Comet#interestRate > supply index does not grow without reserves even with supplies', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + const { albert } = context.actors; + + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + const totalsBeforeSupply = await comet.totalsBasic(); + + // Supply some base tokens to the market + const supplyAmount = BigInt(getConfigForScenario(context).supplyBase) * baseScale; + await context.sourceTokens(supplyAmount, baseToken.address, albert.address); + await baseToken.approve(albert, comet.address); + await albert.safeSupplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Verify supply was successful + const totalsAfterSupply = await comet.totalsBasic(); + expect(totalsAfterSupply.totalSupplyBase.toBigInt()).to.equal(totalsBeforeSupply.totalSupplyBase.toBigInt() + supplyAmount); + + // Get supply index before time skip + const prevSupplyIndex = totalsAfterSupply.baseSupplyIndex; + + // Skip some time (1 hour) + await ethers.provider.send('evm_increaseTime', [3600]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get state after time skip + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + + // Verify baseSupplyIndex has not changed because there are no reserves to fund the interest + expect(finalSupplyIndex.toBigInt()).to.equal(prevSupplyIndex.toBigInt()); + + // Verify utilization is 0 (no borrows) + expect((await comet.getUtilization()).toBigInt()).to.equal(0n); + } +); + +/** + * @notice Verifies that supply index grows when there are both supplies and reserves + * @dev When lenders supply to the market AND there are reserves available, + * the baseSupplyIndex should increase according to the base supply rate. + * Reserves fund the interest payments to lenders when there are no borrowers. + */ +scenario( + 'Comet#interestRate > supply index grows with reserves and supplies', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + const { albert } = context.actors; + + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + + // Supply some base tokens to the market + const supplyAmount = BigInt(getConfigForScenario(context).supplyBase) * baseScale; + await context.sourceTokens(supplyAmount, baseToken.address, albert.address); + await baseToken.approve(albert, comet.address); + await albert.supplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Add reserves to the market (send tokens directly to comet without supplying) + const reservesAmount = BigInt(getConfigForScenario(context).reservesBase) * baseScale; + await context.sourceTokens(reservesAmount, baseToken.address, comet.address); + + // Verify reserves are positive + const reserves = await comet.getReserves(); + expect(reserves.toBigInt()).to.be.greaterThan(0n); + + // Get state before time skip + const totalsBeforeAccrue = await comet.totalsBasic(); + const prevSupplyIndex = totalsBeforeAccrue.baseSupplyIndex; + const prevLastAccrualTime = totalsBeforeAccrue.lastAccrualTime; + + // Verify supply rate is positive (base rate applies since utilization is 0 but reserves exist) + const supplyRate = await comet.getSupplyRate(0); + expect(supplyRate.toBigInt()).to.be.greaterThan(0n); + + // Skip some time (1 hour) + await ethers.provider.send('evm_increaseTime', [3600]); + await ethers.provider.send('evm_mine', []); + + // Trigger accrue + await comet.accrueAccount(ethers.constants.AddressZero); + + // Get state after time skip + const finalTotals = await comet.totalsBasic(); + const finalSupplyIndex = finalTotals.baseSupplyIndex; + const timeElapsed = finalTotals.lastAccrualTime - prevLastAccrualTime; + + // Calculate expected supply index growth + // accruedIndex = prevIndex + prevIndex * supplyRate * timeElapsed / 1e18 + const expectedAccruedIndex = prevSupplyIndex.add( + prevSupplyIndex.mul(supplyRate).mul(timeElapsed).div(exp(1, 18)) + ); + + // Verify baseSupplyIndex has grown + expect(finalSupplyIndex).to.be.greaterThan(prevSupplyIndex); + expect(finalSupplyIndex).to.equal(expectedAccruedIndex); + + // Verify utilization is still 0 (no borrows) + expect((await comet.getUtilization()).toBigInt()).to.equal(0n); + } +); + +/** + * @notice Verifies that supply interest accrual is capped by available reserves when there are no borrows + * @dev In a new market with lenders but no borrowers, lenders earn the base supply rate funded from reserves. + * Without this safeguard, totalSupply() could exceed the actual token balance, causing illiquidity. + * Once reserves are exhausted (totalSupply >= balance), the supply index stops growing + * to ensure lenders can always withdraw their entitled amounts. + */ +scenario( + 'Comet#interestRate > supply interest does not exceed reserves without borrows', + { + filter: async (ctx: CometContext) => await supportUtilizationLimit(ctx) && await isFreshMarket(ctx), + upgrade: { + supplyKink: exp(0.8, 18), + supplyPerYearInterestRateBase: exp(0.001, 18), + supplyPerYearInterestRateSlopeLow: exp(0.04, 18), + supplyPerYearInterestRateSlopeHigh: exp(0.4, 18), + borrowKink: exp(0.8, 18), + borrowPerYearInterestRateBase: exp(0.01, 18), + borrowPerYearInterestRateSlopeLow: exp(0.05, 18), + borrowPerYearInterestRateSlopeHigh: exp(0.3, 18), + }, + }, + async ({ comet }, context: CometContext) => { + const ethers = context.world.deploymentManager.hre.ethers; + const { albert, betty } = context.actors; + + const baseTokenAddress = await comet.baseToken(); + const baseToken = context.getAssetByAddress(baseTokenAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + + // Supply base tokens to the market + const supplyAmount = BigInt(getConfigForScenario(context).supplyBase) * baseScale; + await context.sourceTokens(supplyAmount, baseToken.address, albert.address); + await baseToken.approve(albert, comet.address); + await albert.supplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Another user also supplies + await context.sourceTokens(supplyAmount, baseToken.address, betty.address); + await baseToken.approve(betty, comet.address); + await betty.supplyAsset({ asset: baseToken.address, amount: supplyAmount }); + + // Add reserves to the market + const initialReserves = BigInt(getConfigForScenario(context).reservesBase) * baseScale; + await context.sourceTokens(initialReserves, baseToken.address, comet.address); + + // Get supply rate (base rate since utilization is 0) + const supplyPerSecondInterestRateBase = await comet.supplyPerSecondInterestRateBase(); + + // Calculate time needed for reserves to be consumed by interest + // Interest accrued = principal * rate * time + // When totalSupply() reaches balance, interest stops accruing + // We need to find time such that: initialSupply * (1 + rate*time) >= balance + // Simplification: time = reserves / (supply * rate) + const totalSupplyBase = (await comet.totalsBasic()).totalSupplyBase.toBigInt(); + const expectedTimeToExhaustReserves = (initialReserves * BigInt(exp(1, 18))) / + (totalSupplyBase * supplyPerSecondInterestRateBase.toBigInt()); + + // The contract only re-checks the reserve-exhaustion cutoff once per accrueInternal() call, + // using the index from *before* that call's elapsed time (see getSupplyRate in + // CometWithExtendedAssetList.sol), then applies the full elapsed time's interest in one linear + // step (accruedInterestIndices): baseSupplyIndex_ += mulFactor(baseSupplyIndex_, rate * timeElapsed), + // where mulFactor(n, factor) = n * factor / 1e18 (floored integer division). Since that's plain, + // deterministic integer math, we can replicate it exactly here and predict the resulting index + // for a single large jump instead of approximating it. + const preJumpTotals = await comet.totalsBasic(); + const indexBefore = preJumpTotals.baseSupplyIndex.toBigInt(); + const lastAccrualTimeBefore = preJumpTotals.lastAccrualTime; + + // Sanity check: the cutoff shouldn't have engaged yet. + expect((await comet.getSupplyRate(0)).toBigInt()).to.equal(supplyPerSecondInterestRateBase.toBigInt()); + + const timeToSkip = Number(expectedTimeToExhaustReserves) + 3600; // Skip past exhaustion + await ethers.provider.send('evm_increaseTime', [timeToSkip]); + await ethers.provider.send('evm_mine', []); + await comet.accrueAccount(ethers.constants.AddressZero); + + const totalsAfterExhaustion = await comet.totalsBasic(); + const indexAfterExhaustion = totalsAfterExhaustion.baseSupplyIndex; + const elapsed = BigInt(totalsAfterExhaustion.lastAccrualTime - lastAccrualTimeBefore); + + // Replicate accruedInterestIndices() exactly: mulFactor is floored integer division, so this + // matches the on-chain result to the wei/index-unit, not just approximately. + const expectedIndexAfterExhaustion = indexBefore + + (indexBefore * supplyPerSecondInterestRateBase.toBigInt() * elapsed) / BigInt(exp(1, 18)); + expect(indexAfterExhaustion.toBigInt()).to.equal(expectedIndexAfterExhaustion); + + const totalSupply = await comet.totalSupply(); + const cometBalance = await baseToken.balanceOf(comet.address); + expect(totalSupply.toBigInt()).to.be.approximately(cometBalance, baseScale / 1000n); + + // 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()); + + // The cutoff in getSupplyRate is permanent until totalSupply's present value drops back below + // the balance (e.g. more reserves added, or a withdrawal) — nothing about that changes just + // from time passing, so the rate stays cut off at 0, not the raw base rate. + const supplyRateNow = await comet.getSupplyRate(0); + expect(supplyRateNow.toBigInt()).to.equal(0n); + } +); diff --git a/scenario/LiquidationScenario.ts b/scenario/LiquidationScenario.ts index be64732a7..eb0558541 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,392 @@ 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, i).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, i).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, i).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, 1).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, 1).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, 1).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..809557124 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,703 @@ 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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.connect(pauseGuardian.signer).pauseCollateralAssetSupply(0, true); + + await collateralAsset.approve(albert, comet.address); + await expect( + comet.connect(albert.signer).supplyTo( + betty.address, + collateralAsset.address, + BigInt(getConfigForScenario(context).supplyCollateral) * scale), + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.connect(pauseGuardian.signer).pauseCollateralAssetSupply(0, true); + + await expect( + charles.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).supplyCollateral) * scale, + }), + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, true); + + await collateralAsset.approve(albert, comet.address); + await expect( + albert.supplyAsset({ + asset: collateralAsset.address, + amount: supplyCollateral, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(i); + + log(`Supplying is allowed when collateral asset ${i} supply is unpaused`); + + // Unpause specific collateral asset supply at index i + await comet.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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, true); + + await collateralAsset.approve(albert, comet.address); + await expect( + albert.supplyAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: supplyCollateral, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(i); + + log(`Supplying is allowed when collateral asset ${i} supply is unpaused`); + + // Unpause specific collateral asset supply at index i + await comet.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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetSupply(i, true); + + await collateralAsset.approve(albert, comet.address); + await albert.allow(betty, true); + + await expect( + betty.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: supplyCollateral, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(i); + + log(`Supplying is allowed when collateral asset ${i} supply is unpaused`); + + // Unpause specific collateral asset supply at index i + await comet.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 }, context, world) => { + const { admin, pauseGuardian, albert } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, admin); + 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, i).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, i).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 comet.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expect( + albert.safeSupplyAsset({ + asset: asset, + amount: supplyAmount, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(i); + + log(`Supply is allowed when collateral asset ${i} is activated`); + + // Activate collateral asset + await comet.connect(admin.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 }, context, world) => { + const { admin, pauseGuardian, albert, betty } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, admin); + 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, i).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, i).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 comet.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expect( + albert.safeSupplyAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: supplyAmount, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(i); + + log(`SupplyTo is allowed when collateral asset ${i} is activated`); + + // Activate collateral asset + await comet.connect(admin.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 }, context, world) => { + const { admin, 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, i).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, i).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 comet.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expect( + betty.supplyAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: supplyAmount, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetSupplyPaused').withArgs(i); + + log(`SupplyFrom is allowed when collateral asset ${i} is activated`); + + // Set balance of admin + await context.world.deploymentManager.hre.ethers.provider.send( + 'hardhat_setBalance', + [ + admin.address, + '0x1000000000000000000', // 1,000 ETH + ] + ); + + // Activate collateral asset + await comet.connect(admin.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..cd84f1737 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,623 @@ 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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(0, true); + + // Asset0 transfer should revert + await expect( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).transferCollateral) * scale + }), + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(i, true); + + await expect( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: transferCollateral, + }), + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(i); + + log(`Transferring is allowed when collateral asset ${i} transfer is unpaused`); + + // Unpause specific collateral asset transfer at index i + await comet.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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetTransfer(i, true); + + // Allow betty to transfer asset from albert + await albert.allow(betty, true); + + await expect( + betty.transferAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: transferCollateral, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(i); + + log(`Transferring is allowed when collateral asset ${i} transfer is unpaused`); + + // Unpause specific collateral asset transfer at index i + await comet.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, i).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 }, context, world) => { + const { admin, albert, betty, charles, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, admin); + 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, i).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, i).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 comet.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expect( + betty.transferAssetFrom({ + src: albert.address, + dst: charles.address, + asset: collateralAsset.address, + amount: transferAmount, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(i); + + // Activate collateral asset + await comet.connect(admin.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 }, context, world) => { + const { admin, albert, betty, pauseGuardian } = actors; + + // Fund pause guardian account for gas fees + await fundAccount(world, admin); + 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, i).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, i).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 comet.connect(pauseGuardian.signer).deactivateCollateral(i); + + await expect( + albert.transferAsset({ + dst: betty.address, + asset: collateralAsset.address, + amount: transferAmount, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetTransferPaused').withArgs(i); + + // Activate collateral asset + await comet.connect(admin.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..e77d197c4 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,304 @@ 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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.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 }, 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 comet.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(0, true); + + // Asset0 withdraw should revert + await expect( + albert.withdrawAsset({ + asset: collateralAsset.address, + amount: BigInt(getConfigForScenario(context).withdrawCollateral) * scale + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetWithdrawPaused').withArgs(0); + } +); + scenario( 'Comet#withdraw base reverts if position is undercollateralized', { @@ -370,4 +669,336 @@ 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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, true); + + await expect( + albert.withdrawAsset({ + asset: collateralAsset.address, + amount: withdrawCollateral, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetWithdrawPaused').withArgs(i); + + log(`Withdrawing is allowed when collateral asset ${i} withdraw is unpaused`); + + // Unpause specific collateral asset withdraw at index i + await comet.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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, true); + + await expect( + betty.withdrawAssetFrom({ + src: albert.address, + dst: betty.address, + asset: collateralAsset.address, + amount: withdrawCollateral, + }) + ).to.be.revertedWithCustomError(comet, 'CollateralAssetWithdrawPaused').withArgs(i); + + log(`Withdrawing is allowed when collateral asset ${i} withdraw is unpaused`); + + // Unpause specific collateral asset withdraw at index i + await comet.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 }, 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, i).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, i).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 comet.connect(pauseGuardian.signer).pauseCollateralAssetWithdraw(i, true); + + await expect( + albert.withdrawAssetTo({ + dst: betty.address, + asset: collateralAsset.address, + amount: withdrawCollateral, + }), + ).to.be.revertedWithCustomError(comet, 'CollateralAssetWithdrawPaused').withArgs(i); + + log(`Withdrawing is allowed when collateral asset ${i} withdraw is unpaused`); + + // Unpause specific collateral asset withdraw at index i + await comet.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..0260ab4c5 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 proposal 580 + if (proposal.id.eq(580)) { + console.log('Skipping proposal 580'); 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..2f5e9a00e 100644 --- a/scenario/utils/index.ts +++ b/scenario/utils/index.ts @@ -12,7 +12,7 @@ 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'; @@ -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 = 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,10 @@ 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`, {}, @@ -1521,26 +1626,30 @@ export async function executeOpenProposalAndRelay( const startingBlockNumber = 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 +1728,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 +1787,68 @@ export async function supportsMarketAdminPermissionChecker(ctx: CometContext): P } catch (e) { return false; } -} \ No newline at end of file +} + +type ArrayMethods = keyof Omit; + +type NamedKeys = { + [K in keyof T as K extends number | `${number}` | ArrayMethods ? never : K]: T[K]; +}; + +type Normalize = T extends BigNumber + ? bigint + : T extends string | number | boolean + ? T + : [NamedKeys] extends [Record] + ? T extends (infer U)[] + ? Normalize[] + : T + : { [K in keyof NamedKeys]: Normalize[K]> }; + +type NormalizedStruct = Normalize>; + +/** + * Hybrid array-objects with both numeric and named keys are stripped to plain + * objects with native bigint values, safe to destructure, compare, and serialize. + */ +export function normalizeStructOutput(value: T): NormalizedStruct { + function normalize(val: any): any { + if (BigNumber.isBigNumber(val)) { + return val.toBigInt(); + } + if (val && typeof val === 'object') { + const namedKeys = Object.keys(val).filter((key) => isNaN(Number(key))); + if (namedKeys.length > 0) { + return Object.fromEntries(namedKeys.map((key) => [key, normalize(val[key])])); + } + if (Array.isArray(val)) { + return val.map(normalize); + } + } + return val; + } + + return normalize(value) as NormalizedStruct; +} + +/// Finds the first asset with non-zero configuration values +export async function getActiveAsset(context: CometContext) { + const configurator = await context.getConfigurator(); + const cometAddress = (await context.getComet()).address; + const assetConfigs = normalizeStructOutput(await configurator.getConfiguration(cometAddress)).assetConfigs; + + const assetIndex = assetConfigs.findIndex((asset) => asset.borrowCollateralFactor > 0n && asset.supplyCap > 0n); + + return { + assetIndex, + assetConfig: assetConfigs[assetIndex] + }; +} + +export async function hasActiveAsset(ctx: CometContext): Promise { + const configurator = await ctx.getConfigurator(); + const cometAddress = (await ctx.getComet()).address; + const assetConfigs = normalizeStructOutput(await configurator.getConfiguration(cometAddress)).assetConfigs; + + return assetConfigs.some((asset) => asset.borrowCollateralFactor > 0n && asset.supplyCap > 0n); +} 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..703981ba1 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, @@ -397,4 +592,4 @@ export async function relayArbitrumCCTPMint( ).wait(); } } -} \ No newline at end of file +} diff --git a/scenario/utils/relayBaseMessage.ts b/scenario/utils/relayBaseMessage.ts index e357eceb4..bb3a3cac4 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'; @@ -142,7 +142,7 @@ export default async function relayBaseMessage( } } else if (target === bridgeReceiver.address) { // Cross-chain message passing - if (!tenderlyLogs && relayMessageTxn) { + if (relayMessageTxn) { const proposalCreatedEvent = relayMessageTxn.events.find( (event) => event.address === bridgeReceiver.address ); @@ -154,28 +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` - ); - } - } - - // Handle proposal creation for tenderly - if (tenderlyLogs) { - // We need to check for ProposalCreated events since we don't get them in the loop above - const proposalFilter = bridgeReceiver.filters.ProposalCreated(); - const proposalEvents = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ - fromBlock: 'latest', - toBlock: 'latest', - address: bridgeReceiver.address, - topics: proposalFilter.topics - }); - - for (let event of proposalEvents) { - const { - args: { id, eta }, - } = bridgeReceiver.interface.parseLog(event); - openBridgedProposals.push({ id, eta }); + // 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 +188,94 @@ export default async function relayBaseMessage( } return openBridgedProposals; -} \ No newline at end of file +} + +export async function simulateL2ToL1TokenBridging( + governanceDeploymentManager: DeploymentManager, + bridgeDeploymentManager: DeploymentManager, + tenderlyLogs?: any[] +) { + 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 { signatures, calldatas } = decodedEvent.args; + + + 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) + ]); + + 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..0f3986550 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`); } } @@ -276,6 +276,7 @@ export default async function relayLineaMessage( `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Executed bridged proposal ${id}` ); } + return openBridgedProposals; } diff --git a/scenario/utils/relayMantleMessage.ts b/scenario/utils/relayMantleMessage.ts index 812255b75..c052e0184 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`); } } @@ -158,5 +160,6 @@ export default async function relayMantleMessage( `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Executed bridged proposal ${id}` ); } + return openBridgedProposals; } diff --git a/scenario/utils/relayMessage.ts b/scenario/utils/relayMessage.ts index fa0523665..ac94cb1ae 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 relayBaseMessage from './relayBaseMessage'; +import { relayArbitrumMessage, relayArbitrumCCTPMint, simulateL2ToL1TokenBridging } from './relayArbitrumMessage'; +import relayBaseMessage,{ simulateL2ToL1TokenBridging as simulateBaseL2ToL1TokenBridging} 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 simulateBaseL2ToL1TokenBridging( + 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..695ed040a 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, Contract } from 'ethers'; import { Log } from '@ethersproject/abstract-provider'; import { OpenBridgedProposal } from '../context/Gov'; import { applyL1ToL2Alias, isTenderlyLog } from './index'; @@ -144,35 +144,169 @@ 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 - for (let proposal of openBridgedProposals) { - const { eta, id } = proposal; - // Fast forward l2 time - await setNextBlockTimestamp(bridgeDeploymentManager, eta.toNumber() + 1); + // Execute open bridged proposals now that all messages have been bridged + for (let proposal of openBridgedProposals) { + const { eta, id } = proposal; + // Fast forward l2 time + await setNextBlockTimestamp(bridgeDeploymentManager, eta.toNumber() + 1); - // Execute queued proposal - await setNextBaseFeeToZero(bridgeDeploymentManager); + // Execute queued proposal + await setNextBaseFeeToZero(bridgeDeploymentManager); - if (tenderlyLogs) { - const callData = bridgeReceiver.interface.encodeFunctionData('executeProposal', [id]); - const signer = await bridgeDeploymentManager.getSigner(); + if (tenderlyLogs) { + const callData = bridgeReceiver.interface.encodeFunctionData('executeProposal', [id]); + const signer = await bridgeDeploymentManager.getSigner(); - bridgeDeploymentManager.stashRelayMessage( - bridgeReceiver.address, - callData, - signer.address - ); - } else { - await bridgeReceiver.executeProposal(id, { gasPrice: 0 }); - } - console.log( - `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Executed bridged proposal ${id}` + bridgeDeploymentManager.stashRelayMessage( + bridgeReceiver.address, + callData, + signer.address ); + } else { + await bridgeReceiver.executeProposal(id, { gasPrice: 0 }); } + console.log( + `[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Executed bridged proposal ${id}` + ); + } + + return openBridgedProposals; +} + +export async function simulateL2ToL1TokenBridging( + governanceDeploymentManager: DeploymentManager, + bridgeDeploymentManager: DeploymentManager, + tenderlyLogs?: any[] +) { + 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'; - return openBridgedProposals; + // 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)'; + const depositForBurnSignature = 'depositForBurn(uint256,uint32,bytes32,address,bytes32,uint256,uint32)'; + + for (const event of proposalCreatedEvents) { + const decodedEvent = bridgeReceiver.interface.parseLog(event); + const { signatures, calldatas } = decodedEvent.args; + + 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) + ]); + + 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) + ]); + } + + // 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}`); + } + } + } } } 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..7ce3670f1 100644 --- a/scenario/utils/relayUnichainMessage.ts +++ b/scenario/utils/relayUnichainMessage.ts @@ -124,7 +124,7 @@ export async function relayUnichainMessage( } } else if (target === bridgeReceiver.address) { // Cross-chain message passing - if (!tenderlyLogs && relayMessageTxn) { + if (relayMessageTxn) { const proposalCreatedEvent = relayMessageTxn.events.find(event => event.address === bridgeReceiver.address); const { args: { id, eta } } = bridgeReceiver.interface.parseLog(proposalCreatedEvent); @@ -132,26 +132,9 @@ export async function relayUnichainMessage( openBridgedProposals.push({ id, eta }); } } else { - throw new Error(`[${governanceDeploymentManager.network} -> ${bridgeDeploymentManager.network}] Unrecognized target for cross-chain message`); - } - } - - // Handle proposal creation for tenderly - if (tenderlyLogs) { - // We need to check for ProposalCreated events since we don't get them in the loop above - const proposalFilter = bridgeReceiver.filters.ProposalCreated(); - const proposalEvents = await bridgeDeploymentManager.hre.ethers.provider.getLogs({ - fromBlock: 'latest', - toBlock: 'latest', - address: bridgeReceiver.address, - topics: proposalFilter.topics - }); - - for (let event of proposalEvents) { - const { - args: { id, eta }, - } = bridgeReceiver.interface.parseLog(event); - openBridgedProposals.push({ id, eta }); + // 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`); } } @@ -336,4 +319,4 @@ export async function relayUnichainCCTPMint( ).wait(); } } -} \ No newline at end of file +} diff --git a/scenario/utils/scenarioHelper.ts b/scenario/utils/scenarioHelper.ts index 26381efc0..a9ed6469e 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) { @@ -69,10 +70,25 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { config.liquidationBase = 10000; } + if (ctx.world.base.network === 'mainnet' && ctx.world.base.deployment === 'usdc') { + config.bulkerComet = 500; + config.bulkerBase = 1000; + config.bulkerAsset = 500; + config.bulkerAsset1 = 500; + } + if (ctx.world.base.network === 'mainnet' && ctx.world.base.deployment === 'usds') { config.liquidationAsset = 100; } + if (ctx.world.base.network === 'mainnet' && ctx.world.base.deployment === 'usdc') { + config.bulkerBase = 1000; + config.bulkerAsset = 100; + config.bulkerComet = 500; + config.bulkerBorrowBase = 101; + config.bulkerBorrowAsset = 1; + } + if (ctx.world.base.network === 'mainnet' && ctx.world.base.deployment === 'usdt') { if(i == 12) { config.supplyCollateral = 0; @@ -93,6 +109,9 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } } + if (ctx.world.base.network === 'base' && ctx.world.base.deployment === 'usdbc') { + config.bulkerBase = 100000; + } if (ctx.world.base.network === 'base' && ctx.world.base.deployment === 'usds') { config.liquidationBase2 = 100; @@ -101,6 +120,12 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { if (ctx.world.base.network === 'base' && ctx.world.base.deployment === 'weth') { config.liquidationBase = 1000; + if(i == 1) { // ezETH + config.withdrawBase = 10; + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } } if (ctx.world.base.network === 'optimism' && ctx.world.base.deployment === 'usdc') { @@ -118,25 +143,60 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } if (ctx.world.base.network === 'optimism' && ctx.world.base.deployment === 'usdt') { - config.bulkerAsset = 10000; - config.bulkerAsset1 = 10000; - config.transferAsset1 = 10000; - config.withdrawAsset = 7000; + config.withdrawAsset = 100000; + config.transferAsset = 100000; + config.transferAsset1 = 100000; + config.rewardsAsset = 100000; + config.bulkerAsset = 200000; + config.withdrawAsset = 140000; } if (ctx.world.base.network === 'optimism' && ctx.world.base.deployment === 'weth') { + config.bulkerBorrowBase = 5; + config.bulkerBorrowAsset = 5; + config.bulkerComet = 100000; config.liquidationBase = 1000; + config.withdrawBase = 20; + config.rewardsBase = 20; + config.liquidationBase = 20; + config.supplyBase = 20; + config.transferBase = 20; + config.withdrawBase1 = 20; + if(i == 5) { // ezETH + config.withdrawBase = 10; + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } + if(i == 6) { // weETH + config.withdrawBase = 10; + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } + if(i == 7) { // wrsETH + config.withdrawBase = 10; + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } } if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'usdc') { config.bulkerAsset = 100000; config.bulkerAsset1 = 100000; - config.withdrawAsset = 10000; + config.rewardsAsset = 200000; + config.withdrawAsset = 200000; config.transferAsset = 500000; config.transferAsset1 = 500000; config.transferBase = 100; config.rewardsBase = 100; config.withdrawBase = 100; + if(i == 5) { // ezETH + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } if(i == 7) { // tETH config.supplyCollateral = 2; config.transferCollateral = 2; @@ -150,8 +210,8 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { } if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'usdt') { - config.rewardsAsset = 20000; - config.withdrawAsset = 20000; + config.rewardsAsset = 200000; + config.withdrawAsset = 200000; config.bulkerAsset = 100000; config.bulkerAsset1 = 10000; config.transferAsset = 100000; @@ -161,10 +221,17 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { config.transferCollateral = 2; config.withdrawCollateral = 2; } + if(i == 6) { // tETH + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } } if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'usdc.e') { - config.withdrawAsset = 10000; + config.rewardsAsset = 200000; + config.withdrawAsset = 200000; + config.bulkerBase = 100000; config.bulkerAsset = 100000; config.bulkerAsset1 = 100000; config.transferAsset = 500000; @@ -177,6 +244,19 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { if (ctx.world.base.network === 'arbitrum' && ctx.world.base.deployment === 'weth') { config.liquidationBase = 1000; + config.bulkerBorrowBase = 100; + if(i == 7) { // ezETH + config.withdrawBase = 10; + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } + if(i == 8) { // tETH + config.withdrawBase = 10; + config.supplyCollateral = 2; + config.transferCollateral = 2; + config.withdrawCollateral = 2; + } } if (ctx.world.base.network === 'ronin' && ctx.world.base.deployment === 'weth') { @@ -209,9 +289,9 @@ export function getConfigForScenario(ctx: CometContext, i?: number) { if (ctx.world.base.network === 'polygon' && ctx.world.base.deployment === 'usdt') { config.withdrawAsset = 20000; config.withdrawBase = 100; - config.transferAsset = 500000; + config.transferAsset = 10000000; config.transferBase = 100; - config.rewardsAsset = 20000; + config.rewardsAsset = 10000000; config.rewardsBase = 1000; } diff --git a/src/deploy/NetworkConfiguration.ts b/src/deploy/NetworkConfiguration.ts index 116c0d815..b79e49ead 100644 --- a/src/deploy/NetworkConfiguration.ts +++ b/src/deploy/NetworkConfiguration.ts @@ -3,6 +3,7 @@ import { ConfigurationStruct } from '../../build/types/Configurator'; import { ProtocolConfiguration } from './index'; import { ContractMap } from '../../plugins/deployment_manager/ContractMap'; import { DeploymentManager } from '../../plugins/deployment_manager/DeploymentManager'; +import { exp } from '../../test/helpers'; function address(a: string): string { if (!a.match(/^0x[a-fA-F0-9]{40}$/)) { @@ -27,7 +28,7 @@ function percentage(n: number, checkRange: boolean = true): bigint { throw new Error(`percentage less than 0% [received=${n}]`); } } - return floor(n * 1e18); + return exp(n, 18, 18); } // Note: Expects a string in scientific notation format (e.g. 1000e18 or 1_000e18) @@ -166,9 +167,10 @@ function getOverridesOrConfig( getContractAddress(config.rewardToken, contracts, config.rewardTokenAddress) : undefined, }); - return Object.entries(mapping()).reduce((acc, [k, f]) => { + const result = Object.entries(mapping()).reduce((acc, [k, f]) => { return { [k]: overrides[k] ?? f(config), ...acc }; }, {}); + return result; } export async function getConfiguration( diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 58c7bb9e3..be0626af2 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -154,6 +154,7 @@ export const WHALES = { '0xDBD974Eb5360d053ea0c56B4DaCF4A9D3E894Ee2', // tETH whale '0xbA1333333333a1BA1108E8412f11850A5C319bA9', // tETH whale '0xEA1132120ddcDDA2F119e99Fa7A27a0d036F7Ac9', // ezETH whale + '0x3405A1bd46B85c5C029483FbECf2F3E611026e45', // USDC.e whale ], base: [ '0x6D3c5a4a7aC4B1428368310E4EC3bB1350d01455', // USDbC whale @@ -172,6 +173,10 @@ export const WHALES = { '0x0E635F8EeED4F7279d56692D552F034ECE136019', // USDbC whale '0x58Ee32056D946a37f5b49582dE3dEE1dAc0Bb974', // USDbC whale '0x80a94C36747CF51b2FbabDfF045f6D22c1930eD1', // wrsETH whale + '0x98c7A2338336d2d354663246F64676009c7bDa97', // USDbC whale + '0x0E635F8EeED4F7279d56692D552F034ECE136019', // USDbC whale + '0x58Ee32056D946a37f5b49582dE3dEE1dAc0Bb974', // USDbC whale + '0x2917956eFF0B5eaF030abDB4EF4296DF775009cA', // USDS whale ], scroll: [ '0xaaaaAAAACB71BF2C8CaE522EA5fa455571A74106', // USDC whale @@ -186,6 +191,7 @@ export const WHALES = { '0x8af3827a41c26c7f32c81e93bb66e837e0210d5c', // USDC whale '0xc45A479877e1e9Dfe9FcD4056c699575a1045dAA', // wstETH whale '0x6e57181D6b4b7c138a6F956AD16DAF4f27FC5E04', // COMP whale + '0x7C514346b326147FEE97e5034Df75968f17443f5', // COMP whale '0xE36A30D249f7761327fd973001A32010b521b6Fd', // ezETH whale '0xb40DA71c49c745Dd3ab801882b1D410760541678', // ezETH whale '0x540B1E0D69244057cD0Da2AF4Bca87dA87A824bE', // ezETH whale 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..11c6c797d 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; @@ -422,7 +446,7 @@ describe('absorb', function () { expect(pP1.external).to.be.deep.equal({ COMP: exp(1, 18), USDC: 0n, WBTC: exp(1, 8), WETH: exp(1, 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 }); - expect(pU1.internal).to.be.deep.equal({ COMP: 0n, USDC: 1n, WBTC: 0n, WETH: 0n }); + expect(pU1.internal).to.be.deep.equal({ COMP: 0n, USDC: finalDebt, WBTC: 0n, WETH: 0n }); expect(pU1.external).to.be.deep.equal({ COMP: 0n, USDC: 0n, WBTC: 0n, WETH: 0n }); expect(lA1.numAbsorbs).to.be.equal(1); @@ -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,27 +487,30 @@ 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: { absorber: absorber.address, borrower: underwater.address, - basePaidOut: pU1.internal.USDC - startingDebt, - usdValue: mulPrice(pU1.internal.USDC - startingDebt, usdcPrice, baseScale), - } + basePaidOut: finalDebt - startingDebt, + usdValue: mulPrice(finalDebt - 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..1dd106ff7 100644 --- a/test/accrue-test.ts +++ b/test/accrue-test.ts @@ -194,7 +194,10 @@ describe('accrue', function () { }); it('reverts on overflows', async () => { - const { cometWithExtendedAssetList : comet } = await makeProtocol(); + const { cometWithExtendedAssetList : comet } = await makeProtocol({ + // Ensure supply rate is positive even at low utilization so max index addition overflows. + supplyInterestRateBase: exp(0.01, 18), + }); const t0 = await comet.totalsBasic(); const t1 = Object.assign({}, t0, { @@ -205,15 +208,18 @@ describe('accrue', function () { await fastForward(998); const _s0 = await wait(comet.setTotalsBasic(t1)); 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(wait(comet.accrue())).to.be.reverted; - const t2 = Object.assign({}, t0, { + const t2 = Object.assign({}, t1, { + baseSupplyIndex: t0.baseSupplyIndex, 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/buy-collateral-test.ts b/test/buy-collateral-test.ts index 203139e02..d335b30bc 100644 --- a/test/buy-collateral-test.ts +++ b/test/buy-collateral-test.ts @@ -98,8 +98,8 @@ describe('buyCollateral', function () { // Set reserves to -100 wei let t0 = await comet.totalsBasic(); t0 = Object.assign({}, t0, { - totalSupplyBase: 100e6, - totalBorrowBase: 0n, + totalSupplyBase: 100e6 + 1, + totalBorrowBase: 1n, }); await wait(comet.setTotalsBasic(t0)); 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/erc20-test.ts b/test/erc20-test.ts index 78e819c7c..1c08ed2a2 100644 --- a/test/erc20-test.ts +++ b/test/erc20-test.ts @@ -20,11 +20,14 @@ describe('erc20', function () { }); it('has correct totalSupply', async () => { - const { cometWithExtendedAssetList: comet } = await makeProtocol(); + const { cometWithExtendedAssetList: comet } = await makeProtocol({ + baseTokenBalance: 100e6, + }); await setTotalsBasic(comet, { baseSupplyIndex: 2e15, totalSupplyBase: 50e6, + totalBorrowBase: 1n, }); const totalSupply = await comet.totalSupply(); 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..6cba5df71 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 index (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 index (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('utilization 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('utilization 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 transfer', 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('utilization 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 accrual)', 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 accrual)', 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 lose 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('utilization 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 lose 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 exceeding 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 liquidation', 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/marketupdates/existing-flow-test.ts b/test/marketupdates/existing-flow-test.ts index 5907b2b5b..6a7665035 100644 --- a/test/marketupdates/existing-flow-test.ts +++ b/test/marketupdates/existing-flow-test.ts @@ -133,7 +133,8 @@ describe('configurator', function() { const abi = [ 'event CometDeployed(address indexed cometProxy, address indexed newComet)', - 'event Upgraded(address indexed implementation)' + 'event Upgraded(address indexed implementation)', + 'event AssetListCreated(address indexed assetList, tuple(address asset, address priceFeed, uint8 decimals, uint64 borrowCollateralFactor, uint64 liquidateCollateralFactor, uint64 liquidationFactor, uint128 supplyCap)[] assetConfigs)', ]; // Initialize the contract interface @@ -143,7 +144,9 @@ describe('configurator', function() { txn.receipt.events.forEach(event => { try { const decodedEvent = iface.parseLog(event); - events.push(decodedEvent); + if (decodedEvent.name !== 'AssetListCreated') { + events.push(decodedEvent); + } } catch (error) { console.log('Failed to decode event:', error); } diff --git a/test/marketupdates/market-update-comet-proxy-admin-test.ts b/test/marketupdates/market-update-comet-proxy-admin-test.ts index dc5401a26..6cb9ecaf2 100644 --- a/test/marketupdates/market-update-comet-proxy-admin-test.ts +++ b/test/marketupdates/market-update-comet-proxy-admin-test.ts @@ -80,6 +80,7 @@ describe('CometProxyAdmin', function() { const abi = [ 'event CometDeployed(address indexed cometProxy, address indexed newComet)', 'event Upgraded(address indexed implementation)', + 'event AssetListCreated(address indexed assetList, tuple(address asset, address priceFeed, uint8 decimals, uint64 borrowCollateralFactor, uint64 liquidateCollateralFactor, uint64 liquidationFactor, uint128 supplyCap)[] assetConfigs)', ]; // Initialize the contract interface @@ -96,7 +97,9 @@ describe('CometProxyAdmin', function() { txnForGovernorTimelock.receipt.events.forEach((event) => { try { const decodedEvent = iface.parseLog(event); - eventsForGovernorTimelock.push(decodedEvent); + if (decodedEvent.name !== 'AssetListCreated') { + eventsForGovernorTimelock.push(decodedEvent); + } } catch (error) { console.log('Failed to decode event:', error); } @@ -121,7 +124,9 @@ describe('CometProxyAdmin', function() { txnForMarketAdmin.receipt.events.forEach((event) => { try { const decodedEvent = iface.parseLog(event); - eventsForMarketAdmin.push(decodedEvent); + if (decodedEvent.name !== 'AssetListCreated') { + eventsForMarketAdmin.push(decodedEvent); + } } catch (error) { console.log('Failed to decode event:', error); } @@ -154,6 +159,7 @@ describe('CometProxyAdmin', function() { const abiToCheck = [ 'event CometDeployed(address indexed cometProxy, address indexed newComet)', 'event Upgraded(address indexed implementation)', + 'event AssetListCreated(address indexed assetList, tuple(address asset, address priceFeed, uint8 decimals, uint64 borrowCollateralFactor, uint64 liquidateCollateralFactor, uint64 liquidationFactor, uint128 supplyCap)[] assetConfigs)', ]; // Initialize the contract interface @@ -174,7 +180,9 @@ describe('CometProxyAdmin', function() { txnForGovernorTimelock.receipt.events.forEach((event) => { try { const decodedEvent = iface.parseLog(event); - eventsForGovernorTimelock.push(decodedEvent); + if (decodedEvent.name !== 'AssetListCreated') { + eventsForGovernorTimelock.push(decodedEvent); + } } catch (error) { console.log('Failed to decode event:', error); } @@ -203,7 +211,9 @@ describe('CometProxyAdmin', function() { txnForMarketAdmin.receipt.events.forEach((event) => { try { const decodedEvent = iface.parseLog(event); - eventsForMarketAdmin.push(decodedEvent); + if (decodedEvent.name !== 'AssetListCreated') { + eventsForMarketAdmin.push(decodedEvent); + } } catch (error) { console.log('Failed to decode event:', error); } 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/reserves-test.ts b/test/reserves-test.ts index e92f322f5..8982f381d 100644 --- a/test/reserves-test.ts +++ b/test/reserves-test.ts @@ -45,9 +45,9 @@ describe('getReserves', function () { await setTotalsBasic(comet, { baseSupplyIndex: 2e15, - baseBorrowIndex: 3e15, + baseBorrowIndex: 0, totalSupplyBase: 50n, - totalBorrowBase: 0n, + totalBorrowBase: 1n, }); const reserves = await comet.getReserves(); diff --git a/test/supply-test.ts b/test/supply-test.ts index 2567c7778..d225f0bb3 100644 --- a/test/supply-test.ts +++ b/test/supply-test.ts @@ -1,665 +1,2129 @@ -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, + totalBorrowBase: 1, + }); + + 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, 4); // 4 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-reserves-test.ts b/test/withdraw-reserves-test.ts index 48059caaa..99e23edbc 100644 --- a/test/withdraw-reserves-test.ts +++ b/test/withdraw-reserves-test.ts @@ -93,7 +93,7 @@ describe('withdrawReserves', function () { totalSupplyBase: 50n, }); - expect(await comet.getReserves()).to.be.equal(-100); + expect(await comet.getReserves()).to.be.lessThan(0); await expect(comet.connect(governor).withdrawReserves(alice.address, 100)).to.be.revertedWith( "custom error 'InsufficientReserves()'" 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 +});