diff --git a/contracts/interfaces/RETHInterface.sol b/contracts/interfaces/RETHInterface.sol new file mode 100644 index 000000000..2dedf842c --- /dev/null +++ b/contracts/interfaces/RETHInterface.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.6.10; + +interface RETHInterface { + function getExchangeRate() external view returns (uint256); + + function getETHValue(uint256 rethAmount) external view returns (uint256); + + function getRethValue(uint256 ethAmount) external view returns (uint256); +} diff --git a/contracts/mocks/MockRETHToken.sol b/contracts/mocks/MockRETHToken.sol new file mode 100644 index 000000000..6d376ca72 --- /dev/null +++ b/contracts/mocks/MockRETHToken.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.6.10; + +import {ERC20Upgradeable} from "../packages/oz/upgradeability/ERC20Upgradeable.sol"; + +contract MockRETHToken is ERC20Upgradeable { + uint256 public ethPerToken; + + constructor(string memory _name, string memory _symbol) public { + __ERC20_init_unchained(_name, _symbol); + _setupDecimals(18); + } + + function mint(address account, uint256 amount) public { + _mint(account, amount); + } + + function setEthPerToken(uint256 _ethPerToken) external { + ethPerToken = _ethPerToken; + } + + function getExchangeRate() external returns (uint256) { + return ethPerToken; + } +} diff --git a/contracts/pricers/RethPricer.sol b/contracts/pricers/RethPricer.sol new file mode 100644 index 000000000..7e077c550 --- /dev/null +++ b/contracts/pricers/RethPricer.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.6.10; + +import {OracleInterface} from "../interfaces/OracleInterface.sol"; +import {OpynPricerInterface} from "../interfaces/OpynPricerInterface.sol"; +import {RETHInterface} from "../interfaces/RETHInterface.sol"; +import {SafeMath} from "../packages/oz/SafeMath.sol"; + +/** + * Error Codes + * W1: cannot deploy pricer, rETH address cannot be 0 + * W2: cannot deploy pricer, underlying address cannot be 0 + * W3: cannot deploy pricer, oracle address cannot be 0 + * W4: cannot retrieve price, underlying price is 0 + * W5: cannot set expiry price in oracle, underlying price is 0 and has not been set + * W6: cannot retrieve historical prices, getHistoricalPrice has been deprecated + */ + +/** + * @title RethPricer + * @author Opyn Team + * @notice A Pricer contract for a rETH token + */ +contract RethPricer is OpynPricerInterface { + using SafeMath for uint256; + + /// @notice opyn oracle address + OracleInterface public oracle; + + /// @notice rETH token + RETHInterface public rETH; + + /// @notice underlying asset (WETH) + address public underlying; + + /** + * @param _rETH rETH + * @param _underlying underlying asset for rETH + * @param _oracle Opyn Oracle contract address + */ + constructor( + address _rETH, + address _underlying, + address _oracle + ) public { + require(_rETH != address(0), "W1"); + require(_underlying != address(0), "W2"); + require(_oracle != address(0), "W3"); + + rETH = RETHInterface(_rETH); + oracle = OracleInterface(_oracle); + underlying = _underlying; + } + + /** + * @notice get the live price for the asset + * @dev overrides the getPrice function in OpynPricerInterface + * @return price of 1 rETH in USD, scaled by 1e8 + */ + function getPrice() external view override returns (uint256) { + uint256 underlyingPrice = oracle.getPrice(underlying); + require(underlyingPrice > 0, "W4"); + return _underlyingPriceToRethPrice(underlyingPrice); + } + + /** + * @notice set the expiry price in the oracle + * @dev requires that the underlying price has been set before setting a rETH price + * @param _expiryTimestamp expiry to set a price for + */ + function setExpiryPriceInOracle(uint256 _expiryTimestamp) external { + (uint256 underlyingPriceExpiry, ) = oracle.getExpiryPrice(underlying, _expiryTimestamp); + require(underlyingPriceExpiry > 0, "W5"); + uint256 rEthPrice = _underlyingPriceToRethPrice(underlyingPriceExpiry); + oracle.setExpiryPrice(address(rETH), _expiryTimestamp, rEthPrice); + } + + /** + * @dev convert underlying price to rETH price + * @param _underlyingPrice price of 1 underlying token (ie 1e18 WETH) in USD, scaled by 1e8 + * @return price of 1 rETH in USD, scaled by 1e8 + */ + function _underlyingPriceToRethPrice(uint256 _underlyingPrice) private view returns (uint256) { + uint256 ethPerReth = rETH.getExchangeRate(); + + return ethPerReth.mul(_underlyingPrice).div(1e18); + } + + function getHistoricalPrice(uint80) external view override returns (uint256, uint256) { + revert("W6"); + } +} diff --git a/scripts/deployRethPricer.js b/scripts/deployRethPricer.js new file mode 100644 index 000000000..1bd376fef --- /dev/null +++ b/scripts/deployRethPricer.js @@ -0,0 +1,33 @@ +const yargs = require('yargs') + +const RethPricer = artifacts.require('RethPricer.sol') + +module.exports = async function (callback) { + try { + const options = yargs + .usage( + 'Usage: --network --rETH --underlying --oracle --gasPrice --gasLimit ', + ) + .option('network', { describe: 'Network name', type: 'string', demandOption: true }) + .option('rETH', { describe: 'rETH address', type: 'string', demandOption: true }) + .option('underlying', { describe: 'Underlying address', type: 'string', demandOption: true }) + .option('oracle', { describe: 'Oracle module address', type: 'string', demandOption: true }) + .option('gasPrice', { describe: 'Gas price in WEI', type: 'string', demandOption: false }) + .option('gasLimit', { describe: 'Gas Limit in WEI', type: 'string', demandOption: false }).argv + + console.log(`Deploying rETH pricer contract to ${options.network} 🍕`) + + const tx = await RethPricer.new(options.rETH, options.underlying, options.oracle, { + gasPrice: options.gasPrice, + gas: options.gasLimit, + }) + + console.log('rETH pricer deployed! 🎉') + console.log(`Transaction hash: ${tx.transactionHash}`) + console.log(`Deployed contract address: ${tx.address}`) + + callback() + } catch (err) { + callback(err) + } +} diff --git a/test/unit-tests/rETHPricer.test.ts b/test/unit-tests/rETHPricer.test.ts new file mode 100644 index 000000000..7a38a97f9 --- /dev/null +++ b/test/unit-tests/rETHPricer.test.ts @@ -0,0 +1,122 @@ +import { + MockPricerInstance, + MockOracleInstance, + MockERC20Instance, + MockRETHTokenInstance, + RethPricerInstance, +} from '../../build/types/truffle-types' + +import { underlyingPriceToYTokenPrice } from '../utils' + +import BigNumber from 'bignumber.js' +import { createScaledNumber } from '../utils' +const { expectRevert, time } = require('@openzeppelin/test-helpers') + +const MockPricer = artifacts.require('MockPricer.sol') +const MockOracle = artifacts.require('MockOracle.sol') + +const MockERC20 = artifacts.require('MockERC20.sol') +const MockRETHToken = artifacts.require('MockRETHToken.sol') +const RethPricer = artifacts.require('RethPricer.sol') + +// address(0) +const ZERO_ADDR = '0x0000000000000000000000000000000000000000' + +contract('RethPricer', ([owner, random]) => { + let oracle: MockOracleInstance + let weth: MockERC20Instance + let rETH: MockRETHTokenInstance + // old pricer + let wethPricer: MockPricerInstance + // steth pricer + let rethPricer: RethPricerInstance + + before('Deployment', async () => { + // deploy mock contracts + oracle = await MockOracle.new({ from: owner }) + weth = await MockERC20.new('WETH', 'WETH', 18) + rETH = await MockRETHToken.new('rETH', 'rETH') + // mock underlying pricers + wethPricer = await MockPricer.new(weth.address, oracle.address) + + await oracle.setAssetPricer(weth.address, wethPricer.address) + }) + + describe('constructor', () => { + it('should deploy the contract successfully with correct values', async () => { + rethPricer = await RethPricer.new(rETH.address, weth.address, oracle.address) + + assert.equal(await rethPricer.rETH(), rETH.address) + assert.equal(await rethPricer.underlying(), weth.address) + assert.equal(await rethPricer.oracle(), oracle.address) + }) + + it('should revert if initializing with rETH = 0', async () => { + await expectRevert(RethPricer.new(ZERO_ADDR, weth.address, oracle.address), 'W1') + }) + + it('should revert if initializing with underlying = 0 address', async () => { + await expectRevert(RethPricer.new(rETH.address, ZERO_ADDR, oracle.address), 'W2') + }) + + it('should revert if initializing with oracle = 0 address', async () => { + await expectRevert(RethPricer.new(rETH.address, weth.address, ZERO_ADDR), 'W3') + }) + }) + + describe('getPrice for rETH', () => { + const ethPrice = createScaledNumber(470) + const pricePerShare = new BigNumber('1009262845672227655') + before('mock data in chainlink pricer and rETH', async () => { + await oracle.setRealTimePrice(weth.address, ethPrice) + // await wethPricer.setPrice(ethPrice) + await rETH.setEthPerToken(pricePerShare) + }) + it('should return the price in 1e8', async () => { + // how much 1e8 yToken worth in USD + const rETHprice = await rethPricer.getPrice() + const expectResult = await underlyingPriceToYTokenPrice(new BigNumber(ethPrice), pricePerShare, weth) + assert.equal(rETHprice.toString(), expectResult.toString()) + // hard coded answer + // 1 yWETH = 9.4 USD + assert.equal(rETHprice.toString(), '47435353746') + }) + + it('should return the new price after resetting answer in underlying pricer', async () => { + const newPrice = createScaledNumber(500) + // await wethPricer.setPrice(newPrice) + await oracle.setRealTimePrice(weth.address, newPrice) + const rETHprice = await rethPricer.getPrice() + const expectedResult = await underlyingPriceToYTokenPrice(new BigNumber(newPrice), pricePerShare, weth) + assert.equal(rETHprice.toString(), expectedResult.toString()) + }) + + it('should revert if price is lower than 0', async () => { + // await wethPricer.setPrice('0') + await oracle.setRealTimePrice(weth.address, '0') + await expectRevert(rethPricer.getPrice(), 'W4') + }) + }) + + describe('setExpiryPrice', () => { + let expiry: number + const ethPrice = new BigNumber(createScaledNumber(300)) + const pricePerShare = new BigNumber('1009262845672227655') + + before('setup oracle record for weth price', async () => { + expiry = (await time.latest()) + time.duration.days(30).toNumber() + }) + + it("should revert if oracle don't have price of underlying yet", async () => { + await expectRevert(rethPricer.setExpiryPriceInOracle(expiry), 'W5') + }) + + it('should set price successfully by arbitrary address', async () => { + await oracle.setExpiryPrice(weth.address, expiry, ethPrice) + await rethPricer.setExpiryPriceInOracle(expiry, { from: random }) + const [price] = await oracle.getExpiryPrice(rETH.address, expiry) + const expectedResult = await underlyingPriceToYTokenPrice(ethPrice, pricePerShare, weth) + assert.equal(price.toString(), expectedResult.toString()) + }) + }) +})