-
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>
- Loading branch information
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); | ||
} | ||
} |
Oops, something went wrong.