diff --git a/brownie/abi/ERC4626.json b/brownie/abi/ERC4626.json new file mode 100644 index 0000000000..b45ec3da73 --- /dev/null +++ b/brownie/abi/ERC4626.json @@ -0,0 +1,662 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "Withdraw", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "asset", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "convertToAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "convertToShares", + "outputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "deposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "maxDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "maxMint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "maxRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "maxWithdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "previewDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "previewMint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "previewRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "previewWithdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "redeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "withdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] \ No newline at end of file diff --git a/brownie/scripts/woeth_manipulation.py b/brownie/scripts/woeth_manipulation.py new file mode 100644 index 0000000000..58b16ffdcb --- /dev/null +++ b/brownie/scripts/woeth_manipulation.py @@ -0,0 +1,27 @@ +from world import * + +def expect_approximate(woeth_holder, expected_balance): + balance = woeth.balanceOf(woeth_holder) + diff = abs(expected_balance - balance) + if (diff != 0): + raise Exception("Unexpected balance for account: %s".format(woeth_holder)) + +def confirm_balances_after_upgrade(): + expect_approximate("0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", 1013453939109688661944) + expect_approximate("0xC460B0b6c9b578A4Cb93F99A691e16dB96Ee5833", 575896531839923556165) + expect_approximate("0xdca0a2341ed5438e06b9982243808a76b9add6d0", 319671606657733042618) + expect_approximate("0x8a9d46d28003673cd4fe7a56ecfcfa2be6372e64", 182355401624955452064) + expect_approximate("0xf65ecb5610000100befba41b9f9cf5ca32838078", 97352556026536192865) + expect_approximate("0x0a26e7ab5c554232314a8d459eff0ede72333f08", 91628532171545105831) + + +def manipulate_price(): + OETH_WHALE="0xa4C637e0F704745D182e4D38cAb7E7485321d059" + whl = {'from': OETH_WHALE } + + woeth.convertToAssets(1e18) / 1e18 + oeth.transfer(woeth.address, 10_000 * 1e18, whl) + woeth.convertToAssets(1e18) / 1e18 + + oeth.approve(woeth.address, 1e50, whl) + woeth.deposit(5_000 * 1e18, OETH_WHALE, whl) \ No newline at end of file diff --git a/brownie/world.py b/brownie/world.py index e7c3b65255..738c6e949a 100644 --- a/brownie/world.py +++ b/brownie/world.py @@ -19,6 +19,7 @@ weth = load_contract('ERC20', WETH) ousd = load_contract('ousd', OUSD) oeth = load_contract('ousd', OETH) +woeth = load_contract('erc4626', WOETH) usdt = load_contract('usdt', USDT) usdc = load_contract('usdc', USDC) dai = load_contract('dai', DAI) diff --git a/contracts/contracts/token/WOETH.sol b/contracts/contracts/token/WOETH.sol index 7f67b7a50c..4228f3b16c 100644 --- a/contracts/contracts/token/WOETH.sol +++ b/contracts/contracts/token/WOETH.sol @@ -6,6 +6,7 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { StableMath } from "../utils/StableMath.sol"; import { Governable } from "../governance/Governable.sol"; import { Initializable } from "../utils/Initializable.sol"; import { OETH } from "./OETH.sol"; @@ -13,16 +14,26 @@ import { OETH } from "./OETH.sol"; /** * @title OETH Token Contract * @author Origin Protocol Inc + * + * @dev An important capability of this contract is that it isn't susceptible to changes of the + * exchange rate of WOETH/OETH if/when someone sends the underlying asset (OETH) to the contract. + * If OETH weren't rebasing this could be achieved by solely tracking the ERC20 transfers of the OETH + * token on mint, deposit, redeem, withdraw. The issue is that OETH is rebasing and OETH balances + * will change when the token rebases. For that reason we are tracking the WOETH contract credits and + * credits per token in those 4 actions. That way WOETH can keep an accurate track of the OETH balance + * ignoring any unexpected transfers of OETH to this contract. */ contract WOETH is ERC4626, Governable, Initializable { using SafeERC20 for IERC20; + using StableMath for uint256; + uint256 public oethCreditsHighres; + bool private _oethCreditsInitialized; + // no need to set ERC20 name and symbol since they are overridden in WOETH & WOETHBase constructor( - ERC20 underlying_, - string memory name_, - string memory symbol_ - ) ERC20(name_, symbol_) ERC4626(underlying_) Governable() {} + ERC20 underlying_ + ) ERC20("", "") ERC4626(underlying_) Governable() {} /** * @notice Enable OETH rebasing for this contract @@ -31,6 +42,23 @@ contract WOETH is ERC4626, Governable, Initializable { OETH(address(asset())).rebaseOptIn(); } + function initialize2() external onlyGovernor { + require(!_oethCreditsInitialized, "Initialize2 already called"); + + _oethCreditsInitialized = true; + /* + * This contract is using creditsBalanceOfHighres rather than creditsBalanceOf since this + * ensures better accuracy when rounding. Also creditsBalanceOf can be a little + * finicky since it reports Highres version of credits and creditsPerToken + * when the account is a fresh one. That doesn't have an effect on mainnet since + * WOETH has already seen transactions. But it is rather annoying in unit test + * environment. + */ + (oethCreditsHighres, , ) = OETH(asset()).creditsBalanceOfHighres( + address(this) + ); + } + function name() public view virtual override returns (string memory) { return "Wrapped OETH"; } @@ -49,7 +77,84 @@ contract WOETH is ERC4626, Governable, Initializable { external onlyGovernor { + //@dev TODO: we could implement a feature where if anyone sends OETH direclty to + // the contract, that we can let the governor transfer the excess of the token. require(asset_ != address(asset()), "Cannot collect OETH"); IERC20(asset_).safeTransfer(governor(), amount_); } + + /** + * @dev This function converts requested OETH token amount to its underlying OETH + * credits value that is stored internally in OETH.sol and is required in order to + * be able to rebase. + * + * @param oethAmount Amount of OETH to be converted to OETH credits + * @return amount of OETH credits the OETH amount corresponds to + */ + function _oethToCredits(uint256 oethAmount) internal returns (uint256) { + (, uint256 creditsPerTokenHighres, ) = OETH(asset()) + .creditsBalanceOfHighres(address(this)); + + /** + * Multiplying OETH amount with the creditsPerTokenHighres is exactly the math that + * is internally being done in OETH: + */ + // solhint-disable-next-line max-line-length + /** https://github.com/OriginProtocol/origin-dollar/blob/2314cccf2933f5c1f76a6549c1f5c9cc935b6f05/contracts/contracts/token/OUSD.sol#L242-L249 + * + * This should make sure that the rounding will always be correct / mimic the rounding + * of OETH. + */ + return oethAmount.mulTruncate(creditsPerTokenHighres); + } + + /** @dev See {IERC4262-totalAssets} */ + function totalAssets() public view virtual override returns (uint256) { + (, uint256 creditsPerTokenHighres, ) = OETH(asset()) + .creditsBalanceOfHighres(address(this)); + + return (oethCreditsHighres).divPrecisely(creditsPerTokenHighres); + } + + /** @dev See {IERC4262-deposit} */ + function deposit(uint256 oethAmount, address receiver) + public + virtual + override + returns (uint256 woethAmount) + { + woethAmount = super.deposit(oethAmount, receiver); + oethCreditsHighres += _oethToCredits(oethAmount); + } + + /** @dev See {IERC4262-mint} */ + function mint(uint256 woethAmount, address receiver) + public + virtual + override + returns (uint256 oethAmount) + { + oethAmount = super.mint(woethAmount, receiver); + oethCreditsHighres += _oethToCredits(oethAmount); + } + + /** @dev See {IERC4262-withdraw} */ + function withdraw( + uint256 oethAmount, + address receiver, + address owner + ) public virtual override returns (uint256 woethAmount) { + woethAmount = super.withdraw(oethAmount, receiver, owner); + oethCreditsHighres -= _oethToCredits(oethAmount); + } + + /** @dev See {IERC4262-redeem} */ + function redeem( + uint256 woethAmount, + address receiver, + address owner + ) public virtual override returns (uint256 oethAmount) { + oethAmount = super.redeem(woethAmount, receiver, owner); + oethCreditsHighres -= _oethToCredits(oethAmount); + } } diff --git a/contracts/contracts/token/WOETHBase.sol b/contracts/contracts/token/WOETHBase.sol index b640bcddac..f84e1c237c 100644 --- a/contracts/contracts/token/WOETHBase.sol +++ b/contracts/contracts/token/WOETHBase.sol @@ -11,7 +11,7 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract WOETHBase is WOETH { constructor(ERC20 underlying_) - WOETH(underlying_, "Wrapped Super OETH", "wsuperOETHb") + WOETH(underlying_) {} function name() public view virtual override returns (string memory) { diff --git a/contracts/deploy/deployActions.js b/contracts/deploy/deployActions.js index 4944a896e5..a37b15e172 100644 --- a/contracts/deploy/deployActions.js +++ b/contracts/deploy/deployActions.js @@ -1508,6 +1508,34 @@ const deployWOusd = async () => { ](dWrappedOusdImpl.address, governorAddr, initData); }; +const deployWOeth = async () => { + const { deployerAddr, governorAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + const sGovernor = await ethers.provider.getSigner(governorAddr); + + const oeth = await ethers.getContract("OETHProxy"); + const dWrappedOethImpl = await deployWithConfirmation("WOETH", [ + oeth.address, + "Wrapped OETH IMPL", + "WOETH IMPL", + ]); + await deployWithConfirmation("WOETHProxy"); + const woethProxy = await ethers.getContract("WOETHProxy"); + const woeth = await ethers.getContractAt("WOETH", woethProxy.address); + + const initData = woeth.interface.encodeFunctionData("initialize()", []); + + await woethProxy.connect(sDeployer)[ + // eslint-disable-next-line no-unexpected-multiline + "initialize(address,address,bytes)" + ](dWrappedOethImpl.address, governorAddr, initData); + + await woeth.connect(sGovernor)[ + // eslint-disable-next-line no-unexpected-multiline + "initialize2()" + ](); +}; + const deployOETHSwapper = async () => { const { deployerAddr, governorAddr } = await getNamedAccounts(); const sDeployer = await ethers.provider.getSigner(deployerAddr); @@ -1624,6 +1652,7 @@ module.exports = { deployUniswapV3Pool, deployVaultValueChecker, deployWOusd, + deployWOeth, deployOETHSwapper, deployOUSDSwapper, upgradeNativeStakingSSVStrategy, diff --git a/contracts/deploy/mainnet/001_core.js b/contracts/deploy/mainnet/001_core.js index 61b7451e4d..d7039eadb7 100644 --- a/contracts/deploy/mainnet/001_core.js +++ b/contracts/deploy/mainnet/001_core.js @@ -23,6 +23,7 @@ const { deployUniswapV3Pool, deployVaultValueChecker, deployWOusd, + deployWOeth, deployOETHSwapper, deployOUSDSwapper, } = require("../deployActions"); @@ -54,6 +55,7 @@ const main = async () => { await deployUniswapV3Pool(); await deployVaultValueChecker(); await deployWOusd(); + await deployWOeth(); await deployOETHSwapper(); await deployOUSDSwapper(); console.log("001_core deploy done."); diff --git a/contracts/deploy/mainnet/112_upgrade_woeth.js b/contracts/deploy/mainnet/112_upgrade_woeth.js new file mode 100644 index 0000000000..8b8acbbf53 --- /dev/null +++ b/contracts/deploy/mainnet/112_upgrade_woeth.js @@ -0,0 +1,42 @@ +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "112_upgrade_woeth", + forceDeploy: false, + //forceSkip: true, + reduceQueueTime: true, + deployerIsProposer: false, + // proposalId: + }, + async ({ deployWithConfirmation, ethers }) => { + const cOETHProxy = await ethers.getContract("OETHProxy"); + const cWOETHProxy = await ethers.getContract("WOETHProxy"); + + const dWOETHImpl = await deployWithConfirmation("WOETH", [ + cOETHProxy.address + ]); + + const cWOETH = await ethers.getContractAt("WOETH", cWOETHProxy.address); + + // Governance Actions + // ---------------- + return { + name: `Upgrade WOETH to a new implementation.`, + actions: [ + // 1. Upgrade WOETH + { + contract: cWOETHProxy, + signature: "upgradeTo(address)", + args: [dWOETHImpl.address], + }, + // 2. Run the second initializer + { + contract: cWOETH, + signature: "initialize2()", + args: [], + }, + ], + }; + } +); diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 908eea9f95..7f0bb7315c 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -83,6 +83,9 @@ const simpleOETHFixture = deployments.createFixture(async () => { ); const oeth = await ethers.getContractAt("OETH", oethProxy.address); + const cWOETHProxy = await ethers.getContract("WOETHProxy"); + const woeth = await ethers.getContractAt("WOETH", cWOETHProxy.address); + const oethHarvesterProxy = await ethers.getContract("OETHHarvesterProxy"); const oethHarvester = await ethers.getContractAt( "OETHHarvester", @@ -183,6 +186,7 @@ const simpleOETHFixture = deployments.createFixture(async () => { // OETH oethVault, oeth, + woeth, nativeStakingSSVStrategy, oethDripper, oethHarvester, @@ -227,12 +231,8 @@ const defaultFixture = deployments.createFixture(async () => { ); const oeth = await ethers.getContractAt("OETH", oethProxy.address); - let woeth, woethProxy; - - if (isFork) { - woethProxy = await ethers.getContract("WOETHProxy"); - woeth = await ethers.getContractAt("WOETH", woethProxy.address); - } + const woethProxy = await ethers.getContract("WOETHProxy"); + const woeth = await ethers.getContractAt("WOETH", woethProxy.address); const harvesterProxy = await ethers.getContract("HarvesterProxy"); const harvester = await ethers.getContractAt( @@ -676,10 +676,15 @@ const defaultFixture = deployments.createFixture(async () => { if (!isFork) { await fundAccounts(); - // Matt and Josh each have $100 OUSD + // Matt and Josh each have $100 OUSD & 100 OETH for (const user of [matt, josh]) { await dai.connect(user).approve(vault.address, daiUnits("100")); await vault.connect(user).mint(dai.address, daiUnits("100"), 0); + + // Fund WETH contract + await hardhatSetBalance(user.address, "500"); + await weth.connect(user).deposit({ value: oethUnits("100") }); + await weth.connect(user).approve(oethVault.address, oethUnits("100")); } } return { diff --git a/contracts/test/helpers.js b/contracts/test/helpers.js index eae2879037..8921ebe19a 100644 --- a/contracts/test/helpers.js +++ b/contracts/test/helpers.js @@ -119,6 +119,15 @@ chai.Assertion.addMethod( } ); +chai.Assertion.addMethod("totalSupply", async function (expected, message) { + const contract = this._obj; + const actual = await contract.totalSupply(); + if (!BigNumber.isBigNumber(expected)) { + expected = parseUnits(expected, await decimalsFor(contract)); + } + chai.expect(actual).to.equal(expected, message); +}); + chai.Assertion.addMethod( "assetBalanceOf", async function (expected, asset, message) { diff --git a/contracts/test/token/woeth.js b/contracts/test/token/woeth.js new file mode 100644 index 0000000000..a703c27b68 --- /dev/null +++ b/contracts/test/token/woeth.js @@ -0,0 +1,187 @@ +const { expect } = require("chai"); + +const { loadDefaultFixture } = require("../_fixture"); +const { oethUnits, daiUnits, isFork } = require("../helpers"); +const { hardhatSetBalance } = require("../_fund"); + +describe("WOETH", function () { + if (isFork) { + this.timeout(0); + } + + let oeth, weth, woeth, oethVault, dai, matt, josh, governor; + + beforeEach(async () => { + const fixture = await loadDefaultFixture(); + oeth = fixture.oeth; + woeth = fixture.woeth; + oethVault = fixture.oethVault; + dai = fixture.dai; + matt = fixture.matt; + josh = fixture.josh; + weth = fixture.weth; + governor = fixture.governor; + + // mint some OETH + for (const user of [matt, josh]) { + await oethVault.connect(user).mint(weth.address, oethUnits("100"), 0); + } + + // Josh wraps 50 OETH to WOETH + await oeth.connect(josh).approve(woeth.address, oethUnits("1000")); + await woeth.connect(josh).deposit(oethUnits("50"), josh.address); + + // rebase OETH balances in wallets by 2x + await increaseOETHSupplyAndRebase(await oeth.totalSupply()); + + // josh account starts each test with 100 OETH + }); + + const increaseOETHSupplyAndRebase = async (wethAmount) => { + await weth.connect(josh).deposit({ value: wethAmount }); + await weth.connect(josh).transfer(oethVault.address, wethAmount); + await oethVault.connect(josh).rebase(); + }; + + describe("General functionality", async () => { + it("Initialize2 should not be called twice", async () => { + // this function is already called by the fixture + await expect(woeth.connect(governor).initialize2()).to.be.revertedWith( + "Initialize2 already called" + ); + }); + + it("Initialize2 should not be called by non governor", async () => { + await expect(woeth.connect(josh).initialize2()).to.be.revertedWith( + "Caller is not the Governor" + ); + }); + }); + + describe("Funds in, Funds out", async () => { + it("should deposit at the correct ratio", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await woeth.connect(josh).deposit(oethUnits("50"), josh.address); + await expect(josh).to.have.a.balanceOf("75", woeth); + await expect(josh).to.have.a.balanceOf("50", oeth); + await expect(woeth).to.have.a.totalSupply("75"); + }); + + it("should withdraw at the correct ratio", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await woeth + .connect(josh) + .withdraw(oethUnits("50"), josh.address, josh.address); + await expect(josh).to.have.a.balanceOf("25", woeth); + await expect(josh).to.have.a.balanceOf("150", oeth); + await expect(woeth).to.have.a.totalSupply("25"); + }); + it("should mint at the correct ratio", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await woeth.connect(josh).mint(oethUnits("25"), josh.address); + await expect(josh).to.have.a.balanceOf("75", woeth); + await expect(josh).to.have.a.balanceOf("50", oeth); + await expect(woeth).to.have.a.totalSupply("75"); + }); + + it("should redeem at the correct ratio", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await expect(josh).to.have.a.balanceOf("50", woeth); + await woeth + .connect(josh) + .redeem(oethUnits("50"), josh.address, josh.address); + await expect(josh).to.have.a.balanceOf("0", woeth); + await expect(josh).to.have.a.balanceOf("200", oeth); + await expect(woeth).to.have.a.totalSupply("0"); + }); + + it("should be able to redeem all WOETH", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await expect(josh).to.have.a.balanceOf("50", woeth); + await expect(matt).to.have.a.balanceOf("0", woeth); + + await oeth.connect(matt).approve(woeth.address, oethUnits("100")); + await woeth.connect(matt).mint(oethUnits("50"), matt.address); + + await expect(woeth).to.have.a.totalSupply("100"); + await expect(await woeth.totalAssets()).to.equal(oethUnits("200")); + + // redeem all WOETH held by Josh and Matt + await woeth + .connect(josh) + .redeem(oethUnits("50"), josh.address, josh.address); + await woeth + .connect(matt) + .redeem(oethUnits("50"), matt.address, matt.address); + + await expect(josh).to.have.a.balanceOf("0", woeth); + await expect(matt).to.have.a.balanceOf("0", woeth); + await expect(josh).to.have.a.balanceOf("200", oeth); + await expect(matt).to.have.a.balanceOf("200", oeth); + await expect(woeth).to.have.a.totalSupply("0"); + await expect(await woeth.totalAssets()).to.equal(oethUnits("0")); + }); + }); + + describe("Collects Rebase", async () => { + it("should increase with an OETH rebase", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await expect(woeth).to.have.approxBalanceOf("100", oeth); + await hardhatSetBalance(josh.address, "250"); + + await increaseOETHSupplyAndRebase(oethUnits("200")); + + await expect(woeth).to.have.approxBalanceOf("150", oeth); + await expect(woeth).to.have.a.totalSupply("50"); + }); + + it("should not increase exchange rate when OETH is transferred to the contract", async () => { + await expect(woeth).to.have.a.totalSupply("50"); + await expect(woeth).to.have.approxBalanceOf("100", oeth); + await expect(josh).to.have.a.balanceOf("50", woeth); + + // attempt to "attack" the contract to inflate the WOETH balance + await oeth.connect(josh).transfer(woeth.address, oethUnits("50")); + + // redeeming 50 WOETH should still yield 100 OETH and not let the transfer + // of OETH one line above affect it + await woeth + .connect(josh) + .redeem(oethUnits("50"), josh.address, josh.address); + + await expect(josh).to.have.a.balanceOf("0", woeth); + await expect(woeth).to.have.approxBalanceOf("50", oeth); + await expect(await woeth.totalAssets()).to.equal("0"); + await expect(woeth).to.have.a.totalSupply("0"); + }); + }); + + describe("Check proxy", async () => { + it("should have correct ERC20 properties", async () => { + expect(await woeth.decimals()).to.eq(18); + expect(await woeth.name()).to.eq("Wrapped OETH"); + expect(await woeth.symbol()).to.eq("wOETH"); + }); + }); + + describe("Token recovery", async () => { + it("should allow a governor to recover tokens", async () => { + await dai.connect(matt).transfer(woeth.address, daiUnits("2")); + await expect(woeth).to.have.a.balanceOf("2", dai); + await expect(governor).to.have.a.balanceOf("1000", dai); + await woeth.connect(governor).transferToken(dai.address, daiUnits("2")); + await expect(woeth).to.have.a.balanceOf("0", dai); + await expect(governor).to.have.a.balanceOf("1002", dai); + }); + it("should not allow a governor to collect OETH", async () => { + await expect( + woeth.connect(governor).transferToken(oeth.address, oethUnits("2")) + ).to.be.revertedWith("Cannot collect OETH"); + }); + it("should not allow a non governor to recover tokens ", async () => { + await expect( + woeth.connect(josh).transferToken(oeth.address, oethUnits("2")) + ).to.be.revertedWith("Caller is not the Governor"); + }); + }); +}); diff --git a/contracts/test/token/woeth.mainnet.fork-test.js b/contracts/test/token/woeth.mainnet.fork-test.js new file mode 100644 index 0000000000..b5c5f9d905 --- /dev/null +++ b/contracts/test/token/woeth.mainnet.fork-test.js @@ -0,0 +1,272 @@ +const { expect } = require("chai"); + +const { simpleOETHFixture, createFixtureLoader } = require("./../_fixture"); +const { hardhatSetBalance } = require("../_fund"); +const { oethUnits } = require("../helpers"); + +const oethWhaleFixture = async () => { + const fixture = await simpleOETHFixture(); + + const { weth, oeth, oethVault, woeth, domen } = fixture; + + // Domen is a OETH whale + await oethVault + .connect(domen) + .mint(weth.address, oethUnits("20000"), oethUnits("19999")); + + await oeth.connect(domen).approve(woeth.address, oethUnits("20000")); + + return fixture; +}; + +const loadFixture = createFixtureLoader(oethWhaleFixture); + +describe("ForkTest: wOETH", function () { + this.timeout(0); + + let fixture; + beforeEach(async () => { + fixture = await loadFixture(); + }); + + it("Should have correct name and symbol", async () => { + const { woeth } = fixture; + + expect(await woeth.name()).to.equal("Wrapped OETH"); + expect(await woeth.symbol()).to.equal("wOETH"); + }); + + it("Should prevent total asset manipulation by donations", async () => { + const { oeth, woeth, domen } = fixture; + const totalAssetsBefore = await woeth.totalAssets(); + await oeth.connect(domen).transfer(woeth.address, oethUnits("100")); + const totalAssetsAfter = await woeth.totalAssets(); + + expect(totalAssetsBefore).to.be.equal(totalAssetsAfter); + }); + + it("Deposit should not be manipulated by donations", async () => { + const { oeth, woeth, domen } = fixture; + + await expect(domen).to.have.approxBalanceOf("0", woeth); + + // Wrap some OETH + await woeth.connect(domen).deposit(oethUnits("1000"), domen.address); + + const sharePriceBeforeDonate = await woeth.convertToAssets( + oethUnits("1000") + ); + + // Donate some OETH + oeth.connect(domen).transfer(woeth.address, oethUnits("10000")); + + // Ensure no change in share price + const sharePriceAfterDonate = await woeth.convertToAssets( + oethUnits("1000") + ); + expect(sharePriceBeforeDonate).to.approxEqual( + sharePriceAfterDonate, + "Price manipulation" + ); + + // Wrap again + await woeth.connect(domen).deposit(oethUnits("1000"), domen.address); + + // Ensure the balance is right + await expect(domen).to.have.approxBalanceOf( + // 2000 * 1000 / sharePrice(1000 OETH) + oethUnits("2000").mul(oethUnits("1000")).div(sharePriceAfterDonate), + woeth + ); + }); + + it("Withdraw should not be manipulated by donations", async () => { + const { oeth, woeth, domen } = fixture; + + await expect(domen).to.have.approxBalanceOf("0", woeth); + await expect(domen).to.have.approxBalanceOf("20000", oeth); + + // Wrap some OETH + await woeth.connect(domen).deposit(oethUnits("3000"), domen.address); + + const sharePriceBeforeDonate = await woeth.convertToAssets( + oethUnits("1000") + ); + + // Donate some OETH + oeth.connect(domen).transfer(woeth.address, oethUnits("10000")); + + // Ensure no change in share price + const sharePriceAfterDonate = await woeth.convertToAssets( + oethUnits("1000") + ); + expect(sharePriceBeforeDonate).to.approxEqual( + sharePriceAfterDonate, + "Price manipulation" + ); + + // Withdraw + await woeth + .connect(domen) + .withdraw( + await woeth.maxWithdraw(domen.address), + domen.address, + domen.address + ); + + // Ensure balance is right + await expect(domen).to.have.approxBalanceOf("10000", oeth); + }); + + describe("Funds in, Funds out", async () => { + it("should deposit at the correct ratio", async () => { + const { oeth, woeth, domen } = fixture; + + const totalSupply = await woeth.totalSupply(); + const balanceBefore = await oeth.balanceOf(domen.address); + + // Wrap some OETH + const txResponse = await woeth + .connect(domen) + .deposit(oethUnits("50"), domen.address); + const txReceipt = await txResponse.wait(); + const mintedShares = txReceipt.events[2].args.shares; // 0. transfer oeth, 1. transfer woeth, 2. deposit + const assetTransfered = txReceipt.events[2].args.assets; // 0. transfer oeth, 1. transfer woeth, 2. mint + + await expect(assetTransfered).to.be.equal(oethUnits("50")); + await expect( + await woeth.convertToShares(assetTransfered) + ).to.be.approxEqual(mintedShares); + await expect(woeth).to.have.a.totalSupply(totalSupply.add(mintedShares)); + await expect(await woeth.balanceOf(domen.address)).to.be.equal( + mintedShares + ); + await expect(await oeth.balanceOf(domen.address)).to.be.equal( + balanceBefore.sub(assetTransfered) + ); + }); + it("should withdraw at the correct ratio", async () => { + const { oeth, woeth, domen } = fixture; + // First wrap some OETH + await woeth.connect(domen).deposit(oethUnits("50"), domen.address); + + const totalSupply = await woeth.totalSupply(); + const balanceBefore = await oeth.balanceOf(domen.address); + + // Then unwrap some WOETH + const txResponse = await woeth + .connect(domen) + .withdraw( + await woeth.maxWithdraw(domen.address), + domen.address, + domen.address + ); + const txReceipt = await txResponse.wait(); + const burnedShares = txReceipt.events[2].args.shares; // 0. transfer oeth, 1. transfer woeth, 2. withdraw + const assetTransfered = txReceipt.events[2].args.assets; // 0. transfer oeth, 1. transfer woeth, 2. mint + + await expect(assetTransfered).to.be.approxEqual(oethUnits("50")); + await expect( + await woeth.convertToShares(assetTransfered) + ).to.be.approxEqual(burnedShares); + await expect(woeth).to.have.a.totalSupply(totalSupply.sub(burnedShares)); + await expect(await woeth.balanceOf(domen.address)).to.be.equal(0); + await expect(await oeth.balanceOf(domen.address)).to.be.approxEqual( + balanceBefore.add(assetTransfered) + ); + }); + it("should mint at the correct ratio", async () => { + const { oeth, woeth, domen } = fixture; + + const totalSupply = await woeth.totalSupply(); + const balanceBefore = await oeth.balanceOf(domen.address); + + // Mint some WOETH + const txResponse = await woeth + .connect(domen) + .mint(oethUnits("25"), domen.address); + const txReceipt = await txResponse.wait(); + const mintedShares = txReceipt.events[2].args.shares; // 0. transfer oeth, 1. transfer woeth, 2. mint + const assetTransfered = txReceipt.events[2].args.assets; // 0. transfer oeth, 1. transfer woeth, 2. mint + + await expect(mintedShares).to.be.equal(oethUnits("25")); + await expect(await woeth.convertToAssets(mintedShares)).to.be.approxEqual( + assetTransfered + ); + await expect(woeth).to.have.a.totalSupply(totalSupply.add(mintedShares)); + await expect(await woeth.balanceOf(domen.address)).to.be.equal( + mintedShares + ); + await expect(await oeth.balanceOf(domen.address)).to.be.equal( + balanceBefore.sub(assetTransfered) + ); + }); + it("should redeem at the correct ratio", async () => { + const { oeth, woeth, domen } = fixture; + + // Mint some WOETH + await woeth.connect(domen).mint(oethUnits("25"), domen.address); + + const totalSupply = await woeth.totalSupply(); + const balanceBefore = await oeth.balanceOf(domen.address); + + // Redeem some WOETH + const txResponse = await woeth + .connect(domen) + .redeem( + await woeth.maxRedeem(domen.address), + domen.address, + domen.address + ); + const txReceipt = await txResponse.wait(); + const burnedShares = txReceipt.events[2].args.shares; // 0. transfer oeth, 1. transfer woeth, 2. redeem + const assetTransfered = txReceipt.events[2].args.assets; // 0. transfer oeth, 1. transfer woeth, 2. redeem + + await expect(burnedShares).to.be.equal(oethUnits("25")); + await expect(await woeth.convertToAssets(burnedShares)).to.be.approxEqual( + assetTransfered + ); + await expect(woeth).to.have.a.totalSupply(totalSupply.sub(burnedShares)); + await expect(await woeth.balanceOf(domen.address)).to.be.equal(0); + await expect(await oeth.balanceOf(domen.address)).to.be.approxEqual( + balanceBefore.add(assetTransfered) + ); + }); + it("should redeem at the correct ratio after rebase", async () => { + const { weth, oethVault, woeth, domen, josh } = fixture; + + // Mint some WOETH + const initialDeposit = oethUnits("50"); + await woeth.connect(domen).deposit(initialDeposit, domen.address); + + const totalAssetsBefore = await woeth.totalAssets(); + // Rebase + await hardhatSetBalance(josh.address, "250"); + await weth.connect(josh).deposit({ value: oethUnits("200") }); + await weth.connect(josh).transfer(oethVault.address, oethUnits("200")); + await oethVault.rebase(); + + const totalAssetsAfter = await woeth.totalAssets(); + expect(totalAssetsAfter > totalAssetsBefore).to.be.true; + + // Then unwrap some WOETH + const txResponse = await woeth + .connect(domen) + .redeem( + await woeth.maxRedeem(domen.address), + domen.address, + domen.address + ); + + const txReceipt = await txResponse.wait(); + const burnedShares = txReceipt.events[2].args.shares; // 0. transfer oeth, 1. transfer woeth, 2. redeem + const assetTransfered = txReceipt.events[2].args.assets; // 0. transfer oeth, 1. transfer woeth, 2. redeem + + await expect(assetTransfered > initialDeposit); + await expect(burnedShares).to.be.approxEqual( + await woeth.convertToShares(assetTransfered) + ); + await expect(domen).to.have.a.balanceOf("0", woeth); + }); + }); +});