diff --git a/packages/evm/contracts/adapters/Tellor/ITellor.sol b/packages/evm/contracts/adapters/Tellor/ITellor.sol new file mode 100644 index 00000000..33247fb5 --- /dev/null +++ b/packages/evm/contracts/adapters/Tellor/ITellor.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + + +interface ITellor { + /// @notice Retrieves the data corresponding to a given queryId before a specified timestamp + /// Specs of how query ids are generated can be found here: https://github.com/tellor-io/dataSpecs + /// @param _queryId The ID of the query for which data is requested + /// @param _timestamp The timestamp before which data is to be retrieved + /// @return _available Indicates whether data is available or not + /// @return _value The data retrieved for the query in bytes + /// @return _timestampRetrieved The timestamp when the data was submitted + function getDataBefore( + bytes32 _queryId, + uint256 _timestamp + ) external view returns (bool _available, bytes memory _value, uint256 _timestampRetrieved); + +} \ No newline at end of file diff --git a/packages/evm/contracts/adapters/Tellor/TellorAdapter.sol b/packages/evm/contracts/adapters/Tellor/TellorAdapter.sol new file mode 100644 index 00000000..18e5700c --- /dev/null +++ b/packages/evm/contracts/adapters/Tellor/TellorAdapter.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import { ITellor } from "./ITellor.sol"; +import { BlockHashOracleAdapter } from "../BlockHashOracleAdapter.sol"; + + +contract TellorAdapter is BlockHashOracleAdapter { + /// @notice Tellor has a standard of how to request/submit data to oracle, you can find that information + /// here: https://github.com/tellor-io/dataSpecs + ITellor public tellor; + + error BlockHashNotAvailable(); + + constructor(address _tellorAddress) { + tellor = ITellor(_tellorAddress); + } + + /// @notice Stores a single block hash for a single given block number. + /// @param chainId Network identifier for the chain on which the block was mined. + /// @param blockNumber Identifier for the block for which to set the header. + function storeHash(uint256 chainId, uint256 blockNumber) public { + bytes memory _queryData = abi.encode("EVMHeader", abi.encode(chainId, blockNumber)); + bytes32 _queryId = keccak256(_queryData); + // delay 15 minutes to allow for disputes to be raised if bad value is submitted + // (the longer the delay the stronger the security) + (bool retrieved, bytes memory _hashValue, ) = tellor.getDataBefore(_queryId, block.timestamp - 15 minutes); + if (!retrieved) revert BlockHashNotAvailable(); + _storeHash(chainId, blockNumber, bytes32(_hashValue)); + } + /// @notice Stores the block hashes for a given array of block numbers. + /// @param chainId Network identifier for the chain on which the block was mined. + /// @param blockNumbers List of block identifiers for which to store block hashes. + function storeHashes(uint256 chainId, uint256[] calldata blockNumbers) public { + bytes memory _queryData = abi.encode("EVMHeaderslist", abi.encode(chainId, blockNumbers)); + bytes32 _queryId = keccak256(_queryData); + // delay 15 minutes to allow for disputes to be raised if bad value is submitted + // (the longer the stronger the security) + (bool retrieved, bytes memory _hashValue, ) = tellor.getDataBefore(_queryId, block.timestamp - 15 minutes); + if (!retrieved) revert BlockHashNotAvailable(); + bytes32[] memory _hashes = abi.decode(_hashValue, (bytes32[])); + for (uint256 i = 0; i < blockNumbers.length; i++) { + _storeHash(chainId, blockNumbers[i], _hashes[i]); + } + } +} diff --git a/packages/evm/contracts/adapters/Tellor/test/TellorPlayground.sol b/packages/evm/contracts/adapters/Tellor/test/TellorPlayground.sol new file mode 100644 index 00000000..9d1b9217 --- /dev/null +++ b/packages/evm/contracts/adapters/Tellor/test/TellorPlayground.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +contract TellorPlayground { + // Storage + mapping(bytes32 => mapping(uint256 => bool)) public isDisputed; //queryId -> timestamp -> value + mapping(bytes32 => mapping(uint256 => address)) public reporterByTimestamp; + mapping(address => StakeInfo) public stakerDetails; //mapping from a persons address to their staking info + mapping(bytes32 => uint256[]) public timestamps; + mapping(bytes32 => mapping(uint256 => bytes)) public values; //queryId -> timestamp -> value + mapping(bytes32 => uint256[]) public voteRounds; + + uint256 public voteCount; + // Structs + struct StakeInfo { + uint256 startDate; //stake start date + uint256 stakedBalance; // staked balance + uint256 lockedBalance; // amount locked for withdrawal + uint256 reporterLastTimestamp; // timestamp of reporter's last reported value + uint256 reportsSubmitted; // total number of reports submitted by reporter + } + + // Functions + /** + * @dev A mock function to submit a value to be read without reporter staking needed + * @param _queryId the ID to associate the value to + * @param _value the value for the queryId + * @param _nonce the current value count for the query id + * @param _queryData the data used by reporters to fulfill the data query + */ + // slither-disable-next-line timestamp + function submitValue(bytes32 _queryId, bytes calldata _value, uint256 _nonce, bytes memory _queryData) external { + require(keccak256(_value) != keccak256(""), "value must be submitted"); + require(_nonce == timestamps[_queryId].length || _nonce == 0, "nonce must match timestamp index"); + require(_queryId == keccak256(_queryData) || uint256(_queryId) <= 100, "id must be hash of bytes data"); + values[_queryId][block.timestamp] = _value; + timestamps[_queryId].push(block.timestamp); + reporterByTimestamp[_queryId][block.timestamp] = msg.sender; + stakerDetails[msg.sender].reporterLastTimestamp = block.timestamp; + stakerDetails[msg.sender].reportsSubmitted++; + } + + /** + * @dev A mock function to create a dispute + * @param _queryId The tellorId to be disputed + * @param _timestamp the timestamp of the value to be disputed + */ + function beginDispute(bytes32 _queryId, uint256 _timestamp) external { + values[_queryId][_timestamp] = bytes(""); + isDisputed[_queryId][_timestamp] = true; + voteCount++; + voteRounds[keccak256(abi.encodePacked(_queryId, _timestamp))].push(voteCount); + } + + /** + * @dev Retrieves the latest value for the queryId before the specified timestamp + * @param _queryId is the queryId to look up the value for + * @param _timestamp before which to search for latest value + * @return _ifRetrieve bool true if able to retrieve a non-zero value + * @return _value the value retrieved + * @return _timestampRetrieved the value's timestamp + */ + function getDataBefore( + bytes32 _queryId, + uint256 _timestamp + ) external view returns (bool _ifRetrieve, bytes memory _value, uint256 _timestampRetrieved) { + (bool _found, uint256 _index) = getIndexForDataBefore(_queryId, _timestamp); + if (!_found) return (false, bytes(""), 0); + _timestampRetrieved = getTimestampbyQueryIdandIndex(_queryId, _index); + _value = values[_queryId][_timestampRetrieved]; + return (true, _value, _timestampRetrieved); + } + + /** + * @dev Retrieves latest array index of data before the specified timestamp for the queryId + * @param _queryId is the queryId to look up the index for + * @param _timestamp is the timestamp before which to search for the latest index + * @return _found whether the index was found + * @return _index the latest index found before the specified timestamp + */ + // solhint-disable-next-line code-complexity + function getIndexForDataBefore( + bytes32 _queryId, + uint256 _timestamp + ) public view returns (bool _found, uint256 _index) { + uint256 _count = getNewValueCountbyQueryId(_queryId); + if (_count > 0) { + uint256 _middle; + uint256 _start = 0; + uint256 _end = _count - 1; + uint256 _time; + //Checking Boundaries to short-circuit the algorithm + _time = getTimestampbyQueryIdandIndex(_queryId, _start); + if (_time >= _timestamp) return (false, 0); + _time = getTimestampbyQueryIdandIndex(_queryId, _end); + if (_time < _timestamp) { + while (isInDispute(_queryId, _time) && _end > 0) { + _end--; + _time = getTimestampbyQueryIdandIndex(_queryId, _end); + } + if (_end == 0 && isInDispute(_queryId, _time)) { + return (false, 0); + } + return (true, _end); + } + //Since the value is within our boundaries, do a binary search + while (true) { + _middle = (_end - _start) / 2 + 1 + _start; + _time = getTimestampbyQueryIdandIndex(_queryId, _middle); + if (_time < _timestamp) { + //get immediate next value + uint256 _nextTime = getTimestampbyQueryIdandIndex(_queryId, _middle + 1); + if (_nextTime >= _timestamp) { + if (!isInDispute(_queryId, _time)) { + // _time is correct + return (true, _middle); + } else { + // iterate backwards until we find a non-disputed value + while (isInDispute(_queryId, _time) && _middle > 0) { + _middle--; + _time = getTimestampbyQueryIdandIndex(_queryId, _middle); + } + if (_middle == 0 && isInDispute(_queryId, _time)) { + return (false, 0); + } + // _time is correct + return (true, _middle); + } + } else { + //look from middle + 1(next value) to end + _start = _middle + 1; + } + } else { + uint256 _prevTime = getTimestampbyQueryIdandIndex(_queryId, _middle - 1); + if (_prevTime < _timestamp) { + if (!isInDispute(_queryId, _prevTime)) { + // _prevTime is correct + return (true, _middle - 1); + } else { + // iterate backwards until we find a non-disputed value + _middle--; + while (isInDispute(_queryId, _prevTime) && _middle > 0) { + _middle--; + _prevTime = getTimestampbyQueryIdandIndex(_queryId, _middle); + } + if (_middle == 0 && isInDispute(_queryId, _prevTime)) { + return (false, 0); + } + // _prevtime is correct + return (true, _middle); + } + } else { + //look from start to middle -1(prev value) + _end = _middle - 1; + } + } + } + } + return (false, 0); + } + + /** + * @dev Counts the number of values that have been submitted for a given ID + * @param _queryId the ID to look up + * @return uint256 count of the number of values received for the queryId + */ + function getNewValueCountbyQueryId(bytes32 _queryId) public view returns (uint256) { + return timestamps[_queryId].length; + } + + /** + * @dev Gets the timestamp for the value based on their index + * @param _queryId is the queryId to look up + * @param _index is the value index to look up + * @return uint256 timestamp + */ + function getTimestampbyQueryIdandIndex(bytes32 _queryId, uint256 _index) public view returns (uint256) { + uint256 _len = timestamps[_queryId].length; + if (_len == 0 || _len <= _index) return 0; + return timestamps[_queryId][_index]; + } + + /** + * @dev Returns whether a given value is disputed + * @param _queryId unique ID of the data feed + * @param _timestamp timestamp of the value + * @return bool whether the value is disputed + */ + function isInDispute(bytes32 _queryId, uint256 _timestamp) public view returns (bool) { + return isDisputed[_queryId][_timestamp]; + } +} diff --git a/packages/evm/test/adapters/Tellor/01_TELLORAdapter.spec.ts b/packages/evm/test/adapters/Tellor/01_TELLORAdapter.spec.ts new file mode 100644 index 00000000..6ce5661e --- /dev/null +++ b/packages/evm/test/adapters/Tellor/01_TELLORAdapter.spec.ts @@ -0,0 +1,101 @@ +import { expect } from "chai" +import { ethers, network } from "hardhat" + +const CHAIN_ID = 1 +const BLOCK_NUMBER_ONE = 123 +const BLOCK_NUMBER_TWO = 456 +const BLOCK_NUMBER_THREE = 789 +const abiCoder = ethers.utils.defaultAbiCoder +const keccak256 = ethers.utils.keccak256 +// Encoding data for the oracle according to tellor specs (see: https://github.com/tellor-io/dataSpecs) +let params = abiCoder.encode(["uint256", "uint256"], [CHAIN_ID, BLOCK_NUMBER_ONE]) +let queryData = abiCoder.encode(["string", "bytes"], ["EVMHeader", params]) +let queryId = keccak256(queryData) +const HASH_VALUE_ONE = "0x0000000000000000000000000000000000000000000000000000000000000001" +const HASH_VALUE_TWO = "0x0000000000000000000000000000000000000000000000000000000000000002" +const HASH_VALUE_THREE = "0x0000000000000000000000000000000000000000000000000000000000000003" + +const setup = async () => { + await network.provider.request({ method: "hardhat_reset", params: [] }) + const playground = await ethers.getContractFactory("TellorPlayground") + const tellorPlayground = await playground.deploy() + const TELLORAdapter = await ethers.getContractFactory("TellorAdapter") + const tellorAdapter = await TELLORAdapter.deploy(tellorPlayground.address) + return { + tellorPlayground, + tellorAdapter, + } +} + +const advanceTimeByMinutes = async (minutes: number) => { + // Get the current block + const currentBlock = await ethers.provider.getBlock("latest") + // Calculate the time for the next block + const nextBlockTime = currentBlock.timestamp + minutes * 60 // increase by n minutes + // Advance time by sending a request directly to the node + await ethers.provider.send("evm_setNextBlockTimestamp", [nextBlockTime]) + // Mine the next block for the time change to take effect + await ethers.provider.send("evm_mine", []) +} + +describe("TELLORAdapter", () => { + describe("Constructor", () => { + it("Successfully deploy contract", async () => { + const { tellorPlayground, tellorAdapter } = await setup() + expect(await tellorAdapter.deployed()) + expect(await tellorAdapter.tellor()).to.equal(tellorPlayground.address) + }) + }) + + describe("StoreHash()", () => { + it("Stores hash", async () => { + const { tellorPlayground, tellorAdapter } = await setup() + // submit value to tellor oracle + await tellorPlayground.submitValue(queryId, HASH_VALUE_ONE, 0, queryData) + // fails if 15 minutes have not passed + await expect(tellorAdapter.storeHash(CHAIN_ID, BLOCK_NUMBER_ONE)).to.revertedWithCustomError( + tellorAdapter, + "BlockHashNotAvailable", + ) + // advance time by 15 minutes to bypass security delay + await advanceTimeByMinutes(15) + // store hash + await tellorAdapter.storeHash(CHAIN_ID, BLOCK_NUMBER_ONE) + expect(await tellorAdapter.getHashFromOracle(CHAIN_ID, BLOCK_NUMBER_ONE)).to.equal(HASH_VALUE_ONE) + }) + }) + + describe("StoreHashes()", () => { + it("Stores multiple hashes", async () => { + const { tellorPlayground, tellorAdapter } = await setup() + // submit value to tellor oracle + params = abiCoder.encode(["uint256", "uint256[]"], [CHAIN_ID, [BLOCK_NUMBER_ONE, BLOCK_NUMBER_TWO, BLOCK_NUMBER_THREE]]) + queryData = abiCoder.encode(["string", "bytes"], ["EVMHeaderslist", params]) + queryId = keccak256(queryData) + let value = abiCoder.encode(["bytes32[]"], [[HASH_VALUE_ONE, HASH_VALUE_TWO, HASH_VALUE_THREE]]) + // tellor staked reporter submits value to tellor oracle + await tellorPlayground.submitValue(queryId, value, 0, queryData) + // requesting and storing hashes fails if 15 minutes have not passed since submission (security delay) + await expect(tellorAdapter.storeHashes(CHAIN_ID, [BLOCK_NUMBER_ONE, BLOCK_NUMBER_TWO, BLOCK_NUMBER_THREE])).to.revertedWithCustomError( + tellorAdapter, + "BlockHashNotAvailable", + ) + // advance time by 15 minutes to bypass security delay + await advanceTimeByMinutes(15) + // store hash + await tellorAdapter.storeHashes(CHAIN_ID, [BLOCK_NUMBER_ONE, BLOCK_NUMBER_TWO, BLOCK_NUMBER_THREE]) + expect(await tellorAdapter.getHashFromOracle(CHAIN_ID, BLOCK_NUMBER_ONE)).to.equal(HASH_VALUE_ONE) + expect(await tellorAdapter.getHashFromOracle(CHAIN_ID, BLOCK_NUMBER_TWO)).to.equal(HASH_VALUE_TWO) + expect(await tellorAdapter.getHashFromOracle(CHAIN_ID, BLOCK_NUMBER_THREE)).to.equal(HASH_VALUE_THREE) + }) + }) + + describe("getHashFromOracle()", () => { + it("Returns 0 if no header is stored", async () => { + const { tellorAdapter } = await setup() + expect(await tellorAdapter.getHashFromOracle(CHAIN_ID, BLOCK_NUMBER_ONE)).to.equal( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ) + }) + }) +})