diff --git a/.gitignore b/.gitignore index e9f6fc21..317b4a12 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ dist \#*\# .\#* +### Vim ## +*.swp + # # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/contracts/MedianOracle.sol b/contracts/MedianOracle.sol new file mode 100644 index 00000000..00ab8609 --- /dev/null +++ b/contracts/MedianOracle.sol @@ -0,0 +1,223 @@ +pragma solidity 0.4.24; + +import {SafeMath} from "openzeppelin-solidity/contracts/math/SafeMath.sol"; +import {Ownable} from "openzeppelin-solidity/contracts/ownership/Ownable.sol"; + +import "./lib/Select.sol"; + +interface IOracle { + function getData() external returns (uint256, bool); +} + +/** + * @title Median Oracle + * + * @notice Provides a value onchain that's aggregated from a whitelisted set of + * providers. + */ +contract MedianOracle is Ownable, IOracle { + using SafeMath for uint256; + + struct Report { + uint256 timestamp; + uint256 payload; + } + + // Addresses of providers authorized to push reports. + address[] public providers; + + // Reports indexed by provider address. Report[0].timestamp > 0 + // indicates provider existence. + mapping(address => Report[2]) public providerReports; + + event ProviderAdded(address provider); + event ProviderRemoved(address provider); + event ReportTimestampOutOfRange(address provider); + event ProviderReportPushed(address indexed provider, uint256 payload, uint256 timestamp); + + // The number of seconds after which the report is deemed expired. + uint256 public reportExpirationTimeSec; + + // The number of seconds since reporting that has to pass before a report + // is usable. + uint256 public reportDelaySec; + + // The minimum number of providers with valid reports to consider the + // aggregate report valid. + uint256 public minimumProviders = 1; + + // Timestamp of 1 is used to mark uninitialized and invalidated data. + // This is needed so that timestamp of 1 is always considered expired. + uint256 private constant MAX_REPORT_EXPIRATION_TIME = 520 weeks; + + /** + * @param reportExpirationTimeSec_ The number of seconds after which the + * report is deemed expired. + * @param reportDelaySec_ The number of seconds since reporting that has to + * pass before a report is usable + * @param minimumProviders_ The minimum number of providers with valid + * reports to consider the aggregate report valid. + */ + constructor( + uint256 reportExpirationTimeSec_, + uint256 reportDelaySec_, + uint256 minimumProviders_ + ) public { + require(reportExpirationTimeSec_ <= MAX_REPORT_EXPIRATION_TIME); + require(minimumProviders_ > 0); + reportExpirationTimeSec = reportExpirationTimeSec_; + reportDelaySec = reportDelaySec_; + minimumProviders = minimumProviders_; + } + + /** + * @notice Sets the report expiration period. + * @param reportExpirationTimeSec_ The number of seconds after which the + * report is deemed expired. + */ + function setReportExpirationTimeSec(uint256 reportExpirationTimeSec_) external onlyOwner { + require(reportExpirationTimeSec_ <= MAX_REPORT_EXPIRATION_TIME); + reportExpirationTimeSec = reportExpirationTimeSec_; + } + + /** + * @notice Sets the time period since reporting that has to pass before a + * report is usable. + * @param reportDelaySec_ The new delay period in seconds. + */ + function setReportDelaySec(uint256 reportDelaySec_) external onlyOwner { + reportDelaySec = reportDelaySec_; + } + + /** + * @notice Sets the minimum number of providers with valid reports to + * consider the aggregate report valid. + * @param minimumProviders_ The new minimum number of providers. + */ + function setMinimumProviders(uint256 minimumProviders_) external onlyOwner { + require(minimumProviders_ > 0); + minimumProviders = minimumProviders_; + } + + /** + * @notice Pushes a report for the calling provider. + * @param payload is expected to be 18 decimal fixed point number. + */ + function pushReport(uint256 payload) external { + address providerAddress = msg.sender; + Report[2] storage reports = providerReports[providerAddress]; + uint256[2] memory timestamps = [reports[0].timestamp, reports[1].timestamp]; + + require(timestamps[0] > 0); + + uint8 index_recent = timestamps[0] >= timestamps[1] ? 0 : 1; + uint8 index_past = 1 - index_recent; + + // Check that the push is not too soon after the last one. + require(timestamps[index_recent].add(reportDelaySec) <= now); + + reports[index_past].timestamp = now; + reports[index_past].payload = payload; + + emit ProviderReportPushed(providerAddress, payload, now); + } + + /** + * @notice Invalidates the reports of the calling provider. + */ + function purgeReports() external { + address providerAddress = msg.sender; + require(providerReports[providerAddress][0].timestamp > 0); + providerReports[providerAddress][0].timestamp = 1; + providerReports[providerAddress][1].timestamp = 1; + } + + /** + * @notice Computes median of provider reports whose timestamps are in the + * valid timestamp range. + * @return AggregatedValue: Median of providers reported values. + * valid: Boolean indicating an aggregated value was computed successfully. + */ + function getData() external returns (uint256, bool) { + uint256 reportsCount = providers.length; + uint256[] memory validReports = new uint256[](reportsCount); + uint256 size = 0; + uint256 minValidTimestamp = now.sub(reportExpirationTimeSec); + uint256 maxValidTimestamp = now.sub(reportDelaySec); + + for (uint256 i = 0; i < reportsCount; i++) { + address providerAddress = providers[i]; + Report[2] memory reports = providerReports[providerAddress]; + + uint8 index_recent = reports[0].timestamp >= reports[1].timestamp ? 0 : 1; + uint8 index_past = 1 - index_recent; + uint256 reportTimestampRecent = reports[index_recent].timestamp; + if (reportTimestampRecent > maxValidTimestamp) { + // Recent report is too recent. + uint256 reportTimestampPast = providerReports[providerAddress][index_past] + .timestamp; + if (reportTimestampPast < minValidTimestamp) { + // Past report is too old. + emit ReportTimestampOutOfRange(providerAddress); + } else if (reportTimestampPast > maxValidTimestamp) { + // Past report is too recent. + emit ReportTimestampOutOfRange(providerAddress); + } else { + // Using past report. + validReports[size++] = providerReports[providerAddress][index_past].payload; + } + } else { + // Recent report is not too recent. + if (reportTimestampRecent < minValidTimestamp) { + // Recent report is too old. + emit ReportTimestampOutOfRange(providerAddress); + } else { + // Using recent report. + validReports[size++] = providerReports[providerAddress][index_recent].payload; + } + } + } + + if (size < minimumProviders) { + return (0, false); + } + + return (Select.computeMedian(validReports, size), true); + } + + /** + * @notice Authorizes a provider. + * @param provider Address of the provider. + */ + function addProvider(address provider) external onlyOwner { + require(providerReports[provider][0].timestamp == 0); + providers.push(provider); + providerReports[provider][0].timestamp = 1; + emit ProviderAdded(provider); + } + + /** + * @notice Revokes provider authorization. + * @param provider Address of the provider. + */ + function removeProvider(address provider) external onlyOwner { + delete providerReports[provider]; + for (uint256 i = 0; i < providers.length; i++) { + if (providers[i] == provider) { + if (i + 1 != providers.length) { + providers[i] = providers[providers.length - 1]; + } + providers.length--; + emit ProviderRemoved(provider); + break; + } + } + } + + /** + * @return The number of authorized providers. + */ + function providersSize() external view returns (uint256) { + return providers.length; + } +} diff --git a/contracts/lib/Select.sol b/contracts/lib/Select.sol new file mode 100644 index 00000000..95402bf0 --- /dev/null +++ b/contracts/lib/Select.sol @@ -0,0 +1,32 @@ +pragma solidity ^0.4.24; +import {SafeMath} from "openzeppelin-solidity/contracts/math/SafeMath.sol"; + +/** + * @title Select + * @dev Median Selection Library + */ +library Select { + using SafeMath for uint256; + + /** + * @dev Sorts the input array up to the denoted size, and returns the median. + * @param array Input array to compute its median. + * @param size Number of elements in array to compute the median for. + * @return Median of array. + */ + function computeMedian(uint256[] array, uint256 size) internal pure returns (uint256) { + require(size > 0 && array.length >= size); + for (uint256 i = 1; i < size; i++) { + for (uint256 j = i; j > 0 && array[j - 1] > array[j]; j--) { + uint256 tmp = array[j]; + array[j] = array[j - 1]; + array[j - 1] = tmp; + } + } + if (size % 2 == 1) { + return array[size / 2]; + } else { + return array[size / 2].add(array[size / 2 - 1]) / 2; + } + } +} diff --git a/contracts/mocks/GetMedianOracleDataCallerContract.sol b/contracts/mocks/GetMedianOracleDataCallerContract.sol new file mode 100644 index 00000000..c63c1986 --- /dev/null +++ b/contracts/mocks/GetMedianOracleDataCallerContract.sol @@ -0,0 +1,23 @@ +pragma solidity 0.4.24; + +import "../MedianOracle.sol"; + +contract GetMedianOracleDataCallerContract { + event ReturnValueUInt256Bool(uint256 value, bool valid); + + IOracle public oracle; + + constructor() public {} + + function setOracle(IOracle _oracle) public { + oracle = _oracle; + } + + function getData() public returns (uint256) { + uint256 _value; + bool _valid; + (_value, _valid) = oracle.getData(); + emit ReturnValueUInt256Bool(_value, _valid); + return _value; + } +} diff --git a/contracts/mocks/SelectMock.sol b/contracts/mocks/SelectMock.sol new file mode 100644 index 00000000..0f55dcf9 --- /dev/null +++ b/contracts/mocks/SelectMock.sol @@ -0,0 +1,15 @@ +pragma solidity 0.4.24; + +import "../lib/Select.sol"; + +contract Mock { + event ReturnValueUInt256(uint256 val); +} + +contract SelectMock is Mock { + function computeMedian(uint256[] data, uint256 size) external returns (uint256) { + uint256 result = Select.computeMedian(data, size); + emit ReturnValueUInt256(result); + return result; + } +} diff --git a/package.json b/package.json index 166a9938..3709c2f9 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "lint" ], "dependencies": { - "openzeppelin-contracts-4.4.1": "https://github.com/OpenZeppelin/openzeppelin-contracts.git#4.4.1" + "openzeppelin-contracts-4.4.1": "https://github.com/OpenZeppelin/openzeppelin-contracts.git#4.4.1", + "openzeppelin-solidity": "2.0.0" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "^2.0.1", diff --git a/test/unit/MedianOracle.ts b/test/unit/MedianOracle.ts new file mode 100644 index 00000000..19636f6b --- /dev/null +++ b/test/unit/MedianOracle.ts @@ -0,0 +1,532 @@ +import { ethers } from 'hardhat' +import { BigNumber, Contract, ContractFactory, Signer } from 'ethers' +import { increaseTime } from '../utils/utils' +import { expect } from 'chai' + +let factory: ContractFactory +let oracle: Contract +let accounts: Signer[], + deployer: Signer, + A: Signer, + B: Signer, + C: Signer, + D: Signer +let payload: BigNumber +let callerContract: Contract + +async function setupContractsAndAccounts() { + accounts = await ethers.getSigners() + deployer = accounts[0] + A = accounts[1] + B = accounts[2] + C = accounts[3] + D = accounts[4] + factory = await ethers.getContractFactory('MedianOracle') + oracle = await factory.deploy(60, 10, 1) + await oracle.deployed() +} + +async function setupCallerContract() { + let callerContractFactory = await ethers.getContractFactory( + 'GetMedianOracleDataCallerContract', + ) + callerContract = await callerContractFactory.deploy() + await callerContract.deployed() + await callerContract.setOracle(oracle.address) +} + +describe('MedianOracle:constructor', async function () { + before(async function () { + await setupContractsAndAccounts() + }) + + it('should fail if a parameter is invalid', async function () { + await expect(factory.deploy(60, 10, 0)).to.be.reverted + await expect(factory.deploy(60 * 60 * 24 * 365 * 11, 10, 1)).to.be.reverted + await expect(oracle.setReportExpirationTimeSec(60 * 60 * 24 * 365 * 11)).to + .be.reverted + }) +}) + +describe('MedianOracle:providersSize', async function () { + before(async function () { + await setupContractsAndAccounts() + }) + + it('should return the number of sources added to the whitelist', async function () { + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(3)) + }) +}) + +describe('MedianOracle:addProvider', async function () { + before(async function () { + await setupContractsAndAccounts() + expect(await oracle.providersSize()).to.eq(BigNumber.from(0)) + }) + + it('should emit ProviderAdded message', async function () { + await expect(oracle.addProvider(await A.getAddress())) + .to.emit(oracle, 'ProviderAdded') + .withArgs(await A.getAddress()) + }) + + describe('when successful', async function () { + it('should add source to the whitelist', async function () { + expect(await oracle.providersSize()).to.eq(BigNumber.from(1)) + }) + it('should not add an existing source to the whitelist', async function () { + await expect(oracle.addProvider(await A.getAddress())).to.be.reverted + }) + }) +}) + +describe('MedianOracle:pushReport', async function () { + beforeEach(async function () { + await setupContractsAndAccounts() + payload = BigNumber.from('1000000000000000000') + }) + it('should only push from authorized source', async function () { + await expect(oracle.connect(A).pushReport(payload)).to.be.reverted + }) + it('should fail if reportDelaySec did not pass since the previous push', async function () { + await oracle.addProvider(await A.getAddress()) + await oracle.connect(A).pushReport(payload) + await expect(oracle.connect(A).pushReport(payload)).to.be.reverted + }) + it('should emit ProviderReportPushed message', async function () { + oracle.addProvider(await A.getAddress()) + const tx = await oracle.connect(A).pushReport(payload) + const txReceipt = await tx.wait() + const txBlock = (await ethers.provider.getBlock(txReceipt.blockNumber)) + .timestamp + const txEvents = txReceipt.events?.filter((x: any) => { + return x.event == 'ProviderReportPushed' + }) + expect(txEvents.length).to.equal(1) + const event = txEvents[0] + expect(event.args.length).to.equal(3) + expect(event.args.provider).to.equal(await A.getAddress()) + expect(event.args.payload).to.equal(payload) + expect(event.args.timestamp).to.equal(txBlock) + }) +}) + +describe('MedianOracle:addProvider:accessControl', async function () { + before(async function () { + await setupContractsAndAccounts() + }) + + it('should be callable by owner', async function () { + await oracle.addProvider(await A.getAddress()) + }) + + it('should NOT be callable by non-owner', async function () { + await expect(oracle.connect(B).addProvider(A)).to.be.reverted + }) +}) + +describe('MedianOracle:removeProvider', async function () { + describe('when source is part of the whitelist', () => { + before(async function () { + payload = BigNumber.from('1000000000000000000') + await setupContractsAndAccounts() + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + await oracle.addProvider(await D.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(4)) + }) + it('should emit ProviderRemoved message', async function () { + expect(await oracle.removeProvider(await B.getAddress())) + .to.emit(oracle, 'ProviderRemoved') + .withArgs(await B.getAddress()) + }) + it('should remove source from the whitelist', async function () { + expect(await oracle.providersSize()).to.eq(BigNumber.from(3)) + await expect(oracle.connect(B).pushReport(payload)).to.be.reverted + await oracle.connect(D).pushReport(payload) + }) + }) +}) + +describe('MedianOracle:removeProvider', async function () { + beforeEach(async function () { + await setupContractsAndAccounts() + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + await oracle.addProvider(await D.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(4)) + }) + it('Remove last element', async function () { + await oracle.removeProvider(await D.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(3)) + expect(await oracle.providers(0)).to.eq(await A.getAddress()) + expect(await oracle.providers(1)).to.eq(await B.getAddress()) + expect(await oracle.providers(2)).to.eq(await C.getAddress()) + }) + + it('Remove middle element', async function () { + await oracle.removeProvider(await B.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(3)) + expect(await oracle.providers(0)).to.eq(await A.getAddress()) + expect(await oracle.providers(1)).to.eq(await D.getAddress()) + expect(await oracle.providers(2)).to.eq(await C.getAddress()) + }) + + it('Remove only element', async function () { + await oracle.removeProvider(await A.getAddress()) + await oracle.removeProvider(await B.getAddress()) + await oracle.removeProvider(await C.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(1)) + expect(await oracle.providers(0)).to.eq(await D.getAddress()) + await oracle.removeProvider(await D.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(0)) + }) +}) + +describe('MedianOracle:removeProvider', async function () { + it('when provider is NOT part of the whitelist', async function () { + await setupContractsAndAccounts() + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(2)) + await oracle.removeProvider(await C.getAddress()) + expect(await oracle.providersSize()).to.eq(BigNumber.from(2)) + expect(await oracle.providers(0)).to.eq(await A.getAddress()) + expect(await oracle.providers(1)).to.eq(await B.getAddress()) + }) +}) + +describe('MedianOracle:removeProvider:accessControl', async function () { + beforeEach(async function () { + await setupContractsAndAccounts() + await oracle.addProvider(await A.getAddress()) + }) + + it('should be callable by owner', async function () { + await oracle.removeProvider(await A.getAddress()) + }) + + it('should NOT be callable by non-owner', async function () { + await expect(oracle.connect(A).removeProvider(await A.getAddress())).to.be + .reverted + }) +}) + +describe('MedianOracle:getData', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + await oracle.addProvider(await D.getAddress()) + + await oracle.connect(D).pushReport(BigNumber.from('1000000000000000000')) + await oracle.connect(B).pushReport(BigNumber.from('1041000000000000000')) + await oracle.connect(A).pushReport(BigNumber.from('1053200000000000000')) + await oracle.connect(C).pushReport(BigNumber.from('2041000000000000000')) + + await increaseTime(40) + }) + + describe('when the reports are valid', function () { + it('should calculate the combined market rate and volume', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from('1047100000000000000'), true) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + describe('when one of reports has expired', function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + await oracle.addProvider(await D.getAddress()) + + await oracle.setReportExpirationTimeSec(40) + await oracle.connect(C).pushReport(BigNumber.from('2041000000000000000')) + await increaseTime(41) + + await oracle.connect(B).pushReport(BigNumber.from('1041000000000000000')) + await oracle.connect(D).pushReport(BigNumber.from('1000000000000000000')) + await oracle.connect(A).pushReport(BigNumber.from('1053200000000000000')) + await increaseTime(10) + }) + + it('should emit ReportTimestampOutOfRange message', async function () { + await expect(oracle.getData()) + .to.emit(oracle, 'ReportTimestampOutOfRange') + .withArgs(await C.getAddress()) + }) + it('should calculate the exchange rate', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from('1041000000000000000'), true) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + describe('when one of the reports is too recent', function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + await oracle.addProvider(await D.getAddress()) + + await oracle.connect(C).pushReport(BigNumber.from('2041000000000000000')) + await oracle.connect(D).pushReport(BigNumber.from('1000000000000000000')) + await oracle.connect(A).pushReport(BigNumber.from('1053200000000000000')) + await increaseTime(10) + await oracle.connect(B).pushReport(BigNumber.from('1041000000000000000')) + }) + + it('should emit ReportTimestampOutOfRange message', async function () { + await expect(oracle.getData()) + .to.emit(oracle, 'ReportTimestampOutOfRange') + .withArgs(await B.getAddress()) + }) + it('should calculate the exchange rate', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from('1053200000000000000'), true) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + describe('when not enough providers are valid', function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + await oracle.addProvider(await C.getAddress()) + await oracle.addProvider(await D.getAddress()) + + await expect(oracle.setMinimumProviders(0)).to.be.reverted + await oracle.setMinimumProviders(4) + + await oracle.connect(C).pushReport(BigNumber.from('2041000000000000000')) + await oracle.connect(D).pushReport(BigNumber.from('1000000000000000000')) + await oracle.connect(A).pushReport(BigNumber.from('1053200000000000000')) + await increaseTime(10) + await oracle.connect(B).pushReport(BigNumber.from('1041000000000000000')) + }) + + it('should emit ReportTimestampOutOfRange message', async function () { + await expect(oracle.getData()) + .to.emit(oracle, 'ReportTimestampOutOfRange') + .withArgs(await B.getAddress()) + }) + it('should not have a valid result', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from(0), false) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + describe('when all reports have expired', function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + await oracle.addProvider(await B.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1053200000000000000')) + await oracle.connect(B).pushReport(BigNumber.from('1041000000000000000')) + + await increaseTime(61) + }) + + it('should emit 2 ReportTimestampOutOfRange messages', async function () { + const tx = await oracle.getData() + const txReceipt = await tx.wait() + const txEvents = txReceipt.events?.filter((x: any) => { + return x.event == 'ReportTimestampOutOfRange' + }) + expect(txEvents.length).to.equal(2) + const eventA = txEvents[0] + expect(eventA.args.provider).to.equal(await A.getAddress()) + const eventB = txEvents[1] + expect(eventB.args.provider).to.equal(await B.getAddress()) + }) + it('should return false and 0', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from(0), false) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1100000000000000000')) + await increaseTime(61) + await oracle.connect(A).pushReport(BigNumber.from('1200000000000000000')) + }) + + describe('when recent is too recent and past is too old', function () { + it('should emit ReportTimestampOutOfRange message', async function () { + await expect(oracle.getData()) + .to.emit(oracle, 'ReportTimestampOutOfRange') + .withArgs(await A.getAddress()) + }) + it('should fail', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from(0), false) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + await oracle.addProvider(await A.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1100000000000000000')) + await increaseTime(10) + await oracle.connect(A).pushReport(BigNumber.from('1200000000000000000')) + await increaseTime(1) + await oracle.setReportDelaySec(30) + }) + + describe('when recent is too recent and past is too recent', function () { + it('should emit ReportTimestampOutOfRange message', async function () { + await expect(oracle.getData()) + .to.emit(oracle, 'ReportTimestampOutOfRange') + .withArgs(await A.getAddress()) + }) + it('should fail', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from(0), false) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1100000000000000000')) + await increaseTime(30) + await oracle.connect(A).pushReport(BigNumber.from('1200000000000000000')) + await increaseTime(4) + }) + + describe('when recent is too recent and past is valid', function () { + it('should succeed', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from('1100000000000000000'), true) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1100000000000000000')) + await increaseTime(30) + await oracle.connect(A).pushReport(BigNumber.from('1200000000000000000')) + await increaseTime(10) + }) + + describe('when recent is not too recent nor too old', function () { + it('should succeed', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from('1200000000000000000'), true) + }) + }) +}) + +describe('MedianOracle:getData', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1100000000000000000')) + await increaseTime(30) + await oracle.connect(A).pushReport(BigNumber.from('1200000000000000000')) + await increaseTime(80) + }) + + describe('when recent is not too recent but too old', function () { + it('should fail', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from(0), false) + }) + }) +}) + +describe('MedianOracle:PurgeReports', async function () { + before(async function () { + await setupContractsAndAccounts() + await setupCallerContract() + + await oracle.addProvider(await A.getAddress()) + + await oracle.connect(A).pushReport(BigNumber.from('1100000000000000000')) + await increaseTime(20) + await oracle.connect(A).pushReport(BigNumber.from('1200000000000000000')) + await increaseTime(20) + await oracle.connect(A).purgeReports() + }) + + it('data not available after purge', async function () { + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from(0), false) + }) + it('data available after another report', async function () { + await oracle.connect(A).pushReport(BigNumber.from('1300000000000000000')) + await increaseTime(20) + await expect(callerContract.getData()) + .to.emit(callerContract, 'ReturnValueUInt256Bool') + .withArgs(BigNumber.from('1300000000000000000'), true) + }) + it('cannot purge a non-whitelisted provider', async function () { + await expect(oracle.connect(B).purgeReports()).to.be.reverted + await oracle.connect(A).purgeReports() + await oracle.removeProvider(await A.getAddress()) + await expect(oracle.connect(A).purgeReports()).to.be.reverted + }) +}) diff --git a/test/unit/Select.ts b/test/unit/Select.ts new file mode 100644 index 00000000..789e62d4 --- /dev/null +++ b/test/unit/Select.ts @@ -0,0 +1,88 @@ +import { ethers } from 'hardhat' +import { Contract } from 'ethers' +import { expect } from 'chai' + +describe('Select', () => { + let select: Contract + + beforeEach(async function () { + const factory = await ethers.getContractFactory('SelectMock') + select = await factory.deploy() + await select.deployed() + }) + + describe('computeMedian', function () { + it('median of 1', async function () { + const a = ethers.BigNumber.from(5678) + await expect(select.computeMedian([a], 1)) + .to.emit(select, 'ReturnValueUInt256') + .withArgs(a) + }) + + it('median of 2', async function () { + const list = [ethers.BigNumber.from(10000), ethers.BigNumber.from(30000)] + await expect(select.computeMedian(list, 2)) + .to.emit(select, 'ReturnValueUInt256') + .withArgs(20000) + }) + + it('median of 3', async function () { + const list = [ + ethers.BigNumber.from(10000), + ethers.BigNumber.from(30000), + ethers.BigNumber.from(21000), + ] + await expect(select.computeMedian(list, 3)) + .to.emit(select, 'ReturnValueUInt256') + .withArgs(21000) + }) + + it('median of odd sized list', async function () { + const count = 15 + const list = Array.from({ length: count }, () => + Math.floor(Math.random() * 10 ** 18), + ) + const median = ethers.BigNumber.from( + [...list].sort((a, b) => b - a)[Math.floor(count / 2)].toString(), + ) + const bn_list = Array.from(list, (x) => + ethers.BigNumber.from(x.toString()), + ) + await expect(select.computeMedian(bn_list, count)) + .to.emit(select, 'ReturnValueUInt256') + .withArgs(median) + }) + + it('median of even sized list', async function () { + const count = 20 + const list = Array.from({ length: count }, () => + Math.floor(Math.random() * 10 ** 18), + ) + const bn_list = Array.from(list, (x) => + ethers.BigNumber.from(x.toString()), + ) + list.sort((a, b) => b - a) + let median = ethers.BigNumber.from(list[Math.floor(count / 2)].toString()) + median = median.add( + ethers.BigNumber.from(list[Math.floor(count / 2) - 1].toString()), + ) + median = median.div(2) + + await expect(select.computeMedian(bn_list, count)) + .to.emit(select, 'ReturnValueUInt256') + .withArgs(median) + }) + + it('not enough elements in array', async function () { + await expect(select.computeMedian([1], 2)).to.be.reverted + }) + + it('median of empty list', async function () { + await expect(select.computeMedian([], 1)).to.be.reverted + }) + + it('median of list of size 0', async function () { + await expect(select.computeMedian([10000], 0)).to.be.reverted + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index d9ef4d5a..f7b57b02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6574,6 +6574,11 @@ open@^7.4.2: version "4.4.1" resolved "https://github.com/OpenZeppelin/openzeppelin-contracts.git#6bd6b76d1156e20e45d1016f355d154141c7e5b9" +openzeppelin-solidity@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/openzeppelin-solidity/-/openzeppelin-solidity-2.0.0.tgz#b45dddbdae090f89577598c1a7e7518df61b7ba2" + integrity sha512-SolpxQFArtiYnlSNg3dZ9sz0WVlKtPqSOcJkXRllaZp4+Lpfqz3vxF0yoh7g75TszKPyadqoJmU7+GM/vwh9SA== + optionator@^0.8.1, optionator@^0.8.2: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"