Skip to content

Commit

Permalink
feat: redstone oracle adapter test (#13)
Browse files Browse the repository at this point in the history
Signed-off-by: Pablo Maldonado <pablo@umaproject.org>
Signed-off-by: chrismaree <christopher.maree@gmail.com>
Co-authored-by: chrismaree <christopher.maree@gmail.com>
  • Loading branch information
md0x and chrismaree authored May 20, 2024
1 parent e52f84a commit c98863e
Show file tree
Hide file tree
Showing 11 changed files with 390 additions and 6 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,18 @@ jobs:
with:
version: "nightly-de33b6af53005037b463318d2628b5cfcaf39916"

- name: "Navigate to scripts and install dependencies"
run: |
cd scripts
yarn install
- name: "Show the Foundry config"
run: "forge config"

- name: "Run the tests"
env:
RPC_MAINNET: ${{ secrets.RPC_MAINNET }}
run: "forge test --fork-url $RPC_MAINNET"
run: "forge test --fork-url $RPC_MAINNET --ffi"

- name: "Add test summary"
run: |
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ lcov.info
scripts/node_modules
scripts/dist
scripts/contract-types

node_modules

log.txt
getRedstonePayload.log.txt
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
[submodule "lib/openzeppelin-contracts"]
path = lib/openzeppelin-contracts
url = https://github.com/openzeppelin/openzeppelin-contracts
[submodule "lib/redstone-contracts"]
path = lib/redstone-contracts
url = https://github.com/UMAprotocol/redstone-contracts
1 change: 1 addition & 0 deletions lib/redstone-contracts
Submodule redstone-contracts added at 8bcda0
2 changes: 1 addition & 1 deletion remappings.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
ds-test/=lib/forge-std/lib/ds-test/src/
forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/
redstone-oracle/=lib/redstone-contracts/src/
6 changes: 4 additions & 2 deletions scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"typescript": "^5.2.2",
"axios": "^1.5.1",
"dotenv": "^16.3.1",
"ethers": "^5.7.2"
"ethers": "^5.7.2",
"@redstone-finance/protocol": "^0.5.1",
"@redstone-finance/sdk": "^0.5.1"
}
}
}
69 changes: 69 additions & 0 deletions scripts/src/RedstoneHelpers/getRedstonePayload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const { appendFileSync } = require("fs");
const { RedstonePayload } = require("@redstone-finance/protocol");
const ethers = require("ethers");
const sdk = require("@redstone-finance/sdk");
const args = process.argv.slice(2);

const exit = (code, message) => {
process.stderr.write(message);
appendFileSync("./getRedstonePayload.log.txt", message);
process.exit(code);
};

const parsePrice = (value) => {
const hexString = ethers.utils.hexlify(value);
const bigNumberPrice = ethers.BigNumber.from(hexString);
return bigNumberPrice.toNumber();
};

const pickMedian = (arr) => {
if (arr.length === 0) {
throw new Error("Cannot pick median of empty array");
}
arr.sort((a, b) => a - b);
const middleIndex = Math.floor(arr.length / 2);
if (arr.length % 2 === 0) {
return (arr[middleIndex - 1] + arr[middleIndex]) / 2;
} else {
return arr[middleIndex];
}
};

const main = async () => {
if (args.length === 0) {
exit(1, "You have to provide a data Feed");
}

const dataFeed = args[0];

const getLatestSignedPrice = await sdk.requestDataPackages({
dataServiceId: "redstone-primary-prod",
uniqueSignersCount: 3,
dataFeeds: [dataFeed],
urls: ["https://oracle-gateway-1.a.redstone.finance"],
});

const prices = getLatestSignedPrice[dataFeed].map((dataPackage) =>
parsePrice(dataPackage.dataPackage.dataPoints[0].value)
);

const medianPrice = pickMedian(prices);

const payload = RedstonePayload.prepare(getLatestSignedPrice[dataFeed], "");

const timestampMS =
getLatestSignedPrice[dataFeed][0].dataPackage.timestampMilliseconds;

const encodedData = ethers.utils.defaultAbiCoder.encode(
["bytes", "uint256", "uint256"],
["0x" + payload, timestampMS, medianPrice]
);

process.stdout.write(encodedData);

process.exit(0);
};

