diff --git a/.github/workflows/run-scenarios.yaml b/.github/workflows/run-scenarios.yaml index 9c2990382..6f17d444c 100644 --- a/.github/workflows/run-scenarios.yaml +++ b/.github/workflows/run-scenarios.yaml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - bases: [ development, mainnet, mainnet-weth, goerli, goerli-weth, sepolia-usdc, sepolia-weth, fuji, mumbai, polygon, arbitrum-usdc.e, arbitrum-usdc, arbitrum-goerli-usdc, arbitrum-goerli-usdc.e, base-usdbc, base-weth, base-usdc, base-goerli, base-goerli-weth, linea-goerli, optimism-usdc, optimism-usdt, scroll-goerli, scroll-usdc] + bases: [ development, mainnet, mainnet-weth, goerli, goerli-weth, sepolia-usdc, sepolia-weth, fuji, mumbai, polygon, arbitrum-usdc.e, arbitrum-usdc, arbitrum-goerli-usdc, arbitrum-goerli-usdc.e, base-usdbc, base-weth, base-usdc, base-goerli, base-goerli-weth, linea-goerli, optimism-usdc, optimism-weth, scroll-goerli, scroll-usdc] name: Run scenarios env: ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_KEY }} diff --git a/contracts/Comet.sol b/contracts/Comet.sol index 3c5d56442..e0f9bcf89 100644 --- a/contracts/Comet.sol +++ b/contracts/Comet.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.15; import "./CometMainInterface.sol"; -import "./ERC20.sol"; +import "./IERC20NonStandard.sol"; import "./IPriceFeed.sol"; /** @@ -126,12 +126,6 @@ contract Comet is CometMainInterface { uint256 internal immutable asset10_b; uint256 internal immutable asset11_a; uint256 internal immutable asset11_b; - uint256 internal immutable asset12_a; - uint256 internal immutable asset12_b; - uint256 internal immutable asset13_a; - uint256 internal immutable asset13_b; - uint256 internal immutable asset14_a; - uint256 internal immutable asset14_b; /** * @notice Construct a new protocol instance @@ -139,7 +133,7 @@ contract Comet is CometMainInterface { **/ constructor(Configuration memory config) { // Sanity checks - uint8 decimals_ = ERC20(config.baseToken).decimals(); + uint8 decimals_ = IERC20NonStandard(config.baseToken).decimals(); if (decimals_ > MAX_BASE_DECIMALS) revert BadDecimals(); if (config.storeFrontPriceFactor > FACTOR_SCALE) revert BadDiscount(); if (config.assetConfigs.length > MAX_ASSETS) revert TooManyAssets(); @@ -196,9 +190,44 @@ contract Comet is CometMainInterface { (asset09_a, asset09_b) = getPackedAssetInternal(config.assetConfigs, 9); (asset10_a, asset10_b) = getPackedAssetInternal(config.assetConfigs, 10); (asset11_a, asset11_b) = getPackedAssetInternal(config.assetConfigs, 11); - (asset12_a, asset12_b) = getPackedAssetInternal(config.assetConfigs, 12); - (asset13_a, asset13_b) = getPackedAssetInternal(config.assetConfigs, 13); - (asset14_a, asset14_b) = getPackedAssetInternal(config.assetConfigs, 14); + } + + /** + * @dev Prevents marked functions from being reentered + * Note: this restrict contracts from calling comet functions in their hooks. + * Doing so will cause the transaction to revert. + */ + modifier nonReentrant() { + nonReentrantBefore(); + _; + nonReentrantAfter(); + } + + /** + * @dev Checks that the reentrancy flag is not set and then sets the flag + */ + function nonReentrantBefore() internal { + bytes32 slot = REENTRANCY_GUARD_FLAG_SLOT; + uint256 status; + assembly ("memory-safe") { + status := sload(slot) + } + + if (status == REENTRANCY_GUARD_ENTERED) revert ReentrantCallBlocked(); + assembly ("memory-safe") { + sstore(slot, REENTRANCY_GUARD_ENTERED) + } + } + + /** + * @dev Unsets the reentrancy flag + */ + function nonReentrantAfter() internal { + bytes32 slot = REENTRANCY_GUARD_FLAG_SLOT; + uint256 status; + assembly ("memory-safe") { + sstore(slot, REENTRANCY_GUARD_NOT_ENTERED) + } } /** @@ -241,7 +270,7 @@ contract Comet is CometMainInterface { // Sanity check price feed and asset decimals if (IPriceFeed(priceFeed).decimals() != PRICE_FEED_DECIMALS) revert BadDecimals(); - if (ERC20(asset).decimals() != decimals_) revert BadDecimals(); + if (IERC20NonStandard(asset).decimals() != decimals_) revert BadDecimals(); // Ensure collateral factors are within range if (assetConfig.borrowCollateralFactor >= assetConfig.liquidateCollateralFactor) revert BorrowCFTooLarge(); @@ -319,15 +348,6 @@ contract Comet is CometMainInterface { } else if (i == 11) { word_a = asset11_a; word_b = asset11_b; - } else if (i == 12) { - word_a = asset12_a; - word_b = asset12_b; - } else if (i == 13) { - word_a = asset13_a; - word_b = asset13_b; - } else if (i == 14) { - word_a = asset14_a; - word_b = asset14_b; } else { revert Absurd(); } @@ -482,7 +502,7 @@ contract Comet is CometMainInterface { * @param asset The collateral asset */ function getCollateralReserves(address asset) override public view returns (uint) { - return ERC20(asset).balanceOf(address(this)) - totalsCollateral[asset].totalSupplyAsset; + return IERC20NonStandard(asset).balanceOf(address(this)) - totalsCollateral[asset].totalSupplyAsset; } /** @@ -490,7 +510,7 @@ contract Comet is CometMainInterface { */ function getReserves() override public view returns (int) { (uint64 baseSupplyIndex_, uint64 baseBorrowIndex_) = accruedInterestIndices(getNowInternal() - lastAccrualTime); - uint balance = ERC20(baseToken).balanceOf(address(this)); + uint balance = IERC20NonStandard(baseToken).balanceOf(address(this)); uint totalSupply_ = presentValueSupply(baseSupplyIndex_, totalSupplyBase); uint totalBorrow_ = presentValueBorrow(baseBorrowIndex_, totalBorrowBase); return signed256(balance) - signed256(totalSupply_) + signed256(totalBorrow_); @@ -760,18 +780,50 @@ contract Comet is CometMainInterface { } /** - * @dev Safe ERC20 transfer in, assumes no fee is charged and amount is transferred + * @dev Safe ERC20 transfer in and returns the final amount transferred (taking into account any fees) + * @dev Note: Safely handles non-standard ERC-20 tokens that do not return a value. See here: https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca */ - function doTransferIn(address asset, address from, uint amount) internal { - bool success = ERC20(asset).transferFrom(from, address(this), amount); + function doTransferIn(address asset, address from, uint amount) internal returns (uint) { + uint256 preTransferBalance = IERC20NonStandard(asset).balanceOf(address(this)); + IERC20NonStandard(asset).transferFrom(from, address(this), amount); + bool success; + assembly ("memory-safe") { + switch returndatasize() + case 0 { // This is a non-standard ERC-20 + success := not(0) // set success to true + } + case 32 { // This is a compliant ERC-20 + returndatacopy(0, 0, 32) + success := mload(0) // Set `success = returndata` of override external call + } + default { // This is an excessively non-compliant ERC-20, revert. + revert(0, 0) + } + } if (!success) revert TransferInFailed(); + return IERC20NonStandard(asset).balanceOf(address(this)) - preTransferBalance; } /** * @dev Safe ERC20 transfer out + * @dev Note: Safely handles non-standard ERC-20 tokens that do not return a value. See here: https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca */ function doTransferOut(address asset, address to, uint amount) internal { - bool success = ERC20(asset).transfer(to, amount); + IERC20NonStandard(asset).transfer(to, amount); + bool success; + assembly ("memory-safe") { + switch returndatasize() + case 0 { // This is a non-standard ERC-20 + success := not(0) // set success to true + } + case 32 { // This is a compliant ERC-20 + returndatacopy(0, 0, 32) + success := mload(0) // Set `success = returndata` of override external call + } + default { // This is an excessively non-compliant ERC-20, revert. + revert(0, 0) + } + } if (!success) revert TransferOutFailed(); } @@ -809,7 +861,7 @@ contract Comet is CometMainInterface { * @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 { + function supplyInternal(address operator, address from, address dst, address asset, uint amount) internal nonReentrant { if (isSupplyPaused()) revert Paused(); if (!hasPermission(from, operator)) revert Unauthorized(); @@ -827,7 +879,7 @@ contract Comet is CometMainInterface { * @dev Supply an amount of base asset from `from` to dst */ function supplyBase(address from, address dst, uint256 amount) internal { - doTransferIn(baseToken, from, amount); + amount = doTransferIn(baseToken, from, amount); accrueInternal(); @@ -854,7 +906,7 @@ contract Comet is CometMainInterface { * @dev Supply an amount of collateral asset from `from` to dst */ function supplyCollateral(address from, address dst, address asset, uint128 amount) internal { - doTransferIn(asset, from, amount); + amount = safe128(doTransferIn(asset, from, amount)); AssetInfo memory assetInfo = getAssetInfoByAddress(asset); TotalsCollateral memory totals = totalsCollateral[asset]; @@ -920,7 +972,7 @@ contract Comet is CometMainInterface { * @dev Transfer either collateral or base asset, depending on the asset, if operator is allowed * @dev Note: Specifying an `amount` of uint256.max will transfer all of `src`'s accrued base balance */ - function transferInternal(address operator, address src, address dst, address asset, uint amount) internal { + function transferInternal(address operator, address src, address dst, address asset, uint amount) internal nonReentrant { if (isTransferPaused()) revert Paused(); if (!hasPermission(src, operator)) revert Unauthorized(); if (src == dst) revert NoSelfTransfer(); @@ -1031,7 +1083,7 @@ contract Comet is CometMainInterface { * @dev Withdraw either collateral or base asset, depending on the asset, if operator is allowed * @dev Note: Specifying an `amount` of uint256.max will withdraw all of `src`'s accrued base balance */ - function withdrawInternal(address operator, address src, address to, address asset, uint amount) internal { + function withdrawInternal(address operator, address src, address to, address asset, uint amount) internal nonReentrant { if (isWithdrawPaused()) revert Paused(); if (!hasPermission(src, operator)) revert Unauthorized(); @@ -1192,14 +1244,14 @@ contract Comet is CometMainInterface { * @param baseAmount The amount of base tokens used to buy the collateral * @param recipient The recipient address */ - function buyCollateral(address asset, uint minAmount, uint baseAmount, address recipient) override external { + function buyCollateral(address asset, uint minAmount, uint baseAmount, address recipient) override external nonReentrant { if (isBuyPaused()) revert Paused(); int reserves = getReserves(); if (reserves >= 0 && uint(reserves) >= targetReserves) revert NotForSale(); // Note: Re-entrancy can skip the reserves check above on a second buyCollateral call. - doTransferIn(baseToken, msg.sender, baseAmount); + baseAmount = doTransferIn(baseToken, msg.sender, baseAmount); uint collateralAmount = quoteCollateral(asset, baseAmount); if (collateralAmount < minAmount) revert TooMuchSlippage(); @@ -1254,6 +1306,7 @@ contract Comet is CometMainInterface { * @dev Only callable by governor * @dev Note: Setting the `asset` as Comet's address will allow the manager * to withdraw from Comet's Comet balance + * @dev Note: For USDT, if there is non-zero prior allowance, it must be reset to 0 first before setting a new value in proposal * @param asset The asset that the manager will gain approval of * @param manager The account which will be allowed or disallowed * @param amount The amount of an asset to approve @@ -1261,7 +1314,7 @@ contract Comet is CometMainInterface { function approveThis(address manager, address asset, uint amount) override external { if (msg.sender != governor) revert Unauthorized(); - ERC20(asset).approve(manager, amount); + IERC20NonStandard(asset).approve(manager, amount); } /** @@ -1322,4 +1375,4 @@ contract Comet is CometMainInterface { default { return(0, returndatasize()) } } } -} +} \ No newline at end of file diff --git a/contracts/CometCore.sol b/contracts/CometCore.sol index 94e17d7f0..a59b8eba6 100644 --- a/contracts/CometCore.sol +++ b/contracts/CometCore.sol @@ -56,6 +56,13 @@ abstract contract CometCore is CometConfiguration, CometStorage, CometMath { /// @dev The scale for factors uint64 internal constant FACTOR_SCALE = 1e18; + /// @dev The storage slot for reentrancy guard flags + bytes32 internal constant REENTRANCY_GUARD_FLAG_SLOT = bytes32(keccak256("comet.reentrancy.guard")); + + /// @dev The reentrancy guard statuses + uint256 internal constant REENTRANCY_GUARD_NOT_ENTERED = 0; + uint256 internal constant REENTRANCY_GUARD_ENTERED = 1; + /** * @notice Determine if the manager has permission to act on behalf of the owner * @param owner The owner account @@ -117,4 +124,4 @@ abstract contract CometCore is CometConfiguration, CometStorage, CometMath { function principalValueBorrow(uint64 baseBorrowIndex_, uint256 presentValue_) internal pure returns (uint104) { return safe104((presentValue_ * BASE_INDEX_SCALE + baseBorrowIndex_ - 1) / baseBorrowIndex_); } -} +} \ No newline at end of file diff --git a/contracts/CometMainInterface.sol b/contracts/CometMainInterface.sol index 651821908..5347b22f7 100644 --- a/contracts/CometMainInterface.sol +++ b/contracts/CometMainInterface.sol @@ -25,6 +25,7 @@ abstract contract CometMainInterface is CometCore { error NotForSale(); error NotLiquidatable(); error Paused(); + error ReentrantCallBlocked(); error SupplyCapExceeded(); error TimestampTooLarge(); error TooManyAssets(); diff --git a/contracts/IERC20NonStandard.sol b/contracts/IERC20NonStandard.sol index 93dd3e276..6d301b96f 100644 --- a/contracts/IERC20NonStandard.sol +++ b/contracts/IERC20NonStandard.sol @@ -7,8 +7,37 @@ pragma solidity 0.8.15; * See https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca */ interface IERC20NonStandard { + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + + /** + * @notice Approve `spender` to transfer up to `amount` from `src` + * @dev This will overwrite the approval amount for `spender` + * and is subject to issues noted [here](https://eips.ethereum.org/EIPS/eip-20#approve) + * @param spender The address of the account which may transfer tokens + * @param amount The number of tokens that are approved (-1 means infinite) + */ function approve(address spender, uint256 amount) external; + + /** + * @notice Transfer `value` tokens from `msg.sender` to `to` + * @param to The address of the destination account + * @param value The number of tokens to transfer + */ function transfer(address to, uint256 value) external; + + /** + * @notice Transfer `value` tokens from `from` to `to` + * @param from The address of the source account + * @param to The address of the destination account + * @param value The number of tokens to transfer + */ function transferFrom(address from, address to, uint256 value) external; + + /** + * @notice Gets the balance of the specified address + * @param account The address from which the balance will be retrieved + */ function balanceOf(address account) external view returns (uint256); } \ No newline at end of file diff --git a/contracts/pricefeeds/EzETHExchangeRatePriceFeed.sol b/contracts/pricefeeds/EzETHExchangeRatePriceFeed.sol new file mode 100644 index 000000000..688fc1ac0 --- /dev/null +++ b/contracts/pricefeeds/EzETHExchangeRatePriceFeed.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "../vendor/renzo/IBalancerRateProvider.sol"; +import "../IPriceFeed.sol"; + +/** + * @title ezETH Scaling price feed + * @notice A custom price feed that scales up or down the price received from an underlying Renzo ezETH / ETH exchange rate price feed and returns the result + * @author Compound + */ +contract EzETHExchangeRatePriceFeed is IPriceFeed { + /** Custom errors **/ + error InvalidInt256(); + + /// @notice Version of the price feed + uint public constant override version = 1; + + /// @notice Description of the price feed + string public description; + + /// @notice Number of decimals for returned prices + uint8 public immutable override decimals; + + /// @notice ezETH price feed where prices are fetched from + address public immutable underlyingPriceFeed; + + /// @notice Whether or not the price should be upscaled + bool internal immutable shouldUpscale; + + /// @notice The amount to upscale or downscale the price by + int256 internal immutable rescaleFactor; + + /** + * @notice Construct a new ezETH scaling price feed + * @param ezETHRateProvider The address of the underlying price feed to fetch prices from + * @param decimals_ The number of decimals for the returned prices + **/ + constructor(address ezETHRateProvider, uint8 decimals_, string memory description_) { + underlyingPriceFeed = ezETHRateProvider; + decimals = decimals_; + description = description_; + + uint8 ezETHRateProviderDecimals = 18; + // Note: Solidity does not allow setting immutables in if/else statements + shouldUpscale = ezETHRateProviderDecimals < decimals_ ? true : false; + rescaleFactor = (shouldUpscale + ? signed256(10 ** (decimals_ - ezETHRateProviderDecimals)) + : signed256(10 ** (ezETHRateProviderDecimals - decimals_)) + ); + } + + /** + * @notice Price for the latest round + * @return roundId Round id from the underlying price feed + * @return answer Latest price for the asset in terms of ETH + * @return startedAt Timestamp when the round was started; passed on from underlying price feed + * @return updatedAt Timestamp when the round was last updated; passed on from underlying price feed + * @return answeredInRound Round id in which the answer was computed; passed on from underlying price feed + **/ + function latestRoundData() override external view returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) { + uint256 rate = IBalancerRateProvider(underlyingPriceFeed).getRate(); + // protocol uses only the answer value. Other data fields are not provided by the underlying pricefeed and are not used in Comet protocol + // https://etherscan.io/address/0x387dBc0fB00b26fb085aa658527D5BE98302c84C#readProxyContract + return (0, scalePrice(int256(rate)), 0, 0, 0); + } + + function signed256(uint256 n) internal pure returns (int256) { + if (n > uint256(type(int256).max)) revert InvalidInt256(); + return int256(n); + } + + function scalePrice(int256 price) internal view returns (int256) { + int256 scaledPrice; + if (shouldUpscale) { + scaledPrice = price * rescaleFactor; + } else { + scaledPrice = price / rescaleFactor; + } + return scaledPrice; + } +} diff --git a/contracts/pricefeeds/ReverseMultiplicativePriceFeed.sol b/contracts/pricefeeds/ReverseMultiplicativePriceFeed.sol new file mode 100644 index 000000000..779566f16 --- /dev/null +++ b/contracts/pricefeeds/ReverseMultiplicativePriceFeed.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "../vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "../IPriceFeed.sol"; + +/** + * @title Reverse multiplicative price feed + * @notice A custom price feed that multiplies the price from one price feed and the inverse price from another price feed and returns the result + * @dev for example if we need tokenX to eth, but there is only tokenX to usd, we can use this price feed to get tokenX to eth: tokenX to usd * reversed(eth to usd) + * @author Compound + */ +contract ReverseMultiplicativePriceFeed is IPriceFeed { + /** Custom errors **/ + error BadDecimals(); + error InvalidInt256(); + + /// @notice Version of the price feed + uint public constant VERSION = 1; + + /// @notice Description of the price feed + string public override description; + + /// @notice Number of decimals for returned prices + uint8 public immutable override decimals; + + /// @notice Chainlink price feed A + address public immutable priceFeedA; + + /// @notice Chainlink price feed B + address public immutable priceFeedB; + + /// @notice Combined scale of the two underlying Chainlink price feeds + int public immutable priceFeedAScalling; + + /// @notice Scale of this price feed + int public immutable priceFeedScale; + + /** + * @notice Construct a new reverse multiplicative price feed + * @param priceFeedA_ The address of the first price feed to fetch prices from + * @param priceFeedB_ The address of the second price feed to fetch prices from that should be reversed + * @param decimals_ The number of decimals for the returned prices + * @param description_ The description of the price feed + **/ + constructor(address priceFeedA_, address priceFeedB_, uint8 decimals_, string memory description_) { + priceFeedA = priceFeedA_; + priceFeedB = priceFeedB_; + uint8 priceFeedADecimals = AggregatorV3Interface(priceFeedA_).decimals(); + priceFeedAScalling = signed256(10 ** (priceFeedADecimals)); + + if (decimals_ > 18) revert BadDecimals(); + decimals = decimals_; + description = description_; + priceFeedScale = int256(10 ** decimals); + } + + /** + * @notice Calculates the latest round data using data from the two price feeds + * @return roundId Round id from price feed B + * @return answer Latest price + * @return startedAt Timestamp when the round was started; passed on from price feed B + * @return updatedAt Timestamp when the round was last updated; passed on from price feed B + * @return answeredInRound Round id in which the answer was computed; passed on from price feed B + * @dev Note: Only the `answer` really matters for downstream contracts that use this price feed (e.g. Comet) + **/ + function latestRoundData() override external view returns (uint80, int256, uint256, uint256, uint80) { + (, int256 priceA, , , ) = AggregatorV3Interface(priceFeedA).latestRoundData(); + (uint80 roundId_, int256 priceB, uint256 startedAt_, uint256 updatedAt_, uint80 answeredInRound_) = AggregatorV3Interface(priceFeedB).latestRoundData(); + + if (priceA <= 0 || priceB <= 0) return (roundId_, 0, startedAt_, updatedAt_, answeredInRound_); + + // int256 price = priceA * priceB * priceFeedScale / combinedScale; + int256 price = priceA * int256(10**(AggregatorV3Interface(priceFeedB).decimals())) * priceFeedScale / priceB / priceFeedAScalling; + return (roundId_, price, startedAt_, updatedAt_, answeredInRound_); + } + + function signed256(uint256 n) internal pure returns (int256) { + if (n > uint256(type(int256).max)) revert InvalidInt256(); + return int256(n); + } + + /** + * @notice Price for the latest round + * @return The version of the price feed contract + **/ + function version() external pure returns (uint256) { + return VERSION; + } +} diff --git a/contracts/test/EvilToken.sol b/contracts/test/EvilToken.sol index b17b7ae58..5bc7826af 100644 --- a/contracts/test/EvilToken.sol +++ b/contracts/test/EvilToken.sol @@ -13,7 +13,8 @@ contract EvilToken is FaucetToken { enum AttackType { TRANSFER_FROM, WITHDRAW_FROM, - SUPPLY_FROM + SUPPLY_FROM, + BUY_COLLATERAL } struct ReentryAttack { @@ -52,20 +53,27 @@ contract EvilToken is FaucetToken { attack = attack_; } - function transfer(address, uint256) external override returns (bool) { - return performAttack(); + function transfer(address dst, uint256 amount) public override returns (bool) { + numberOfCalls++; + if (numberOfCalls > attack.maxCalls){ + return super.transfer(dst, amount); + } else { + return performAttack(address(this), dst, amount); + } } - function transferFrom(address, address, uint256) external override returns (bool) { - return performAttack(); + function transferFrom(address src, address dst, uint256 amount) public override returns (bool) { + numberOfCalls++; + if (numberOfCalls > attack.maxCalls) { + return super.transferFrom(src, dst, amount); + } else { + return performAttack(src, dst, amount); + } } - function performAttack() internal returns (bool) { + function performAttack(address src, address dst, uint256 amount) internal returns (bool) { ReentryAttack memory reentryAttack = attack; - numberOfCalls++; - if (numberOfCalls > reentryAttack.maxCalls) { - // do nothing - } else if (reentryAttack.attackType == AttackType.TRANSFER_FROM) { + if (reentryAttack.attackType == AttackType.TRANSFER_FROM) { Comet(payable(msg.sender)).transferFrom( reentryAttack.source, reentryAttack.destination, @@ -85,6 +93,13 @@ contract EvilToken is FaucetToken { reentryAttack.asset, reentryAttack.amount ); + } else if (reentryAttack.attackType == AttackType.BUY_COLLATERAL) { + Comet(payable(msg.sender)).buyCollateral( + reentryAttack.asset, + 0, + reentryAttack.amount, + reentryAttack.destination + ); } else { revert("invalid reentry attack"); } diff --git a/contracts/test/FaucetToken.sol b/contracts/test/FaucetToken.sol index 5c4a8a4cf..f8d1f3662 100644 --- a/contracts/test/FaucetToken.sol +++ b/contracts/test/FaucetToken.sol @@ -24,7 +24,7 @@ contract StandardToken { decimals = _decimalUnits; } - function transfer(address dst, uint256 amount) external virtual returns (bool) { + function transfer(address dst, uint256 amount) public virtual returns (bool) { require(amount <= balanceOf[msg.sender], "ERC20: transfer amount exceeds balance"); balanceOf[msg.sender] = balanceOf[msg.sender] - amount; balanceOf[dst] = balanceOf[dst] + amount; @@ -32,7 +32,7 @@ contract StandardToken { return true; } - function transferFrom(address src, address dst, uint256 amount) external virtual returns (bool) { + function transferFrom(address src, address dst, uint256 amount) public virtual returns (bool) { require(amount <= allowance[src][msg.sender], "ERC20: transfer amount exceeds allowance"); require(amount <= balanceOf[src], "ERC20: transfer amount exceeds balance"); allowance[src][msg.sender] = allowance[src][msg.sender] - amount; @@ -64,4 +64,4 @@ contract FaucetToken is StandardToken { totalSupply += value; emit Transfer(address(this), _owner, value); } -} +} \ No newline at end of file diff --git a/contracts/test/NonStandardFaucetFeeToken.sol b/contracts/test/NonStandardFaucetFeeToken.sol new file mode 100644 index 000000000..a53069997 --- /dev/null +++ b/contracts/test/NonStandardFaucetFeeToken.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "../IERC20NonStandard.sol"; + +/** + * @title Non-standard ERC20 token + * @dev Implementation of the basic standard token. + * See https://github.com/ethereum/EIPs/issues/20 + * @dev With USDT fee token mechanism + * @dev Note: `transfer` and `transferFrom` do not return a boolean + */ +contract NonStandardFeeToken is IERC20NonStandard { + string public name; + string public symbol; + uint8 public decimals; + address public owner; + uint256 public totalSupply; + mapping(address => mapping (address => uint256)) public allowance; + mapping(address => uint256) public balanceOf; + event Approval(address indexed owner, address indexed spender, uint256 value); + event Transfer(address indexed from, address indexed to, uint256 value); + event Params(uint feeBasisPoints, uint maxFee); + + // additional variables for use if transaction fees ever became necessary + uint public basisPointsRate = 0; + uint public maximumFee = 0; + + constructor(uint256 _initialAmount, string memory _tokenName, uint8 _decimalUnits, string memory _tokenSymbol) { + totalSupply = _initialAmount; + balanceOf[msg.sender] = _initialAmount; + name = _tokenName; + symbol = _tokenSymbol; + decimals = _decimalUnits; + } + + function transfer(address dst, uint256 amount) external virtual { + require(amount <= balanceOf[msg.sender], "ERC20: transfer amount exceeds balance"); + uint256 fee = amount * basisPointsRate / 10000; + uint256 sendAmount = amount - fee; + if (fee > maximumFee) { + fee = maximumFee; + } + + // For testing purpose, just forward fee to contract itself + if (fee > 0) { + balanceOf[address(this)] = balanceOf[address(this)] + fee; + } + + balanceOf[msg.sender] = balanceOf[msg.sender] - amount; + balanceOf[dst] = balanceOf[dst] + sendAmount; + emit Transfer(msg.sender, dst, sendAmount); + } + + function transferFrom(address src, address dst, uint256 amount) external virtual { + require(amount <= allowance[src][msg.sender], "ERC20: transfer amount exceeds allowance"); + require(amount <= balanceOf[src], "ERC20: transfer amount exceeds balance"); + uint256 fee = amount * basisPointsRate / 10000; + uint256 sendAmount = amount - fee; + if (fee > maximumFee) { + fee = maximumFee; + } + + // For testing purpose, just forward fee to contract itself + if (fee > 0) { + balanceOf[address(this)] = balanceOf[address(this)] + fee; + } + + allowance[src][msg.sender] = allowance[src][msg.sender] - amount; + balanceOf[src] = balanceOf[src] - amount; + balanceOf[dst] = balanceOf[dst] + sendAmount; + emit Transfer(src, dst, sendAmount); + } + + function approve(address _spender, uint256 amount) external { + allowance[msg.sender][_spender] = amount; + emit Approval(msg.sender, _spender, amount); + } + + // For testing, just don't limit access on setting fees + function setParams(uint256 newBasisPoints, uint256 newMaxFee) public { + basisPointsRate = newBasisPoints; + maximumFee = newMaxFee * (10**decimals); + + emit Params(basisPointsRate, maximumFee); + } +} + +/** + * @title The Compound Faucet Test Token + * @author Compound + * @notice A simple test token that lets anyone get more of it. + */ +contract NonStandardFaucetFeeToken is NonStandardFeeToken { + constructor(uint256 _initialAmount, string memory _tokenName, uint8 _decimalUnits, string memory _tokenSymbol) + NonStandardFeeToken(_initialAmount, _tokenName, _decimalUnits, _tokenSymbol) { + } + + function allocateTo(address _owner, uint256 value) public { + balanceOf[_owner] += value; + totalSupply += value; + emit Transfer(address(this), _owner, value); + } +} \ No newline at end of file diff --git a/contracts/vendor/renzo/IBalancerRateProvider.sol b/contracts/vendor/renzo/IBalancerRateProvider.sol new file mode 100644 index 000000000..bea3f6feb --- /dev/null +++ b/contracts/vendor/renzo/IBalancerRateProvider.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IBalancerRateProvider { + function getRate() external view returns (uint256); +} diff --git a/deployments/optimism/weth/configuration.json b/deployments/optimism/weth/configuration.json new file mode 100644 index 000000000..72097e6fd --- /dev/null +++ b/deployments/optimism/weth/configuration.json @@ -0,0 +1,53 @@ +{ + "name": "Compound WETH", + "symbol": "cWETHv3", + "baseToken": "WETH", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "borrowMin": "0.1e18", + "pauseGuardian": "0x3fFd6c073a4ba24a113B18C8F373569640916A45", + "storeFrontPriceFactor": 0.7, + "targetReserves": "5000e18", + "rates": { + "supplyKink": 0.85, + "supplySlopeLow": 0.014, + "supplySlopeHigh": 1, + "supplyBase": 0, + "borrowKink": 0.85, + "borrowSlopeLow": 0.014, + "borrowSlopeHigh": 1, + "borrowBase": 0.01 + }, + "tracking": { + "indexScale": "1e15", + "baseSupplySpeed": "0e15", + "baseBorrowSpeed": "0e15", + "baseMinForRewards": "1000e18" + }, + "assets": { + "wstETH": { + "address": "0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb", + "decimals": "18", + "borrowCF": 0.88, + "liquidateCF": 0.93, + "liquidationFactor": 0.97, + "supplyCap": "0e18" + }, + "rETH": { + "address": "0x9Bcef72be871e61ED4fBbc7630889beE758eb81D", + "decimals": "18", + "borrowCF": 0.90, + "liquidateCF": 0.93, + "liquidationFactor": 0.97, + "supplyCap": "0e18" + }, + "WBTC": { + "address": "0x68f180fcCe6836688e9084f035309E29Bf0A2095", + "decimals": "8", + "borrowCF": 0.80, + "liquidateCF": 0.85, + "liquidationFactor": 0.90, + "supplyCap": "0e8" + } + } + } + \ No newline at end of file diff --git a/deployments/optimism/weth/deploy.ts b/deployments/optimism/weth/deploy.ts new file mode 100644 index 000000000..1e89cc2d4 --- /dev/null +++ b/deployments/optimism/weth/deploy.ts @@ -0,0 +1,73 @@ +import { Deployed, DeploymentManager } from '../../../plugins/deployment_manager'; +import { DeploySpec, deployComet, exp } from '../../../src/deploy'; + +export default async function deploy(deploymentManager: DeploymentManager, deploySpec: DeploySpec): Promise { + const WETH = await deploymentManager.existing('WETH', '0x4200000000000000000000000000000000000006', 'optimism'); + const rETH = await deploymentManager.existing('rETH', '0x9Bcef72be871e61ED4fBbc7630889beE758eb81D', 'optimism'); + const wstETH = await deploymentManager.existing('wstETH', '0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb', 'optimism'); + const WBTC = await deploymentManager.existing('WBTC', '0x68f180fcCe6836688e9084f035309E29Bf0A2095', 'optimism'); + const COMP = await deploymentManager.existing('COMP', '0x7e7d4467112689329f7E06571eD0E8CbAd4910eE', 'optimism'); + + // Deploy WstETHPriceFeed + const wstETHPriceFeed = await deploymentManager.deploy( + 'wstETH:priceFeed', + 'pricefeeds/ScalingPriceFeed.sol', + [ + '0x524299Ab0987a7c4B3c8022a35669DdcdC715a10', // wstETH / ETH price feed + 8 // decimals + ] + ); + + // Deploy constant price feed for WETH + const wethConstantPriceFeed = await deploymentManager.deploy( + 'WETH:priceFeed', + 'pricefeeds/ConstantPriceFeed.sol', + [ + 8, // decimals + exp(1, 8) // constantPrice + ] + ); + + // Deploy scaling price feed for rETH + const rETHScalingPriceFeed = await deploymentManager.deploy( + 'rETH:priceFeed', + 'pricefeeds/ScalingPriceFeed.sol', + [ + '0xb429DE60943a8e6DeD356dca2F93Cd31201D9ed0', // rETH / ETH price feed + 8 // decimals + ] + ); + + // Deploy scaling price feed for WBTC + const WBTCReverseMultiplicativePriceFeed = await deploymentManager.deploy( + 'WBTC:priceFeed', + 'pricefeeds/ReverseMultiplicativePriceFeed.sol', + [ + '0x718A5788b89454aAE3A028AE9c111A29Be6c2a6F', // WBTC / USD price feed + '0x13e3Ee699D1909E989722E753853AE30b17e08c5', // ETH / USD price feed + 8, // decimals + 'WBTC / USD, USD / ETH' // description + ] + ); + + // Import shared contracts from cUSDCv3 + const cometAdmin = await deploymentManager.fromDep('cometAdmin', 'optimism', 'usdc'); + const $configuratorImpl = await deploymentManager.fromDep('configurator:implementation', 'optimism', 'usdc'); + const configurator = await deploymentManager.fromDep('configurator', 'optimism', 'usdc'); + const rewards = await deploymentManager.fromDep('rewards', 'optimism', 'usdc'); + const bulker = await deploymentManager.fromDep('bulker', 'optimism', 'usdc'); + const localTimelock = await deploymentManager.fromDep('timelock', 'optimism', 'usdc'); + const bridgeReceiver = await deploymentManager.fromDep('bridgeReceiver', 'optimism', 'usdc'); + + + // Deploy Comet + const deployed = await deployComet(deploymentManager, deploySpec); + + return { + ...deployed, + bridgeReceiver, + bulker, + rewards, + COMP + }; +} diff --git a/deployments/optimism/weth/migrations/1716544199_configurate_and_ens.ts b/deployments/optimism/weth/migrations/1716544199_configurate_and_ens.ts new file mode 100644 index 000000000..3957d51f4 --- /dev/null +++ b/deployments/optimism/weth/migrations/1716544199_configurate_and_ens.ts @@ -0,0 +1,316 @@ +import { DeploymentManager } from '../../../../plugins/deployment_manager/DeploymentManager'; +import { Contract, ethers } from 'ethers'; +import { migration } from '../../../../plugins/deployment_manager/Migration'; +import { + calldata, + exp, + getConfigurationStruct, + proposal, +} from '../../../../src/deploy'; +import { expect } from 'chai'; + +const ENSName = 'compound-community-licenses.eth'; +const ENSResolverAddress = '0x4976fb03C32e5B8cfe2b6cCB31c09Ba78EBaBa41'; +const ENSRegistryAddress = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'; +const ENSSubdomainLabel = 'v3-additional-grants'; +const ENSSubdomain = `${ENSSubdomainLabel}.${ENSName}`; +const ENSTextRecordKey = 'v3-official-markets'; +const opCOMPAddress = '0x7e7d4467112689329f7E06571eD0E8CbAd4910eE'; + +const wethAmountToBridge = exp(500, 18); + +export default migration('1713012100_configurate_and_ens', { + async prepare(deploymentManager: DeploymentManager) { + const cometFactory = await deploymentManager.deploy('cometFactory', 'CometFactory.sol', [], true); + return { newFactoryAddress: cometFactory.address }; + }, + + enact: async (deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager, { newFactoryAddress }) => { + const trace = deploymentManager.tracer(); + const { utils } = ethers; + + const { + bridgeReceiver, + timelock: localTimelock, + comet, + cometAdmin, + configurator, + rewards, + WETH + } = await deploymentManager.getContracts(); + + const { + opL1CrossDomainMessenger, + opL1StandardBridge, + governor + } = await govDeploymentManager.getContracts(); + + // ENS Setup + // See also: https://docs.ens.domains/contract-api-reference/name-processing + const ENSResolver = await govDeploymentManager.existing( + 'ENSResolver', + ENSResolverAddress + ); + const subdomainHash = ethers.utils.namehash(ENSSubdomain); + const opChainId = ( + await deploymentManager.hre.ethers.provider.getNetwork() + ).chainId.toString(); + const newMarketObject = { baseSymbol: 'WETH', cometAddress: comet.address }; + const officialMarketsJSON = JSON.parse( + await ENSResolver.text(subdomainHash, ENSTextRecordKey) + ); + + // add arbitrum-usdt comet (0xd98Be00b5D27fc98112BdE293e487f8D4cA57d07) + // arbitrum chain id is 42161 + if (!(officialMarketsJSON[42161].find(market => market.baseSymbol === 'USDT'))) { + officialMarketsJSON[42161].push({ baseSymbol: 'USDT', cometAddress: '0xd98Be00b5D27fc98112BdE293e487f8D4cA57d07' }); + } + + // add arbitrum-weth comet (0x6f7D514bbD4aFf3BcD1140B7344b32f063dEe486) + // arbitrum chain id is 42161 + if (!(officialMarketsJSON[42161].find(market => market.baseSymbol === 'WETH'))) { + officialMarketsJSON[42161].push({ baseSymbol: 'WETH', cometAddress: '0x6f7D514bbD4aFf3BcD1140B7344b32f063dEe486' }); + } + + // add optimism-usdt comet (0x995E394b8B2437aC8Ce61Ee0bC610D617962B214) + // optimism chain id is 10 + if (!(officialMarketsJSON[10].find(market => market.baseSymbol === 'USDT'))) { + officialMarketsJSON[10].push({ baseSymbol: 'USDT', cometAddress: '0x995E394b8B2437aC8Ce61Ee0bC610D617962B214' }); + } + + // add polygon-usdt comet (0xaeB318360f27748Acb200CE616E389A6C9409a07) + // optimism chain id is 137 + if (!(officialMarketsJSON[137].find(market => market.baseSymbol === 'USDT'))) { + officialMarketsJSON[137].push({ baseSymbol: 'USDT', cometAddress: '0xaeB318360f27748Acb200CE616E389A6C9409a07' }); + } + + if (officialMarketsJSON[opChainId]) { + officialMarketsJSON[opChainId].push(newMarketObject); + } else { + officialMarketsJSON[opChainId] = [newMarketObject]; + } + + const configuration = await getConfigurationStruct(deploymentManager); + const setFactoryCalldata = await calldata( + configurator.populateTransaction.setFactory( + comet.address, + newFactoryAddress + ) + ); + const setConfigurationCalldata = await calldata( + configurator.populateTransaction.setConfiguration( + comet.address, + configuration + ) + ); + const deployAndUpgradeToCalldata = utils.defaultAbiCoder.encode( + ['address', 'address'], + [configurator.address, comet.address] + ); + const setRewardConfigCalldata = utils.defaultAbiCoder.encode( + ['address', 'address'], + [comet.address, opCOMPAddress] + ); + + // wrap and transfer eth to comet on optimism to save on gas + const depositCalldata = await calldata(WETH.populateTransaction.deposit()); + const transferCalldata = await calldata(WETH.populateTransaction.transfer(comet.address, wethAmountToBridge)); + + const l2ProposalData = utils.defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'string[]', 'bytes[]'], + [ + [ + configurator.address, + configurator.address, + cometAdmin.address, + rewards.address, + WETH.address, + WETH.address + ], + [0, 0, 0, 0, wethAmountToBridge, 0], + [ + 'setFactory(address,address)', + 'setConfiguration(address,(address,address,address,address,address,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint104,uint104,uint104,(address,address,uint8,uint64,uint64,uint64,uint128)[]))', + 'deployAndUpgradeTo(address,address)', + 'setRewardConfig(address,address)', + 'deposit()', + 'transfer(address,uint256)' + ], + [ + setFactoryCalldata, + setConfigurationCalldata, + deployAndUpgradeToCalldata, + setRewardConfigCalldata, + depositCalldata, + transferCalldata + ], + ] + ); + + const mainnetActions = [ + // 1. Bridge ETH from Ethereum to OP timelock using L1StandardBridge + { + contract: opL1StandardBridge, + // function depositETHTo(address _to,uint32 _minGasLimit,bytes calldata _extraData) + signature: + 'depositETHTo(address,uint32,bytes)', + args: [ + localTimelock.address, + 200_000, + '0x', + ], + value: wethAmountToBridge + }, + // 2. Set Comet configuration + deployAndUpgradeTo new Comet, set Reward Config on Optimism + { + contract: opL1CrossDomainMessenger, + signature: 'sendMessage(address,bytes,uint32)', + args: [bridgeReceiver.address, l2ProposalData, 3_000_000], + }, + // 3. Update the list of official markets + { + target: ENSResolverAddress, + signature: 'setText(bytes32,string,string)', + calldata: ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'string', 'string'], + [subdomainHash, ENSTextRecordKey, JSON.stringify(officialMarketsJSON)] + ), + }, + ]; + + const description = '# Initialize cWETHv3 on Optimism\n\n## Proposal summary\n\nCompound Growth Program [AlphaGrowth] proposes deployment of Compound III to the Optimism network. This proposal takes the governance steps recommended and necessary to initialize a Compound III WETH market on Optimism; upon execution, cWETHv3 will be ready for use. 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). The new parameters include setting the risk parameters based off of the [recommendations from Gauntlet](https://www.comp.xyz/t/add-market-eth-on-optimism/5274/5).\n\nFurther detailed information can be found on the corresponding [proposal pull request](https://github.com/compound-finance/comet/pull/865), [deploy market GitHub action run]() and [forum discussion](https://www.comp.xyz/t/add-market-eth-on-optimism/5274).\n\n\n## Proposal Actions\n\nThe first action sends mainnet ETH to Arbitrum’s Timelock in order to transfer it to the new Comet and thus seed the market reserves.\n\nThe second proposal action sets the Comet configuration and deploys a new Comet implementation on Optimism. This sends the encoded `setFactory`, `setConfiguration`, `deployAndUpgradeTo` calls across the bridge to the governance receiver on Optimism. It also calls `setRewardConfig` on the Optimism rewards contract, to establish Optimism’s bridged version of COMP as the reward token for the deployment and set the initial supply speed to be 4 COMP/day and borrow speed to be 3 COMP/day. Also it wraps received ETH and transfers it to the new Comet.\n\nThe third action updates the ENS TXT record `v3-official-markets` on `v3-additional-grants.compound-community-licenses.eth`, updating the official markets JSON to include the new Arbitrum cUSDCv3 market.'; + const txn = await govDeploymentManager.retry(async () =>{ + return trace(await governor.propose(...(await proposal(mainnetActions, description)))); + } + ); + + const event = txn.events.find((event) => event.event === 'ProposalCreated'); + const [proposalId] = event.args; + + trace(`Created proposal ${proposalId}.`); + }, + + async enacted(deploymentManager: DeploymentManager): Promise { + return false; + }, + + async verify(deploymentManager: DeploymentManager, govDeploymentManager: DeploymentManager) { + const ethers = deploymentManager.hre.ethers; + + const { + comet, + rewards, + wstETH, + rETH, + WBTC + } = await deploymentManager.getContracts(); + + const { + timelock + } = await govDeploymentManager.getContracts(); + + // 1. & 2 + const wstethInfo = await comet.getAssetInfoByAddress(wstETH.address); + const rethInfo = await comet.getAssetInfoByAddress(rETH.address); + const wbtcInfo = await comet.getAssetInfoByAddress(WBTC.address); + // expect(rethInfo.supplyCap).to.be.eq(exp(470, 18)); + // expect(wstethInfo.supplyCap).to.be.eq(exp(1_300, 18)); + // expect(wbtcInfo.supplyCap).to.be.eq(exp(60, 8)); + expect(await comet.pauseGuardian()).to.be.eq('0x3fFd6c073a4ba24a113B18C8F373569640916A45'); + expect(await comet.getReserves()).to.be.equal(wethAmountToBridge); + + const opCOMP = new Contract( + opCOMPAddress, + ['function balanceOf(address account) external view returns (uint256)'], + deploymentManager.hre.ethers.provider + ); + expect((await opCOMP.balanceOf(rewards.address)).gt(exp(2_500, 18))).to.be.true; + // expect(await comet.baseTrackingSupplySpeed()).to.be.equal(exp(4 / 86400, 15, 18)); // 46296296296 + // expect(await comet.baseTrackingBorrowSpeed()).to.be.equal(exp(3 / 86400, 15, 18)); // 34722222222 + + // 3. + const ENSResolver = await govDeploymentManager.existing('ENSResolver', ENSResolverAddress); + const ENSRegistry = await govDeploymentManager.existing('ENSRegistry', ENSRegistryAddress); + const subdomainHash = ethers.utils.namehash(ENSSubdomain); + const officialMarketsJSON = await ENSResolver.text(subdomainHash, ENSTextRecordKey); + const officialMarkets = JSON.parse(officialMarketsJSON); + expect(await ENSRegistry.recordExists(subdomainHash)).to.be.equal(true); + expect(await ENSRegistry.owner(subdomainHash)).to.be.equal(timelock.address); + expect(await ENSRegistry.resolver(subdomainHash)).to.be.equal(ENSResolverAddress); + expect(await ENSRegistry.ttl(subdomainHash)).to.be.equal(0); + expect(officialMarkets).to.deep.equal({ + 1: [ + { + baseSymbol: 'USDC', + cometAddress: '0xc3d688B66703497DAA19211EEdff47f25384cdc3', + }, + { + baseSymbol: 'WETH', + cometAddress: '0xA17581A9E3356d9A858b789D68B4d866e593aE94', + }, + ], + 137: [ + { + baseSymbol: 'USDC', + cometAddress: '0xF25212E676D1F7F89Cd72fFEe66158f541246445', + }, + { + baseSymbol: 'USDT', + cometAddress: '0xaeB318360f27748Acb200CE616E389A6C9409a07', + } + ], + 8453: [ + { + baseSymbol: 'USDbC', + cometAddress: '0x9c4ec768c28520B50860ea7a15bd7213a9fF58bf', + }, + { + baseSymbol: 'WETH', + cometAddress: '0x46e6b214b524310239732D51387075E0e70970bf', + }, + { + baseSymbol: 'USDC', + cometAddress: '0xb125E6687d4313864e53df431d5425969c15Eb2F', + }, + ], + 42161: [ + { + baseSymbol: 'USDC.e', + cometAddress: '0xA5EDBDD9646f8dFF606d7448e414884C7d905dCA', + }, + { + baseSymbol: 'USDC', + cometAddress: '0x9c4ec768c28520B50860ea7a15bd7213a9fF58bf', + }, + { + baseSymbol: 'WETH', + cometAddress: '0x6f7D514bbD4aFf3BcD1140B7344b32f063dEe486', + }, + { + baseSymbol: 'USDT', + cometAddress: '0xd98Be00b5D27fc98112BdE293e487f8D4cA57d07', + }, + ], + 534352: [ + { + baseSymbol: 'USDC', + cometAddress: '0xB2f97c1Bd3bf02f5e74d13f02E3e26F93D77CE44', + }, + ], + 10: [ + { + baseSymbol: 'USDC', + cometAddress: '0x2e44e174f7D53F0212823acC11C01A11d58c5bCB', + }, + { + baseSymbol: 'USDT', + cometAddress: '0x995E394b8B2437aC8Ce61Ee0bC610D617962B214', + }, + { + baseSymbol: 'WETH', + cometAddress: comet.address, + }, + ], + }); + } +}); diff --git a/deployments/optimism/weth/relations.ts b/deployments/optimism/weth/relations.ts new file mode 100644 index 000000000..11e6f1f53 --- /dev/null +++ b/deployments/optimism/weth/relations.ts @@ -0,0 +1,50 @@ +import baseRelationConfig from '../../relations'; + +export default { + ...baseRelationConfig, + governor: { + artifact: + 'contracts/bridges/optimism/OptimismBridgeReceiver.sol:OptimismBridgeReceiver', + }, + + OssifiableProxy: { + artifact: 'contracts/ERC20.sol:ERC20' + }, + + TransparentUpgradeableProxy: { + artifact: 'contracts/ERC20.sol:ERC20' + }, + + l2CrossDomainMessenger: { + delegates: { + field: { + slot: + '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc', + }, + }, + }, + + l2StandardBridge: { + delegates: { + field: { + slot: + '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc', + }, + }, + }, + + // wstETH + '0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb': { + artifact: 'contracts/ERC20.sol:ERC20', + delegates: { + field: { + slot: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' + } + } + }, + + // rETH + '0x9Bcef72be871e61ED4fBbc7630889beE758eb81D': { + artifact: 'contracts/ERC20.sol:ERC20' + }, +}; diff --git a/deployments/optimism/weth/roots.json b/deployments/optimism/weth/roots.json new file mode 100644 index 000000000..3713e4f4d --- /dev/null +++ b/deployments/optimism/weth/roots.json @@ -0,0 +1,4 @@ +{ + "l2CrossDomainMessenger": "0x4200000000000000000000000000000000000007", + "l2StandardBridge": "0x4200000000000000000000000000000000000010" +} diff --git a/hardhat.config.ts b/hardhat.config.ts index a78992283..6242ce789 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -38,6 +38,7 @@ import baseGoerliWethRelationConfigMap from './deployments/base-goerli/weth/rela import lineaGoerliRelationConfigMap from './deployments/linea-goerli/usdc/relations'; import optimismRelationConfigMap from './deployments/optimism/usdc/relations'; import optimismUsdtRelationConfigMap from './deployments/optimism/usdt/relations'; +import optimismWethRelationConfigMap from './deployments/optimism/weth/relations'; import scrollGoerliRelationConfigMap from './deployments/scroll-goerli/usdc/relations'; import scrollRelationConfigMap from './deployments/scroll/usdc/relations'; @@ -374,7 +375,8 @@ const config: HardhatUserConfig = { }, optimism: { usdc: optimismRelationConfigMap, - usdt: optimismUsdtRelationConfigMap + usdt: optimismUsdtRelationConfigMap, + weth: optimismWethRelationConfigMap }, 'scroll-goerli': { usdc: scrollGoerliRelationConfigMap @@ -511,6 +513,14 @@ const config: HardhatUserConfig = { network: 'optimism', deployment: 'usdt', auxiliaryBase: 'mainnet', + deployment: 'weth', + auxiliaryBase: 'mainnet' + }, + { + name: 'optimism-weth', + network: 'optimism', + deployment: 'weth', + auxiliaryBase: 'mainnet' }, { name: 'scroll-goerli', diff --git a/scenario/LiquidationScenario.ts b/scenario/LiquidationScenario.ts index 567c72cf0..c39389d65 100644 --- a/scenario/LiquidationScenario.ts +++ b/scenario/LiquidationScenario.ts @@ -1,6 +1,7 @@ import { scenario } from './context/CometContext'; import { event, expect } from '../test/helpers'; import { expectRevertCustom, timeUntilUnderwater } from './utils'; +import { matchesDeployment } from './utils'; scenario( 'Comet#liquidation > isLiquidatable=true for underwater position', @@ -32,6 +33,75 @@ scenario( } ); +scenario( + 'Comet#liquidation > allows liquidation of underwater positions with token fees', + { + tokenBalances: { + $comet: { $base: 1000 }, + }, + cometBalances: { + albert: { + $base: -1000, + $asset0: .001 + }, + betty: { $base: 10 } + }, + filter: async (ctx) => matchesDeployment(ctx, [{ network: 'mainnet', deployment: 'usdt' }]), + }, + async ({ comet, actors }, context, world) => { + // Set fees for USDT for testing + const USDT = await world.deploymentManager.existing('USDT', await comet.baseToken(), world.base.network); + const USDTAdminAddress = await USDT.owner(); + await world.deploymentManager.hre.network.provider.send('hardhat_setBalance', [ + USDTAdminAddress, + world.deploymentManager.hre.ethers.utils.hexStripZeros(world.deploymentManager.hre.ethers.utils.parseEther('100').toHexString()), + ]); + await world.deploymentManager.hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [USDTAdminAddress], + }); + // mine a block to ensure the impersonation is effective + const USDTAdminSigner = await world.deploymentManager.hre.ethers.getSigner(USDTAdminAddress); + // 10 basis points, and max 10 USDT + await USDT.connect(USDTAdminSigner).setParams(10, 10); + + const { albert, betty } = actors; + + await world.increaseTime( + await timeUntilUnderwater({ + comet, + actor: albert, + fudgeFactor: 60n * 10n // 10 minutes past when position is underwater + }) + ); + + const lp0 = await comet.liquidatorPoints(betty.address); + + await betty.absorb({ absorber: betty.address, accounts: [albert.address] }); + + const lp1 = await comet.liquidatorPoints(betty.address); + + // increments absorber's numAbsorbs + expect(lp1.numAbsorbs).to.eq(lp0.numAbsorbs + 1); + // increases absorber's numAbsorbed + expect(lp1.numAbsorbed.toNumber()).to.eq(lp0.numAbsorbed.toNumber() + 1); + // XXX test approxSpend? + + const baseBalance = await albert.getCometBaseBalance(); + expect(Number(baseBalance)).to.be.greaterThanOrEqual(0); + + // clears out all of liquidated user's collateral + const numAssets = await comet.numAssets(); + for (let i = 0; i < numAssets; i++) { + const { asset } = await comet.getAssetInfo(i); + expect(await comet.collateralBalanceOf(albert.address, asset)).to.eq(0); + } + + // clears assetsIn + expect((await comet.userBasic(albert.address)).assetsIn).to.eq(0); + } +); + scenario( 'Comet#liquidation > prevents liquidation when absorb is paused', { diff --git a/scenario/SupplyScenario.ts b/scenario/SupplyScenario.ts index a3ec668af..325da8664 100644 --- a/scenario/SupplyScenario.ts +++ b/scenario/SupplyScenario.ts @@ -2,6 +2,9 @@ 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 { ContractReceipt } from 'ethers'; +import { matchesDeployment } from './utils'; +import { exp } from '../test/helpers'; +import { ethers } from 'hardhat'; // XXX introduce a SupplyCapConstraint to separately test the happy path and revert path instead // of testing them conditionally @@ -136,6 +139,52 @@ scenario( } ); +scenario( + 'Comet#supply > base asset with token fees', + { + tokenBalances: { + albert: { $base: 1000 }, // in units of asset, not wei + }, + filter: async (ctx) => matchesDeployment(ctx, [{ network: 'mainnet', deployment: 'usdt' }]) + }, + async ({ comet, actors }, context, world) => { + // Set fees for USDT for testing + const USDT = await world.deploymentManager.existing('USDT', await comet.baseToken(), world.base.network); + const USDTAdminAddress = await USDT.owner(); + await world.deploymentManager.hre.network.provider.send('hardhat_setBalance', [ + USDTAdminAddress, + ethers.utils.hexStripZeros(ethers.utils.parseEther('100').toHexString()), + ]); + await world.deploymentManager.hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [USDTAdminAddress], + }); + // mine a block to ensure the impersonation is effective + const USDTAdminSigner = await world.deploymentManager.hre.ethers.getSigner(USDTAdminAddress); + // 10 basis points, and max 10 USDT + await USDT.connect(USDTAdminSigner).setParams(10, 10); + + const { albert } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(1000n * scale); + + // Albert supplies 1000 units of base to Comet + await baseAsset.approve(albert, comet.address); + const txn = await albert.supplyAsset({ asset: baseAsset.address, amount: 1000n * scale }); + + const baseIndexScale = (await comet.baseIndexScale()).toBigInt(); + const baseSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex.toBigInt(); + const baseSupplied = getExpectedBaseBalance(999n * scale, baseIndexScale, baseSupplyIndex); + + expect(await comet.balanceOf(albert.address)).to.be.equal(baseSupplied); + + return txn; // return txn to measure gas + } +); + scenario( 'Comet#supply > repay borrow', { @@ -167,6 +216,104 @@ scenario( } ); +scenario( + 'Comet#supply > repay borrow with token fees', + { + tokenBalances: { + albert: { $base: '==1000' } + }, + cometBalances: { + albert: { $base: -1000 } // in units of asset, not wei + }, + filter: async (ctx) => matchesDeployment(ctx, [{ network: 'mainnet', deployment: 'usdt' }]), + }, + async ({ comet, actors }, context, world) => { + // Set fees for USDT for testing + const USDT = await world.deploymentManager.existing('USDT', await comet.baseToken(), world.base.network); + const USDTAdminAddress = await USDT.owner(); + await world.deploymentManager.hre.network.provider.send('hardhat_setBalance', [ + USDTAdminAddress, + ethers.utils.hexStripZeros(ethers.utils.parseEther('100').toHexString()), + ]); + await world.deploymentManager.hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [USDTAdminAddress], + }); + // mine a block to ensure the impersonation is effective + const USDTAdminSigner = await world.deploymentManager.hre.ethers.getSigner(USDTAdminAddress); + // 10 basis points, and max 10 USDT + await USDT.connect(USDTAdminSigner).setParams(10, 10); + + const { albert } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + const utilization = await comet.getUtilization(); + const borrowRate = (await comet.getBorrowRate(utilization)).toBigInt(); + + expectApproximately(await albert.getCometBaseBalance(), -1000n * scale, getInterest(1000n * scale, borrowRate, 1n) + 1n); + + // Albert repays 1000 units of base borrow + await baseAsset.approve(albert, comet.address); + const txn = await albert.supplyAsset({ asset: baseAsset.address, amount: 1000n * scale }); + + // XXX all these timings are crazy + // Expect to have -1000000, due to token fee, alber only repay 999 USDT instead of 1000 USDT, thus alber still owe 1 USDT which is 1000000 + expectApproximately(await albert.getCometBaseBalance(), -1n * exp(1, 6), getInterest(1000n * scale, borrowRate, 4n) + 2n); + + return txn; // return txn to measure gas + } +); + +scenario( + 'Comet#supply > repay all borrow with token fees', + { + tokenBalances: { + albert: { $base: '==1000' } + }, + cometBalances: { + albert: { $base: -999 } // in units of asset, not wei + }, + filter: async (ctx) => matchesDeployment(ctx, [{ network: 'mainnet', deployment: 'usdt' }]), + }, + async ({ comet, actors }, context, world) => { + // Set fees for USDT for testing + const USDT = await world.deploymentManager.existing('USDT', await comet.baseToken(), world.base.network); + const USDTAdminAddress = await USDT.owner(); + await world.deploymentManager.hre.network.provider.send('hardhat_setBalance', [ + USDTAdminAddress, + ethers.utils.hexStripZeros(ethers.utils.parseEther('100').toHexString()), + ]); + await world.deploymentManager.hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [USDTAdminAddress], + }); + // mine a block to ensure the impersonation is effective + const USDTAdminSigner = await world.deploymentManager.hre.ethers.getSigner(USDTAdminAddress); + // 10 basis points, and max 10 USDT + await USDT.connect(USDTAdminSigner).setParams(10, 10); + + const { albert } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + const utilization = await comet.getUtilization(); + const borrowRate = (await comet.getBorrowRate(utilization)).toBigInt(); + + expectApproximately(await albert.getCometBaseBalance(), -999n * scale, getInterest(999n * scale, borrowRate, 1n) + 1n); + + // Albert repays 1000 units of base borrow + await baseAsset.approve(albert, comet.address); + const txn = await albert.supplyAsset({ asset: baseAsset.address, amount: 1000n * scale }); + + // XXX all these timings are crazy + // albert supply 1000 USDT to repay, 1000USDT * (99.9%) = 999 USDT, thus albert should have just enough to repay his debt of 999 USDT. + expectApproximately(await albert.getCometBaseBalance(), 0n, getInterest(1000n * scale, borrowRate, 4n) + 2n); + + return txn; // return txn to measure gas + } +); + scenario( 'Comet#supplyFrom > base asset', { @@ -200,6 +347,56 @@ scenario( } ); +scenario( + 'Comet#supplyFrom > base asset with token fees', + { + tokenBalances: { + albert: { $base: 1000 }, // in units of asset, not wei + }, + filter: async (ctx) => matchesDeployment(ctx, [{ network: 'mainnet', deployment: 'usdt' }]), + }, + async ({ comet, actors }, context, world) => { + // Set fees for USDT for testing + const USDT = await world.deploymentManager.existing('USDT', await comet.baseToken(), world.base.network); + const USDTAdminAddress = await USDT.owner(); + await world.deploymentManager.hre.network.provider.send('hardhat_setBalance', [ + USDTAdminAddress, + ethers.utils.hexStripZeros(ethers.utils.parseEther('100').toHexString()), + ]); + await world.deploymentManager.hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [USDTAdminAddress], + }); + // mine a block to ensure the impersonation is effective + const USDTAdminSigner = await world.deploymentManager.hre.ethers.getSigner(USDTAdminAddress); + // 10 basis points, and max 10 USDT + await USDT.connect(USDTAdminSigner).setParams(10, 10); + + const { albert, betty } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const scale = (await comet.baseScale()).toBigInt(); + + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(1000n * scale); + expect(await comet.balanceOf(betty.address)).to.be.equal(0n); + + await baseAsset.approve(albert, comet.address); + await albert.allow(betty, true); + + // Betty supplies 1000 units of base from Albert + const txn = await betty.supplyAssetFrom({ src: albert.address, dst: betty.address, asset: baseAsset.address, amount: 1000n * scale }); + + const baseIndexScale = (await comet.baseIndexScale()).toBigInt(); + const baseSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex.toBigInt(); + const baseSupplied = getExpectedBaseBalance(999n * scale, baseIndexScale, baseSupplyIndex); + + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(0n); + expect(await comet.balanceOf(betty.address)).to.be.equal(baseSupplied); + + return txn; // return txn to measure gas + } +); + scenario( 'Comet#supplyFrom > repay borrow', { @@ -309,7 +506,8 @@ scenario( /ERC20: insufficient allowance/, /transfer amount exceeds spender allowance/, /Dai\/insufficient-allowance/, - symbol === 'WETH' ? /Transaction reverted without a reason string/ : /.^/ + symbol === 'WETH' ? /Transaction reverted without a reason string/ : /.^/, + symbol === 'wstETH' ? /0xc2139725/ : /.^/ ] ); } @@ -393,7 +591,8 @@ scenario( [ /transfer amount exceeds balance/, /Dai\/insufficient-balance/, - symbol === 'WETH' ? /Transaction reverted without a reason string/ : /.^/ + symbol === 'WETH' ? /Transaction reverted without a reason string/ : /.^/, + symbol === 'wstETH' ? /0x00b284f2/ : /.^/ ] ); } diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 209bfff19..9d415532c 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -123,7 +123,14 @@ export const WHALES = { ], optimism: [ '0x2A82Ae142b2e62Cb7D10b55E323ACB1Cab663a26', // OP whale - '0x8af3827a41c26c7f32c81e93bb66e837e0210d5c' // USDC whale + '0x8af3827a41c26c7f32c81e93bb66e837e0210d5c', // USDC whale + '0x86Bb63148d17d445Ed5398ef26Aa05Bf76dD5b59', // WETH whales: + '0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8', + '0x86E715415D8C8435903d1e8204fA1e9784Aa7305', + '0xc45A479877e1e9Dfe9FcD4056c699575a1045dAA', // wstETH whale + '0xBA12222222228d8Ba445958a75a0704d566BF2C8', // rETH whales: + '0x724dc807b04555b71ed48a6896b6F41593b8C637', + '0x274d9E726844AB52E351e8F1272e7fc3f58B7E5F' ] }; diff --git a/test/buy-collateral-test.ts b/test/buy-collateral-test.ts index ca271af03..b82ad10ab 100644 --- a/test/buy-collateral-test.ts +++ b/test/buy-collateral-test.ts @@ -1,4 +1,4 @@ -import { EvilToken, EvilToken__factory, FaucetToken } from '../build/types'; +import { EvilToken, EvilToken__factory, NonStandardFaucetFeeToken__factory, FaucetToken, NonStandardFaucetFeeToken } from '../build/types'; import { ethers, event, expect, exp, getBlock, makeProtocol, portfolio, ReentryAttack, wait } from './helpers'; describe('buyCollateral', function () { @@ -323,12 +323,93 @@ describe('buyCollateral', function () { await expect(cometAsA.buyCollateral(COMP.address, exp(50, 18), 50e6, alice.address)).to.be.revertedWith("custom error 'Paused()'"); }); - it.skip('buys the correct amount in a fee-like situation', async () => { - // Note: fee-tokens are not currently supported (for efficiency) and should not be added + it('buys the correct amount in a fee-like situation', async () => { + const protocol = await makeProtocol({ + base: 'USDT', + storeFrontPriceFactor: exp(0.5, 18), + targetReserves: 100, + assets: { + USDT: { + initial: 1e6, + decimals: 6, + initialPrice: 1, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }, + COMP: { + initial: 1e7, + decimals: 18, + initialPrice: 1, + liquidationFactor: exp(0.8, 18), + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }, + } + }); + + const { comet, tokens, users: [alice] } = protocol; + const { USDT, COMP } = tokens; + + // Set both COMP and USDT with 1% fees + // So we can test internal accounting works correctly in both ways: 1. correctly deducting fees from payment during buyCollateral 2. correctly deducting fees from collateral token to buyer + await (COMP as NonStandardFaucetFeeToken).setParams(100, 10000); + await (USDT as NonStandardFaucetFeeToken).setParams(100, 10000); + + const cometAsA = comet.connect(alice); + const baseAsA = USDT.connect(alice); + + // Reserves are at 0 wei + + // Set up token balances and accounting + await USDT.allocateTo(alice.address, 100e6); + await COMP.allocateTo(comet.address, exp(60, 18)); + + const r0 = await comet.getReserves(); + const p0 = await portfolio(protocol, alice.address); + await wait(baseAsA.approve(comet.address, exp(50, 6))); + // Alice buys 50e6 wei USDT worth of COMP + + // Some math writeup for better understanding in each expects number: + // assetPriceDiscount = 1 - (storeFrontPriceFactor * (1 - liquidationFactor)) * assetPrice + // assetPriceDiscount = 1 - (0.5 * (1 - 0.8)) * 1 = 0.9 + // collateralAmount = basePrice * baseAmount / assetPriceDiscount + // collateralAmount = 1 * 50 * (1 - Token Fee) / 0.9 = 1 * 50 * 0.99 / 0.9 = 55 + // actualReceiveCollateral = 55 * (1 - Token Fee) = 55 * 0.99 = 54.45 + const txn = await wait(cometAsA.buyCollateral(COMP.address, exp(50, 18), 50e6, alice.address)); + const p1 = await portfolio(protocol, alice.address); + const r1 = await comet.getReserves(); + + expect(r0).to.be.equal(0n); + expect(r0).to.be.lt(await comet.targetReserves()); + expect(p0.internal).to.be.deep.equal({ USDT: 0n, COMP: 0n }); + expect(p0.external).to.be.deep.equal({ USDT: exp(100, 6), COMP: 0n }); + expect(p1.internal).to.be.deep.equal({ USDT: 0n, COMP: 0n }); + expect(p1.external).to.be.deep.equal({ USDT: exp(50, 6), COMP: exp(54.45, 18) }); + expect(r1).to.be.equal(exp(49.5, 6)); // 50 * 0.99 = 49.5 + expect(event(txn, 0)).to.be.deep.equal({ + Transfer: { + from: alice.address, + to: comet.address, + amount: exp(49.5, 6), + } + }); + expect(event(txn, 1)).to.be.deep.equal({ + Transfer: { + from: comet.address, + to: alice.address, + amount: exp(54.45, 18), + } + }); + expect(event(txn, 2)).to.be.deep.equal({ + BuyCollateral: { + buyer: alice.address, + asset: COMP.address, + baseAmount: exp(49.5, 6), + collateralAmount: exp(55, 18), + } + }); }); describe('reentrancy', function() { - it('is not broken by reentrancy supply ', async () => { + it('is blocked during reentrant supply', async () => { const wethArgs = { initial: 1e4, decimals: 18, @@ -379,7 +460,7 @@ describe('buyCollateral', function () { source: evilAlice.address, destination: evilBob.address, asset: EVIL.address, - amount: 1e6, + amount: 3000e6, maxCalls: 1 }); await EVIL.setAttack(attack); @@ -409,7 +490,7 @@ describe('buyCollateral', function () { // approve Comet to move funds await normalUSDC.connect(normalAlice).approve(normalComet.address, exp(5000, 6)); await EVIL.connect(evilAlice).approve(EVIL.address, exp(5000, 6)); - + await EVIL.connect(evilAlice).approve(evilComet.address, exp(5000, 6)); // perform the supplies for each protocol in the same block, so that the // same amount of time elapses for each when calculating interest await ethers.provider.send('evm_setAutomine', [false]); @@ -441,10 +522,11 @@ describe('buyCollateral', function () { .connect(evilAlice) .buyCollateral( evilWETH.address, - exp(.5, 18), + exp(0, 18), exp(3000, 6), evilAlice.address ); + await evilComet.accrueAccount(evilAlice.address); // !important; reenable automine @@ -460,7 +542,9 @@ describe('buyCollateral', function () { expect(normalTotalsBasic.baseBorrowIndex).to.equal(evilTotalsBasic.baseBorrowIndex); expect(normalTotalsBasic.trackingSupplyIndex).to.equal(evilTotalsBasic.trackingSupplyIndex); expect(normalTotalsBasic.trackingBorrowIndex).to.equal(evilTotalsBasic.trackingBorrowIndex); - expect(normalTotalsBasic.totalSupplyBase).to.equal(evilTotalsBasic.totalSupplyBase); + expect(normalTotalsBasic.totalSupplyBase).to.equal(1e6); + // EvilToken attack should be blocked + expect(evilTotalsBasic.totalSupplyBase).to.equal(0); expect(normalTotalsBasic.totalBorrowBase).to.equal(evilTotalsBasic.totalBorrowBase); expect(normalTotalsCollateral.totalSupplyAsset).to.eq(evilTotalsCollateral.totalSupplyAsset); @@ -474,7 +558,73 @@ describe('buyCollateral', function () { const normalBobPortfolio = await portfolio(normalProtocol, normalBob.address); const evilBobPortfolio = await portfolio(evilProtocol, evilBob.address); - expect(normalBobPortfolio.internal.USDC).to.equal(evilBobPortfolio.internal.EVIL); + expect(normalBobPortfolio.internal.USDC).to.equal(1e6); + // EvilToken attack should be blocked, so totalSupplyBase should be 0 + expect(evilBobPortfolio.internal.EVIL).to.equal(0); + }); + + it('reentrant buyCollateral is reverted', async () => { + const wethArgs = { + initial: 1e4, + decimals: 18, + initialPrice: 3000, + }; + const baseTokenArgs = { + decimals: 6, + initial: 1e6, + initialPrice: 1, + }; + + // malicious scenario, EVIL token is base + const evilProtocol = await makeProtocol({ + base: 'EVIL', + assets: { + EVIL: { + ...baseTokenArgs, + factory: await ethers.getContractFactory('EvilToken') as EvilToken__factory, + }, + WETH: wethArgs, + }, + targetReserves: 1 + }); + const { + comet: evilComet, + tokens: evilTokens, + users: [evilAlice, evilBob] + } = evilProtocol; + const { WETH: evilWETH, EVIL } = <{ WETH: FaucetToken, EVIL: EvilToken }>evilTokens; + + // add attack to EVIL token + const attack = Object.assign({}, await EVIL.getAttack(), { + attackType: ReentryAttack.BuyCollateral, + source: evilAlice.address, + destination: evilBob.address, + asset: evilWETH.address, + amount: 3000e6, + maxCalls: 1 + }); + await EVIL.setAttack(attack); + + // allocate tokens (evil) + await evilWETH.allocateTo(evilComet.address, exp(100, 18)); + await EVIL.allocateTo(evilAlice.address, exp(5000, 6)); + + // approve Comet to move funds + await EVIL.connect(evilAlice).approve(EVIL.address, exp(5000, 6)); + await EVIL.connect(evilAlice).approve(evilComet.address, exp(5000, 6)); + + // authorize EVIL, since callback will originate from EVIL token address + await evilComet.connect(evilAlice).allow(EVIL.address, true); + + // call buyCollateral; supplyFrom is called in callback + await expect(evilComet + .connect(evilAlice) + .buyCollateral( + evilWETH.address, + exp(0, 18), + exp(3000, 6), + evilAlice.address + )).to.be.revertedWith("custom error 'ReentrantCallBlocked()'"); }); }); -}); +}); \ No newline at end of file diff --git a/test/comet-ext-test.ts b/test/comet-ext-test.ts index 265130296..d297bf22b 100644 --- a/test/comet-ext-test.ts +++ b/test/comet-ext-test.ts @@ -1,11 +1,11 @@ -import { CometHarnessInterface, FaucetToken } from '../build/types'; +import { CometHarnessInterface, FaucetToken, NonStandardFaucetFeeToken } from '../build/types'; import { expect, exp, makeProtocol, setTotalsBasic } from './helpers'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; describe('CometExt', function () { let comet: CometHarnessInterface; let user: SignerWithAddress; - let tokens: { [symbol: string]: FaucetToken }; + let tokens: { [symbol: string]: FaucetToken | NonStandardFaucetFeeToken }; beforeEach(async () => { ({ diff --git a/test/helpers.ts b/test/helpers.ts index 31ebf1d81..95107403d 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -30,6 +30,8 @@ import { Configurator__factory, CometHarnessInterface, CometInterface, + NonStandardFaucetFeeToken, + NonStandardFaucetFeeToken__factory, } from '../build/types'; import { BigNumber } from 'ethers'; import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider'; @@ -42,7 +44,8 @@ export type Numeric = number | bigint; export enum ReentryAttack { TransferFrom = 0, WithdrawFrom = 1, - SupplyFrom = 2 + SupplyFrom = 2, + BuyCollateral = 3, } export type ProtocolOpts = { @@ -58,7 +61,7 @@ export type ProtocolOpts = { supplyCap?: Numeric; initialPrice?: number; priceFeedDecimals?: number; - factory?: FaucetToken__factory | EvilToken__factory | FaucetWETH__factory; + factory?: FaucetToken__factory | EvilToken__factory | FaucetWETH__factory | NonStandardFaucetFeeToken__factory; }; }; name?: string; @@ -96,7 +99,7 @@ export type Protocol = { reward: string; comet: Comet; tokens: { - [symbol: string]: FaucetToken; + [symbol: string]: FaucetToken | NonStandardFaucetFeeToken; }; unsupportedToken: FaucetToken; priceFeeds: { @@ -114,7 +117,7 @@ export type ConfiguratorAndProtocol = { export type RewardsOpts = { governor?: SignerWithAddress; - configs?: [Comet, FaucetToken, Numeric?][]; + configs?: [Comet, FaucetToken | NonStandardFaucetFeeToken, Numeric?][]; }; export type Rewards = { @@ -503,7 +506,7 @@ export async function makeBulker(opts: BulkerOpts): Promise { bulker }; } -export async function bumpTotalsCollateral(comet: CometHarnessInterface, token: FaucetToken, delta: bigint): Promise { +export async function bumpTotalsCollateral(comet: CometHarnessInterface, token: FaucetToken | NonStandardFaucetFeeToken, delta: bigint): Promise { const t0 = await comet.totalsCollateral(token.address); const t1 = Object.assign({}, t0, { totalSupplyAsset: t0.totalSupplyAsset.toBigInt() + delta }); await token.allocateTo(comet.address, delta); @@ -628,4 +631,4 @@ function convertToBigInt(arr) { export function getGasUsed(tx: TransactionResponseExt): bigint { return tx.receipt.gasUsed.mul(tx.receipt.effectiveGasPrice).toBigInt(); -} +} \ No newline at end of file diff --git a/test/supply-test.ts b/test/supply-test.ts index 0987a2f1c..c883fdb8f 100644 --- a/test/supply-test.ts +++ b/test/supply-test.ts @@ -1,5 +1,5 @@ -import { ethers, event, expect, exp, makeProtocol, portfolio, ReentryAttack, setTotalsBasic, wait, fastForward } from './helpers'; -import { EvilToken, EvilToken__factory } from '../build/types'; +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 () => { @@ -52,7 +52,7 @@ describe('supplyTo', function () { 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(120000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(124000); }); it('supplies max base borrow balance (including accrued) from sender if the asset is base', async () => { @@ -259,7 +259,7 @@ describe('supplyTo', function () { 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(120000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(124000); }); it('supplies collateral from sender if the asset is collateral', async () => { @@ -305,7 +305,7 @@ describe('supplyTo', function () { 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(140000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(153000); }); it('calculates base principal correctly', async () => { @@ -405,11 +405,129 @@ describe('supplyTo', function () { await expect(cometAsB.supplyTo(alice.address, COMP.address, ethers.constants.MaxUint256)).to.be.revertedWith("custom error 'InvalidUInt128()'"); }); - it.skip('supplies the correct amount in a fee-like situation', async () => { - // Note: fee-tokens are not currently supported (for efficiency) and should not be added + it('supplies base the correct amount in a fee-like situation', async () => { + const assets = defaultAssets(); + // Add USDT to assets on top of default assets + assets['USDT'] = { + initial: 1e6, + decimals: 6, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + const protocol = await makeProtocol({ base: 'USDT', assets: assets }); + const { comet, tokens, users: [alice, bob] } = protocol; + const { USDT } = tokens; + + // Set fee to 0.1% + await (USDT as NonStandardFaucetFeeToken).setParams(10, 10); + + const _i0 = await USDT.allocateTo(bob.address, 1000e6); + const baseAsB = USDT.connect(bob); + const cometAsB = comet.connect(bob); + + const t0 = await comet.totalsBasic(); + const p0 = await portfolio(protocol, alice.address); + const q0 = await portfolio(protocol, bob.address); + const _a0 = await wait(baseAsB.approve(comet.address, 1000e6)); + const s0 = await wait(cometAsB.supplyTo(alice.address, USDT.address, 1000e6)); + const t1 = await comet.totalsBasic(); + const p1 = await portfolio(protocol, alice.address); + const q1 = await portfolio(protocol, bob.address); + + expect(event(s0, 0)).to.be.deep.equal({ + Transfer: { + from: bob.address, + to: comet.address, + amount: BigInt(999e6), + } + }); + expect(event(s0, 1)).to.be.deep.equal({ + Supply: { + from: bob.address, + dst: alice.address, + amount: BigInt(999e6), + } + }); + expect(event(s0, 2)).to.be.deep.equal({ + Transfer: { + from: ethers.constants.AddressZero, + to: alice.address, + amount: BigInt(999e6), + } + }); + + expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); + expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); + expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); + expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: exp(1000, 6) }); + expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: exp(999, 6) }); + expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); + expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); + expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, USDT: 0n }); + expect(t1.totalSupplyBase).to.be.equal(t0.totalSupplyBase.add(999e6)); + expect(t1.totalBorrowBase).to.be.equal(t0.totalBorrowBase); + // Fee Token logics will cost a bit more gas than standard ERC20 token with no fee calculation + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(151000); + }); + + it('supplies collateral the correct amount in a fee-like situation', async () => { + const assets = defaultAssets(); + // Add FeeToken Collateral to assets on top of default assets + assets['FeeToken'] = { + initial: 1e8, + decimals: 18, + factory: (await ethers.getContractFactory('NonStandardFaucetFeeToken')) as NonStandardFaucetFeeToken__factory, + }; + + const protocol = await makeProtocol({ base: 'USDC', assets: assets }); + const { comet, tokens, users: [alice, bob] } = protocol; + const { FeeToken } = tokens; + + // Set fee to 0.1% + await (FeeToken as NonStandardFaucetFeeToken).setParams(10, 10); + + const _i0 = await FeeToken.allocateTo(bob.address, 2000e8); + const baseAsB = FeeToken.connect(bob); + const cometAsB = comet.connect(bob); + + const t0 = await comet.totalsCollateral(FeeToken.address); + const p0 = await portfolio(protocol, alice.address); + const q0 = await portfolio(protocol, bob.address); + const _a0 = await wait(baseAsB.approve(comet.address, 2000e8)); + const s0 = await wait(cometAsB.supplyTo(alice.address, FeeToken.address, 2000e8)); + const t1 = await comet.totalsCollateral(FeeToken.address); + const p1 = await portfolio(protocol, alice.address); + const q1 = await portfolio(protocol, bob.address); + + expect(event(s0, 0)).to.be.deep.equal({ + Transfer: { + from: bob.address, + to: comet.address, + amount: BigInt(1998e8), + } + }); + expect(event(s0, 1)).to.be.deep.equal({ + SupplyCollateral: { + from: bob.address, + dst: alice.address, + asset: FeeToken.address, + amount: BigInt(1998e8), + } + }); + + expect(p0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); + expect(p0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); + expect(q0.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); + expect(q0.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: exp(2000, 8) }); + expect(p1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: exp(1998, 8) }); + expect(p1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); + expect(q1.internal).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); + expect(q1.external).to.be.deep.equal({ USDC: 0n, COMP: 0n, WETH: 0n, WBTC: 0n, FeeToken: 0n }); + expect(t1.totalSupplyAsset).to.be.equal(t0.totalSupplyAsset.add(1998e8)); + // Fee Token logics will cost a bit more gas than standard ERC20 token with no fee calculation + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(186000); }); - it('prevents exceeding the supply cap via re-entrancy', async () => { + it('blocks reentrancy from exceeding the supply cap', async () => { const { comet, tokens, users: [alice, bob] } = await makeProtocol({ assets: { USDC: { @@ -436,10 +554,11 @@ describe('supplyTo', function () { 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.revertedWith("custom error 'SupplyCapExceeded()'"); + ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); }); }); diff --git a/test/update-assets-in-test.ts b/test/update-assets-in-test.ts index 347000b4c..d9943abd5 100644 --- a/test/update-assets-in-test.ts +++ b/test/update-assets-in-test.ts @@ -24,7 +24,7 @@ describe('updateAssetsIn', function () { ]); }); - it('works for up to 15 assets', async () => { + it('works for up to 12 assets', async () => { const { comet, tokens, users } = await makeProtocol({ assets: { USDC: {}, @@ -40,16 +40,13 @@ describe('updateAssetsIn', function () { ASSET10: {}, ASSET11: {}, ASSET12: {}, - ASSET13: {}, - ASSET14: {}, - ASSET15: {}, }, }); const [user] = users; - const asset15address = tokens['ASSET15'].address; + const asset12address = tokens['ASSET12'].address; - await comet.updateAssetsInExternal(user.address, asset15address, 0, 1); - expect(await comet.getAssetList(user.address)).to.deep.equal([asset15address]); + await comet.updateAssetsInExternal(user.address, asset12address, 0, 1); + expect(await comet.getAssetList(user.address)).to.deep.equal([asset12address]); }); it('does not change state when both initialUserBalance and finalUserBalance are 0', async () => { @@ -105,4 +102,4 @@ describe('updateAssetsIn', function () { comet.updateAssetsInExternal(user.address, erroneousAssetAddress, 0, 100) ).to.be.revertedWith("custom error 'BadAsset()'"); }); -}); +}); \ No newline at end of file diff --git a/test/withdraw-test.ts b/test/withdraw-test.ts index a28d8735b..7fe2c0f71 100644 --- a/test/withdraw-test.ts +++ b/test/withdraw-test.ts @@ -54,7 +54,7 @@ describe('withdrawTo', function () { 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(100000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(106000); }); it('does not emit Transfer for 0 burn', async () => { @@ -144,7 +144,7 @@ describe('withdrawTo', function () { 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(110000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(115000); }); it('withdraw max base should withdraw 0 if user has a borrow position', async () => { @@ -190,7 +190,7 @@ describe('withdrawTo', function () { 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(110000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(121000); }); // This demonstrates a weird quirk of the present value/principal value rounding down math. @@ -279,7 +279,7 @@ describe('withdrawTo', function () { 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(80000); + expect(Number(s0.receipt.gasUsed)).to.be.lessThan(85000); }); it('calculates base principal correctly', async () => { @@ -475,7 +475,7 @@ describe('withdraw', function () { }); describe('reentrancy', function () { - it('is not broken by malicious reentrancy transferFrom', async () => { + it('blocks malicious reentrant transferFrom', async () => { const { comet, tokens, users: [alice, bob] } = await makeProtocol({ assets: { USDC: { @@ -508,10 +508,10 @@ describe('withdraw', function () { await comet.setCollateralBalance(alice.address, EVIL.address, exp(1, 6)); await comet.connect(alice).allow(EVIL.address, true); - // in callback, EVIL token calls transferFrom(alice.address, bob.address, 1e6) + // In callback, EVIL token calls transferFrom(alice.address, bob.address, 1e6) await expect( comet.connect(alice).withdraw(EVIL.address, 1e6) - ).to.be.revertedWith("custom error 'NotCollateralized()'"); + ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); // no USDC transferred expect(await USDC.balanceOf(comet.address)).to.eq(100e6); @@ -521,7 +521,7 @@ describe('withdraw', function () { expect(await USDC.balanceOf(bob.address)).to.eq(0); }); - it('is not broken by malicious reentrancy withdrawFrom', async () => { + it('blocks malicious reentrant withdrawFrom', async () => { const { comet, tokens, users: [alice, bob] } = await makeProtocol({ assets: { USDC: { @@ -558,7 +558,7 @@ describe('withdraw', function () { // in callback, EvilToken attempts to withdraw USDC to bob's address await expect( comet.connect(alice).withdraw(EVIL.address, 1e6) - ).to.be.revertedWith("custom error 'NotCollateralized()'"); + ).to.be.revertedWithCustomError(comet, 'ReentrantCallBlocked'); // no USDC transferred expect(await USDC.balanceOf(comet.address)).to.eq(100e6);