diff --git a/README.md b/README.md index 0127fca6..7a6ace86 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ An adapter's parameters and acceptance logic are easily observed on-chain. | ------------------------------------------------------------------- | -------- | ------ | ----------------------- | -------------------------------------------- | | [ChainlinkOracle](src/adapter/chainlink/ChainlinkOracle.sol) | External | Push | Provider feeds | feed, max staleness | | [ChronicleOracle](src/adapter/chainlink/ChronicleOracle.sol) | External | Push | Provider feeds | feed, max staleness | +| [StorkOracle](src/adapter/stork/StorkOracle.sol) | External | Pull | Provider feeds | feed, max staleness | | [PythOracle](src/adapter/pyth/PythOracle.sol) | External | Pull | Provider feeds | feed, max staleness, max confidence interval | | [RedstoneCoreOracle](src/adapter/redstone/RedstoneCoreOracle.sol) | External | Pull | Provider feeds | feed, max staleness, cache ttl | | [LidoOracle](src/adapter/lido/LidoOracle.sol) | Onchain | Rate | wstETH/stETH | - | diff --git a/src/adapter/stork/IStork.sol b/src/adapter/stork/IStork.sol new file mode 100644 index 00000000..f77bc1ef --- /dev/null +++ b/src/adapter/stork/IStork.sol @@ -0,0 +1,14 @@ +pragma solidity ^0.8.0; + +interface IStorkTemporalNumericValueUnsafeGetter { + function getTemporalNumericValueUnsafeV1( + bytes32 id + ) external view returns (StorkStructs.TemporalNumericValue memory value); +} + +contract StorkStructs { + struct TemporalNumericValue { + uint64 timestampNs; // nanosecond level precision timestamp of latest publisher update in batch + int192 quantizedValue; + } +} diff --git a/src/adapter/stork/StorkOracle.sol b/src/adapter/stork/StorkOracle.sol new file mode 100644 index 00000000..b3fa99a4 --- /dev/null +++ b/src/adapter/stork/StorkOracle.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "../../lib/ScaleUtils.sol"; +import "./IStork.sol"; +import {BaseAdapter, Errors, IPriceOracle} from "../BaseAdapter.sol"; +import {IStorkTemporalNumericValueUnsafeGetter} from "./IStork.sol"; + +/// @title StorkOracle +/// @custom:security-contact security@euler.xyz +/// @author Stork Labs (https://www.stork.network/) +/// @notice PriceOracle adapter for Stork price feeds. +contract StorkOracle is BaseAdapter { + /// @notice The maximum length of time that a price can be in the future. + uint256 internal constant MAX_AHEADNESS = 1 minutes; + /// @notice The maximum permitted value for `maxStaleness`. + uint256 internal constant MAX_STALENESS_UPPER_BOUND = 15 minutes; + // @notice The number of decimals in values returned by the Stork contract. + int8 internal constant STORK_DECIMALS = 18; + /// @inheritdoc IPriceOracle + string public constant name = "StorkOracle"; + /// @notice The address of the Stork oracle proxy. + address public immutable stork; + /// @notice The address of the base asset corresponding to the feed. + address public immutable base; + /// @notice The address of the quote asset corresponding to the feed. + address public immutable quote; + /// @notice The id of the feed in the Stork network. + /// @dev See https://docs.stork.network/resources/asset-id-registry. + bytes32 public immutable feedId; + /// @notice The maximum allowed age of the price. + uint256 public immutable maxStaleness; + /// @dev Used for correcting for the decimals of base and quote. + uint8 internal immutable baseDecimals; + /// @dev Used for correcting for the decimals of base and quote. + uint8 internal immutable quoteDecimals; + + /// @notice Deploy a StorkOracle. + /// @param _stork The address of the Stork oracle proxy. + /// @param _base The address of the base asset corresponding to the feed. + /// @param _quote The address of the quote asset corresponding to the feed. + /// @param _feedId The id of the feed in the Stork network. + /// @param _maxStaleness The maximum allowed age of the price. + constructor( + address _stork, + address _base, + address _quote, + bytes32 _feedId, + uint256 _maxStaleness + ) { + if (_maxStaleness > MAX_STALENESS_UPPER_BOUND) { + revert Errors.PriceOracle_InvalidConfiguration(); + } + + stork = _stork; + base = _base; + quote = _quote; + feedId = _feedId; + maxStaleness = _maxStaleness; + baseDecimals = _getDecimals(base); + quoteDecimals = _getDecimals(quote); + } + + /// @notice Fetch the latest Stork price and transform it to a quote. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @return The converted amount. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote); + + StorkStructs.TemporalNumericValue memory temporalNumericValue = _fetchTemporalNumericValue(); + + uint256 value = uint256(uint192(temporalNumericValue.quantizedValue)); + int8 feedExponent = int8(baseDecimals) + STORK_DECIMALS; + + Scale scale; + if (feedExponent > 0) { + scale = ScaleUtils.from(quoteDecimals, uint8(feedExponent)); + } else { + scale = ScaleUtils.from(quoteDecimals + uint8(-feedExponent), 0); + } + return ScaleUtils.calcOutAmount(inAmount, value, scale, inverse); + } + + /// @notice Get the latest Stork price and perform sanity checks. + /// @dev Revert conditions: update timestamp is too stale or too ahead, price is negative or zero, + /// @return The Stork price struct without modification. + function _fetchTemporalNumericValue() internal view returns (StorkStructs.TemporalNumericValue memory) { + StorkStructs.TemporalNumericValue memory v = IStorkTemporalNumericValueUnsafeGetter(stork).getTemporalNumericValueUnsafeV1(feedId); + uint256 publishTimestampSeconds = v.timestampNs / 1e9; + if (publishTimestampSeconds < block.timestamp) { + // Verify that the price is not too stale + uint256 staleness = block.timestamp - publishTimestampSeconds; + if (staleness > maxStaleness) revert Errors.PriceOracle_InvalidAnswer(); + } else { + // Verify that the price is not too ahead + uint256 aheadness = publishTimestampSeconds - block.timestamp; + if (aheadness > MAX_AHEADNESS) revert Errors.PriceOracle_InvalidAnswer(); + } + + if (v.quantizedValue <= 0) { + revert Errors.PriceOracle_InvalidAnswer(); + } + return v; + } +} \ No newline at end of file diff --git a/test/adapter/stork/StorkOracle.bounds.t.sol b/test/adapter/stork/StorkOracle.bounds.t.sol new file mode 100644 index 00000000..e207521a --- /dev/null +++ b/test/adapter/stork/StorkOracle.bounds.t.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "./StorkOracleHelper.sol"; + +contract StorkOracleBoundsTest is StorkOracleHelper { + function test_Bounds(FuzzableState memory s) public { + setBounds( + Bounds({ + minBaseDecimals: 0, + maxBaseDecimals: 18, + minQuoteDecimals: 0, + maxQuoteDecimals: 18, + minInAmount: 0, + maxInAmount: type(uint128).max, + minPrice: 1, + maxPrice: 1_000_000_000_000 + }) + ); + setUpState(s); + setUpOracle(s); + + uint256 outAmount = StorkOracle(oracle).getQuote(s.inAmount, s.base, s.quote); + assertEq(outAmount, calcOutAmount(s)); + + uint256 outAmountInverse = StorkOracle(oracle).getQuote(s.inAmount, s.quote, s.base); + assertEq(outAmountInverse, calcOutAmountInverse(s)); + } +} diff --git a/test/adapter/stork/StorkOracle.fork.t.sol b/test/adapter/stork/StorkOracle.fork.t.sol new file mode 100644 index 00000000..180382e0 --- /dev/null +++ b/test/adapter/stork/StorkOracle.fork.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + + +import {ForkTest} from "test/utils/ForkTest.sol"; +import {StorkOracle} from "../../../src/adapter/stork/StorkOracle.sol"; +import {StorkStructs, IStorkTemporalNumericValueUnsafeGetter} from "../../../src/adapter/stork/IStork.sol"; +import {BTC, USD} from "test/utils/EthereumAddresses.sol"; +import {stdStorage, StdStorage} from "forge-std/StdStorage.sol"; + + +contract StorkOracleForkTest is ForkTest { + using stdStorage for StdStorage; + + StorkOracle oracle; + + address storkContractAddress = 0x035B5438444f26e6Aab81E91d475b7B1Ac4Fb22b; + bytes32 feedId = 0x7404e3d104ea7841c3d9e6fd20adfe99b4ad586bc08d8f3bd3afef894cf184de; // BTCUSD + address base = BTC; + address quote = USD; + uint256 blockNumber = 22241301; + uint256 expectedValue = 79749.8e18; + + function setUp() public { + _setUpFork(blockNumber); + } + + function test_GetQuote_Integrity() public { + oracle = new StorkOracle(storkContractAddress, base, quote, feedId, 15 minutes); + + uint256 outAmount = oracle.getQuote(1e18, base, quote); + assertApproxEqRel(outAmount, expectedValue, 0.1e18); + uint256 outAmountInverse = oracle.getQuote(expectedValue, quote, base); + assertApproxEqRel(outAmountInverse, 1e18, 0.1e18); + } + + function test_GetQuotes_Integrity() public { + oracle = new StorkOracle(storkContractAddress, base, quote, feedId, 15 minutes); + + (uint256 bidOutAmount, uint256 askOutAmount) = oracle.getQuotes(1e18, base, quote); + assertApproxEqRel(bidOutAmount, expectedValue, 0.1e18); + assertApproxEqRel(askOutAmount, expectedValue, 0.1e18); + assertEq(bidOutAmount, askOutAmount); + + (uint256 bidOutAmountInverse, uint256 askOutAmountInverse) = oracle.getQuotes(expectedValue, quote, base); + assertApproxEqRel(bidOutAmountInverse, 1e18, 0.1e18); + assertApproxEqRel(askOutAmountInverse, 1e18, 0.1e18); + assertEq(bidOutAmountInverse, askOutAmountInverse); + } +} diff --git a/test/adapter/stork/StorkOracle.prop.t.sol b/test/adapter/stork/StorkOracle.prop.t.sol new file mode 100644 index 00000000..56c8a391 --- /dev/null +++ b/test/adapter/stork/StorkOracle.prop.t.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {AdapterPropTest} from "test/adapter/AdapterPropTest.sol"; +import {StorkOracleHelper} from "./StorkOracleHelper.sol"; + +contract StorkraclePropTest is StorkOracleHelper, AdapterPropTest { + function testProp_Bidirectional(FuzzableState memory s, Prop_Bidirectional memory p) public { + setUpPropTest(s); + checkProp(p); + } + + function testProp_NoOtherPaths(FuzzableState memory s, Prop_NoOtherPaths memory p) public { + setUpPropTest(s); + checkProp(p); + } + + function testProp_IdempotentQuoteAndQuotes(FuzzableState memory s, Prop_IdempotentQuoteAndQuotes memory p) public { + setUpPropTest(s); + checkProp(p); + } + + function testProp_SupportsZero(FuzzableState memory s, Prop_SupportsZero memory p) public { + setUpPropTest(s); + checkProp(p); + } + + function testProp_ContinuousDomain(FuzzableState memory s, Prop_ContinuousDomain memory p) public { + setUpPropTest(s); + checkProp(p); + } + + function testProp_OutAmountIncreasing(FuzzableState memory s, Prop_OutAmountIncreasing memory p) public { + setUpPropTest(s); + checkProp(p); + } + + function setUpPropTest(FuzzableState memory s) internal { + setUpState(s); + setUpOracle(s); + adapter = address(oracle); + base = s.base; + quote = s.quote; + } +} diff --git a/test/adapter/stork/StorkOracle.unit.t.sol b/test/adapter/stork/StorkOracle.unit.t.sol new file mode 100644 index 00000000..1405318a --- /dev/null +++ b/test/adapter/stork/StorkOracle.unit.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "../../../src/adapter/stork/StorkOracle.sol"; +import {Errors} from "src/lib/Errors.sol"; +import {Test} from "forge-std/Test.sol"; +import {boundAddr} from "test/utils/TestUtils.sol"; +import {StorkOracle} from "../../../src/adapter/stork/StorkOracle.sol"; +import {StorkOracleHelper} from "./StorkOracleHelper.sol"; + +contract StorkOracleTest is StorkOracleHelper { + function test_Constructor_Integrity(FuzzableState memory s) public { + setUpState(s); + setUpOracle(s); + + assertEq(address(StorkOracle(oracle).stork()), STORK); + assertEq(StorkOracle(oracle).base(), s.base); + assertEq(StorkOracle(oracle).quote(), s.quote); + assertEq(StorkOracle(oracle).feedId(), s.feedId); + assertEq(StorkOracle(oracle).maxStaleness(), s.maxStaleness); + } + + function test_Constructor_RevertsWhen_MaxStalenessTooHigh(FuzzableState memory s) public { + setBehavior(Behavior.Constructor_MaxStalenessTooHigh, true); + setUpState(s); + vm.expectRevert(); + setUpOracle(s); + } + + function test_Quote_RevertsWhen_InvalidTokens(FuzzableState memory s, address otherA, address otherB) public { + setUpState(s); + setUpOracle(s); + otherA = boundAddr(otherA); + otherB = boundAddr(otherB); + vm.assume(otherA != s.base && otherA != s.quote); + vm.assume(otherB != s.base && otherB != s.quote); + expectNotSupported(s.inAmount, s.base, s.base); + expectNotSupported(s.inAmount, s.quote, s.quote); + expectNotSupported(s.inAmount, s.base, otherA); + expectNotSupported(s.inAmount, otherA, s.base); + expectNotSupported(s.inAmount, s.quote, otherA); + expectNotSupported(s.inAmount, otherA, s.quote); + expectNotSupported(s.inAmount, otherA, otherA); + expectNotSupported(s.inAmount, otherA, otherB); + } + + function test_Quote_RevertsWhen_ZeroPrice(FuzzableState memory s) public { + setBehavior(Behavior.FeedReturnsZeroPrice, true); + setUpState(s); + setUpOracle(s); + + bytes memory err = abi.encodeWithSelector(Errors.PriceOracle_InvalidAnswer.selector); + expectRevertForAllQuotePermutations(s.inAmount, s.base, s.quote, err); + } + + function test_Quote_RevertsWhen_NegativePrice(FuzzableState memory s) public { + setBehavior(Behavior.FeedReturnsNegativePrice, true); + setUpState(s); + setUpOracle(s); + + bytes memory err = abi.encodeWithSelector(Errors.PriceOracle_InvalidAnswer.selector); + expectRevertForAllQuotePermutations(s.inAmount, s.base, s.quote, err); + } + + function test_Quote_RevertsWhen_StalePrice(FuzzableState memory s) public { + setBehavior(Behavior.FeedReturnsStalePrice, true); + setUpState(s); + setUpOracle(s); + + bytes memory err = abi.encodeWithSelector(Errors.PriceOracle_InvalidAnswer.selector); + expectRevertForAllQuotePermutations(s.inAmount, s.base, s.quote, err); + } + + function test_Quote_RevertsWhen_AheadPrice(FuzzableState memory s) public { + setBehavior(Behavior.FeedReturnsTooAheadPrice, true); + setUpState(s); + setUpOracle(s); + + bytes memory err = abi.encodeWithSelector(Errors.PriceOracle_InvalidAnswer.selector); + expectRevertForAllQuotePermutations(s.inAmount, s.base, s.quote, err); + } + + function test_Quote_Integrity(FuzzableState memory s) public { + setUpState(s); + setUpOracle(s); + + uint256 expectedOutAmount = calcOutAmount(s); + uint256 outAmount = StorkOracle(oracle).getQuote(s.inAmount, s.base, s.quote); + assertEq(outAmount, expectedOutAmount); + + (uint256 bidOutAmount, uint256 askOutAmount) = StorkOracle(oracle).getQuotes(s.inAmount, s.base, s.quote); + assertEq(bidOutAmount, expectedOutAmount); + assertEq(askOutAmount, expectedOutAmount); + } + + function test_Quote_Integrity_Inverse(FuzzableState memory s) public { + setUpState(s); + setUpOracle(s); + + uint256 expectedOutAmount = calcOutAmountInverse(s); + uint256 outAmount = StorkOracle(oracle).getQuote(s.inAmount, s.quote, s.base); + assertEq(outAmount, expectedOutAmount); + + (uint256 bidOutAmount, uint256 askOutAmount) = StorkOracle(oracle).getQuotes(s.inAmount, s.quote, s.base); + assertEq(bidOutAmount, expectedOutAmount); + assertEq(askOutAmount, expectedOutAmount); + } +} diff --git a/test/adapter/stork/StorkOracleHelper.sol b/test/adapter/stork/StorkOracleHelper.sol new file mode 100644 index 00000000..1fc9f535 --- /dev/null +++ b/test/adapter/stork/StorkOracleHelper.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {FixedPointMathLib} from "@solady/utils/FixedPointMathLib.sol"; +import {AdapterHelper} from "test/adapter/AdapterHelper.sol"; +import {boundAddr, distinct} from "test/utils/TestUtils.sol"; +import {StubStork} from "./StubStork.sol"; +import {StorkOracle} from "../../../src/adapter/stork/StorkOracle.sol"; +import {StorkStructs} from "../../../src/adapter/stork/IStork.sol"; + +contract StorkOracleHelper is AdapterHelper { + uint256 internal constant MAX_STALENESS_UPPER_BOUND = 15 minutes; + + struct Bounds { + uint8 minBaseDecimals; + uint8 maxBaseDecimals; + uint8 minQuoteDecimals; + uint8 maxQuoteDecimals; + uint256 minInAmount; + uint256 maxInAmount; + int64 minPrice; + int64 maxPrice; + } + + Bounds internal DEFAULT_BOUNDS = Bounds({ + minBaseDecimals: 0, + maxBaseDecimals: 18, + minQuoteDecimals: 0, + maxQuoteDecimals: 18, + minInAmount: 0, + maxInAmount: type(uint128).max, + minPrice: 1, + maxPrice: 1_000_000_000_000 + }); + + Bounds internal bounds = DEFAULT_BOUNDS; + + function setBounds(Bounds memory _bounds) internal { + bounds = _bounds; + } + + address STORK; + + struct FuzzableState { + // Config + address base; + address quote; + bytes32 feedId; + uint256 maxStaleness; + uint8 baseDecimals; + uint8 quoteDecimals; + // Answer + StorkStructs.TemporalNumericValue v; + // Environment + uint256 inAmount; + uint256 timestamp; + } + + constructor() { + STORK = address(new StubStork()); + } + + function setUpState(FuzzableState memory s) internal { + s.base = boundAddr(s.base); + s.quote = boundAddr(s.quote); + vm.assume(distinct(s.base, s.quote, STORK)); + vm.assume(s.feedId != 0); + + if (behaviors[Behavior.Constructor_MaxStalenessTooHigh]) { + s.maxStaleness = bound(s.maxStaleness, MAX_STALENESS_UPPER_BOUND + 1, type(uint128).max); + } else { + s.maxStaleness = bound(s.maxStaleness, 0, MAX_STALENESS_UPPER_BOUND); + } + + s.baseDecimals = uint8(bound(s.baseDecimals, bounds.minBaseDecimals, bounds.maxBaseDecimals)); + s.quoteDecimals = uint8(bound(s.quoteDecimals, bounds.minQuoteDecimals, bounds.maxQuoteDecimals)); + + vm.mockCall(s.base, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(s.baseDecimals)); + vm.mockCall(s.quote, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(s.quoteDecimals)); + + if (behaviors[Behavior.FeedReturnsNegativePrice]) { + s.v.quantizedValue = int192(bound(s.v.quantizedValue, type(int64).min, -1)); + } else if (behaviors[Behavior.FeedReturnsZeroPrice]) { + s.v.quantizedValue = 0; + } else { + s.v.quantizedValue = int192(bound(s.v.quantizedValue, bounds.minPrice, bounds.maxPrice)); + } + + s.v.timestampNs = uint64(bound(uint256(s.v.timestampNs), (1 minutes + 1) * 1e9, type(uint64).max)); + uint256 valueTimestampSeconds = uint256(s.v.timestampNs / 1e9); + + if (behaviors[Behavior.FeedReturnsStalePrice]) { + s.timestamp = bound(s.timestamp, valueTimestampSeconds + s.maxStaleness + 1, type(uint144).max); + } else if (behaviors[Behavior.FeedReturnsTooAheadPrice]) { + s.timestamp = bound(s.timestamp, 0, valueTimestampSeconds - 1 minutes - 1); + } else { + s.timestamp = bound(s.timestamp, valueTimestampSeconds - 1 minutes, valueTimestampSeconds + s.maxStaleness); + } + + if (behaviors[Behavior.FeedReverts]) { + StubStork(STORK).setRevert(true); + } else { + StubStork(STORK).setPrice(s.v); + } + + s.inAmount = bound(s.inAmount, 1, type(uint128).max); + vm.warp(s.timestamp); + } + + function setUpOracle(FuzzableState memory s) internal { + oracle = address(new StorkOracle(STORK, s.base, s.quote, s.feedId, s.maxStaleness)); + } + + function calcOutAmount(FuzzableState memory s) internal pure returns (uint256) { + int8 diff = int8(s.baseDecimals) + 18; + if (diff > 0) { + return FixedPointMathLib.fullMulDiv( + s.inAmount, uint256(uint192(s.v.quantizedValue)) * 10 ** s.quoteDecimals, 10 ** (uint8(diff)) + ); + } else { + return FixedPointMathLib.fullMulDiv( + s.inAmount, uint256(uint192(s.v.quantizedValue)) * 10 ** (s.quoteDecimals + uint8(-diff)), 1 + ); + } + } + + function calcOutAmountInverse(FuzzableState memory s) internal pure returns (uint256) { + int8 diff = int8(s.baseDecimals) + 18; + if (diff > 0) { + return FixedPointMathLib.fullMulDiv( + s.inAmount, 10 ** uint8(diff), uint256(uint192(s.v.quantizedValue)) * 10 ** s.quoteDecimals + ); + } else { + return FixedPointMathLib.fullMulDiv( + s.inAmount, 1, uint256(uint192(s.v.quantizedValue)) * 10 ** (s.quoteDecimals + uint8(-diff)) + ); + } + } +} diff --git a/test/adapter/stork/StubStork.sol b/test/adapter/stork/StubStork.sol new file mode 100644 index 00000000..e7b3dbb4 --- /dev/null +++ b/test/adapter/stork/StubStork.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {StorkStructs} from "../../../src/adapter/stork/IStork.sol"; + + +contract StubStork { + StorkStructs.TemporalNumericValue value; + bool doRevert; + string revertMsg = "oops"; + + function setPrice(StorkStructs.TemporalNumericValue memory _value) external { + value = _value; + } + + function setRevert(bool _doRevert) external { + doRevert = _doRevert; + } + + function getTemporalNumericValueUnsafeV1( + bytes32 + ) external view returns (StorkStructs.TemporalNumericValue memory) { + if (doRevert) revert(revertMsg); + return value; + } +}