diff --git a/.gitignore b/.gitignore index c509d60a8..65a6a11d4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ specs/.certora_verify .certora_config/ .certora_build.json .coverage_contracts/ -.coverage_artifacts/ \ No newline at end of file +.coverage_artifacts/ +*.DS_Store \ No newline at end of file diff --git a/contracts/pricers/MaticXPricer.sol b/contracts/pricers/MaticXPricer.sol new file mode 100644 index 000000000..cb6f3f622 --- /dev/null +++ b/contracts/pricers/MaticXPricer.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.6.10; + +import {AggregatorInterface} from "../interfaces/AggregatorInterface.sol"; +import {OracleInterface} from "../interfaces/OracleInterface.sol"; +import {OpynPricerInterface} from "../interfaces/OpynPricerInterface.sol"; +import {SafeMath} from "../packages/oz/SafeMath.sol"; + +/** + * @notice A Pricer contract for maticX as reported by Chainlink + */ +contract MaticXPricer is OpynPricerInterface { + using SafeMath for uint256; + + /// @dev base decimals + uint256 internal constant BASE = 8; + + /// @notice maticX chainlink response decimals + uint256 public aggregatorDecimals; + + /// @notice the opyn oracle address + OracleInterface public oracle; + /// @notice the aggregator for an asset + AggregatorInterface public aggregator; + + /// @notice asset that this pricer will a get price for + address public asset; + /// @notice bot address that is allowed to call setExpiryPriceInOracle + address public bot; + + /** + * @param _bot priveleged address that can call setExpiryPriceInOracle + * @param _asset asset that this pricer will get a price for + * @param _aggregator Chainlink aggregator contract for the asset + * @param _oracle Opyn Oracle address + */ + constructor( + address _bot, + address _asset, + address _aggregator, + address _oracle + ) public { + require(_bot != address(0), "ChainLinkPricer: Cannot set 0 address as bot"); + require(_oracle != address(0), "ChainLinkPricer: Cannot set 0 address as oracle"); + require(_aggregator != address(0), "ChainLinkPricer: Cannot set 0 address as aggregator"); + + bot = _bot; + oracle = OracleInterface(_oracle); + aggregator = AggregatorInterface(_aggregator); + asset = _asset; + + aggregatorDecimals = uint256(aggregator.decimals()); + } + + /** + * @notice set the expiry price in the oracle, can only be called by Bot address + * @dev a roundId must be provided to confirm price validity, which is the first Chainlink price provided after the expiryTimestamp + * @param _expiryTimestamp expiry to set a price for + * @param _roundId the first roundId after expiryTimestamp + */ + function setExpiryPriceInOracle(uint256 _expiryTimestamp, uint80 _roundId) external { + (, int256 price, , uint256 roundTimestamp, ) = aggregator.getRoundData(_roundId); + + require(_expiryTimestamp <= roundTimestamp, "ChainLinkPricer: roundId not first after expiry"); + require(price >= 0, "ChainLinkPricer: invalid price"); + + if (msg.sender != bot) { + bool isCorrectRoundId; + uint80 previousRoundId = uint80(uint256(_roundId).sub(1)); + + while (!isCorrectRoundId) { + (, , , uint256 previousRoundTimestamp, ) = aggregator.getRoundData(previousRoundId); + + if (previousRoundTimestamp == 0) { + require(previousRoundId > 0, "ChainLinkPricer: Invalid previousRoundId"); + previousRoundId = previousRoundId - 1; + } else if (previousRoundTimestamp > _expiryTimestamp) { + revert("ChainLinkPricer: previousRoundId not last before expiry"); + } else { + isCorrectRoundId = true; + } + } + } + + oracle.setExpiryPrice(asset, _expiryTimestamp, uint256(price)); + } + + /** + * @notice get the live price for the asset + * @dev overides the getPrice function in OpynPricerInterface + * @return price of the asset in USD, scaled by 1e8 + */ + function getPrice() external view override returns (uint256) { + (, int256 answer, , , ) = aggregator.latestRoundData(); + require(answer > 0, "ChainLinkPricer: price is lower than 0"); + // chainlink's answer is already 1e8 + return _scaleToBase(uint256(answer)); + } + + /** + * @notice get historical chainlink price + * @param _roundId chainlink round id + * @return round price and timestamp + */ + function getHistoricalPrice(uint80 _roundId) external view override returns (uint256, uint256) { + (, int256 price, , uint256 roundTimestamp, ) = aggregator.getRoundData(_roundId); + return (_scaleToBase(uint256(price)), roundTimestamp); + } + + /** + * @notice scale aggregator response to base decimals (1e8) + * @param _price aggregator price + * @return price scaled to 1e8 + */ + function _scaleToBase(uint256 _price) internal view returns (uint256) { + if (aggregatorDecimals > BASE) { + uint256 exp = aggregatorDecimals.sub(BASE); + _price = _price.div(10**exp); + } else if (aggregatorDecimals < BASE) { + uint256 exp = BASE.sub(aggregatorDecimals); + _price = _price.mul(10**exp); + } + + return _price; + } +} diff --git a/docs/contracts-documentation/pricers/MaticXPricer.md b/docs/contracts-documentation/pricers/MaticXPricer.md new file mode 100644 index 000000000..a169f00bd --- /dev/null +++ b/docs/contracts-documentation/pricers/MaticXPricer.md @@ -0,0 +1,81 @@ +# `MaticXPricer` + +A Pricer contract for maticX as reported by Chainlink + +## Modifiers: + +- `onlyBot()` + +## Functions: + +- `constructor(address _bot, address _asset, address _aggregator, address _oracle) (public)` + +- `setExpiryPriceInOracle(uint256 _expiryTimestamp, uint80 _roundId) (external)` + +- `getPrice() (external)` + +- `getHistoricalPrice(uint80 _roundId) (external)` + +- `_scaleToBase(uint256 _price) (internal)` + +### Modifier `onlyBot()` + +modifier to check if sender address is equal to bot address + +### Function `constructor(address _bot, address _asset, address _aggregator, address _oracle) public` + +#### Parameters: + +- `_bot`: priveleged address that can call setExpiryPriceInOracle + +- `_asset`: asset that this pricer will get a price for + +- `_aggregator`: Chainlink aggregator contract for the asset + +- `_oracle`: Opyn Oracle address + +### Function `setExpiryPriceInOracle(uint256 _expiryTimestamp, uint80 _roundId) external` + +set the expiry price in the oracle, can only be called by Bot address + +a roundId must be provided to confirm price validity, which is the first Chainlink price provided after the expiryTimestamp + +#### Parameters: + +- `_expiryTimestamp`: expiry to set a price for + +- `_roundId`: the first roundId after expiryTimestamp + +### Function `getPrice() → uint256 external` + +get the live price for the asset + +overides the getPrice function in OpynPricerInterface + +#### Return Values: + +- price of the asset in USD, scaled by 1e8 + +### Function `getHistoricalPrice(uint80 _roundId) → uint256, uint256 external` + +get historical chainlink price + +#### Parameters: + +- `_roundId`: chainlink round id + +#### Return Values: + +- round price and timestamp + +### Function `_scaleToBase(uint256 _price) → uint256 internal` + +scale aggregator response to base decimals (1e8) + +#### Parameters: + +- `_price`: aggregator price + +#### Return Values: + +- price scaled to 1e8 diff --git a/scripts/deployMaticX.js b/scripts/deployMaticX.js new file mode 100644 index 000000000..e9b6aea3a --- /dev/null +++ b/scripts/deployMaticX.js @@ -0,0 +1,34 @@ +const yargs = require('yargs') + +const MaticXPricer = artifacts.require('MaticX.sol') + +module.exports = async function(callback) { + try { + const options = yargs + .usage( + 'Usage: --network --bot --asset --aggregator --oracle --gasPrice --gasLimit ', + ) + .option('network', {describe: 'Network name', type: 'string', demandOption: true}) + .option('bot', {describe: 'Bot address', type: 'string', demandOption: true}) + .option('asset', {describe: 'Asset address', type: 'string', demandOption: true}) + .option('aggregator', {describe: 'maticX aggregator 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 maticX pricer contract on ${options.network} 🍕`) + + const tx = await MaticXPricer.new(options.bot, options.asset, options.aggregator, options.oracle, { + gasPrice: options.gasPrice, + gas: options.gasLimit, + }) + + console.log('maticX 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/MaticXPricer.test.ts b/test/unit-tests/MaticXPricer.test.ts new file mode 100644 index 000000000..7059babbb --- /dev/null +++ b/test/unit-tests/MaticXPricer.test.ts @@ -0,0 +1,195 @@ +import { + MaticXPricerInstance, + MockOracleInstance, + MockChainlinkAggregatorInstance, + MockERC20Instance, +} from '../../build/types/truffle-types' + +import { createTokenAmount } from '../utils' +const { expectRevert, time } = require('@openzeppelin/test-helpers') + +const ChainlinkPricer = artifacts.require('MaticXPricer.sol') +const MockOracle = artifacts.require('MockOracle.sol') +const MockChainlinkAggregator = artifacts.require('MockChainlinkAggregator.sol') +const MockERC20 = artifacts.require('MockERC20.sol') + +// address(0) +const ZERO_ADDR = '0x0000000000000000000000000000000000000000' + +contract('ChainlinkPricer', ([owner, bot, random]) => { + let wethAggregator: MockChainlinkAggregatorInstance + let oracle: MockOracleInstance + let weth: MockERC20Instance + // otoken + let pricer: MaticXPricerInstance + + before('Deployment', async () => { + // deploy mock contracts + oracle = await MockOracle.new({ from: owner }) + wethAggregator = await MockChainlinkAggregator.new() + weth = await MockERC20.new('WETH', 'WETH', 18) + // deploy pricer + pricer = await ChainlinkPricer.new(bot, weth.address, wethAggregator.address, oracle.address) + }) + + describe('constructor', () => { + it('should set the config correctly', async () => { + const asset = await pricer.asset() + assert.equal(asset, weth.address) + const bot = await pricer.bot() + assert.equal(bot, bot) + const aggregator = await pricer.aggregator() + assert.equal(aggregator, wethAggregator.address) + const oracleModule = await pricer.oracle() + assert.equal(oracleModule, oracle.address) + }) + it('should revert if initializing aggregator with 0 address', async () => { + await expectRevert( + ChainlinkPricer.new(bot, weth.address, ZERO_ADDR, wethAggregator.address), + 'ChainLinkPricer: Cannot set 0 address as aggregator', + ) + }) + it('should revert if initializing oracle with 0 address', async () => { + await expectRevert( + ChainlinkPricer.new(bot, weth.address, oracle.address, ZERO_ADDR), + 'ChainLinkPricer: Cannot set 0 address as oracle', + ) + }) + it('should revert if initializing bot with 0 address', async () => { + await expectRevert( + ChainlinkPricer.new(ZERO_ADDR, weth.address, oracle.address, wethAggregator.address), + 'ChainLinkPricer: Cannot set 0 address as bot', + ) + }) + }) + + describe('getPrice', () => { + // aggregator have price in 1e8 + const ethPrice = createTokenAmount(300, 8) + before('mock data in weth aggregator', async () => { + await wethAggregator.setLatestAnswer(ethPrice) + }) + it('should return the price in 1e8', async () => { + const price = await pricer.getPrice() + const expectedResult = createTokenAmount(300, 8) + assert.equal(price.toString(), expectedResult.toString()) + }) + it('should return the new price after resetting answer in aggregator', async () => { + const newPrice = createTokenAmount(400, 8) + await wethAggregator.setLatestAnswer(newPrice) + const price = await pricer.getPrice() + const expectedResult = createTokenAmount(400, 8) + assert.equal(price.toString(), expectedResult.toString()) + }) + it('should revert if price is lower than 0', async () => { + await wethAggregator.setLatestAnswer(-1) + await expectRevert(pricer.getPrice(), 'ChainLinkPricer: price is lower than 0') + }) + }) + + describe('setExpiryPrice', () => { + // time order: t0, t1, t2, t3, t4 + let t0: number, t1: number, t2: number, t3: number, t4: number + // p0 = price at t0 ... etc + const p0 = createTokenAmount(100, 8) + const p1 = createTokenAmount(150.333, 8) + const p2 = createTokenAmount(180, 8) + const p3 = createTokenAmount(200, 8) + const p4 = createTokenAmount(140, 8) + + before('setup history in aggregator', async () => { + // set t0, t1, t2, expiry, t3, t4 + t0 = (await time.latest()).toNumber() + // set round answers + await wethAggregator.setRoundAnswer(0, p0) + await wethAggregator.setRoundAnswer(1, p1) + await wethAggregator.setRoundAnswer(2, p2) + await wethAggregator.setRoundAnswer(3, p3) + await wethAggregator.setRoundAnswer(4, p4) + + // set round timestamps + await wethAggregator.setRoundTimestamp(0, t0) + t1 = t0 + 60 * 1 + await wethAggregator.setRoundTimestamp(1, t1) + t2 = t0 + 60 * 2 + await wethAggregator.setRoundTimestamp(2, t2) + t3 = t0 + 60 * 3 + await wethAggregator.setRoundTimestamp(3, t3) + t4 = t0 + 60 * 4 + await wethAggregator.setRoundTimestamp(4, t4) + }) + + it('bot should set the correct price to the oracle', async () => { + const expiryTimestamp = (t0 + t1) / 2 // between t0 and t1 + const roundId = 1 + + await pricer.setExpiryPriceInOracle(expiryTimestamp, roundId, { from: bot }) + const priceFromOracle = await oracle.getExpiryPrice(weth.address, expiryTimestamp) + assert.equal(p1.toString(), priceFromOracle[0].toString()) + }) + + it('should revert if sender is not bot address and roundId is too old', async () => { + const expiryTimestamp = (t1 + t2) / 2 // between t1 and t2 + const roundId = 1 + await expectRevert( + pricer.setExpiryPriceInOracle(expiryTimestamp, roundId, { from: random }), + 'ChainLinkPricer: roundId not first after expiry', + ) + }) + + it('should revert if sender is not bot address and roundId is too late', async () => { + const expiryTimestamp = (t1 + t2) / 2 // between t1 and t2 + const roundId = 3 + await expectRevert( + pricer.setExpiryPriceInOracle(expiryTimestamp, roundId, { from: random }), + 'ChainLinkPricer: previousRoundId not last before expiry', + ) + }) + + it('anyone should be able to set prices', async () => { + const expiryTimestamp = (t1 + t2) / 2 // between t1 and t2 + const roundId = 2 + await pricer.setExpiryPriceInOracle(expiryTimestamp, roundId, { from: random }) + const priceFromOracle = await oracle.getExpiryPrice(weth.address, expiryTimestamp) + assert.equal(p2.toString(), priceFromOracle[0].toString()) + }) + + it('should revert if round ID is too late: price[roundId].timestamp < expiry', async () => { + const expiryTimestamp = (t1 + t2) / 2 // between t1 and t2 + const roundId = 1 + await expectRevert( + pricer.setExpiryPriceInOracle(expiryTimestamp, roundId, { from: bot }), + 'ChainLinkPricer: roundId not first after expiry', + ) + }) + }) + + describe('get historical price', async () => { + let t0: number + // p0 = price at t0 ... etc + const p0 = createTokenAmount(100, 8) + + before('setup history in aggregator', async () => { + t0 = (await time.latest()).toNumber() + // set round answers + await wethAggregator.setRoundAnswer(0, p0) + + // set round timestamps + await wethAggregator.setRoundTimestamp(0, t0) + }) + + it('should return historical price with timestamp', async () => { + const roundData = await pricer.getHistoricalPrice(0) + + assert.equal(roundData[0].toString(), p0, 'Historical round price mismatch') + + assert.equal(roundData[1].toNumber(), t0, 'Historical round timestamp mismatch') + }) + + it('should revert when no data round available', async () => { + const invalidRoundId = 1050 + + await expectRevert(pricer.getHistoricalPrice(invalidRoundId), 'No data present') + }) + }) +})