Skip to content

Commit

Permalink
feat: coinbase api oracle and api adapter (#6)
Browse files Browse the repository at this point in the history
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
5 people authored May 20, 2024
1 parent 8a21cce commit e494b51
Show file tree
Hide file tree
Showing 5 changed files with 444 additions and 0 deletions.
105 changes: 105 additions & 0 deletions src/adapters/source-adapters/CoinbaseSourceAdapter.sol
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.
}
}
16 changes: 16 additions & 0 deletions src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol
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);
}
116 changes: 116 additions & 0 deletions src/oracles/CoinbaseOracle.sol
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);
}
}
59 changes: 59 additions & 0 deletions test/unit/CoinbaseOracle.sol
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);
}
}
Loading

0 comments on commit e494b51

Please sign in to comment.