main().catch((error) => {
exit(4, `An error occurred: ${error.message}`);
});
63 changes: 63 additions & 0 deletions scripts/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,40 @@
"@ethersproject/properties" "^5.7.0"
"@ethersproject/strings" "^5.7.0"

"@redstone-finance/oracles-smartweave-contracts@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@redstone-finance/oracles-smartweave-contracts/-/oracles-smartweave-contracts-0.5.1.tgz#fa68f76fb9258e4f037cde4f03b0ad87e8a8e8a2"
integrity sha512-1FwvgRzuAbQyLpeY9WJqViEp2mB3mNJWO78ogjGgCXGNeXsODoUTBWHRpS0GHpWoW8UKR+MC/t0ZSB+E0XsHCA==

"@redstone-finance/protocol@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@redstone-finance/protocol/-/protocol-0.5.1.tgz#6fc47001ee386157a989d95c26dea6f2152b3422"
integrity sha512-MJ8BYuQ34xakfD7SA2OidtrLKDm5J5bZLrWZlsYj8hBs14h/vpFQsdbYJGbiIrN17aIb0c7rST0Q314T30BzJg==
dependencies:
ethers "^5.7.2"

"@redstone-finance/sdk@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@redstone-finance/sdk/-/sdk-0.5.1.tgz#3676bf4ffce2081b841367f9c98c42651684a226"
integrity sha512-ElJioHHwJWSRUJqcOV+Iog6b501veZ5/QarfxPMaGGne6ZRKlXGDuj8dDglIZvkXwwSN2j6nCcU5km2RHdLN/Q==
dependencies:
"@redstone-finance/oracles-smartweave-contracts" "^0.5.1"
"@redstone-finance/protocol" "^0.5.1"
"@redstone-finance/utils" "^0.5.1"
axios "^1.6.2"
ethers "^5.7.2"

"@redstone-finance/utils@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@redstone-finance/utils/-/utils-0.5.1.tgz#f91ca100ce1a1c870892db40249aebabaa0e411a"
integrity sha512-ui8scibt3mnHC9FjLVxT2k0GrDjlptIvAvNDCRsxm0jL+uttLgMmEp0kZkZ62aWFtSS0ORNDN8cER43yNNWWmQ==
dependencies:
axios "^1.6.2"
consola "^2.15.3"
decimal.js "^10.4.3"
ethers "^5.7.2"
zod "^3.22.4"

"@typechain/ethers-v5@^11.1.2":
version "11.1.2"
resolved "https://registry.yarnpkg.com/@typechain/ethers-v5/-/ethers-v5-11.1.2.tgz#82510c1744f37a2f906b9e0532ac18c0b74ffe69"
Expand Down Expand Up @@ -407,6 +441,15 @@ axios@^1.5.1:
form-data "^4.0.0"
proxy-from-env "^1.1.0"

axios@^1.6.2:
version "1.6.8"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66"
integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
proxy-from-env "^1.1.0"

balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
Expand Down Expand Up @@ -513,13 +556,23 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==

consola@^2.15.3:
version "2.15.3"
resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550"
integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==

debug@^4.3.1:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"

decimal.js@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==

deep-extend@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
Expand Down Expand Up @@ -601,6 +654,11 @@ follow-redirects@^1.15.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a"
integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==

follow-redirects@^1.15.6:
version "1.15.6"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==

form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
Expand Down Expand Up @@ -876,3 +934,8 @@ ws@7.4.6:
version "7.4.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==

