From 4db8aca43bc1f48c3666df0fe29ca5e9f012fe43 Mon Sep 17 00:00:00 2001 From: Jared Flatow Date: Fri, 23 Sep 2022 14:50:39 -0700 Subject: [PATCH] Mainnet ETH Deployment * Update Github Actions to include mainnet-eth * ETH-base bulker scenario * WstETHPriceFeed + tests (#600) * Non-ETH and ETH bulker scenarios (all actions in one txn) * Mainnet WETH Bulker (#611) * Add a supply cap constraint and set initial caps to 0 for cWETHv3 * Update the collateral params based on Gauntlet recommendations (#628) https://hackmd.io/wncIvkFTReWUe2AMMK2ezA?view * Price feeds for WETH deployment + Bulker changes for OZ audit (#625) This PR implements and modifies price feeds to support the upcoming WETH deployment. The favored plan so far is to use ETH-denominated price feeds as opposed to USD price feeds, but stick with using 8 decimals for prices to avoid having to change the `Comet` and `Configurator` implementations. This would require: - A new wrapper price feed (`ScalingPriceFeed.sol`) that scales prices up or down to 8 decimals - A new `ConstantPriceFeed` that always returns 1e8 for the `WETH` base asset, since should always hold a 1:1 value with ETH - Modifications to the `WstETHPriceFeed` to return prices in terms of ETH instead of USD This is an alternative approach to #626, which is a more complex change but could be a better long-term solution. *Note: This PR also now contains the changes from #634 and #635, which address some suggestions made by OZ for their audit of `WstETHPriceFeed` and `Bulker`.* --- .github/workflows/enact-migration.yaml | 4 +- .github/workflows/prepare-migration.yaml | 2 +- .github/workflows/run-scenarios.yaml | 2 +- contracts/Bulker.sol | 153 ----------- contracts/Comet.sol | 8 +- contracts/CometCore.sol | 1 - contracts/ConstantPriceFeed.sol | 45 ++++ contracts/IERC20NonStandard.sol | 14 + contracts/IPriceFeed.sol | 25 ++ contracts/IWstETH.sol | 23 ++ contracts/ScalingPriceFeed.sol | 81 ++++++ contracts/WstETHPriceFeed.sol | 73 ++++++ contracts/bulkers/BaseBulker.sol | 272 ++++++++++++++++++++ contracts/bulkers/MainnetBulker.sol | 80 ++++++ contracts/test/NonStandardFaucetToken.sol | 66 +++++ contracts/test/SimplePriceFeed.sol | 50 ++-- contracts/test/SimpleWstETH.sol | 12 + deployments/mainnet/weth/configuration.json | 45 ++++ deployments/mainnet/weth/deploy.ts | 55 ++++ deployments/mainnet/weth/relations.ts | 17 ++ deployments/mumbai/usdc/deploy.ts | 2 +- hardhat.config.ts | 9 + plugins/deployment_manager/Cache.ts | 13 +- plugins/deployment_manager/Deploy.ts | 4 +- plugins/deployment_manager/Utils.ts | 11 + scenario/BulkerScenario.ts | 179 ++++++++++++- scenario/GovernanceScenario.ts | 67 ++++- scenario/MainnetBulkerScenario.ts | 122 +++++++++ scenario/RewardsScenario.ts | 8 +- scenario/SupplyScenario.ts | 14 +- scenario/constraints/SupplyCapConstraint.ts | 60 +++++ scenario/constraints/index.ts | 1 + scenario/context/CometContext.ts | 19 +- scenario/utils/index.ts | 33 ++- src/deploy/NetworkConfiguration.ts | 6 +- test/absorb-test.ts | 20 +- test/bulker-test.ts | 108 ++++++-- test/constant-price-feed-test.ts | 49 ++++ test/helpers.ts | 8 +- test/is-borrow-collateralized-test.ts | 8 +- test/is-liquidatable-test.ts | 9 +- test/scaling-price-feed-test.ts | 115 +++++++++ test/wsteth-price-feed.ts | 117 +++++++++ 43 files changed, 1740 insertions(+), 270 deletions(-) delete mode 100644 contracts/Bulker.sol create mode 100644 contracts/ConstantPriceFeed.sol create mode 100644 contracts/IERC20NonStandard.sol create mode 100644 contracts/IPriceFeed.sol create mode 100644 contracts/IWstETH.sol create mode 100644 contracts/ScalingPriceFeed.sol create mode 100644 contracts/WstETHPriceFeed.sol create mode 100644 contracts/bulkers/BaseBulker.sol create mode 100644 contracts/bulkers/MainnetBulker.sol create mode 100644 contracts/test/NonStandardFaucetToken.sol create mode 100644 contracts/test/SimpleWstETH.sol create mode 100644 deployments/mainnet/weth/configuration.json create mode 100644 deployments/mainnet/weth/deploy.ts create mode 100644 deployments/mainnet/weth/relations.ts create mode 100644 scenario/MainnetBulkerScenario.ts create mode 100644 scenario/constraints/SupplyCapConstraint.ts create mode 100644 test/constant-price-feed-test.ts create mode 100644 test/scaling-price-feed-test.ts create mode 100644 test/wsteth-price-feed.ts diff --git a/.github/workflows/enact-migration.yaml b/.github/workflows/enact-migration.yaml index a49d05e5d..e0c607834 100644 --- a/.github/workflows/enact-migration.yaml +++ b/.github/workflows/enact-migration.yaml @@ -22,7 +22,7 @@ on: description: Simulate no_enacted: type: boolean - description: Do not write Enacted + description: Do not write Enacted run_id: description: Run ID for Artifact eth_pk: @@ -40,7 +40,7 @@ jobs: - name: Seacrest uses: hayesgm/seacrest@v1 with: - ethereum_url: "${{ fromJSON('{\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"kovan\":\"https://kovan-eth.compound.finance\",\"mainnet\":\"https://mainnet-eth.compound.finance\",\"goerli\":\"https://goerli.infura.io/v3/$INFURA_KEY\",\"mumbai\":\"https://polygon-mumbai.infura.io/v3/$INFURA_KEY\"}')[inputs.network] }}" + ethereum_url: "${{ fromJSON('{\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"kovan\":\"https://kovan.infura.io/v3/$INFURA_KEY\",\"mainnet\":\"https://mainnet.infura.io/v3/$INFURA_KEY\",\"goerli\":\"https://goerli.infura.io/v3/$INFURA_KEY\",\"mumbai\":\"https://polygon-mumbai.infura.io/v3/$INFURA_KEY\"}')[inputs.network] }}" port: 8585 if: github.event.inputs.eth_pk == '' diff --git a/.github/workflows/prepare-migration.yaml b/.github/workflows/prepare-migration.yaml index 328413f49..c92a6425b 100644 --- a/.github/workflows/prepare-migration.yaml +++ b/.github/workflows/prepare-migration.yaml @@ -35,7 +35,7 @@ jobs: - name: Seacrest uses: hayesgm/seacrest@v1 with: - ethereum_url: "${{ fromJSON('{\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"kovan\":\"https://kovan-eth.compound.finance\",\"mainnet\":\"https://mainnet-eth.compound.finance\",\"goerli\":\"https://goerli.infura.io/v3/$INFURA_KEY\",\"mumbai\":\"https://polygon-mumbai.infura.io/v3/$INFURA_KEY\"}')[inputs.network] }}" + ethereum_url: "${{ fromJSON('{\"fuji\":\"https://api.avax-test.network/ext/bc/C/rpc\",\"kovan\":\"https://kovan.infura.io/v3/$INFURA_KEY\",\"mainnet\":\"https://mainnet.infura.io/v3/$INFURA_KEY\",\"goerli\":\"https://goerli.infura.io/v3/$INFURA_KEY\",\"mumbai\":\"https://polygon-mumbai.infura.io/v3/$INFURA_KEY\"}')[inputs.network] }}" port: 8585 if: github.event.inputs.eth_pk == '' diff --git a/.github/workflows/run-scenarios.yaml b/.github/workflows/run-scenarios.yaml index 25346a1ad..f11d2284b 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, goerli, fuji, mumbai ] + bases: [ development, mainnet, mainnet-weth, goerli, fuji, mumbai ] name: Run scenarios env: ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_KEY }} diff --git a/contracts/Bulker.sol b/contracts/Bulker.sol deleted file mode 100644 index 82ec598ab..000000000 --- a/contracts/Bulker.sol +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.15; - -import "./CometInterface.sol"; -import "./ERC20.sol"; -import "./IWETH9.sol"; - -interface IClaimable { - function claim(address comet, address src, bool shouldAccrue) external; - - function claimTo(address comet, address src, address to, bool shouldAccrue) external; -} - -contract Bulker { - /** General configuration constants **/ - address public immutable admin; - address payable public immutable weth; - - /** Actions **/ - uint public constant ACTION_SUPPLY_ASSET = 1; - uint public constant ACTION_SUPPLY_ETH = 2; - uint public constant ACTION_TRANSFER_ASSET = 3; - uint public constant ACTION_WITHDRAW_ASSET = 4; - uint public constant ACTION_WITHDRAW_ETH = 5; - uint public constant ACTION_CLAIM_REWARD = 6; - - /** Custom errors **/ - error InvalidArgument(); - error FailedToSendEther(); - error Unauthorized(); - - constructor(address admin_, address payable weth_) { - admin = admin_; - weth = weth_; - } - - /** - * @notice Fallback for receiving ether. Needed for ACTION_WITHDRAW_ETH. - */ - receive() external payable {} - - /** - * @notice A public function to sweep accidental ERC-20 transfers to this contract. Tokens are sent to admin (Timelock) - * @param recipient The address that will receive the swept funds - * @param asset The address of the ERC-20 token to sweep - */ - function sweepToken(address recipient, ERC20 asset) external { - if (msg.sender != admin) revert Unauthorized(); - - uint256 balance = asset.balanceOf(address(this)); - asset.transfer(recipient, balance); - } - - /** - * @notice A public function to sweep accidental ETH transfers to this contract. Tokens are sent to admin (Timelock) - * @param recipient The address that will receive the swept funds - */ - function sweepEth(address recipient) external { - if (msg.sender != admin) revert Unauthorized(); - - uint256 balance = address(this).balance; - (bool success, ) = recipient.call{ value: balance }(""); - if (!success) revert FailedToSendEther(); - } - - /** - * @notice Executes a list of actions in order - * @param actions The list of actions to execute in order - * @param data The list of calldata to use for each action - */ - function invoke(uint[] calldata actions, bytes[] calldata data) external payable { - if (actions.length != data.length) revert InvalidArgument(); - - uint unusedEth = msg.value; - for (uint i = 0; i < actions.length; ) { - uint action = actions[i]; - if (action == ACTION_SUPPLY_ASSET) { - (address comet, address to, address asset, uint amount) = abi.decode(data[i], (address, address, address, uint)); - supplyTo(comet, to, asset, amount); - } else if (action == ACTION_SUPPLY_ETH) { - (address comet, address to, uint amount) = abi.decode(data[i], (address, address, uint)); - unusedEth -= amount; - supplyEthTo(comet, to, amount); - } else if (action == ACTION_TRANSFER_ASSET) { - (address comet, address to, address asset, uint amount) = abi.decode(data[i], (address, address, address, uint)); - transferTo(comet, to, asset, amount); - } else if (action == ACTION_WITHDRAW_ASSET) { - (address comet, address to, address asset, uint amount) = abi.decode(data[i], (address, address, address, uint)); - withdrawTo(comet, to, asset, amount); - } else if (action == ACTION_WITHDRAW_ETH) { - (address comet, address to, uint amount) = abi.decode(data[i], (address, address, uint)); - withdrawEthTo(comet, to, amount); - } else if (action == ACTION_CLAIM_REWARD) { - (address comet, address rewards, address src, bool shouldAccrue) = abi.decode(data[i], (address, address, address, bool)); - claimReward(comet, rewards, src, shouldAccrue); - } - unchecked { i++; } - } - - // Refund unused ETH back to msg.sender - if (unusedEth > 0) { - (bool success, ) = msg.sender.call{ value: unusedEth }(""); - if (!success) revert FailedToSendEther(); - } - } - - /** - * @notice Supplies an asset to a user in Comet - */ - function supplyTo(address comet, address to, address asset, uint amount) internal { - CometInterface(comet).supplyFrom(msg.sender, to, asset, amount); - } - - /** - * @notice Wraps ETH and supplies WETH to a user in Comet - */ - function supplyEthTo(address comet, address to, uint amount) internal { - IWETH9(weth).deposit{ value: amount }(); - IWETH9(weth).approve(comet, amount); - CometInterface(comet).supplyFrom(address(this), to, weth, amount); - } - - /** - * @notice Transfers an asset to a user in Comet - */ - function transferTo(address comet, address to, address asset, uint amount) internal { - CometInterface(comet).transferAssetFrom(msg.sender, to, asset, amount); - } - - /** - * @notice Withdraws an asset to a user in Comet - */ - function withdrawTo(address comet, address to, address asset, uint amount) internal { - CometInterface(comet).withdrawFrom(msg.sender, to, asset, amount); - } - - /** - * @notice Withdraws WETH from Comet to a user after unwrapping it to ETH - */ - function withdrawEthTo(address comet, address to, uint amount) internal { - CometInterface(comet).withdrawFrom(msg.sender, address(this), weth, amount); - IWETH9(weth).withdraw(amount); - (bool success, ) = to.call{ value: amount }(""); - if (!success) revert FailedToSendEther(); - } - - /** - * @notice Claim reward for a user - */ - function claimReward(address comet, address rewards, address src, bool shouldAccrue) internal { - IClaimable(rewards).claim(comet, src, shouldAccrue); - } -} diff --git a/contracts/Comet.sol b/contracts/Comet.sol index 96347a61a..3c5d56442 100644 --- a/contracts/Comet.sol +++ b/contracts/Comet.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.15; import "./CometMainInterface.sol"; import "./ERC20.sol"; -import "./vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "./IPriceFeed.sol"; /** * @title Compound's Comet Contract @@ -144,7 +144,7 @@ contract Comet is CometMainInterface { if (config.storeFrontPriceFactor > FACTOR_SCALE) revert BadDiscount(); if (config.assetConfigs.length > MAX_ASSETS) revert TooManyAssets(); if (config.baseMinForRewards == 0) revert BadMinimum(); - if (AggregatorV3Interface(config.baseTokenPriceFeed).decimals() != PRICE_FEED_DECIMALS) revert BadDecimals(); + if (IPriceFeed(config.baseTokenPriceFeed).decimals() != PRICE_FEED_DECIMALS) revert BadDecimals(); // Copy configuration unchecked { @@ -240,7 +240,7 @@ contract Comet is CometMainInterface { } // Sanity check price feed and asset decimals - if (AggregatorV3Interface(priceFeed).decimals() != PRICE_FEED_DECIMALS) revert BadDecimals(); + if (IPriceFeed(priceFeed).decimals() != PRICE_FEED_DECIMALS) revert BadDecimals(); if (ERC20(asset).decimals() != decimals_) revert BadDecimals(); // Ensure collateral factors are within range @@ -471,7 +471,7 @@ contract Comet is CometMainInterface { * @return The price, scaled by `PRICE_SCALE` */ function getPrice(address priceFeed) override public view returns (uint256) { - (, int price, , , ) = AggregatorV3Interface(priceFeed).latestRoundData(); + (, int price, , , ) = IPriceFeed(priceFeed).latestRoundData(); if (price <= 0) revert BadPrice(); return uint256(price); } diff --git a/contracts/CometCore.sol b/contracts/CometCore.sol index 57e75401e..94e17d7f0 100644 --- a/contracts/CometCore.sol +++ b/contracts/CometCore.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.15; import "./CometConfiguration.sol"; import "./CometStorage.sol"; import "./CometMath.sol"; -import "./vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; abstract contract CometCore is CometConfiguration, CometStorage, CometMath { struct AssetInfo { diff --git a/contracts/ConstantPriceFeed.sol b/contracts/ConstantPriceFeed.sol new file mode 100644 index 000000000..043c9cadd --- /dev/null +++ b/contracts/ConstantPriceFeed.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "./IPriceFeed.sol"; + +contract ConstantPriceFeed is IPriceFeed { + /// @notice Version of the price feed + uint public constant override version = 1; + + /// @notice Description of the price feed + string public constant description = "Constant price feed"; + + /// @notice Number of decimals for returned prices + uint8 public immutable override decimals; + + /// @notice The constant price + int public immutable constantPrice; + + /** + * @notice Construct a new scaling price feed + * @param decimals_ The number of decimals for the returned prices + **/ + constructor(uint8 decimals_, int256 constantPrice_) { + decimals = decimals_; + constantPrice = constantPrice_; + } + + /** + * @notice Price for the latest round + * @return roundId Round id from the underlying price feed + * @return answer Latest price for the asset (will always be a constant price) + * @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() external view returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) { + return (0, constantPrice, block.timestamp, block.timestamp, 0); + } +} \ No newline at end of file diff --git a/contracts/IERC20NonStandard.sol b/contracts/IERC20NonStandard.sol new file mode 100644 index 000000000..40a613264 --- /dev/null +++ b/contracts/IERC20NonStandard.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +/** + * @title IERC20NonStandard + * @dev Version of ERC20 with no return values for `transfer` and `transferFrom` + * See https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca + */ +interface IERC20NonStandard { + function approve(address spender, uint256 amount) external; + function transfer(address to, uint256 value) external; + function transferFrom(address from, address to, uint256 value) external; + function balanceOf(address account) external view returns (uint256); +} \ No newline at end of file diff --git a/contracts/IPriceFeed.sol b/contracts/IPriceFeed.sol new file mode 100644 index 000000000..ff6b42d32 --- /dev/null +++ b/contracts/IPriceFeed.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +/** + * @dev Interface for price feeds used by Comet + * Note This is Chainlink's AggregatorV3Interface, but without the `getRoundData` function. + */ +interface IPriceFeed { + function decimals() external view returns (uint8); + + function description() external view returns (string memory); + + function version() external view returns (uint256); + + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); +} \ No newline at end of file diff --git a/contracts/IWstETH.sol b/contracts/IWstETH.sol new file mode 100644 index 000000000..eb27514a9 --- /dev/null +++ b/contracts/IWstETH.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "./ERC20.sol"; + +/** + * @dev Interface for interacting with WstETH contract + * Note Not a comprehensive interface + */ +interface IWstETH is ERC20 { + function stETH() external returns (address); + + function wrap(uint256 _stETHAmount) external returns (uint256); + function unwrap(uint256 _wstETHAmount) external returns (uint256); + + function receive() external payable; + + function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256); + function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256); + + function stEthPerToken() external view returns (uint256); + function tokensPerStEth() external view returns (uint256); +} \ No newline at end of file diff --git a/contracts/ScalingPriceFeed.sol b/contracts/ScalingPriceFeed.sol new file mode 100644 index 000000000..8d98d28f2 --- /dev/null +++ b/contracts/ScalingPriceFeed.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "./vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "./IPriceFeed.sol"; + +contract ScalingPriceFeed 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 Underlying Chainlink 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 scaling price feed + * @param underlyingPriceFeed_ The address of the underlying price feed to fetch prices from + * @param decimals_ The number of decimals for the returned prices + **/ + constructor(address underlyingPriceFeed_, uint8 decimals_) { + underlyingPriceFeed = underlyingPriceFeed_; + decimals = decimals_; + description = AggregatorV3Interface(underlyingPriceFeed_).description(); + + uint8 chainlinkPriceFeedDecimals = AggregatorV3Interface(underlyingPriceFeed_).decimals(); + // Note: Solidity does not allow setting immutables in if/else statements + shouldUpscale = chainlinkPriceFeedDecimals < decimals_ ? true : false; + rescaleFactor = (shouldUpscale + ? signed256(10 ** (decimals_ - chainlinkPriceFeedDecimals)) + : signed256(10 ** (chainlinkPriceFeedDecimals - 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 + ) { + (uint80 roundId_, int256 price, uint256 startedAt_, uint256 updatedAt_, uint80 answeredInRound_) = AggregatorV3Interface(underlyingPriceFeed).latestRoundData(); + return (roundId_, scalePrice(price), startedAt_, updatedAt_, answeredInRound_); + } + + 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; + } +} \ No newline at end of file diff --git a/contracts/WstETHPriceFeed.sol b/contracts/WstETHPriceFeed.sol new file mode 100644 index 000000000..fa5f3c6dd --- /dev/null +++ b/contracts/WstETHPriceFeed.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "./vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "./IPriceFeed.sol"; +import "./IWstETH.sol"; + +contract WstETHPriceFeed is IPriceFeed { + /** Custom errors **/ + error BadDecimals(); + error InvalidInt256(); + + /// @notice Version of the price feed + uint public constant override version = 1; + + /// @notice Description of the price feed + string public constant override description = "Custom price feed for wstETH / ETH"; + + /// @notice Number of decimals for returned prices + uint8 public immutable override decimals; + + /// @notice Chainlink stETH / ETH price feed + address public immutable stETHtoETHPriceFeed; + + /// @notice Number of decimals for the stETH / ETH price feed + uint public immutable stETHToETHPriceFeedDecimals; + + /// @notice WstETH contract address + address public immutable wstETH; + + /// @notice Scale for WstETH contract + int public immutable wstETHScale; + + constructor(address stETHtoETHPriceFeed_, address wstETH_, uint8 decimals_) { + stETHtoETHPriceFeed = stETHtoETHPriceFeed_; + stETHToETHPriceFeedDecimals = AggregatorV3Interface(stETHtoETHPriceFeed_).decimals(); + wstETH = wstETH_; + // Note: Safe to convert directly to an int256 because wstETH.decimals == 18 + wstETHScale = int256(10 ** IWstETH(wstETH).decimals()); + + // Note: stETH / ETH price feed has 18 decimals so `decimals_` should always be less than or equals to that + if (decimals_ > stETHToETHPriceFeedDecimals) revert BadDecimals(); + decimals = decimals_; + } + + function signed256(uint256 n) internal pure returns (int256) { + if (n > uint256(type(int256).max)) revert InvalidInt256(); + return int256(n); + } + + /** + * @notice WstETH price for the latest round + * @return roundId Round id from the stETH price feed + * @return answer Latest price for wstETH / USD + * @return startedAt Timestamp when the round was started; passed on from stETH price feed + * @return updatedAt Timestamp when the round was last updated; passed on from stETH price feed + * @return answeredInRound Round id in which the answer was computed; passed on from stETH price feed + **/ + function latestRoundData() override external view returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) { + (uint80 roundId_, int256 stETHPrice, uint256 startedAt_, uint256 updatedAt_, uint80 answeredInRound_) = AggregatorV3Interface(stETHtoETHPriceFeed).latestRoundData(); + uint256 tokensPerStEth = IWstETH(wstETH).tokensPerStEth(); + int256 price = stETHPrice * wstETHScale / signed256(tokensPerStEth); + // Note: Assumes the stETH price feed has an equal or larger amount of decimals than this price feed + int256 scaledPrice = price / int256(10 ** (stETHToETHPriceFeedDecimals - decimals)); + return (roundId_, scaledPrice, startedAt_, updatedAt_, answeredInRound_); + } +} \ No newline at end of file diff --git a/contracts/bulkers/BaseBulker.sol b/contracts/bulkers/BaseBulker.sol new file mode 100644 index 000000000..ffad7dca5 --- /dev/null +++ b/contracts/bulkers/BaseBulker.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "../CometInterface.sol"; +import "../IERC20NonStandard.sol"; +import "../IWETH9.sol"; + +/** + * @dev Interface for claiming rewards from the CometRewards contract + */ +interface IClaimable { + function claim(address comet, address src, bool shouldAccrue) external; + + function claimTo(address comet, address src, address to, bool shouldAccrue) external; +} + +/** + * @title Compound's Bulker contract + * @notice Executes multiple Comet-related actions in a single transaction + * @author Compound + * @dev Note: Only intended to be used on EVM chains that have a native token and wrapped native token that implements the IWETH interface + */ +contract BaseBulker { + /** Custom events **/ + + event AdminTransferred(address indexed oldAdmin, address indexed newAdmin); + + /** General configuration constants **/ + + /// @notice The admin of the Bulker contract + address public admin; + + /// @notice The address of the wrapped representation of the chain's native asset + address payable public immutable wrappedNativeToken; + + /** Actions **/ + + /// @notice The action for supplying an asset to Comet + bytes32 public constant ACTION_SUPPLY_ASSET = "ACTION_SUPPLY_ASSET"; + + /// @notice The action for supplying a native asset (e.g. ETH on Ethereum mainnet) to Comet + bytes32 public constant ACTION_SUPPLY_NATIVE_TOKEN = "ACTION_SUPPLY_NATIVE_TOKEN"; + + /// @notice The action for transferring an asset within Comet + bytes32 public constant ACTION_TRANSFER_ASSET = "ACTION_TRANSFER_ASSET"; + + /// @notice The action for withdrawing an asset from Comet + bytes32 public constant ACTION_WITHDRAW_ASSET = "ACTION_WITHDRAW_ASSET"; + + /// @notice The action for withdrawing a native asset from Comet + bytes32 public constant ACTION_WITHDRAW_NATIVE_TOKEN = "ACTION_WITHDRAW_NATIVE_TOKEN"; + + /// @notice The action for claiming rewards from the Comet rewards contract + bytes32 public constant ACTION_CLAIM_REWARD = "ACTION_CLAIM_REWARD"; + + /** Custom errors **/ + + error InvalidArgument(); + error FailedToSendNativeToken(); + error TransferInFailed(); + error TransferOutFailed(); + error Unauthorized(); + error UnhandledAction(); + + /** + * @notice Construct a new BaseBulker instance + * @param admin_ The admin of the Bulker contract + * @param wrappedNativeToken_ The address of the wrapped representation of the chain's native asset + **/ + constructor(address admin_, address payable wrappedNativeToken_) { + admin = admin_; + wrappedNativeToken = wrappedNativeToken_; + } + + /** + * @notice Fallback for receiving native token. Needed for ACTION_WITHDRAW_NATIVE_TOKEN + */ + receive() external payable {} + + /** + * @notice A public function to sweep accidental ERC-20 transfers to this contract + * @dev Note: Make sure to check that the asset being swept out is not malicious + * @param recipient The address that will receive the swept funds + * @param asset The address of the ERC-20 token to sweep + */ + function sweepToken(address recipient, address asset) external { + if (msg.sender != admin) revert Unauthorized(); + + uint256 balance = IERC20NonStandard(asset).balanceOf(address(this)); + doTransferOut(asset, recipient, balance); + } + + /** + * @notice A public function to sweep accidental native token transfers to this contract + * @param recipient The address that will receive the swept funds + */ + function sweepNativeToken(address recipient) external { + if (msg.sender != admin) revert Unauthorized(); + + uint256 balance = address(this).balance; + (bool success, ) = recipient.call{ value: balance }(""); + if (!success) revert FailedToSendNativeToken(); + } + + /** + * @notice Transfers the admin rights to a new address + */ + function transferAdmin(address newAdmin) external { + if (msg.sender != admin) revert Unauthorized(); + + address oldAdmin = admin; + admin = newAdmin; + emit AdminTransferred(oldAdmin, newAdmin); + } + + /** + * @notice Executes a list of actions in order + * @param actions The list of actions to execute in order + * @param data The list of calldata to use for each action + */ + function invoke(bytes32[] calldata actions, bytes[] calldata data) external payable { + if (actions.length != data.length) revert InvalidArgument(); + + uint unusedNativeToken = msg.value; + for (uint i = 0; i < actions.length; ) { + bytes32 action = actions[i]; + if (action == ACTION_SUPPLY_ASSET) { + (address comet, address to, address asset, uint amount) = abi.decode(data[i], (address, address, address, uint)); + supplyTo(comet, to, asset, amount); + } else if (action == ACTION_SUPPLY_NATIVE_TOKEN) { + (address comet, address to, uint amount) = abi.decode(data[i], (address, address, uint)); + unusedNativeToken -= amount; + supplyNativeTokenTo(comet, to, amount); + } else if (action == ACTION_TRANSFER_ASSET) { + (address comet, address to, address asset, uint amount) = abi.decode(data[i], (address, address, address, uint)); + transferTo(comet, to, asset, amount); + } else if (action == ACTION_WITHDRAW_ASSET) { + (address comet, address to, address asset, uint amount) = abi.decode(data[i], (address, address, address, uint)); + withdrawTo(comet, to, asset, amount); + } else if (action == ACTION_WITHDRAW_NATIVE_TOKEN) { + (address comet, address to, uint amount) = abi.decode(data[i], (address, address, uint)); + withdrawNativeTokenTo(comet, to, amount); + } else if (action == ACTION_CLAIM_REWARD) { + (address comet, address rewards, address src, bool shouldAccrue) = abi.decode(data[i], (address, address, address, bool)); + claimReward(comet, rewards, src, shouldAccrue); + } else { + handleAction(action, data[i]); + } + unchecked { i++; } + } + + // Refund unused native token back to msg.sender + if (unusedNativeToken > 0) { + (bool success, ) = msg.sender.call{ value: unusedNativeToken }(""); + if (!success) revert FailedToSendNativeToken(); + } + } + + /** + * @notice Handles any actions not handled by the BaseBulker implementation + * @dev Note: Meant to be overridden by contracts that extend BaseBulker and want to support more actions + */ + function handleAction(bytes32 action, bytes calldata data) virtual internal { + revert UnhandledAction(); + } + + /** + * @notice Supplies an asset to a user in Comet + * @dev Note: This contract must have permission to manage msg.sender's Comet account + */ + function supplyTo(address comet, address to, address asset, uint amount) internal { + CometInterface(comet).supplyFrom(msg.sender, to, asset, amount); + } + + /** + * @notice Wraps the native token and supplies wrapped native token to a user in Comet + */ + function supplyNativeTokenTo(address comet, address to, uint amount) internal { + IWETH9(wrappedNativeToken).deposit{ value: amount }(); + IWETH9(wrappedNativeToken).approve(comet, amount); + CometInterface(comet).supplyFrom(address(this), to, wrappedNativeToken, amount); + } + + /** + * @notice Transfers an asset to a user in Comet + * @dev Note: This contract must have permission to manage msg.sender's Comet account + */ + function transferTo(address comet, address to, address asset, uint amount) internal { + CometInterface(comet).transferAssetFrom(msg.sender, to, asset, amount); + } + + /** + * @notice Withdraws an asset to a user in Comet + * @dev Note: This contract must have permission to manage msg.sender's Comet account + */ + function withdrawTo(address comet, address to, address asset, uint amount) internal { + CometInterface(comet).withdrawFrom(msg.sender, to, asset, amount); + } + + /** + * @notice Withdraws wrapped native token from Comet, unwraps it to the native token, and transfers it to a user + * @dev Note: This contract must have permission to manage msg.sender's Comet account + */ + function withdrawNativeTokenTo(address comet, address to, uint amount) internal { + CometInterface(comet).withdrawFrom(msg.sender, address(this), wrappedNativeToken, amount); + IWETH9(wrappedNativeToken).withdraw(amount); + (bool success, ) = to.call{ value: amount }(""); + if (!success) revert FailedToSendNativeToken(); + } + + /** + * @notice Claims rewards for a user + */ + function claimReward(address comet, address rewards, address src, bool shouldAccrue) internal { + IClaimable(rewards).claim(comet, src, shouldAccrue); + } + + /** + * @notice Similar to ERC-20 transfer, except it properly handles `transferFrom` from non-standard ERC-20 tokens + * @param asset The ERC-20 token to transfer in + * @param from The address to transfer from + * @param amount The amount of the token to transfer + * @dev Note: This does not check that the amount transferred in is actually equals to the amount specified (e.g. fee tokens will not revert) + * @dev Note: This wrapper 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 { + IERC20NonStandard(asset).transferFrom(from, address(this), amount); + + bool success; + assembly { + 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(); + } + + /** + * @notice Similar to ERC-20 transfer, except it properly handles `transfer` from non-standard ERC-20 tokens + * @param asset The ERC-20 token to transfer out + * @param to The recipient of the token transfer + * @param amount The amount of the token to transfer + * @dev Note: This wrapper 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 { + IERC20NonStandard(asset).transfer(to, amount); + + bool success; + assembly { + 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(); + } +} diff --git a/contracts/bulkers/MainnetBulker.sol b/contracts/bulkers/MainnetBulker.sol new file mode 100644 index 000000000..60dd6f4c8 --- /dev/null +++ b/contracts/bulkers/MainnetBulker.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +import "./BaseBulker.sol"; +import "../IWstETH.sol"; + +/** + * @title Compound's Bulker contract for Ethereum mainnet + * @notice Executes multiple Comet-related actions in a single transaction + * @author Compound + */ +contract MainnetBulker is BaseBulker { + /** General configuration constants **/ + + /// @notice The address of Lido staked ETH + address public immutable steth; + + /// @notice The address of Lido wrapped staked ETH + address public immutable wsteth; + + /** Actions **/ + + /// @notice The action for supplying staked ETH to Comet + bytes32 public constant ACTION_SUPPLY_STETH = "ACTION_SUPPLY_STETH"; + + /// @notice The action for withdrawing staked ETH from Comet + bytes32 public constant ACTION_WITHDRAW_STETH = "ACTION_WITHDRAW_STETH"; + + /** + * @notice Construct a new MainnetBulker instance + * @param admin_ The admin of the Bulker contract + * @param weth_ The address of wrapped ETH + * @param wsteth_ The address of Lido wrapped staked ETH + **/ + constructor( + address admin_, + address payable weth_, + address wsteth_ + ) BaseBulker(admin_, weth_) { + wsteth = wsteth_; + steth = IWstETH(wsteth_).stETH(); + } + + /** + * @notice Handles actions specific to the Ethereum mainnet version of Bulker, specifically supplying and withdrawing stETH + */ + function handleAction(bytes32 action, bytes calldata data) override internal { + if (action == ACTION_SUPPLY_STETH) { + (address comet, address to, uint stETHAmount) = abi.decode(data, (address, address, uint)); + supplyStEthTo(comet, to, stETHAmount); + } else if (action == ACTION_WITHDRAW_STETH) { + (address comet, address to, uint wstETHAmount) = abi.decode(data, (address, address, uint)); + withdrawStEthTo(comet, to, wstETHAmount); + } else { + revert UnhandledAction(); + } + } + + /** + * @notice Wraps stETH to wstETH and supplies to a user in Comet + * @dev Note: This contract must have permission to manage msg.sender's Comet account + */ + function supplyStEthTo(address comet, address to, uint stETHAmount) internal { + doTransferIn(steth, msg.sender, stETHAmount); + ERC20(steth).approve(wsteth, stETHAmount); + uint wstETHAmount = IWstETH(wsteth).wrap(stETHAmount); + ERC20(wsteth).approve(comet, wstETHAmount); + CometInterface(comet).supplyFrom(address(this), to, wsteth, wstETHAmount); + } + + /** + * @notice Withdraws wstETH from Comet, unwraps it to stETH, and transfers it to a user + * @dev Note: This contract must have permission to manage msg.sender's Comet account + */ + function withdrawStEthTo(address comet, address to, uint wstETHAmount) internal { + CometInterface(comet).withdrawFrom(msg.sender, address(this), wsteth, wstETHAmount); + uint stETHAmount = IWstETH(wsteth).unwrap(wstETHAmount); + doTransferOut(steth, to, stETHAmount); + } +} \ No newline at end of file diff --git a/contracts/test/NonStandardFaucetToken.sol b/contracts/test/NonStandardFaucetToken.sol new file mode 100644 index 000000000..3089d3e98 --- /dev/null +++ b/contracts/test/NonStandardFaucetToken.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +/** + * @title Non-standard ERC20 token + * @dev Implementation of the basic standard token. + * See https://github.com/ethereum/EIPs/issues/20 + * @dev Note: `transfer` and `transferFrom` do not return a boolean + */ +contract NonStandardToken { + string public name; + string public symbol; + uint8 public decimals; + 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); + + 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"); + balanceOf[msg.sender] = balanceOf[msg.sender] - amount; + balanceOf[dst] = balanceOf[dst] + amount; + emit Transfer(msg.sender, dst, amount); + } + + 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"); + allowance[src][msg.sender] = allowance[src][msg.sender] - amount; + balanceOf[src] = balanceOf[src] - amount; + balanceOf[dst] = balanceOf[dst] + amount; + emit Transfer(src, dst, amount); + } + + function approve(address _spender, uint256 amount) external returns (bool) { + allowance[msg.sender][_spender] = amount; + emit Approval(msg.sender, _spender, amount); + return true; + } +} + +/** + * @title The Compound Faucet Test Token + * @author Compound + * @notice A simple test token that lets anyone get more of it. + */ +contract NonStandardFaucetToken is NonStandardToken { + constructor(uint256 _initialAmount, string memory _tokenName, uint8 _decimalUnits, string memory _tokenSymbol) + NonStandardToken(_initialAmount, _tokenName, _decimalUnits, _tokenSymbol) { + } + + function allocateTo(address _owner, uint256 value) public { + balanceOf[_owner] += value; + totalSupply += value; + emit Transfer(address(this), _owner, value); + } +} diff --git a/contracts/test/SimplePriceFeed.sol b/contracts/test/SimplePriceFeed.sol index ca2545ec8..94b0f6efd 100644 --- a/contracts/test/SimplePriceFeed.sol +++ b/contracts/test/SimplePriceFeed.sol @@ -4,40 +4,42 @@ pragma solidity 0.8.15; import "../vendor/@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; contract SimplePriceFeed is AggregatorV3Interface { - int public price; - - uint8 public immutable override decimals; - string public constant override description = "Mock Chainlink price aggregator"; uint public constant override version = 1; - constructor(int initialPrice, uint8 decimals_) { - price = initialPrice; + uint8 public immutable override decimals; + + uint80 internal roundId; + int256 internal answer; + uint256 internal startedAt; + uint256 internal updatedAt; + uint80 internal answeredInRound; + + constructor(int answer_, uint8 decimals_) { + answer = answer_; decimals = decimals_; } - function setPrice(int price_) public { - price = price_; + 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 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) { - return (_roundId, price, 0, 0, 0); + function getRoundData(uint80 roundId_) override external view returns (uint80, int256, uint256, uint256, uint80) { + return (roundId_, answer, startedAt, updatedAt, answeredInRound); } - function latestRoundData() override external view returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) { - return (0, price, 0, 0, 0); + function latestRoundData() override external view returns (uint80, int256, uint256, uint256, uint80) { + return (roundId, answer, startedAt, updatedAt, answeredInRound); } } diff --git a/contracts/test/SimpleWstETH.sol b/contracts/test/SimpleWstETH.sol new file mode 100644 index 000000000..99c91da0b --- /dev/null +++ b/contracts/test/SimpleWstETH.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.15; + +contract SimpleWstETH { + uint8 public constant decimals = 18; + + uint public immutable tokensPerStEth; + + constructor(uint tokensPerStEth_) { + tokensPerStEth = tokensPerStEth_; + } +} \ No newline at end of file diff --git a/deployments/mainnet/weth/configuration.json b/deployments/mainnet/weth/configuration.json new file mode 100644 index 000000000..571680564 --- /dev/null +++ b/deployments/mainnet/weth/configuration.json @@ -0,0 +1,45 @@ +{ + "name": "Compound WETH", + "symbol": "cWETHv3", + "baseToken": "WETH", + "baseTokenAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "borrowMin": "100e6", + "governor": "0x6d903f6003cca6255d85cca4d3b5e5146dc33925", + "pauseGuardian": "0xbbf3f1421d886e9b2c5d716b5192ac998af2012c", + "storeFrontPriceFactor": 0.5, + "targetReserves": "5000000e6", + "rates": { + "supplyKink": 0.8, + "supplySlopeLow": 0.0325, + "supplySlopeHigh": 0.4, + "supplyBase": 0, + "borrowKink": 0.8, + "borrowSlopeLow": 0.035, + "borrowSlopeHigh": 0.25, + "borrowBase": 0.015 + }, + "tracking": { + "indexScale": "1e15", + "baseSupplySpeed": "0e15", + "baseBorrowSpeed": "0e15", + "baseMinForRewards": "1000000e6" + }, + "assets": { + "cbETH": { + "address": "0xBe9895146f7AF43049ca1c1AE358B0541Ea49704", + "decimals": "18", + "borrowCF": 0.90, + "liquidateCF": 0.93, + "liquidationFactor": 0.95, + "supplyCap": "0" + }, + "wstETH": { + "address": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", + "decimals": "18", + "borrowCF": 0.90, + "liquidateCF": 0.93, + "liquidationFactor": 0.95, + "supplyCap": "0" + } + } +} diff --git a/deployments/mainnet/weth/deploy.ts b/deployments/mainnet/weth/deploy.ts new file mode 100644 index 000000000..7ca3d599f --- /dev/null +++ b/deployments/mainnet/weth/deploy.ts @@ -0,0 +1,55 @@ +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 stETH = await deploymentManager.existing('stETH', '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'); + const wstETH = await deploymentManager.existing('wstETH', '0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0'); + + // Deploy WstETHPriceFeed + const wstETHPriceFeed = await deploymentManager.deploy( + 'wstETH:priceFeed', + 'WstETHPriceFeed.sol', + [ + '0x86392dC19c0b719886221c78AB11eb8Cf5c52812', // stETHtoETHPriceFeed + wstETH.address, // wstETH + 8 // decimals + ] + ); + + // Deploy constant price feed for WETH + const wethConstantPriceFeed = await deploymentManager.deploy( + 'WETH:priceFeed', + 'ConstantPriceFeed.sol', + [ + 8, // decimals + exp(1, 8) // constantPrice + ] + ); + + // Deploy scaling price feed for cbETH + const cbETHScalingPriceFeed = await deploymentManager.deploy( + 'cbETH:priceFeed', + 'ScalingPriceFeed.sol', + [ + '0xF017fcB346A1885194689bA23Eff2fE6fA5C483b', // cbETH / ETH price feed + 8 // decimals + ] + ); + + // Deploy all Comet-related contracts + const deployed = await deployComet(deploymentManager, deploySpec); + const { comet } = deployed; + + // Deploy Bulker + const bulker = await deploymentManager.deploy( + 'bulker', + 'bulkers/MainnetBulker.sol', + [ + await comet.governor(), // admin_ + await comet.baseToken(), // weth_ + wstETH.address // wsteth_ + ] + ); + + return { ...deployed, bulker }; +} diff --git a/deployments/mainnet/weth/relations.ts b/deployments/mainnet/weth/relations.ts new file mode 100644 index 000000000..37278965c --- /dev/null +++ b/deployments/mainnet/weth/relations.ts @@ -0,0 +1,17 @@ +import { RelationConfigMap } from '../../../plugins/deployment_manager/RelationConfig'; +import baseRelationConfig from '../../relations'; + +export default { + ...baseRelationConfig, + 'wstETH': { + artifact: 'contracts/bulkers/IWstETH.sol', + relations: { + stETH: { + field: async (wstETH) => wstETH.stETH() + } + } + }, + 'AppProxyUpgradeable': { + artifact: 'contracts/ERC20.sol:ERC20', + } +}; \ No newline at end of file diff --git a/deployments/mumbai/usdc/deploy.ts b/deployments/mumbai/usdc/deploy.ts index 4c9ad88e1..60594e20f 100644 --- a/deployments/mumbai/usdc/deploy.ts +++ b/deployments/mumbai/usdc/deploy.ts @@ -112,7 +112,7 @@ async function deployContracts(deploymentManager: DeploymentManager, deploySpec: // Deploy Bulker const bulker = await deploymentManager.deploy( 'bulker', - 'Bulker.sol', + 'bulkers/BaseBulker.sol', [localTimelock.address, WETH.address] ); diff --git a/hardhat.config.ts b/hardhat.config.ts index a95bb5d1d..a37551af8 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -19,6 +19,7 @@ import './tasks/scenario/task.ts'; import relationConfigMap from './deployments/relations'; import goerliRelationConfigMap from './deployments/goerli/usdc/relations'; import mumbaiRelationConfigMap from './deployments/mumbai/usdc/relations'; +import mainnetWethRelationConfigMap from './deployments/mainnet/weth/relations'; task('accounts', 'Prints the list of accounts', async (taskArgs, hre) => { for (const account of await hre.ethers.getSigners()) console.log(account.address); @@ -183,6 +184,9 @@ const config: HardhatUserConfig = { }, mumbai: { usdc: mumbaiRelationConfigMap + }, + mainnet: { + weth: mainnetWethRelationConfigMap } }, }, @@ -195,6 +199,11 @@ const config: HardhatUserConfig = { deployment: 'usdc', allocation: 1.0, // eth }, + { + name: 'mainnet-weth', + network: 'mainnet', + deployment: 'weth', + }, { name: 'development', network: 'hardhat', diff --git a/plugins/deployment_manager/Cache.ts b/plugins/deployment_manager/Cache.ts index e2168173b..74cf77fbc 100644 --- a/plugins/deployment_manager/Cache.ts +++ b/plugins/deployment_manager/Cache.ts @@ -1,7 +1,7 @@ import * as fs from 'fs/promises'; import * as nodepath from 'path'; import { inspect } from 'util'; -import { fileExists, objectFromMap, objectToMap } from './Utils'; +import { fileExists, objectFromMap, objectToMap, stringifyJson } from './Utils'; export type FileSpec = string | string[] | { rel: string | string[] } | { top: string | string[] }; @@ -29,17 +29,6 @@ function parseJson(x: string | undefined): K { } } -function stringifyJson(k: K): string { - return JSON.stringify( - k, - (_key, value) => - typeof value === 'bigint' - ? value.toString() - : value, - 4 - ); -} - type CacheMap = Map; export class Cache { diff --git a/plugins/deployment_manager/Deploy.ts b/plugins/deployment_manager/Deploy.ts index 0d9cdab18..c4c45570c 100644 --- a/plugins/deployment_manager/Deploy.ts +++ b/plugins/deployment_manager/Deploy.ts @@ -8,7 +8,7 @@ import { putVerifyArgs } from './VerifyArgs'; import { Cache } from './Cache'; import { storeBuildFile } from './ContractMap'; import { BuildFile, TraceFn } from './Types'; -import { debug, getPrimaryContract } from './Utils'; +import { debug, getPrimaryContract, stringifyJson } from './Utils'; import { VerifyArgs, verifyContract, VerificationStrategy } from './Verify'; export interface DeployOpts { @@ -29,7 +29,7 @@ async function doDeploy( src: string ): Promise { const trace = opts.trace ?? debug; - trace(`Deploying ${name} with args ${JSON.stringify(args)} via ${src}`); + trace(`Deploying ${name} with args ${stringifyJson(args)} via ${src}`); const contract = await factory.deploy(...args); await contract.deployed(); trace(contract.deployTransaction, `Deployed ${name} @ ${contract.address}`); diff --git a/plugins/deployment_manager/Utils.ts b/plugins/deployment_manager/Utils.ts index 1bef3e0cd..cc51bc64f 100644 --- a/plugins/deployment_manager/Utils.ts +++ b/plugins/deployment_manager/Utils.ts @@ -26,6 +26,17 @@ export function debug(...args: any[]) { } } +export function stringifyJson(k: K): string { + return JSON.stringify( + k, + (_key, value) => + typeof value === 'bigint' + ? value.toString() + : value, + 4 + ); +} + export async function fileExists(path: string): Promise { try { await fs.stat(path); diff --git a/scenario/BulkerScenario.ts b/scenario/BulkerScenario.ts index 3b5cacea4..ed4ecbd04 100644 --- a/scenario/BulkerScenario.ts +++ b/scenario/BulkerScenario.ts @@ -1,13 +1,13 @@ import { scenario } from './context/CometContext'; import { constants, utils } from 'ethers'; import { expect } from 'chai'; -import { expectBase, isRewardSupported, isBulkerSupported, getExpectedBaseBalance } from './utils'; +import { expectBase, isRewardSupported, isBulkerSupported, getExpectedBaseBalance, matchesDeployment } from './utils'; import { exp } from '../test/helpers'; scenario( - 'Comet#bulker > all non-reward actions in one txn', + 'Comet#bulker > (non-WETH base) all non-reward actions in one txn', { - filter: async (ctx) => await isBulkerSupported(ctx), + filter: async (ctx) => await isBulkerSupported(ctx) && !matchesDeployment(ctx, [{deployment: 'weth'}, {network: 'mumbai'}]), tokenBalances: { albert: { $base: '== 0', $asset0: 3000 }, $comet: { $base: 5000 }, @@ -79,9 +79,84 @@ scenario( ); scenario( - 'Comet#bulker > all actions in one txn', + 'Comet#bulker > (WETH base) all non-reward actions in one txn', { - filter: async (ctx) => await isBulkerSupported(ctx) && await isRewardSupported(ctx), + filter: async (ctx) => await isBulkerSupported(ctx) && matchesDeployment(ctx, [{deployment: 'weth'}]), + supplyCaps: { + $asset0: 3000, + }, + tokenBalances: { + albert: { $base: '== 0', $asset0: 3000 }, + $comet: { $base: 5000 }, + }, + }, + async ({ comet, actors, bulker }, context) => { + const { albert, betty } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + const { asset: collateralAssetAddress, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(collateralAssetAddress); + const collateralScale = scaleBN.toBigInt(); + const toSupplyCollateral = 3000n * collateralScale; + const toBorrowBase = 1500n * baseScale; + const toTransferBase = 500n * baseScale; + const toSupplyEth = exp(0.01, 18); + const toWithdrawEth = exp(0.005, 18); + + // Approvals + await collateralAsset.approve(albert, comet.address); + await albert.allow(bulker.address, true); + + // Initial expectations + expect(await collateralAsset.balanceOf(albert.address)).to.be.equal(toSupplyCollateral); + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(0n); + expect(await comet.balanceOf(albert.address)).to.be.equal(0n); + + // Albert's actions: + // 1. Supplies 3000 units of collateral + // 2. Borrows 1500 base + // 3. Transfers 500 base to Betty + // 4. Supplies 0.01 ETH + // 5. Withdraws 0.005 ETH + const supplyAssetCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'uint'], [comet.address, albert.address, collateralAsset.address, toSupplyCollateral]); + const withdrawAssetCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'uint'], [comet.address, albert.address, baseAsset.address, toBorrowBase]); + const transferAssetCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'uint'], [comet.address, betty.address, baseAsset.address, toTransferBase]); + const supplyNativeTokenCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, albert.address, toSupplyEth]); + const withdrawNativeTokenCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, albert.address, toWithdrawEth]); + const calldata = [ + supplyAssetCalldata, + withdrawAssetCalldata, + transferAssetCalldata, + supplyNativeTokenCalldata, + withdrawNativeTokenCalldata + ]; + const actions = [ + await bulker.ACTION_SUPPLY_ASSET(), + await bulker.ACTION_WITHDRAW_ASSET(), + await bulker.ACTION_TRANSFER_ASSET(), + await bulker.ACTION_SUPPLY_NATIVE_TOKEN(), + await bulker.ACTION_WITHDRAW_NATIVE_TOKEN(), + ]; + const txn = await albert.invoke({ actions, calldata }, { value: toSupplyEth }); + + // Final expectations + const baseIndexScale = (await comet.baseIndexScale()).toBigInt(); + const baseSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex.toBigInt(); + const baseTransferred = getExpectedBaseBalance(toTransferBase, baseIndexScale, baseSupplyIndex); + expect(await comet.collateralBalanceOf(albert.address, collateralAsset.address)).to.be.equal(toSupplyCollateral); + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(toBorrowBase); + expectBase(await comet.balanceOf(betty.address), baseTransferred); + expectBase(await comet.borrowBalanceOf(albert.address), toBorrowBase + toTransferBase - (toSupplyEth - toWithdrawEth)); + + return txn; // return txn to measure gas + } +); + +scenario( + 'Comet#bulker > (non-WETH base) all actions in one txn', + { + filter: async (ctx) => await isBulkerSupported(ctx) && await isRewardSupported(ctx) && !matchesDeployment(ctx, [{deployment: 'weth'}]), tokenBalances: { albert: { $base: '== 1000000', $asset0: 100 }, $comet: { $base: 5000 }, @@ -97,6 +172,7 @@ scenario( const collateralAsset = context.getAssetByAddress(collateralAssetAddress); const collateralScale = scaleBN.toBigInt(); const [rewardTokenAddress] = await rewards.rewardConfig(comet.address); + const toSupplyBase = 1_000_000n * baseScale; const toSupplyCollateral = 100n * collateralScale; const toBorrowBase = 1500n * baseScale; const toTransferBase = 500n * baseScale; @@ -109,7 +185,7 @@ scenario( await albert.allow(bulker.address, true); // Accrue some rewards to Albert, then transfer away Albert's supplied base - await albert.safeSupplyAsset({ asset: baseAssetAddress, amount: 1_000_000n * baseScale }); + await albert.safeSupplyAsset({ asset: baseAssetAddress, amount: toSupplyBase }); await world.increaseTime(86400); // fast forward a day await albert.transferAsset({ dst: constants.AddressZero, asset: baseAssetAddress, amount: constants.MaxUint256 }); // transfer all base away @@ -168,3 +244,94 @@ scenario( return txn; // return txn to measure gas } ); + +scenario( + 'Comet#bulker > (WETH base) all actions in one txn', + { + filter: async (ctx) => await isBulkerSupported(ctx) && await isRewardSupported(ctx) && matchesDeployment(ctx, [{deployment: 'weth'}]), + tokenBalances: { + albert: { $base: '== 10', $asset0: 10 }, + $comet: { $base: 5000 }, + }, + }, + async ({ comet, actors, rewards, bulker }, context, world) => { + const { albert, betty } = actors; + const baseAssetAddress = await comet.baseToken(); + const baseAsset = context.getAssetByAddress(baseAssetAddress); + const baseScale = (await comet.baseScale()).toBigInt(); + const { asset: collateralAssetAddress, scale: scaleBN } = await comet.getAssetInfo(0); + const collateralAsset = context.getAssetByAddress(collateralAssetAddress); + const collateralScale = scaleBN.toBigInt(); + const [rewardTokenAddress] = await rewards.rewardConfig(comet.address); + const toSupplyBase = 10n * baseScale; + const toSupplyCollateral = 10n * collateralScale; + const toBorrowBase = 5n * baseScale; + const toTransferBase = 2n * baseScale; + const toSupplyEth = exp(0.01, 18); + const toWithdrawEth = exp(0.005, 18); + + // Approvals + await baseAsset.approve(albert, comet.address); + await collateralAsset.approve(albert, comet.address); + await albert.allow(bulker.address, true); + + // Accrue some rewards to Albert, then transfer away Albert's supplied base + await albert.safeSupplyAsset({ asset: baseAssetAddress, amount: toSupplyBase }); + await world.increaseTime(86400); // fast forward a day + await albert.transferAsset({ dst: constants.AddressZero, asset: baseAssetAddress, amount: constants.MaxUint256 }); // transfer all base away + + // Initial expectations + expect(await collateralAsset.balanceOf(albert.address)).to.be.equal(toSupplyCollateral); + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(0n); + expect(await comet.balanceOf(albert.address)).to.be.equal(0n); + const startingRewardBalance = await albert.getErc20Balance(rewardTokenAddress); + const rewardOwed = ((await rewards.callStatic.getRewardOwed(comet.address, albert.address)).owed).toBigInt(); + const expectedFinalRewardBalance = collateralAssetAddress === rewardTokenAddress ? + startingRewardBalance + rewardOwed - toSupplyCollateral : + startingRewardBalance + rewardOwed; + + // Albert's actions: + // 1. Supplies 10 units of collateral + // 2. Borrows 5 base + // 3. Transfers 2 base to Betty + // 4. Supplies 0.01 ETH + // 5. Withdraws 0.005 ETH + // 6. Claim rewards + const supplyAssetCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'uint'], [comet.address, albert.address, collateralAsset.address, toSupplyCollateral]); + const withdrawAssetCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'uint'], [comet.address, albert.address, baseAsset.address, toBorrowBase]); + const transferAssetCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'uint'], [comet.address, betty.address, baseAsset.address, toTransferBase]); + const supplyNativeTokenCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, albert.address, toSupplyEth]); + const withdrawNativeTokenCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, albert.address, toWithdrawEth]); + const claimRewardCalldata = utils.defaultAbiCoder.encode(['address', 'address', 'address', 'bool'], [comet.address, rewards.address, albert.address, true]); + const calldata = [ + supplyAssetCalldata, + withdrawAssetCalldata, + transferAssetCalldata, + supplyNativeTokenCalldata, + withdrawNativeTokenCalldata, + claimRewardCalldata + ]; + const actions = [ + await bulker.ACTION_SUPPLY_ASSET(), + await bulker.ACTION_WITHDRAW_ASSET(), + await bulker.ACTION_TRANSFER_ASSET(), + await bulker.ACTION_SUPPLY_NATIVE_TOKEN(), + await bulker.ACTION_WITHDRAW_NATIVE_TOKEN(), + await bulker.ACTION_CLAIM_REWARD(), + ]; + const txn = await albert.invoke({ actions, calldata }, { value: toSupplyEth }); + + // Final expectations + const baseIndexScale = (await comet.baseIndexScale()).toBigInt(); + const baseSupplyIndex = (await comet.totalsBasic()).baseSupplyIndex.toBigInt(); + const baseTransferred = getExpectedBaseBalance(toTransferBase, baseIndexScale, baseSupplyIndex); + expect(await comet.collateralBalanceOf(albert.address, collateralAsset.address)).to.be.equal(toSupplyCollateral); + expect(await baseAsset.balanceOf(albert.address)).to.be.equal(toBorrowBase); + expect(await albert.getErc20Balance(rewardTokenAddress)).to.be.equal(expectedFinalRewardBalance); + expectBase(await comet.balanceOf(betty.address), baseTransferred); + // NOTE: differs from the equivalent scenario for non-ETH markets + expectBase(await comet.borrowBalanceOf(albert.address), toBorrowBase + toTransferBase - (toSupplyEth - toWithdrawEth)); + + return txn; // return txn to measure gas + } +); \ No newline at end of file diff --git a/scenario/GovernanceScenario.ts b/scenario/GovernanceScenario.ts index fc11dd9d0..77a6681d5 100644 --- a/scenario/GovernanceScenario.ts +++ b/scenario/GovernanceScenario.ts @@ -6,7 +6,7 @@ import { FaucetToken } from '../build/types'; import { calldata } from '../src/deploy'; import { COMP_WHALES } from '../src/deploy'; import { impersonateAddress } from '../plugins/scenario/utils'; -import { isBridgedDeployment, fastL2GovernanceExecute } from './utils'; +import { isBridgedDeployment, fastL2GovernanceExecute, matchesDeployment } from './utils'; scenario('upgrade Comet implementation and initialize', {filter: async (ctx) => !isBridgedDeployment(ctx)}, async ({ comet, configurator, proxyAdmin }, context) => { // For this scenario, we will be using the value of LiquidatorPoints.numAbsorbs for address ZERO to test that initialize has been called @@ -93,7 +93,7 @@ scenario('upgrade Comet implementation and call new function', {filter: async (c scenario('add new asset', { - filter: async (ctx) => !isBridgedDeployment(ctx), + filter: async (ctx) => !isBridgedDeployment(ctx) && !matchesDeployment(ctx, [{network: 'mainnet', deployment: 'weth'}]), tokenBalances: { $comet: { $base: '>= 1000' }, }, @@ -143,7 +143,68 @@ scenario('add new asset', // Try to supply new token and borrow base const baseAssetAddress = await comet.baseToken(); - const borrowAmount = 1_000n * (await comet.baseScale()).toBigInt(); + const borrowAmount = 1000n * (await comet.baseScale()).toBigInt(); + await dogecoin.connect(albert.signer).approve(comet.address, exp(100, 8)); + await albert.supplyAsset({ asset: dogecoin.address, amount: exp(100, 8) }); + await albert.withdrawAsset({ asset: baseAssetAddress, amount: borrowAmount }); + + expect(await albert.getCometCollateralBalance(dogecoin.address)).to.be.equal(exp(100, 8)); + expect(await albert.getCometBaseBalance()).to.be.equal(-borrowAmount); + }); + +scenario('add new asset (mainnet-weth)', + { + filter: async (ctx) => !isBridgedDeployment(ctx) && matchesDeployment(ctx, [{network: 'mainnet', deployment: 'weth'}]), + tokenBalances: { + $comet: { $base: '>= 1000' }, + }, + }, + async ({ comet, configurator, proxyAdmin, actors }, context) => { + const { albert } = actors; + + // Deploy new token and pricefeed + const dm = context.world.deploymentManager; + const dogecoin = await dm.deploy( + 'DOGE', + 'test/FaucetToken.sol', + [exp(1_000_000, 8).toString(), 'Dogecoin', 8, 'DOGE'], + true + ); + const dogecoinPricefeed = await dm.deploy( + 'DOGE:priceFeed', + 'test/SimplePriceFeed.sol', + [exp(1_000, 8).toString(), 8], + true + ); + + // Allocate some tokens to Albert + await dogecoin.allocateTo(albert.address, exp(100, 8)); + + // Execute a governance proposal to: + // 1. Add new asset via Configurator + // 2. Deploy and upgrade to new implementation of Comet + const newAssetConfig = { + asset: dogecoin.address, + priceFeed: dogecoinPricefeed.address, + decimals: await dogecoin.decimals(), + borrowCollateralFactor: exp(0.8, 18), + liquidateCollateralFactor: exp(0.85, 18), + liquidationFactor: exp(0.95, 18), + supplyCap: exp(1_000, 8), + }; + + const addAssetCalldata = await calldata(configurator.populateTransaction.addAsset(comet.address, newAssetConfig)); + const deployAndUpgradeToCalldata = utils.defaultAbiCoder.encode(['address', 'address'], [configurator.address, comet.address]); + await context.fastGovernanceExecute( + [configurator.address, proxyAdmin.address], + [0, 0], + ['addAsset(address,(address,address,uint8,uint64,uint64,uint64,uint128))', 'deployAndUpgradeTo(address,address)'], + [addAssetCalldata, deployAndUpgradeToCalldata] + ); + + // Try to supply new token and borrow base + const baseAssetAddress = await comet.baseToken(); + const borrowAmount = 10n * (await comet.baseScale()).toBigInt(); await dogecoin.connect(albert.signer).approve(comet.address, exp(100, 8)); await albert.supplyAsset({ asset: dogecoin.address, amount: exp(100, 8) }); await albert.withdrawAsset({ asset: baseAssetAddress, amount: borrowAmount }); diff --git a/scenario/MainnetBulkerScenario.ts b/scenario/MainnetBulkerScenario.ts new file mode 100644 index 000000000..0b3c9b4a0 --- /dev/null +++ b/scenario/MainnetBulkerScenario.ts @@ -0,0 +1,122 @@ +import { ethers, utils } from 'ethers'; +import { expect } from 'chai'; +import { scenario } from './context/CometContext'; +import CometAsset from './context/CometAsset'; +import { + ERC20, + IWstETH +} from '../build/types'; +import { exp } from '../test/helpers'; +import { expectApproximately, isBulkerSupported, matchesDeployment } from './utils'; + +scenario( + 'MainnetBulker > wraps stETH before supplying', + { + filter: async (ctx) => await isBulkerSupported(ctx) && matchesDeployment(ctx, [{network: 'mainnet', deployment: 'weth'}]), + supplyCaps: { + $asset1: 1, + }, + tokenBalances: { + albert: { $asset1: '== 0' }, + }, + }, + async ({ comet, actors, bulker }, context) => { + const { albert } = actors; + + const stETH = await context.world.deploymentManager.contract('stETH') as ERC20; + const wstETH = await context.world.deploymentManager.contract('wstETH') as IWstETH; + + const toSupplyStEth = exp(.1, 18); + + await context.sourceTokens(toSupplyStEth, new CometAsset(stETH), albert); + + expect(await stETH.balanceOf(albert.address)).to.be.approximately(toSupplyStEth, 1); + + // approve bulker as albert + await stETH.connect(albert.signer).approve(bulker.address, toSupplyStEth); + + const supplyStEthCalldata = utils.defaultAbiCoder.encode( + ['address', 'address', 'uint'], + [comet.address, albert.address, toSupplyStEth] + ); + const calldata = [supplyStEthCalldata]; + const actions = [await bulker.ACTION_SUPPLY_STETH()]; + + await albert.invoke({ actions, calldata }); + + expect(await stETH.balanceOf(albert.address)).to.be.equal(0n); + expectApproximately( + await comet.collateralBalanceOf(albert.address, wstETH.address), + await wstETH.getWstETHByStETH(toSupplyStEth), + 1n + ); + } +); + +scenario( + 'MainnetBulker > unwraps wstETH before withdrawing', + { + filter: async (ctx) => await isBulkerSupported(ctx) && matchesDeployment(ctx, [{network: 'mainnet', deployment: 'weth'}]), + supplyCaps: { + $asset1: 2, + }, + tokenBalances: { + albert: { $asset1: 2 }, + $comet: { $asset1: 5 }, + }, + }, + async ({ comet, actors, bulker }, context) => { + const { albert } = actors; + + const stETH = await context.world.deploymentManager.contract('stETH') as ERC20; + const wstETH = await context.world.deploymentManager.contract('wstETH') as IWstETH; + + const toWithdrawStEth = exp(1, 18); + + // approvals/allowances + await albert.allow(bulker.address, true); + await wstETH.connect(albert.signer).approve(comet.address, toWithdrawStEth); + + // supply wstETH + await albert.supplyAsset({asset: wstETH.address, amount: toWithdrawStEth }); + + const withdrawStEthCalldata = utils.defaultAbiCoder.encode( + ['address', 'address', 'uint'], + [comet.address, albert.address, toWithdrawStEth] + ); + const calldata = [withdrawStEthCalldata]; + const actions = [await bulker.ACTION_WITHDRAW_STETH()]; + + await albert.invoke({ actions, calldata }); + + expectApproximately( + await stETH.balanceOf(albert.address), + await wstETH.getStETHByWstETH(toWithdrawStEth), + 1n + ); + expect(await comet.collateralBalanceOf(albert.address, wstETH.address)).to.equal(0); + } +); + +scenario( + 'MainnetBulker > it reverts when passed an action that does not exist', + { + filter: async (ctx) => await isBulkerSupported(ctx) && matchesDeployment(ctx, [{network: 'mainnet', deployment: 'weth'}]), + }, + async ({ comet, actors }) => { + const { betty } = actors; + + const supplyGalacticCreditsCalldata = utils.defaultAbiCoder.encode( + ['address', 'address', 'uint'], + [comet.address, betty.address, exp(1, 18)] + ); + const calldata = [supplyGalacticCreditsCalldata]; + const actions = [ + ethers.utils.formatBytes32String('ACTION_SUPPLY_GALACTIC_CREDITS') + ]; + + await expect( + betty.invoke({ actions, calldata }) + ).to.be.revertedWith("custom error 'UnhandledAction()'"); + } +); \ No newline at end of file diff --git a/scenario/RewardsScenario.ts b/scenario/RewardsScenario.ts index 0a960f9cb..8d3379ab8 100644 --- a/scenario/RewardsScenario.ts +++ b/scenario/RewardsScenario.ts @@ -1,7 +1,7 @@ import { scenario } from './context/CometContext'; import { expect } from 'chai'; import { exp } from '../test/helpers'; -import { isRewardSupported } from './utils'; +import { isRewardSupported, matchesDeployment } from './utils'; function calculateRewardsOwed( userBalance: bigint, @@ -80,7 +80,7 @@ scenario( scenario( 'Comet#rewards > manager can claimTo supply rewards from a managed account', { - filter: async (ctx) => await isRewardSupported(ctx), + filter: async (ctx) => await isRewardSupported(ctx) && !matchesDeployment(ctx, [{network: 'mainnet', deployment: 'weth'}]), tokenBalances: { albert: { $base: ' == 1000000' }, // in units of asset, not wei }, @@ -156,8 +156,8 @@ scenario( const baseScale = (await comet.baseScale()).toBigInt(); const toBorrow = 1_000n * baseScale; - const [rewardTokenAddress, rescaleFactor] = await rewards.rewardConfig(comet.address); - const rewardToken = context.getAssetByAddress(rewardTokenAddress); + const { rescaleFactor } = await context.getRewardConfig(); + const rewardToken = await context.getRewardToken(); const rewardScale = exp(1, await rewardToken.decimals()); await collateralAsset.approve(albert, comet.address); diff --git a/scenario/SupplyScenario.ts b/scenario/SupplyScenario.ts index a1a4427f1..10f1b0c6f 100644 --- a/scenario/SupplyScenario.ts +++ b/scenario/SupplyScenario.ts @@ -207,7 +207,7 @@ scenario( albert: { $base: 1010 } }, cometBalances: { - betty: { $base: -1000 } // in units of asset, not wei + betty: { $base: '<= -1000' } // in units of asset, not wei }, }, async ({ comet, actors }, context) => { @@ -247,7 +247,8 @@ scenario( asset: baseAsset.address, amount: 100n * scale, }) - ).to.be.revertedWith('ERC20: transfer amount exceeds allowance'); + ).to.be.reverted; + // ).to.be.revertedWith('ERC20: transfer amount exceeds allowance'); } ); @@ -274,7 +275,8 @@ scenario( asset: baseAsset.address, amount: 100n * scale, }) - ).to.be.revertedWith('ERC20: transfer amount exceeds allowance'); + ).to.be.reverted; + // ).to.be.revertedWith('ERC20: transfer amount exceeds allowance'); } ); @@ -329,7 +331,8 @@ scenario( asset: baseAsset.address, amount: 100n * scale, }) - ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); + ).to.be.reverted; + // ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); } ); @@ -355,7 +358,8 @@ scenario( asset: baseAsset.address, amount: 100n * scale, }) - ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); + ).to.be.reverted; + // ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); } ); diff --git a/scenario/constraints/SupplyCapConstraint.ts b/scenario/constraints/SupplyCapConstraint.ts new file mode 100644 index 000000000..acd5311b9 --- /dev/null +++ b/scenario/constraints/SupplyCapConstraint.ts @@ -0,0 +1,60 @@ +import { Constraint } from '../../plugins/scenario'; +import { CometContext } from '../context/CometContext'; +import { expect } from 'chai'; +import { Requirements } from './Requirements'; +import { exp } from '../../test/helpers'; +import { ComparisonOp, getAssetFromName, parseAmount } from '../utils'; + +export class SupplyCapConstraint implements Constraint { + async solve(requirements: R, _initialContext: T) { + const supplyCaps = requirements.supplyCaps; + if (supplyCaps !== undefined) { + const solutions = []; + solutions.push(async function barelyMeet(context: T) { + const supplyAmountPerAsset = {}; + for (const [assetName, rawAmount] of Object.entries(supplyCaps)) { + const asset = await getAssetFromName(assetName, context); + const decimals = await asset.token.decimals(); + const amount = parseAmount(rawAmount); + expect(amount.op).to.equal(ComparisonOp.GTE, `Operation ${amount.op} not supported (yet) by supply cap constraint`); + supplyAmountPerAsset[asset.address] = exp(amount.val, decimals); + } + await context.bumpSupplyCaps(supplyAmountPerAsset); + return context; + }); + return solutions; + } + } + + async check(requirements: R, context: T) { + const supplyCaps = requirements.supplyCaps; + if (supplyCaps !== undefined) { + const comet = await context.getComet(); + for (const [assetName, rawAmount] of Object.entries(supplyCaps)) { + const asset = await getAssetFromName(assetName, context); + const assetInfo = await comet.getAssetInfoByAddress(asset.address); + const decimals = await asset.token.decimals(); + const amount = parseAmount(rawAmount); + const actualCap = assetInfo.supplyCap.toBigInt(); + const expectedCap = exp(amount.val, decimals); + switch (amount.op) { + case ComparisonOp.EQ: + expect(actualCap).to.equal(expectedCap); + break; + case ComparisonOp.GTE: + expect(actualCap).to.be.at.least(expectedCap); + break; + case ComparisonOp.LTE: + expect(actualCap).to.be.at.most(expectedCap); + break; + case ComparisonOp.GT: + expect(actualCap).to.be.above(expectedCap); + break; + case ComparisonOp.LT: + expect(actualCap).to.be.below(expectedCap); + break; + } + } + } + } +} \ No newline at end of file diff --git a/scenario/constraints/index.ts b/scenario/constraints/index.ts index d079dda41..f87ae1a17 100644 --- a/scenario/constraints/index.ts +++ b/scenario/constraints/index.ts @@ -2,6 +2,7 @@ export { TokenBalanceConstraint } from './TokenBalanceConstraint'; export { PauseConstraint } from './PauseConstraint'; export { ModernConstraint } from './ModernConstraint'; export { UtilizationConstraint } from './UtilizationConstraint'; +export { SupplyCapConstraint } from './SupplyCapConstraint'; export { CometBalanceConstraint } from './CometBalanceConstraint'; export { MigrationConstraint, VerifyMigrationConstraint } from './MigrationConstraint'; export { ProposalConstraint } from './ProposalConstraint'; diff --git a/scenario/context/CometContext.ts b/scenario/context/CometContext.ts index af39d3571..72648c41a 100644 --- a/scenario/context/CometContext.ts +++ b/scenario/context/CometContext.ts @@ -1,4 +1,4 @@ -import { BigNumberish } from 'ethers'; +import { BigNumber, BigNumberish } from 'ethers'; import { World, buildScenarioFn } from '../../plugins/scenario'; import { Migration } from '../../plugins/deployment_manager'; import { debug } from '../../plugins/deployment_manager/Utils'; @@ -7,6 +7,7 @@ import { ModernConstraint, PauseConstraint, UtilizationConstraint, + SupplyCapConstraint, CometBalanceConstraint, MigrationConstraint, VerifyMigrationConstraint, @@ -26,6 +27,7 @@ import { Fauceteer, Bulker, BaseBridgeReceiver, + ERC20, } from '../../build/types'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { sourceTokens } from '../../plugins/scenario/utils/TokenSourcer'; @@ -63,7 +65,7 @@ export class CometContext { } async getCompWhales(): Promise { - return COMP_WHALES[this.world.base.name === 'mainnet' ? 'mainnet' : 'testnet']; + return COMP_WHALES[this.world.base.network === 'mainnet' ? 'mainnet' : 'testnet']; } async getWhales(): Promise { @@ -102,6 +104,12 @@ export class CometContext { return this.world.deploymentManager.contract('rewards'); } + async getRewardToken(): Promise { + const signer = await this.world.deploymentManager.getSigner(); + const { token } = await this.getRewardConfig(); + return ERC20__factory.connect(token, signer); + } + async getBulker(): Promise { return this.world.deploymentManager.contract('bulker'); } @@ -120,6 +128,12 @@ export class CometContext { return configurator.getConfiguration(comet.address); } + async getRewardConfig(): Promise<{token: string, rescaleFactor: BigNumber, shouldUpscale: boolean}> { + const comet = await this.getComet(); + const rewards = await this.getRewards(); + return await rewards.rewardConfig(comet.address); + } + async upgrade(configOverrides: ProtocolConfiguration): Promise { const { world } = this; @@ -359,6 +373,7 @@ export const constraints = [ new VerifyMigrationConstraint(), new ModernConstraint(), new PauseConstraint(), + new SupplyCapConstraint(), new CometBalanceConstraint(), new TokenBalanceConstraint(), new UtilizationConstraint(), diff --git a/scenario/utils/index.ts b/scenario/utils/index.ts index 171e09ef2..f144111d5 100644 --- a/scenario/utils/index.ts +++ b/scenario/utils/index.ts @@ -55,7 +55,7 @@ export function expectRevertCustom(tx: Promise, custom: string) .catch(e => { const selector = utils.keccak256(custom.split('').reduce((a, s) => a + s.charCodeAt(0).toString(16), '0x')).slice(2, 2 + 8); const patterns = [ - new RegExp(`custom error '${custom.replace(/[()]/g, "\\$&")}'`), + new RegExp(`custom error '${custom.replace(/[()]/g, '\\$&')}'`), new RegExp(`unrecognized custom error with selector ${selector}`), ]; for (const pattern of patterns) @@ -212,11 +212,11 @@ export function parseAmount(amount): ComparativeAmount { return amount >= 0 ? { val: amount, op: ComparisonOp.GTE } : { val: amount, op: ComparisonOp.LTE }; case 'string': return matchGroup(amount, { - 'GTE': />=\s*(\d+)/, - 'GT': />\s*(\d+)/, - 'LTE': /<=\s*(\d+)/, - 'LT': /<\s*(\d+)/, - 'EQ': /==\s*(\d+)/, + 'GTE': />=\s*(-?\d+)/, + 'GT': />\s*(-?\d+)/, + 'LTE': /<=\s*(-?\d+)/, + 'LT': /<\s*(-?\d+)/, + 'EQ': /==\s*(-?\d+)/, }); case 'object': return amount; @@ -263,6 +263,27 @@ export async function isBulkerSupported(ctx: CometContext): Promise { return bulker == null ? false : true; } +type DeploymentCriterion = { + network?: string; + deployment?: string; +} + +export function matchesDeployment(ctx: CometContext, deploymentCriteria: DeploymentCriterion[]): boolean { + const currentDeployment = { + network: ctx.world.base.network, + deployment: ctx.world.base.deployment + }; + + function matchesCurrentDeployment(deploymentCriterion: DeploymentCriterion) { + for (const [k, v] of Object.entries(deploymentCriterion)) { + if (currentDeployment[k] !== v) return false; + } + return true; + } + + return deploymentCriteria.some(matchesCurrentDeployment); +} + export async function isRewardSupported(ctx: CometContext): Promise { const rewards = await ctx.getRewards(); const comet = await ctx.getComet(); diff --git a/src/deploy/NetworkConfiguration.ts b/src/deploy/NetworkConfiguration.ts index 7e585ea5c..af18c667d 100644 --- a/src/deploy/NetworkConfiguration.ts +++ b/src/deploy/NetworkConfiguration.ts @@ -58,7 +58,7 @@ interface NetworkAssetConfiguration { supplyCap: number; } -interface NetworkConfiguration { +export interface NetworkConfiguration { name: string; symbol: string; governor?: string; @@ -95,7 +95,7 @@ function getAssetConfigs( ): AssetConfigStruct[] { return Object.entries(assets).map(([assetName, assetConfig]) => ({ asset: getContractAddress(assetName, contracts, assetConfig.address), - priceFeed: address(assetConfig.priceFeed), + priceFeed: getContractAddress(`${assetName}:priceFeed`, contracts, assetConfig.priceFeed), decimals: number(assetConfig.decimals), borrowCollateralFactor: percentage(assetConfig.borrowCF), liquidateCollateralFactor: percentage(assetConfig.liquidateCF), @@ -131,7 +131,7 @@ function getOverridesOrConfig( governor: _ => config.governor ? address(config.governor) : getContractAddress('timelock', contracts), pauseGuardian: _ => config.pauseGuardian ? address(config.pauseGuardian) : getContractAddress('timelock', contracts), baseToken: _ => getContractAddress(config.baseToken, contracts, config.baseTokenAddress), - baseTokenPriceFeed: _ => address(config.baseTokenPriceFeed), + baseTokenPriceFeed: _ => getContractAddress(`${config.baseToken}:priceFeed`, contracts, config.baseTokenPriceFeed), baseBorrowMin: _ => number(config.borrowMin), // TODO: in token units (?) storeFrontPriceFactor: _ => percentage(config.storeFrontPriceFactor), targetReserves: _ => number(config.targetReserves), diff --git a/test/absorb-test.ts b/test/absorb-test.ts index 4a18ea406..88ae85c5a 100644 --- a/test/absorb-test.ts +++ b/test/absorb-test.ts @@ -65,7 +65,7 @@ describe('absorb', function () { expect(lU1.numAbsorbed).to.be.equal(0); expect(lU1.approxSpend).to.be.equal(0); - const usdcPrice = await priceFeeds['USDC'].price(); + const [_, usdcPrice] = await priceFeeds['USDC'].latestRoundData(); const baseScale = await comet.baseScale(); expect(event(a0, 0)).to.be.deep.equal({ AbsorbDebt: { @@ -137,7 +137,7 @@ describe('absorb', function () { //expect(lA1.approxSpend).to.be.equal(459757131288n); expect(lA1.approxSpend).to.be.lt(a0.receipt.gasUsed.mul(a0.receipt.effectiveGasPrice)); - const usdcPrice = await priceFeeds['USDC'].price(); + const [_, usdcPrice] = await priceFeeds['USDC'].latestRoundData(); const baseScale = await comet.baseScale(); expect(event(a0, 0)).to.be.deep.equal({ AbsorbDebt: { @@ -267,10 +267,10 @@ describe('absorb', function () { //expect(lA1.approxSpend).to.be.equal(130651238630n); expect(lA1.approxSpend).to.be.lt(a0.receipt.gasUsed.mul(a0.receipt.effectiveGasPrice)); - const usdcPrice = await priceFeeds['USDC'].price(); - const compPrice = await priceFeeds['COMP'].price(); - const wbtcPrice = await priceFeeds['WBTC'].price(); - const wethPrice = await priceFeeds['WETH'].price(); + const [_a, usdcPrice] = await priceFeeds['USDC'].latestRoundData(); + const [_b, compPrice] = await priceFeeds['COMP'].latestRoundData(); + const [_c, wbtcPrice] = await priceFeeds['WBTC'].latestRoundData(); + const [_d, wethPrice] = await priceFeeds['WETH'].latestRoundData(); const baseScale = await comet.baseScale(); const compScale = exp(1, await COMP.decimals()); const wbtcScale = exp(1, await WBTC.decimals()); @@ -430,10 +430,10 @@ describe('absorb', function () { //expect(lA1.approxSpend).to.be.equal(1672498842684n); expect(lA1.approxSpend).to.be.lt(a0.receipt.gasUsed.mul(a0.receipt.effectiveGasPrice)); - const usdcPrice = await priceFeeds['USDC'].price(); - const compPrice = await priceFeeds['COMP'].price(); - const wbtcPrice = await priceFeeds['WBTC'].price(); - const wethPrice = await priceFeeds['WETH'].price(); + const [_a, usdcPrice] = await priceFeeds['USDC'].latestRoundData(); + const [_b, compPrice] = await priceFeeds['COMP'].latestRoundData(); + const [_c, wbtcPrice] = await priceFeeds['WBTC'].latestRoundData(); + const [_d, wethPrice] = await priceFeeds['WETH'].latestRoundData(); const baseScale = await comet.baseScale(); const compScale = exp(1, await COMP.decimals()); const wbtcScale = exp(1, await WBTC.decimals()); diff --git a/test/bulker-test.ts b/test/bulker-test.ts index 574d9229d..bb719104c 100644 --- a/test/bulker-test.ts +++ b/test/bulker-test.ts @@ -1,5 +1,5 @@ -import { baseBalanceOf, ethers, expect, exp, makeProtocol, wait, makeBulker, defaultAssets, getGasUsed, makeRewards, fastForward } from './helpers'; -import { FaucetWETH__factory } from '../build/types'; +import { baseBalanceOf, ethers, expect, exp, makeProtocol, wait, makeBulker, defaultAssets, getGasUsed, makeRewards, fastForward, event } from './helpers'; +import { FaucetWETH__factory, NonStandardFaucetToken__factory } from '../build/types'; // XXX Improve the "no permission" tests that should expect a custom error when // when https://github.com/nomiclabs/hardhat/issues/1618 gets fixed. @@ -68,7 +68,7 @@ describe('bulker', function () { expect(await comet.collateralBalanceOf(bob.address, COMP.address)).to.be.equal(supplyAmount); }); - it('supply ETH', async () => { + it('supply native token', async () => { const protocol = await makeProtocol({ assets: defaultAssets({}, { WETH: { factory: await ethers.getContractFactory('FaucetWETH') as FaucetWETH__factory } @@ -82,13 +82,13 @@ describe('bulker', function () { // Alice supplies 10 ETH through the bulker const supplyAmount = exp(10, 18); - const supplyEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount]); - await bulker.connect(alice).invoke([await bulker.ACTION_SUPPLY_ETH()], [supplyEthCalldata], { value: supplyAmount }); + const supplyNativeTokenCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount]); + await bulker.connect(alice).invoke([await bulker.ACTION_SUPPLY_NATIVE_TOKEN()], [supplyNativeTokenCalldata], { value: supplyAmount }); expect(await comet.collateralBalanceOf(alice.address, WETH.address)).to.be.equal(supplyAmount); }); - it('supply ETH refunds unused ETH', async () => { + it('supply native token refunds unused native token', async () => { const protocol = await makeProtocol({ assets: defaultAssets({}, { WETH: { factory: await ethers.getContractFactory('FaucetWETH') as FaucetWETH__factory } @@ -103,15 +103,15 @@ describe('bulker', function () { // Alice supplies 10 ETH through the bulker but actually sends 20 ETH const aliceBalanceBefore = await alice.getBalance(); const supplyAmount = exp(10, 18); - const supplyEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount]); - const txn = await wait(bulker.connect(alice).invoke([await bulker.ACTION_SUPPLY_ETH()], [supplyEthCalldata], { value: supplyAmount * 2n })); + const supplyNativeTokenCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount]); + const txn = await wait(bulker.connect(alice).invoke([await bulker.ACTION_SUPPLY_NATIVE_TOKEN()], [supplyNativeTokenCalldata], { value: supplyAmount * 2n })); const aliceBalanceAfter = await alice.getBalance(); expect(await comet.collateralBalanceOf(alice.address, WETH.address)).to.be.equal(supplyAmount); expect(aliceBalanceBefore.sub(aliceBalanceAfter)).to.be.equal(supplyAmount + getGasUsed(txn)); }); - it('supply ETH with insufficient ETH', async () => { + it('supply native token with insufficient native token', async () => { const protocol = await makeProtocol({ assets: defaultAssets({}, { WETH: { factory: await ethers.getContractFactory('FaucetWETH') as FaucetWETH__factory } @@ -125,8 +125,8 @@ describe('bulker', function () { // Alice supplies 10 ETH through the bulker but only sends 5 ETH const supplyAmount = exp(10, 18); - const supplyEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount]); - await expect(bulker.connect(alice).invoke([await bulker.ACTION_SUPPLY_ETH()], [supplyEthCalldata], { value: supplyAmount / 2n })) + const supplyNativeTokenCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount]); + await expect(bulker.connect(alice).invoke([await bulker.ACTION_SUPPLY_NATIVE_TOKEN()], [supplyNativeTokenCalldata], { value: supplyAmount / 2n })) .to.be.revertedWith('code 0x11 (Arithmetic operation underflowed or overflowed outside of an unchecked block)'); }); @@ -249,7 +249,7 @@ describe('bulker', function () { expect(await COMP.balanceOf(bob.address)).to.be.equal(withdrawAmount); }); - it('withdraw ETH', async () => { + it('withdraw native token', async () => { const protocol = await makeProtocol({ assets: defaultAssets({}, { WETH: { factory: await ethers.getContractFactory('FaucetWETH') as FaucetWETH__factory } @@ -274,8 +274,8 @@ describe('bulker', function () { // Alice supplies 10 ETH through the bulker const aliceBalanceBefore = await alice.getBalance(); - const withdrawEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, withdrawAmount]); - const txn = await wait(bulker.connect(alice).invoke([await bulker.ACTION_WITHDRAW_ETH()], [withdrawEthCalldata])); + const withdrawNativeTokenCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, withdrawAmount]); + const txn = await wait(bulker.connect(alice).invoke([await bulker.ACTION_WITHDRAW_NATIVE_TOKEN()], [withdrawNativeTokenCalldata])); const aliceBalanceAfter = await alice.getBalance(); expect(await comet.collateralBalanceOf(alice.address, WETH.address)).to.be.equal(0); @@ -348,7 +348,7 @@ describe('bulker', function () { .to.be.reverted; // Should revert with "custom error 'Unauthorized()'" }); - it('reverts on withdraw ETH if no permission granted to bulker', async () => { + it('reverts on withdraw native token if no permission granted to bulker', async () => { const protocol = await makeProtocol({ assets: defaultAssets({}, { WETH: { factory: await ethers.getContractFactory('FaucetWETH') as FaucetWETH__factory } @@ -358,13 +358,44 @@ describe('bulker', function () { const bulkerInfo = await makeBulker({ weth: WETH.address }); const { bulker } = bulkerInfo; - const withdrawEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, 1]); - await expect(bulker.connect(alice).invoke([await bulker.ACTION_WITHDRAW_ETH()], [withdrawEthCalldata])) + const withdrawNativeTokenCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, 1]); + await expect(bulker.connect(alice).invoke([await bulker.ACTION_WITHDRAW_NATIVE_TOKEN()], [withdrawNativeTokenCalldata])) .to.be.reverted; // Should revert with "custom error 'Unauthorized()'" }); describe('admin functions', function () { - it('sweep ERC20 token', async () => { + it('transferAdmin', async () => { + const protocol = await makeProtocol({}); + const { governor, tokens: { WETH }, users: [alice] } = protocol; + const bulkerInfo = await makeBulker({ admin: governor, weth: WETH.address }); + const { bulker } = bulkerInfo; + + expect(await bulker.admin()).to.be.equal(governor.address); + + // Admin transferred + const txn = await wait(bulker.connect(governor).transferAdmin(alice.address)); + + expect(event(txn, 0)).to.be.deep.equal({ + AdminTransferred: { + oldAdmin: governor.address, + newAdmin: alice.address + } + }); + expect(await bulker.admin()).to.be.equal(alice.address); + }); + + it('revert is transferAdmin called by non-admin', async () => { + const protocol = await makeProtocol({}); + const { governor, tokens: { WETH }, users: [alice] } = protocol; + const bulkerInfo = await makeBulker({ admin: governor, weth: WETH.address }); + const { bulker } = bulkerInfo; + + await expect( + bulker.connect(alice).transferAdmin(alice.address) + ).to.be.revertedWith("custom error 'Unauthorized()'"); + }); + + it('sweep standard ERC20 token', async () => { const protocol = await makeProtocol({}); const { governor, tokens: { USDC, WETH }, users: [alice] } = protocol; const bulkerInfo = await makeBulker({ admin: governor, weth: WETH.address }); @@ -388,7 +419,36 @@ describe('bulker', function () { expect(newGovBalance.sub(oldGovBalance)).to.be.equal(transferAmount); }); - it('sweep ETH', async () => { + it('sweep non-standard ERC20 token', async () => { + const protocol = await makeProtocol({}); + const { governor, tokens: { WETH }, users: [alice] } = protocol; + const bulkerInfo = await makeBulker({ admin: governor, weth: WETH.address }); + const { bulker } = bulkerInfo; + + // Deploy non-standard token + const factory = (await ethers.getContractFactory('NonStandardFaucetToken')) as NonStandardFaucetToken__factory; + const nonStandardToken = await factory.deploy(1000e6, 'Tether', 6, 'USDT'); + await nonStandardToken.deployed(); + + // Alice "accidentally" sends 10 non-standard tokens to the Bulker + const transferAmount = exp(10, 6); + await nonStandardToken.allocateTo(alice.address, transferAmount); + await nonStandardToken.connect(alice).transfer(bulker.address, transferAmount); + + const oldBulkerBalance = await nonStandardToken.balanceOf(bulker.address); + const oldGovBalance = await nonStandardToken.balanceOf(governor.address); + + // Governor sweeps tokens + await bulker.connect(governor).sweepToken(governor.address, nonStandardToken.address); + + const newBulkerBalance = await nonStandardToken.balanceOf(bulker.address); + const newGovBalance = await nonStandardToken.balanceOf(governor.address); + + expect(newBulkerBalance.sub(oldBulkerBalance)).to.be.equal(-transferAmount); + expect(newGovBalance.sub(oldGovBalance)).to.be.equal(transferAmount); + }); + + it('sweep native token', async () => { const protocol = await makeProtocol({}); const { governor, tokens: { WETH }, users: [alice] } = protocol; const bulkerInfo = await makeBulker({ admin: governor, weth: WETH.address }); @@ -402,7 +462,7 @@ describe('bulker', function () { const oldGovBalance = await ethers.provider.getBalance(governor.address); // Governor sweeps ETH - const txn = await wait(bulker.connect(governor).sweepEth(governor.address)); + const txn = await wait(bulker.connect(governor).sweepNativeToken(governor.address)); const newBulkerBalance = await ethers.provider.getBalance(bulker.address); const newGovBalance = await ethers.provider.getBalance(governor.address); @@ -422,14 +482,14 @@ describe('bulker', function () { .to.be.revertedWith("custom error 'Unauthorized()'"); }); - it('reverts if sweepEth is called by non-admin', async () => { + it('reverts if sweepNativeToken is called by non-admin', async () => { const protocol = await makeProtocol({}); const { governor, tokens: { WETH }, users: [alice] } = protocol; const bulkerInfo = await makeBulker({ admin: governor, weth: WETH.address }); const { bulker } = bulkerInfo; // Alice sweeps ETH - await expect(bulker.connect(alice).sweepEth(governor.address)) + await expect(bulker.connect(alice).sweepNativeToken(governor.address)) .to.be.revertedWith("custom error 'Unauthorized()'"); }); }); @@ -472,7 +532,7 @@ describe('bulker multiple actions', function () { expect(await USDC.balanceOf(alice.address)).to.be.equal(borrowAmount); }); - it('supply ETH to multiple accounts', async () => { + it('supply native token to multiple accounts', async () => { const protocol = await makeProtocol({ assets: defaultAssets({}, { WETH: { factory: await ethers.getContractFactory('FaucetWETH') as FaucetWETH__factory } @@ -489,7 +549,7 @@ describe('bulker multiple actions', function () { const supplyAliceEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, alice.address, supplyAmount / 2n]); const supplyBobEthCalldata = ethers.utils.defaultAbiCoder.encode(['address', 'address', 'uint'], [comet.address, bob.address, supplyAmount / 2n]); await bulker.connect(alice).invoke( - [await bulker.ACTION_SUPPLY_ETH(), await bulker.ACTION_SUPPLY_ETH()], + [await bulker.ACTION_SUPPLY_NATIVE_TOKEN(), await bulker.ACTION_SUPPLY_NATIVE_TOKEN()], [supplyAliceEthCalldata, supplyBobEthCalldata], { value: supplyAmount } ); diff --git a/test/constant-price-feed-test.ts b/test/constant-price-feed-test.ts new file mode 100644 index 000000000..aa496b440 --- /dev/null +++ b/test/constant-price-feed-test.ts @@ -0,0 +1,49 @@ +import { ethers, exp, expect, getBlock } from './helpers'; +import { + ConstantPriceFeed__factory +} from '../build/types'; + +export async function makeConstantPriceFeed({ decimals, constantPrice }) { + const constantPriceFeedFactory = (await ethers.getContractFactory('ConstantPriceFeed')) as ConstantPriceFeed__factory; + const constantPriceFeed = await constantPriceFeedFactory.deploy(decimals, constantPrice); + await constantPriceFeed.deployed(); + + return constantPriceFeed; +} + +describe('constant price feed', function () { + describe('latestRoundData', function () { + it('returns constant price for 8 decimals', async () => { + const constantPriceFeed = await makeConstantPriceFeed({ decimals: 8, constantPrice: exp(1, 8) }); + const latestRoundData = await constantPriceFeed.latestRoundData(); + const price = latestRoundData.answer.toBigInt(); + + expect(price).to.eq(exp(1, 8)); + }); + + it('returns constant price for 18 decimals', async () => { + const constantPriceFeed = await makeConstantPriceFeed({ decimals: 18, constantPrice: exp(1, 18) }); + const latestRoundData = await constantPriceFeed.latestRoundData(); + const price = latestRoundData.answer.toBigInt(); + + expect(price).to.eq(exp(1, 18)); + }); + + it('returns expected roundId, startedAt, updatedAt and answeredInRound values', async () => { + const constantPriceFeed = await makeConstantPriceFeed({ decimals: 18, constantPrice: exp(1, 18) }); + + const { + roundId, + startedAt, + updatedAt, + answeredInRound + } = await constantPriceFeed.latestRoundData(); + const currentTimestamp = (await getBlock()).timestamp; + + expect(roundId).to.eq(0); + expect(startedAt).to.eq(currentTimestamp); + expect(updatedAt).to.eq(currentTimestamp); + expect(answeredInRound).to.eq(0); + }); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts index a3cf1ff59..24db5ed64 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -4,8 +4,8 @@ import { expect } from 'chai'; import { Block } from '@ethersproject/abstract-provider'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { - Bulker, - Bulker__factory, + BaseBulker, + BaseBulker__factory, CometExt, CometExt__factory, CometHarness__factory, @@ -130,7 +130,7 @@ export type BulkerOpts = { export type BulkerInfo = { opts: BulkerOpts; - bulker: Bulker; + bulker: BaseBulker; }; export function dfn(x: T | undefined | null, dflt: T): T { @@ -493,7 +493,7 @@ export async function makeBulker(opts: BulkerOpts): Promise { const admin = opts.admin || signers[0]; const weth = opts.weth; - const BulkerFactory = (await ethers.getContractFactory('Bulker')) as Bulker__factory; + const BulkerFactory = (await ethers.getContractFactory('BaseBulker')) as BaseBulker__factory; const bulker = await BulkerFactory.deploy(admin.address, weth); await bulker.deployed(); diff --git a/test/is-borrow-collateralized-test.ts b/test/is-borrow-collateralized-test.ts index 4eafecb44..9b44da0bd 100644 --- a/test/is-borrow-collateralized-test.ts +++ b/test/is-borrow-collateralized-test.ts @@ -105,7 +105,13 @@ describe('isBorrowCollateralized', function () { expect(await comet.isBorrowCollateralized(alice.address)).to.be.true; - await priceFeeds.COMP.setPrice(exp(0.5, 8)); + await priceFeeds.COMP.setRoundData( + 0, // roundId + exp(0.5, 8), // answer + 0, // startedAt + 0, // updatedAt + 0 // answeredInRound + ); expect(await comet.isBorrowCollateralized(alice.address)).to.be.false; }); diff --git a/test/is-liquidatable-test.ts b/test/is-liquidatable-test.ts index 768decc36..2984910d9 100644 --- a/test/is-liquidatable-test.ts +++ b/test/is-liquidatable-test.ts @@ -144,7 +144,14 @@ describe('isLiquidatable', function () { expect(await comet.isLiquidatable(alice.address)).to.be.false; // price drops - await priceFeeds.COMP.setPrice(exp(0.5, 8)); + await priceFeeds.COMP.setRoundData( + 0, // roundId + exp(0.5, 8), // answer + 0, // startedAt + 0, // updatedAt + 0 // answeredInRound + ); + expect(await comet.isLiquidatable(alice.address)).to.be.true; }); }); diff --git a/test/scaling-price-feed-test.ts b/test/scaling-price-feed-test.ts new file mode 100644 index 000000000..050649cf5 --- /dev/null +++ b/test/scaling-price-feed-test.ts @@ -0,0 +1,115 @@ +import { ethers, exp, expect } from './helpers'; +import { + SimplePriceFeed__factory, + ScalingPriceFeed__factory +} from '../build/types'; + +export async function makeScalingPriceFeed({ price, priceFeedDecimals }) { + const SimplePriceFeedFactory = (await ethers.getContractFactory('SimplePriceFeed')) as SimplePriceFeed__factory; + const simplePriceFeed = await SimplePriceFeedFactory.deploy(price, priceFeedDecimals); + await simplePriceFeed.deployed(); + + const scalingPriceFeedFactory = (await ethers.getContractFactory('ScalingPriceFeed')) as ScalingPriceFeed__factory; + const scalingPriceFeed = await scalingPriceFeedFactory.deploy(simplePriceFeed.address, 8); + await scalingPriceFeed.deployed(); + + return { + simplePriceFeed, + scalingPriceFeed + }; +} + +const testCases = [ + // Price feeds with same amount of decimals as scaling + { + price: exp(100, 8), + priceFeedDecimals: 8, + result: exp(100, 8) + }, + { + price: exp(123456, 8), + priceFeedDecimals: 8, + result: exp(123456, 8) + }, + { + price: exp(-1000, 8), + priceFeedDecimals: 8, + result: exp(-1000, 8) + }, + // Price feeds with more decimals than scaling + { + price: exp(100, 18), + priceFeedDecimals: 18, + result: exp(100, 8) + }, + { + price: exp(123456, 18), + priceFeedDecimals: 18, + result: exp(123456, 8) + }, + { + price: exp(-1000, 18), + priceFeedDecimals: 18, + result: exp(-1000, 8) + }, + // Price feeds with less decimals than scaling + { + price: exp(100, 6), + priceFeedDecimals: 6, + result: exp(100, 8) + }, + { + price: exp(123456, 6), + priceFeedDecimals: 6, + result: exp(123456, 8) + }, + { + price: exp(-1000, 6), + priceFeedDecimals: 6, + result: exp(-1000, 8) + }, +]; + +describe('scaling price feed', function () { + it(`description is set properly`, async () => { + const { simplePriceFeed, scalingPriceFeed } = await makeScalingPriceFeed({ price: exp(10, 18), priceFeedDecimals: 18 }); + + expect(await scalingPriceFeed.description()).to.eq(await simplePriceFeed.description()); + }); + + describe('latestRoundData', function () { + for (const { price, priceFeedDecimals, result } of testCases) { + it(`price (${price}), priceFeedDecimals (${priceFeedDecimals}) -> ${result}`, async () => { + const { scalingPriceFeed } = await makeScalingPriceFeed({ price, priceFeedDecimals }); + const latestRoundData = await scalingPriceFeed.latestRoundData(); + const res = latestRoundData.answer.toBigInt(); + + expect(res).to.eq(result); + }); + } + + it('passes along roundId, startedAt, updatedAt and answeredInRound values from underlying price feed', async () => { + const { simplePriceFeed, scalingPriceFeed } = await makeScalingPriceFeed({ price: exp(10, 18), priceFeedDecimals: 18 }); + + await simplePriceFeed.setRoundData( + exp(15, 18), // roundId_, + 1, // answer_, + exp(16, 8), // startedAt_, + exp(17, 8), // updatedAt_, + exp(18, 18) // answeredInRound_ + ); + + const { + roundId, + startedAt, + updatedAt, + answeredInRound + } = await scalingPriceFeed.latestRoundData(); + + expect(roundId.toBigInt()).to.eq(exp(15, 18)); + expect(startedAt.toBigInt()).to.eq(exp(16, 8)); + expect(updatedAt.toBigInt()).to.eq(exp(17, 8)); + expect(answeredInRound.toBigInt()).to.eq(exp(18, 18)); + }); + }); +}); diff --git a/test/wsteth-price-feed.ts b/test/wsteth-price-feed.ts new file mode 100644 index 000000000..edfe32807 --- /dev/null +++ b/test/wsteth-price-feed.ts @@ -0,0 +1,117 @@ +import { ethers, exp, expect } from './helpers'; +import { + SimplePriceFeed__factory, + SimpleWstETH__factory, + WstETHPriceFeed__factory +} from '../build/types'; + +export async function makeWstETH({ stEthPrice, tokensPerStEth }) { + const SimplePriceFeedFactory = (await ethers.getContractFactory('SimplePriceFeed')) as SimplePriceFeed__factory; + const stETHPriceFeed = await SimplePriceFeedFactory.deploy(stEthPrice, 18); + + const SimpleWstETHFactory = (await ethers.getContractFactory('SimpleWstETH')) as SimpleWstETH__factory; + const simpleWstETH = await SimpleWstETHFactory.deploy(tokensPerStEth); + + const wstETHPriceFeedFactory = (await ethers.getContractFactory('WstETHPriceFeed')) as WstETHPriceFeed__factory; + const wstETHPriceFeed = await wstETHPriceFeedFactory.deploy( + stETHPriceFeed.address, + simpleWstETH.address, + 8 + ); + await wstETHPriceFeed.deployed(); + + return { + simpleWstETH, + stETHPriceFeed, + wstETHPriceFeed + }; +} + +const testCases = [ + { + stEthPrice: exp(1300, 18), + tokensPerStEth: exp(.9, 18), + result: 144444444444n + }, + { + stEthPrice: exp(1000, 18), + tokensPerStEth: exp(.9, 18), + result: 111111111111n + }, + { + stEthPrice: exp(1000, 18), + tokensPerStEth: exp(.2, 18), + result: exp(5000, 8) + }, + { + stEthPrice: exp(1000, 18), + tokensPerStEth: exp(.5, 18), + result: exp(2000, 8) + }, + { + stEthPrice: exp(1000, 18), + tokensPerStEth: exp(.8, 18), + result: exp(1250, 8) + }, + { + stEthPrice: exp(-1000, 18), + tokensPerStEth: exp(.8, 18), + result: exp(-1250, 8) + }, +]; + +describe('wstETH price feed', function () { + it('reverts if constructed with bad decimals', async () => { + const SimplePriceFeedFactory = (await ethers.getContractFactory('SimplePriceFeed')) as SimplePriceFeed__factory; + const stETHPriceFeed = await SimplePriceFeedFactory.deploy(exp(1, 18), 18); + + const SimpleWstETHFactory = (await ethers.getContractFactory('SimpleWstETH')) as SimpleWstETH__factory; + const simpleWstETH = await SimpleWstETHFactory.deploy(exp(0.9, 18)); + + const wstETHPriceFeedFactory = (await ethers.getContractFactory('WstETHPriceFeed')) as WstETHPriceFeed__factory; + await expect(wstETHPriceFeedFactory.deploy( + stETHPriceFeed.address, + simpleWstETH.address, + 20 // decimals_ is too high + )).to.be.revertedWith("custom error 'BadDecimals()'"); + }); + + describe('latestRoundData', function () { + for (const { stEthPrice, tokensPerStEth, result } of testCases) { + it(`stEthPrice (${stEthPrice}), tokensPerStEth (${tokensPerStEth}) -> ${result}`, async () => { + const { wstETHPriceFeed } = await makeWstETH({ stEthPrice, tokensPerStEth }); + const latestRoundData = await wstETHPriceFeed.latestRoundData(); + const price = latestRoundData.answer.toBigInt(); + + expect(price).to.eq(result); + }); + } + + it('passes along roundId, startedAt, updatedAt and answeredInRound values from stETH price feed', async () => { + const { stETHPriceFeed, wstETHPriceFeed } = await makeWstETH({ + stEthPrice: exp(1000, 18), + tokensPerStEth: exp(.8, 18), + }); + + await stETHPriceFeed.setRoundData( + exp(15, 18), // roundId_, + 1, // answer_, + exp(16, 8), // startedAt_, + exp(17, 8), // updatedAt_, + exp(18, 18) // answeredInRound_ + ); + + const { + roundId, + startedAt, + updatedAt, + answeredInRound + } = await wstETHPriceFeed.latestRoundData(); + + expect(roundId.toBigInt()).to.eq(exp(15, 18)); + expect(startedAt.toBigInt()).to.eq(exp(16, 8)); + expect(updatedAt.toBigInt()).to.eq(exp(17, 8)); + expect(answeredInRound.toBigInt()).to.eq(exp(18, 18)); + }); + }); +});