diff --git a/common/configuration.ts b/common/configuration.ts index daae83074..a0ed2962d 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -88,6 +88,7 @@ export interface ITokens { wsgUSDbC?: string yvCurveUSDPcrvUSD?: string yvCurveUSDCcrvUSD?: string + wsuperOETHb?: string pyUSD?: string aEthPyUSD?: string @@ -536,6 +537,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { USDz: '0x04D5ddf5f3a8939889F11E97f8c4BB48317F1938', meUSD: '0xbb819D845b573B5D7C538F5b85057160cfb5f313', AERO: '0x940181a94A35A4569E4529A3CDfB74e38FD98631', + wsuperOETHb: '0x7FcD174E80f264448ebeE8c88a7C4476AAF58Ea6' }, chainlinkFeeds: { DAI: '0x591e79239a7d679378ec8c847e5038150364c78f', // 0.3%, 24hr @@ -555,6 +557,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { eUSD: '0x9b2C948dbA5952A1f5Ab6fA16101c1392b8da1ab', // 0.5%, 24h USDz: '0xe25969e2Fa633a0C027fAB8F30Fc9C6A90D60B48', // 0.5%, 24h AERO: '0x4EC5970fC728C5f65ba413992CD5fF6FD70fcfF0', // 0.5%, 24h + wsuperOETHb: '0x28C964c985fe84736fAdc7Cf0bBd58B54bc7CF93' }, GNOSIS_EASY_AUCTION: '0xb1875Feaeea32Bbb02DE83D81772e07E37A40f02', // mock COMET_REWARDS: '0x123964802e6ABabBE1Bc9547D72Ef1B69B00A6b1', diff --git a/contracts/plugins/assets/origin/OETHCollateralL2Base.sol b/contracts/plugins/assets/origin/OETHCollateralL2Base.sol new file mode 100644 index 000000000..d597b2b09 --- /dev/null +++ b/contracts/plugins/assets/origin/OETHCollateralL2Base.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "../../../libraries/Fixed.sol"; +import "../AppreciatingFiatCollateral.sol"; +import "../OracleLib.sol"; + +interface IWSuperOETHb { + event Approval(address indexed owner, address indexed spender, uint256 value); + event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares); + event GovernorshipTransferred(address indexed previousGovernor, address indexed newGovernor); + event PendingGovernorshipTransfer( + address indexed previousGovernor, + address indexed newGovernor + ); + event Transfer(address indexed from, address indexed to, uint256 value); + event Withdraw( + address indexed caller, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); + + function allowance(address owner, address spender) external view returns (uint256); + + function approve(address spender, uint256 amount) external returns (bool); + + function asset() external view returns (address); + + function balanceOf(address account) external view returns (uint256); + + function claimGovernance() external; + + function convertToAssets(uint256 shares) external view returns (uint256 assets); + + function convertToShares(uint256 assets) external view returns (uint256 shares); + + function decimals() external view returns (uint8); + + function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool); + + function deposit(uint256 assets, address receiver) external returns (uint256); + + function governor() external view returns (address); + + function increaseAllowance(address spender, uint256 addedValue) external returns (bool); + + function initialize() external; + + function isGovernor() external view returns (bool); + + function maxDeposit(address) external view returns (uint256); + + function maxMint(address) external view returns (uint256); + + function maxRedeem(address owner) external view returns (uint256); + + function maxWithdraw(address owner) external view returns (uint256); + + function mint(uint256 shares, address receiver) external returns (uint256); + + function name() external view returns (string memory); + + function previewDeposit(uint256 assets) external view returns (uint256); + + function previewMint(uint256 shares) external view returns (uint256); + + function previewRedeem(uint256 shares) external view returns (uint256); + + function previewWithdraw(uint256 assets) external view returns (uint256); + + function redeem( + uint256 shares, + address receiver, + address owner + ) external returns (uint256); + + function symbol() external view returns (string memory); + + function totalAssets() external view returns (uint256); + + function totalSupply() external view returns (uint256); + + function transfer(address recipient, uint256 amount) external returns (bool); + + function transferFrom( + address sender, + address recipient, + uint256 amount + ) external returns (bool); + + function transferGovernance(address _newGovernor) external; + + function transferToken(address asset_, uint256 amount_) external; + + function withdraw( + uint256 assets, + address receiver, + address owner + ) external returns (uint256); +} + +interface IMorphoChainlinkOracleV2 { + function price() external view returns (uint256); +} + +/** + * @title Origin Staked ETH Collateral for Base L2 + * @notice Collateral plugin for Origin OETH, + * tok = wsuperOETHb (wrapped superOETHb) + * ref = superOETHb (pegged to ETH 1:1) + * tar = ETH + * UoA = USD + */ +contract OETHCollateralL2Base is AppreciatingFiatCollateral { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + IMorphoChainlinkOracleV2 public immutable targetPerTokChainlinkFeed; // {tar/token} + + AggregatorV3Interface public immutable uoaPerTargetChainlinkFeed; // {UoA/tar} + uint48 public immutable uoaPerTargetChainlinkTimeout; // {s} + + /// @param config.chainlinkFeed - ignored + /// @param config.oracleTimeout - ignored + /// @param config.oracleError {1} Should be the oracle error for UoA/tok + constructor( + CollateralConfig memory config, + uint192 revenueHiding, + IMorphoChainlinkOracleV2 _targetPerTokChainlinkFeed, + AggregatorV3Interface _uoaPerTargetChainlinkFeed, + uint48 _uoaPerTargetChainlinkTimeout + ) AppreciatingFiatCollateral(config, revenueHiding) { + require(config.defaultThreshold != 0, "defaultThreshold zero"); + + require(address(_targetPerTokChainlinkFeed) != address(0), "targetPerTokFeed missing"); + require(address(_uoaPerTargetChainlinkFeed) != address(0), "uoaPerTargetFeed missing"); + + targetPerTokChainlinkFeed = _targetPerTokChainlinkFeed; + + uoaPerTargetChainlinkFeed = _uoaPerTargetChainlinkFeed; + uoaPerTargetChainlinkTimeout = _uoaPerTargetChainlinkTimeout; + + maxOracleTimeout = uint48(Math.max(maxOracleTimeout, _uoaPerTargetChainlinkTimeout)); + } + + /// Can revert, used by other contract functions in order to catch errors + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + /// @return pegPrice {target/ref} The actual price observed in the peg + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + // {tar/tok} + // {ETH/wsuperOETHb} + uint192 targetPerTok = _safeWrap(targetPerTokChainlinkFeed.price()) / 1e18; + + // {UoA/tar} + // {USD/ETH} + uint192 uoaPerTar = uoaPerTargetChainlinkFeed.price(uoaPerTargetChainlinkTimeout); + + // {UoA/tok} = {UoA/tar} * {tar/tok} + // USD/wsuperOETHb = USD/ETH * ETH/wsuperOETHb + uint192 p = uoaPerTar.mul(targetPerTok); + uint192 err = p.mul(oracleError, CEIL); + + high = p + err; + low = p - err; + // assert(low <= high); obviously true just by inspection + + // {tar/ref} = {tar/tok} / {ref/tok} Get current market peg + // ETH/superOETHb = ETH/wsuperOETHb / superOETHb/wsuperOETHb + pegPrice = targetPerTok.div(underlyingRefPerTok()); + } + + /// @return {ref/tok} Quantity of whole reference units per whole collateral tokens + /// {superOETHb/wsuperOETHb} + function underlyingRefPerTok() public view override returns (uint192) { + return _safeWrap(IWSuperOETHb(address(erc20)).convertToAssets(FIX_ONE)); + } +} diff --git a/test/plugins/individual-collateral/origin/OETHCollateralL2Base.test.ts b/test/plugins/individual-collateral/origin/OETHCollateralL2Base.test.ts new file mode 100644 index 000000000..e183da143 --- /dev/null +++ b/test/plugins/individual-collateral/origin/OETHCollateralL2Base.test.ts @@ -0,0 +1,268 @@ +import collateralTests from '../collateralTests' +import { setStorageAt, getStorageAt } from '@nomicfoundation/hardhat-network-helpers' +import { CollateralFixtureContext, CollateralOpts, MintCollateralFunc } from '../pluginTestTypes' +import { mintWSUPEROETHB } from './helpers' +import { expect } from 'chai' +import { ethers } from 'hardhat' +import { ContractFactory, BigNumber, BigNumberish } from 'ethers' +import { + ERC20Mock, + MockV3Aggregator, + MockV3Aggregator__factory, + TestICollateral, + IWSuperOETHb, +} from '../../../../typechain' +import { pushOracleForward } from '../../../utils/oracles' +import { bn, fp } from '../../../../common/numbers' +import { ZERO_ADDRESS } from '../../../../common/constants' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { + PRICE_TIMEOUT, + MAX_TRADE_VOL, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + FORK_BLOCK_BASE, + BASE_PRICE_FEEDS, + BASE_FEEDS_TIMEOUT, + BASE_ORACLE_ERROR, + BASE_WSUPEROETHB, + BASE_WSUPEROETHB_WHALE, +} from './constants' +import { getResetFork } from '../helpers' + +/* + Define interfaces +*/ +interface WSUPEROETHBCollateralFixtureContext extends CollateralFixtureContext { + wsuperoethb: IWSuperOETHb + targetPerRefChainlinkFeed: MockV3Aggregator + uoaPerTargetChainlinkFeed: MockV3Aggregator +} + +/* + Define deployment functions +*/ + +interface WSUPEROETHBCollateralOpts extends CollateralOpts { + targetPerTokChainlinkFeed?: string + uoaPerTargetChainlinkFeed?: string + uoaPerTargetChainlinkTimeout?: BigNumberish +} + +export const defaultWSUPEROETHBCollateralOpts: WSUPEROETHBCollateralOpts = { + erc20: BASE_WSUPEROETHB, + targetName: ethers.utils.formatBytes32String('ETH'), + rewardERC20: ZERO_ADDRESS, + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: BASE_PRICE_FEEDS.wsuperOETHb_ETH, // ignored + oracleTimeout: '1000', // ignored + oracleError: BASE_ORACLE_ERROR, + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + targetPerTokChainlinkFeed: BASE_PRICE_FEEDS.wsuperOETHb_ETH, + uoaPerTargetChainlinkFeed: BASE_PRICE_FEEDS.ETH_USD, + uoaPerTargetChainlinkTimeout: BASE_FEEDS_TIMEOUT.ETH_USD, + revenueHiding: fp('1e-4'), +} + +export const deployCollateral = async ( + opts: WSUPEROETHBCollateralOpts = {} +): Promise => { + opts = { ...defaultWSUPEROETHBCollateralOpts, ...opts } + + const WSuperOETHbCollateralFactory: ContractFactory = await ethers.getContractFactory( + 'OETHCollateralL2Base' + ) + + const collateral = await WSuperOETHbCollateralFactory.deploy( + { + erc20: opts.erc20, + targetName: opts.targetName, + rewardERC20: opts.rewardERC20, + priceTimeout: opts.priceTimeout, + chainlinkFeed: opts.chainlinkFeed, + oracleError: opts.oracleError, + oracleTimeout: opts.oracleTimeout, + maxTradeVolume: opts.maxTradeVolume, + defaultThreshold: opts.defaultThreshold, + delayUntilDefault: opts.delayUntilDefault, + }, + opts.revenueHiding, + opts.targetPerTokChainlinkFeed, + opts.chainlinkFeed ?? opts.uoaPerTargetChainlinkFeed, + opts.uoaPerTargetChainlinkTimeout, + { gasLimit: 2000000000 } + ) + + // Push forward chainlink feed + await pushOracleForward(opts.uoaPerTargetChainlinkFeed!) + + await collateral.deployed() + // sometimes we are trying to test a negative test case and we want this to fail silently + // fortunately this syntax fails silently because our tools are terrible + await expect(collateral.refresh()) + + return collateral +} + +const defaultAnswers = { + targetPerRefChainlinkFeed: bn('1e18'), + uoaPerTargetChainlinkFeed: bn('2000e8'), + refPerTokenChainlinkFeed: bn('1.1e18'), +} + +type Fixture = () => Promise + +const makeCollateralFixtureContext = ( + alice: SignerWithAddress, + opts: CollateralOpts = {} +): Fixture => { + const collateralOpts = { ...defaultWSUPEROETHBCollateralOpts, ...opts } + + const makeCollateralFixtureContext = async () => { + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + + const targetPerRefChainlinkFeed = await MockV3AggregatorFactory.deploy( + 18, + defaultAnswers.targetPerRefChainlinkFeed + ) + const uoaPerTargetChainlinkFeed = await MockV3AggregatorFactory.deploy( + 8, + defaultAnswers.uoaPerTargetChainlinkFeed + ) + + collateralOpts.chainlinkFeed = uoaPerTargetChainlinkFeed.address + collateralOpts.uoaPerTargetChainlinkFeed = uoaPerTargetChainlinkFeed.address + + const wsuperOETHb = (await ethers.getContractAt( + 'IWSuperOETHb', + BASE_WSUPEROETHB + )) as IWSuperOETHb + const rewardToken = (await ethers.getContractAt('ERC20Mock', ZERO_ADDRESS)) as ERC20Mock + const collateral = await deployCollateral(collateralOpts) + + return { + alice, + collateral, + wsuperoethb: wsuperOETHb, + tok: wsuperOETHb, + rewardToken, + chainlinkFeed: uoaPerTargetChainlinkFeed, + targetPerRefChainlinkFeed: targetPerRefChainlinkFeed, + uoaPerTargetChainlinkFeed, + } + } + + return makeCollateralFixtureContext +} + +/* + Define helper functions +*/ + +const mintCollateralTo: MintCollateralFunc = async ( + ctx: WSUPEROETHBCollateralFixtureContext, + amount: BigNumberish, + user: SignerWithAddress, + recipient: string +) => { + await mintWSUPEROETHB(ctx.wsuperoethb, user, amount, recipient, BASE_WSUPEROETHB_WHALE) +} + +const reduceTargetPerRef = async ( + ctx: WSUPEROETHBCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + const lastRound = await ctx.chainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.sub(lastRound.answer.mul(pctDecrease).div(100)) + await ctx.chainlinkFeed.updateAnswer(nextAnswer) +} + +const increaseTargetPerRef = async ( + ctx: WSUPEROETHBCollateralFixtureContext, + pctIncrease: BigNumberish +) => { + const lastRound = await ctx.chainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(pctIncrease).div(100)) + await ctx.chainlinkFeed.updateAnswer(nextAnswer) +} + +const reduceRefPerTok = async ( + ctx: WSUPEROETHBCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + const slot = 2 + const storedTotalSupply = BigNumber.from(await getStorageAt(ctx.tok.address, slot)) + const newStoredTotalAssets = storedTotalSupply.add(storedTotalSupply.mul(pctDecrease).div(100)) + await setStorageAt(ctx.tok.address, slot, newStoredTotalAssets) +} + +const increaseRefPerTok = async ( + ctx: WSUPEROETHBCollateralFixtureContext, + pctIncrease: BigNumberish +) => { + const slot = 2 + const storedTotalSupply = BigNumber.from(await getStorageAt(ctx.tok.address, slot)) + const newStoredTotalAssets = storedTotalSupply.sub(storedTotalSupply.mul(pctIncrease).div(100)) + await setStorageAt(ctx.tok.address, slot, newStoredTotalAssets) +} + +const getExpectedPrice = async (ctx: WSUPEROETHBCollateralFixtureContext): Promise => { + const uoaPerTargetChainlinkFeedAnswer = await ctx.uoaPerTargetChainlinkFeed.latestAnswer() + const uoaPerTargetChainlinkFeedDecimals = await ctx.uoaPerTargetChainlinkFeed.decimals() + + const refPerTok = await ctx.collateral.refPerTok() + + const result = uoaPerTargetChainlinkFeedAnswer + .mul(bn(10).pow(18 - uoaPerTargetChainlinkFeedDecimals)) + .mul(refPerTok) + .div(fp('1')) + + return result +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificConstructorTests = () => {} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificStatusTests = () => {} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const beforeEachRewardsTest = async () => {} + +/* + Run the test suite +*/ + +const opts = { + deployCollateral, + collateralSpecificConstructorTests, + collateralSpecificStatusTests, + beforeEachRewardsTest, + makeCollateralFixtureContext, + mintCollateralTo, + reduceTargetPerRef, + increaseTargetPerRef, + reduceRefPerTok, + increaseRefPerTok, + getExpectedPrice, + itClaimsRewards: it.skip, + itChecksTargetPerRefDefault: it.skip, + itChecksTargetPerRefDefaultUp: it.skip, + itChecksRefPerTokDefault: it, + itChecksPriceChanges: it, + itChecksNonZeroDefaultThreshold: it, + itHasRevenueHiding: it, + resetFork: getResetFork(FORK_BLOCK_BASE), + collateralName: 'OETHCollateralL2Base', + chainlinkDefaultAnswer: defaultAnswers.uoaPerTargetChainlinkFeed, + itIsPricedByPeg: true, + itHasOracleRefPerTok: true, + targetNetwork: 'base', + toleranceDivisor: bn('1e2'), +} + +collateralTests(opts) diff --git a/test/plugins/individual-collateral/origin/constants.ts b/test/plugins/individual-collateral/origin/constants.ts new file mode 100644 index 000000000..bdeb9a1f2 --- /dev/null +++ b/test/plugins/individual-collateral/origin/constants.ts @@ -0,0 +1,31 @@ +import { bn, fp } from '../../../../common/numbers' +import { networkConfig } from '../../../../common/configuration' +import { combinedError } from '../../../../scripts/deployment/utils' + +// Mainnet Addresses + +// Base Addresses +export const BASE_WSUPEROETHB = networkConfig['8453'].tokens.wsuperOETHb as string +export const BASE_WSUPEROETHB_WHALE = '0x190e5C6AabB2BeC4eB0B9b2274e9b62cdaEDF356' // Silo +export const FORK_BLOCK_BASE = 21698000 +export const BASE_PRICE_FEEDS = { + // traditional finance notation, opposite of our unit system + wsuperOETHb_ETH: networkConfig['8453'].chainlinkFeeds.wsuperOETHb, // {ETH/wsuperOETHb} + ETH_USD: networkConfig['8453'].chainlinkFeeds.ETHUSD, // {USD/ETH} +} +export const BASE_FEEDS_TIMEOUT = { + wsuperOETHb_ETH: bn(86400), + ETH_USD: bn(1200), +} +export const BASE_ORACLE_ERROR = combinedError( + fp('0.0015'), + combinedError(fp('0.005'), fp('0.005')) +) + +// Data +export const PRICE_TIMEOUT = bn('604800') // 1 week +export const ORACLE_TIMEOUT = bn(86400) // 24 hours in seconds +export const ORACLE_ERROR = fp('0.005') +export const DEFAULT_THRESHOLD = bn(5).mul(bn(10).pow(16)) // 0.05 +export const DELAY_UNTIL_DEFAULT = bn(86400) +export const MAX_TRADE_VOL = bn(1000) diff --git a/test/plugins/individual-collateral/origin/helpers.ts b/test/plugins/individual-collateral/origin/helpers.ts new file mode 100644 index 000000000..4f573f153 --- /dev/null +++ b/test/plugins/individual-collateral/origin/helpers.ts @@ -0,0 +1,20 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { IERC20 } from '../../../../typechain' +import { whileImpersonating } from '../../../utils/impersonation' +import { BigNumberish } from 'ethers' +import { FORK_BLOCK_BASE, BASE_WSUPEROETHB_WHALE } from './constants' +import { getResetFork } from '../helpers' + +export const mintWSUPEROETHB = async ( + wsuperoethb: IERC20, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string, + whale: string = BASE_WSUPEROETHB_WHALE +) => { + await whileImpersonating(whale, async (wsuperoethbWhale) => { + await wsuperoethb.connect(wsuperoethbWhale).transfer(recipient, amount) + }) +} + +export const resetFork = getResetFork(FORK_BLOCK_BASE)