-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: coinbase api oracle and api adapter (#6)
Signed-off-by: Pablo Maldonado <pablo@umaproject.org> Signed-off-by: Matt Rice <matthewcrice32@gmail.com> Signed-off-by: Reinis Martinsons <reinis@umaproject.org> Co-authored-by: Matt Rice <matthewcrice32@gmail.com> Co-authored-by: Reinis Martinsons <reinis@umaproject.org> Co-authored-by: chrismaree <christopher.maree@gmail.com> Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com>
1 parent
8a21cce
commit e494b51
Showing
5 changed files
with
444 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity 0.8.17; | ||
|
||
import {DecimalLib} from "../lib/DecimalLib.sol"; | ||
import {IAggregatorV3SourceCoinbase} from "../../interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; | ||
import {DiamondRootOval} from "../../DiamondRootOval.sol"; | ||
|
||
/** | ||
* @title CoinbaseSourceAdapter | ||
* @notice A contract to read data from CoinbaseOracle and standardize it for Oval. | ||
* @dev Can fetch information from CoinbaseOracle source at a desired timestamp for historic lookups. | ||
*/ | ||
abstract contract CoinbaseSourceAdapter is DiamondRootOval { | ||
IAggregatorV3SourceCoinbase public immutable COINBASE_SOURCE; | ||
uint8 private immutable SOURCE_DECIMALS; | ||
string public TICKER; | ||
|
||
event SourceSet(address indexed sourceOracle, uint8 indexed sourceDecimals, string ticker); | ||
|
||
constructor(IAggregatorV3SourceCoinbase _source, string memory _ticker) { | ||
COINBASE_SOURCE = _source; | ||
SOURCE_DECIMALS = _source.decimals(); | ||
TICKER = _ticker; | ||
|
||
emit SourceSet(address(_source), SOURCE_DECIMALS, TICKER); | ||
} | ||
|
||
/** | ||
* @notice Tries getting the latest data as of the requested timestamp. | ||
* If this is not possible, returns the earliest data available past the requested timestamp within provided traversal limitations. | ||
* @param timestamp The timestamp to try getting the latest data at. | ||
* @param maxTraversal The maximum number of rounds to traverse when looking for historical data. | ||
* @return answer The answer as of the requested timestamp, or the earliest available data if not available, in 18 decimals. | ||
* @return updatedAt The timestamp of the answer. | ||
*/ | ||
function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) | ||
public | ||
view | ||
virtual | ||
override | ||
returns (int256, uint256, uint256) | ||
{ | ||
(int256 answer, uint256 updatedAt) = _tryLatestRoundDataAt(timestamp, maxTraversal); | ||
return (DecimalLib.convertDecimals(answer, SOURCE_DECIMALS, 18), updatedAt, 1); | ||
} | ||
|
||
/** | ||
* @notice Initiate a snapshot of the source data. This is a no-op for Coinbase. | ||
*/ | ||
function snapshotData() public virtual override {} | ||
|
||
/** | ||
* @notice Returns the latest data from the source. | ||
* @return answer The latest answer in 18 decimals. | ||
* @return updatedAt The timestamp of the answer. | ||
*/ | ||
function getLatestSourceData() public view virtual override returns (int256, uint256) { | ||
(, int256 sourceAnswer,, uint256 updatedAt,) = COINBASE_SOURCE.latestRoundData(TICKER); | ||
return (DecimalLib.convertDecimals(sourceAnswer, SOURCE_DECIMALS, 18), updatedAt); | ||
} | ||
|
||
function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { | ||
return (0, 0); | ||
} | ||
|
||
// Tries getting the latest data as of the requested timestamp. If this is not possible, | ||
// returns the earliest data available past the requested timestamp considering the maxTraversal limitations. | ||
function _tryLatestRoundDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (int256, uint256) { | ||
(uint80 roundId, int256 answer,, uint256 updatedAt,) = COINBASE_SOURCE.latestRoundData(TICKER); | ||
|
||
// If the latest update is older than or equal to the requested timestamp, return the latest data. | ||
if (updatedAt <= timestamp) { | ||
return (answer, updatedAt); | ||
} | ||
|
||
// Attempt traversing historical round data backwards from roundId. | ||
(int256 historicalAnswer, uint256 historicalUpdatedAt) = _searchRoundDataAt(timestamp, roundId, maxTraversal); | ||
|
||
// Validate returned data. If it is uninitialized, fall back to returning the current latest round data. | ||
if (historicalUpdatedAt > 0) { | ||
return (historicalAnswer, historicalUpdatedAt); | ||
} | ||
|
||
return (answer, updatedAt); | ||
} | ||
|
||
// Searches for the latest historical data not newer than the requested timestamp. | ||
// Returns newer data than requested if it exceeds traversal limits or holds uninitialized data that should be handled by the caller. | ||
function _searchRoundDataAt(uint256 timestamp, uint80 latestRoundId, uint256 maxTraversal) | ||
internal | ||
view | ||
returns (int256, uint256) | ||
{ | ||
int256 answer; | ||
uint256 updatedAt; | ||
for (uint80 i = 1; i <= maxTraversal && latestRoundId >= i; i++) { | ||
(, answer,, updatedAt,) = COINBASE_SOURCE.getRoundData(TICKER, latestRoundId - i); | ||
if (updatedAt <= timestamp) { | ||
return (answer, updatedAt); | ||
} | ||
} | ||
|
||
return (answer, updatedAt); // Did not find requested round. Return earliest round or uninitialized data. | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity 0.8.17; | ||
|
||
interface IAggregatorV3SourceCoinbase { | ||
function decimals() external view returns (uint8); | ||
|
||
function latestRoundData(string memory ticker) | ||
external | ||
view | ||
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); | ||
|
||
function getRoundData(string memory ticker, uint80 _roundId) | ||
external | ||
view | ||
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity 0.8.17; | ||
|
||
import {IAggregatorV3SourceCoinbase} from "../interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; | ||
|
||
/** | ||
* @title CoinbaseOracle | ||
* @notice A smart contract that serves as an oracle for price data reported by a designated reporter. | ||
*/ | ||
contract CoinbaseOracle is IAggregatorV3SourceCoinbase { | ||
address immutable reporter; | ||
|
||
uint8 public immutable decimals; | ||
|
||
struct PriceData { | ||
uint80 lastRoundId; | ||
mapping(uint80 => int256) roundAnswers; | ||
mapping(uint80 => uint256) roundTimestamps; | ||
} | ||
|
||
mapping(string => PriceData) private prices; | ||
|
||
event PricePushed(string indexed ticker, uint80 indexed roundId, int256 price, uint256 timestamp); | ||
|
||
/** | ||
* @notice Constructor to initialize the CoinbaseOracle contract. | ||
* @param _decimals The number of decimals in the reported price. | ||
* @param _reporter The address of the reporter allowed to push price data. | ||
*/ | ||
constructor(uint8 _decimals, address _reporter) { | ||
decimals = _decimals; | ||
reporter = _reporter; | ||
} | ||
|
||
/** | ||
* @notice Returns the latest round data for a given ticker. | ||
* @param ticker The ticker symbol to retrieve the data for. | ||
* @return roundId The ID of the latest round. | ||
* @return answer The latest price. | ||
* @return startedAt The timestamp when the round started. | ||
* @return updatedAt The timestamp when the round was updated. | ||
* @return answeredInRound The round ID in which the answer was computed. | ||
*/ | ||
function latestRoundData(string memory ticker) | ||
external | ||
view | ||
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) | ||
{ | ||
PriceData storage priceData = prices[ticker]; | ||
int256 latestAnswer = priceData.roundAnswers[priceData.lastRoundId]; | ||
uint256 latestTimestamp = priceData.roundTimestamps[priceData.lastRoundId]; | ||
return (priceData.lastRoundId, latestAnswer, latestTimestamp, latestTimestamp, priceData.lastRoundId); | ||
} | ||
|
||
/** | ||
* @notice Returns the data for a specific round for a given ticker. | ||
* @param ticker The ticker symbol to retrieve the data for. | ||
* @param roundId The round ID to retrieve the data for. | ||
* @return roundId The ID of the round. | ||
* @return answer The price of the round. | ||
* @return startedAt The timestamp when the round started. | ||
* @return updatedAt The timestamp when the round was updated. | ||
* @return answeredInRound The round ID in which the answer was computed. | ||
*/ | ||
function getRoundData(string memory ticker, uint80 roundId) | ||
external | ||
view | ||
returns (uint80, int256, uint256, uint256, uint80) | ||
{ | ||
PriceData storage priceData = prices[ticker]; | ||
int256 latestAnswer = priceData.roundAnswers[roundId]; | ||
uint256 latestTimestamp = priceData.roundTimestamps[roundId]; | ||
return (roundId, latestAnswer, latestTimestamp, latestTimestamp, roundId); | ||
} | ||
|
||
/** | ||
* @notice Pushes a new price to the oracle for a given ticker. | ||
* @param priceData The encoded price data. | ||
* @param signature The signature to verify the authenticity of the data. | ||
*/ | ||
function pushPrice(bytes memory priceData, bytes memory signature) external { | ||
( | ||
string memory kind, // e.g. "price" | ||
uint256 timestamp, // e.g. 1629350000 | ||
string memory ticker, // e.g. "BTC" | ||
uint256 price // 6 decimals | ||
) = abi.decode(priceData, (string, uint256, string, uint256)); | ||
|
||
require(keccak256(abi.encodePacked(kind)) == keccak256(abi.encodePacked("price")), "Invalid kind."); | ||
|
||
PriceData storage priceDataStruct = prices[ticker]; | ||
uint256 latestTimestamp = priceDataStruct.roundTimestamps[priceDataStruct.lastRoundId]; | ||
|
||
require(timestamp > latestTimestamp, "Invalid timestamp."); | ||
require(recoverSigner(priceData, signature) == reporter, "Invalid signature."); | ||
require(price < uint256(type(int256).max), "Price exceeds max value."); | ||
|
||
priceDataStruct.lastRoundId++; | ||
priceDataStruct.roundAnswers[priceDataStruct.lastRoundId] = int256(price); | ||
priceDataStruct.roundTimestamps[priceDataStruct.lastRoundId] = timestamp; | ||
|
||
emit PricePushed(ticker, priceDataStruct.lastRoundId, int256(price), timestamp); | ||
} | ||
|
||
/** | ||
* @notice Internal function to recover the signer of a message. | ||
* @param message The message that was signed. | ||
* @param signature The signature to recover the signer from. | ||
* @return The address of the signer. | ||
*/ | ||
function recoverSigner(bytes memory message, bytes memory signature) internal pure returns (address) { | ||
(bytes32 r, bytes32 s, uint8 v) = abi.decode(signature, (bytes32, bytes32, uint8)); | ||
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(message))); | ||
return ecrecover(hash, v, r, s); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity 0.8.17; | ||
|
||
import {CommonTest} from "../Common.sol"; | ||
import {BaseController} from "../../src/controllers/BaseController.sol"; | ||
import {CoinbaseSourceAdapter} from "../../src/adapters/source-adapters/CoinbaseSourceAdapter.sol"; | ||
import {DecimalLib} from "../../src/adapters/lib/DecimalLib.sol"; | ||
import {IAggregatorV3SourceCoinbase} from "../../src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; | ||
import {CoinbaseOracle} from "../../src/oracles/CoinbaseOracle.sol"; | ||
|
||
contract CoinbaseSourceAdapterTest is CommonTest { | ||
CoinbaseOracle coinbaseOracle; | ||
|
||
address public reporter; | ||
uint256 public reporterPk; | ||
string public constant ethTicker = "ETH"; | ||
string public constant btcTicker = "BTC"; | ||
|
||
function setUp() public { | ||
(address _reporter, uint256 _reporterPk) = makeAddrAndKey("reporter"); | ||
reporter = _reporter; | ||
reporterPk = _reporterPk; | ||
coinbaseOracle = new CoinbaseOracle(6, reporter); | ||
} | ||
|
||
function testPushPriceETH() public { | ||
_testPushPrice(ethTicker, 10e6); | ||
} | ||
|
||
function testPushPriceBTC() public { | ||
_testPushPrice(btcTicker, 20e6); | ||
} | ||
|
||
function testPushPriceBothTickers() public { | ||
_testPushPrice(ethTicker, 10e6); | ||
vm.warp(block.timestamp + 1); | ||
_testPushPrice(btcTicker, 20e6); | ||
} | ||
|
||
function _testPushPrice(string memory ticker, uint256 price) internal { | ||
string memory kind = "price"; | ||
uint256 timestamp = block.timestamp; | ||
|
||
bytes memory encodedData = abi.encode(kind, timestamp, ticker, price); | ||
|
||
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(encodedData))); | ||
|
||
(uint8 v, bytes32 r, bytes32 s) = vm.sign(reporterPk, hash); | ||
|
||
bytes memory signature = abi.encode(r, s, v); | ||
|
||
coinbaseOracle.pushPrice(encodedData, signature); | ||
|
||
(, int256 answer, uint256 updatedAt,,) = coinbaseOracle.latestRoundData(ticker); | ||
|
||
assertEq(uint256(answer), price); | ||
assertEq(updatedAt, timestamp); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity 0.8.17; | ||
|
||
import {CommonTest} from "../Common.sol"; | ||
|
||
import {BaseController} from "../../src/controllers/BaseController.sol"; | ||
import {CoinbaseSourceAdapter} from "../../src/adapters/source-adapters/CoinbaseSourceAdapter.sol"; | ||
import {DecimalLib} from "../../src/adapters/lib/DecimalLib.sol"; | ||
import {IAggregatorV3SourceCoinbase} from "../../src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; | ||
import {CoinbaseOracle} from "../../src/oracles/CoinbaseOracle.sol"; | ||
|
||
contract TestedSourceAdapter is CoinbaseSourceAdapter { | ||
constructor(IAggregatorV3SourceCoinbase source, string memory ticker) CoinbaseSourceAdapter(source, ticker) {} | ||
|
||
function internalLatestData() public view override returns (int256, uint256, uint256) {} | ||
|
||
function canUnlock(address caller, uint256 cachedLatestTimestamp) public view virtual override returns (bool) {} | ||
|
||
function lockWindow() public view virtual override returns (uint256) {} | ||
|
||
function maxTraversal() public view virtual override returns (uint256) {} | ||
|
||
function internalDataAtRound(uint256 roundId) public view override returns (int256, uint256) {} | ||
} | ||
|
||
contract CoinbaseSourceAdapterTest is CommonTest { | ||
CoinbaseOracle coinbase; | ||
TestedSourceAdapter sourceAdapter; | ||
|
||
address public reporter; | ||
uint256 public reporterPk; | ||
|
||
string public ticker = "ETH"; | ||
uint256 public price = 3000e6; | ||
|
||
function pushPrice(string memory ticker, uint256 price, uint256 timestamp) public { | ||
string memory kind = "price"; | ||
|
||
bytes memory encodedData = abi.encode(kind, timestamp, ticker, price); | ||
|
||
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(encodedData))); | ||
|
||
(uint8 v, bytes32 r, bytes32 s) = vm.sign(reporterPk, hash); | ||
|
||
bytes memory signature = abi.encode(r, s, v); | ||
|
||
coinbase.pushPrice(encodedData, signature); | ||
} | ||
|
||
function scaleCoinbaseTo18(int256 input) public pure returns (int256) { | ||
return (input * 10 ** 18) / 10 ** 6; | ||
} | ||
|
||
function setUp() public { | ||
(address _reporter, uint256 _reporterPk) = makeAddrAndKey("reporter"); | ||
reporter = _reporter; | ||
reporterPk = _reporterPk; | ||
coinbase = new CoinbaseOracle(6, reporter); | ||
sourceAdapter = new TestedSourceAdapter(IAggregatorV3SourceCoinbase(address(coinbase)), ticker); | ||
|
||
// Push some prices to the oracle | ||
vm.warp(100000000); | ||
pushPrice(ticker, price, block.timestamp); | ||
vm.warp(block.timestamp + 1 hours); | ||
pushPrice(ticker, price - 500, block.timestamp); | ||
vm.warp(block.timestamp + 1 hours); | ||
pushPrice(ticker, price - 1000, block.timestamp); | ||
vm.warp(block.timestamp + 1 hours); | ||
pushPrice(ticker, price - 1500, block.timestamp); | ||
} | ||
|
||
function testCorrectlyStandardizesOutputs() public { | ||
(, int256 latestCoinbasePrice,, uint256 latestCoinbaseTimestamp,) = coinbase.latestRoundData(ticker); | ||
(int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData(); | ||
|
||
assertTrue(scaleCoinbaseTo18(latestCoinbasePrice) == latestSourceAnswer); | ||
assertTrue(latestSourceTimestamp == latestCoinbaseTimestamp); | ||
} | ||
|
||
function testCorrectlyLooksBackThroughRounds() public { | ||
(uint80 latestRound, int256 latestAnswer,, uint256 latestUpdatedAt,) = coinbase.latestRoundData(ticker); | ||
assertTrue(uint256(latestAnswer) == price - 1500); | ||
|
||
uint256 targetTime = block.timestamp - 1 hours; | ||
(int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10); | ||
(, int256 answer, uint256 startedAt,,) = coinbase.getRoundData(ticker, latestRound - 1); | ||
assertTrue(startedAt <= targetTime); // The time from the chainlink source is at least 1 hours old. | ||
assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); | ||
assertTrue(uint256(answer) == (price - 1000)); | ||
assertTrue(startedAt == lookBackTimestamp); | ||
|
||
// Next, try looking back 2 hours. Equally, we should get the price from 2 rounds ago. | ||
targetTime = block.timestamp - 2 hours; | ||
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10); | ||
(, answer, startedAt,,) = coinbase.getRoundData(ticker, latestRound - 2); | ||
assertTrue(startedAt <= targetTime); // The time from the chainlink source is at least 2 hours old. | ||
assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); | ||
assertTrue(uint256(answer) == (price - 500)); | ||
assertTrue(startedAt == lookBackTimestamp); | ||
|
||
// Now, try 4 hours old, this time we don't have data from 4 hours ago, so we should get the latest data available. | ||
targetTime = block.timestamp - 4 hours; | ||
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10); | ||
|
||
assertTrue(scaleCoinbaseTo18(latestAnswer) == lookBackPrice); | ||
assertTrue(latestUpdatedAt == lookBackTimestamp); | ||
} | ||
|
||
function testCorrectlyBoundsMaxLookBack() public { | ||
// If we limit how far we can lookback the source should correctly return the oldest data it can find, up to | ||
// that limit. From the previous tests we showed that looking back 2 hours should return the price from round 2. | ||
// If we try look back longer than this we should get the price from round 2, no matter how far we look back. | ||
uint256 targetTime = block.timestamp - 2 hours; | ||
(int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2); | ||
(uint80 latestRound,,,,) = coinbase.latestRoundData(ticker); | ||
(, int256 answer, uint256 startedAt,,) = coinbase.getRoundData(ticker, latestRound - 2); | ||
|
||
assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); | ||
assertTrue(startedAt == lookBackTimestamp); | ||
|
||
// Now, lookback longer than 2 hours. should get the same value as before. | ||
targetTime = block.timestamp - 3 hours; | ||
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2); | ||
assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); | ||
assertTrue(startedAt == lookBackTimestamp); | ||
targetTime = block.timestamp - 10 hours; | ||
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2); | ||
assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); | ||
assertTrue(startedAt == lookBackTimestamp); | ||
} | ||
|
||
function testNonHistoricalData() public { | ||
coinbase = new CoinbaseOracle(6, reporter); | ||
sourceAdapter = new TestedSourceAdapter(IAggregatorV3SourceCoinbase(address(coinbase)), ticker); | ||
|
||
// Push only one price to the oracle | ||
vm.warp(100000000); | ||
pushPrice(ticker, price, block.timestamp); | ||
|
||
uint256 targetTime = block.timestamp - 1 hours; | ||
|
||
(, int256 answer,, uint256 updatedAt,) = coinbase.latestRoundData(ticker); | ||
|
||
(int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 0); | ||
assertEq(lookBackPrice, scaleCoinbaseTo18(answer)); | ||
assertEq(lookBackTimestamp, updatedAt); | ||
} | ||
} |