zod@^3.22.4:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
154 changes: 154 additions & 0 deletions test/fork/adapters/RedStoneAsChainlinkSourceAdapter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.17;

// These tests show that we can treat redstone exactly as chainlink and use it within the Oval ecosystem without the
// need for a new adapter. This assumes the use of Redstone Classic.

import {CommonTest} from "../../Common.sol";
import {BaseController} from "../../../src/controllers/BaseController.sol";

import {DecimalLib} from "../../../src/adapters/lib/DecimalLib.sol";
import {ChainlinkSourceAdapter} from "../../../src/adapters/source-adapters/ChainlinkSourceAdapter.sol";

import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregatorV3Source.sol";
import {MergedPriceFeedAdapterWithRounds} from
"redstone-oracle/on-chain-relayer/contracts/price-feeds/with-rounds/MergedPriceFeedAdapterWithRounds.sol";

contract TestedSourceAdapter is ChainlinkSourceAdapter {
constructor(IAggregatorV3Source source) ChainlinkSourceAdapter(source) {}

function internalLatestData() public view override returns (int256, uint256, uint256) {}

function internalDataAtRound(uint256 roundId) public view override returns (int256, 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) {}
}

contract RedstoneAsChainlinkSourceAdapterTest is CommonTest {
uint256 targetBlock = 19889008;

MergedPriceFeedAdapterWithRounds redstone;
TestedSourceAdapter sourceAdapter;

function setUp() public {
vm.createSelectFork("mainnet", targetBlock);
redstone = MergedPriceFeedAdapterWithRounds(0xdDb6F90fFb4d3257dd666b69178e5B3c5Bf41136); // Redstone weETH
sourceAdapter = new TestedSourceAdapter(IAggregatorV3Source(address(redstone)));
}

function testCorrectlyStandardizesOutputs() public {
(, int256 latestRedstoneAnswer,, uint256 latestRedstoneTimestamp,) = redstone.latestRoundData();
(int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData();
assertTrue(scaleRedstoneTo18(latestRedstoneAnswer) == latestSourceAnswer);
assertTrue(latestSourceTimestamp == latestRedstoneTimestamp);
}

function testCanApplyRedstoneUpdateToSource() public {
(uint80 roundId, int256 latestAnswer, uint256 latestTimestamp,,) = redstone.latestRoundData();
// Values read from the contract at block before applying the update.
assertTrue(roundId == 2009);
assertTrue(latestAnswer == 316882263951);
assertTrue(latestTimestamp == 1715934227);
applyKnownRedstoneUpdate();
(roundId, latestAnswer, latestTimestamp,,) = redstone.latestRoundData();
assertTrue(roundId == 2010);
assertTrue(latestAnswer == 313659742144);
assertTrue(latestTimestamp == block.timestamp);
}

function testCorrectlyLooksBackThroughRounds() public {
// Try fetching the price from some periods in the past and make sure it returns the corespending value for
// given historic lookback. By looking at the contract history on-chain around the blocknumber, we can see
// how many rounds back we expect to look. Looking 1 hour back shows no updates were applied in that interval.
// we should be able to query one hour ago and get the latest round data from both the redstone source and the
// adapter.
uint256 targetTime = block.timestamp - 1 hours;
(uint80 latestRound,,,,) = redstone.latestRoundData();

(int256 lookBackPrice, uint256 lookBackTimestamp, uint256 roundId) =
sourceAdapter.tryLatestDataAt(targetTime, 10);
(, int256 answer,, uint256 updatedAt,) = redstone.getRoundData(latestRound);
assertTrue(roundId == latestRound);
assertTrue(updatedAt <= targetTime);
assertTrue(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == lookBackTimestamp);

// Next, try looking back 2 hours. by looking on-chain we can see only one update was applied. Therefore we
// should get the values from latestRound -1 (one update applied relative to the "latest" round).
targetTime = block.timestamp - 2 hours;
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10);
(, answer,, updatedAt,) = redstone.getRoundData(latestRound - 1);
assertTrue(updatedAt <= targetTime);
assertTrue(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == lookBackTimestamp);

// Next, try land at 2 rounds ago. Again, by looking on-chain, we can see this is ~2 23 mins before the current
// fork timestamp. We should be able to show the value is the oldest value within this interval.
targetTime = block.timestamp - 2 hours - 23 minutes;
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10);
(, answer,, updatedAt,) = redstone.getRoundData(latestRound - 2);
assertTrue(updatedAt <= targetTime);
assertTrue(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == lookBackTimestamp);

// Now, try 3 hours old. On-chain there were 5 updates in this interval. we should be able to show the value is
// the oldest value within this interval.
targetTime = block.timestamp - 3 hours;
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10);
(, answer,, updatedAt,) = redstone.getRoundData(latestRound - 5);
assertTrue(updatedAt <= targetTime);
assertTrue(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == 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 23 hours returns 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,
// if we bound the maximum lookback to 2 rounds.
uint256 targetTime = block.timestamp - 2 hours - 23 minutes;
(int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2);
(uint80 latestRound,,,,) = redstone.latestRoundData();
(, int256 answer,, uint256 updatedAt,) = redstone.getRoundData(latestRound - 2);
assertTrue(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == 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(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == lookBackTimestamp);
targetTime = block.timestamp - 10 hours;
(lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2);
assertTrue(scaleRedstoneTo18(answer) == lookBackPrice);
assertTrue(updatedAt == lookBackTimestamp);
}

function testNonHistoricalData() public {
uint256 targetTime = block.timestamp - 1 hours;

(, int256 answer,, uint256 updatedAt,) = redstone.latestRoundData();

(int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 0);
assertEq(lookBackPrice / 10 ** 10, answer);
assertEq(lookBackTimestamp, updatedAt);
}

function applyKnownRedstoneUpdate() internal {
// Update payload taken from: https://etherscan.io/tx/0xbcde8a894337a7e1f29dcad1f78cb0246c1b29305b5c62e43cf0e1801acc11c9
bytes memory updatePayload =
hex"c14c92040000000000000000000000000000000000000000000000000000018f86086c107765455448000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000490793d7c0018f86086c1000000020000001f6c1ccae51e44aa9e5f57431f59cbd228c190ba072e1eb0af683fef58e02ac0b7f6432b819356ed51210945643f7362871371344395bf48df443663ee525dcef1c7765455448000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000490793d7c0018f86086c100000002000000180353e157cebadd3ba9a25140b95360e0deb02f23274d275301e53680c135c7c0033dcf794366503d9d500d9003842d03abe812bc9535b644e1b5f0cee72845f1c00023137313539343036363233373123302e332e3623646174612d7061636b616765732d77726170706572000029000002ed57011e0000";
vm.prank(0x517a67D809549093bD3Ef7C6195546B8BDF24C04); // Permissioned Redstone updater.

(bool success,) = address(redstone).call(updatePayload);
require(success, "Failed to update Redstone data");
}

function scaleRedstoneTo18(int256 input) public pure returns (int256) {
return (input * 10 ** 18) / 10 ** 8;
}
}
4 changes: 2 additions & 2 deletions test/unit/CoinbaseSourceAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ contract CoinbaseSourceAdapterTest is CommonTest {
string public ticker = "ETH";
uint256 public price = 3000e6;

function pushPrice(string memory ticker, uint256 price, uint256 timestamp) public {
function pushPrice(string memory ticker, uint256 priceToPush, uint256 timestamp) public {
string memory kind = "price";

bytes memory encodedData = abi.encode(kind, timestamp, ticker, price);
bytes memory encodedData = abi.encode(kind, timestamp, ticker, priceToPush);

bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(encodedData)));

Expand Down
Loading

0 comments on commit c98863e

Please sign in to comment.