diff --git a/src/interfaces/IPyth.sol b/src/interfaces/IPyth.sol new file mode 100644 index 000000000..b6370aff8 --- /dev/null +++ b/src/interfaces/IPyth.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.15; + +/// @title IPyth +/// @notice Minimal interface for Pyth Network price feeds +/// @dev Based on Pyth Network's IPyth interface +interface IPyth { + /// @notice Price struct returned by Pyth Network + /// @param price Price value (multiply by 10^expo to get the decimal value) + /// @param conf Confidence interval, indicating that the actual asset price is ± conf (multiply by 10^expo to get the decimal value) + /// @param expo Exponent + /// @param publishTime Timestamp when price was published + struct Price { + int64 price; + uint64 conf; + int32 expo; + uint256 publishTime; + } + + /// @notice Get the price object with a published timestamp from before than `age` seconds in the past + /// @dev Returns the latest price object for the requested price feed ID, if it has been updated sufficiently recently + /// + /// @param priceId The ID of the price feed + /// @param age Maximum age of the on-chain price in seconds + /// @return price Price struct containing price, confidence interval, exponent, and publishTime + function getPriceNoOlderThan( + bytes32 priceId, + uint256 age + ) external view returns (Price memory price); +} diff --git a/src/libraries/SafeCast.sol b/src/libraries/SafeCast.sol index 4afc41c7f..07bfabb93 100644 --- a/src/libraries/SafeCast.sol +++ b/src/libraries/SafeCast.sol @@ -25,6 +25,13 @@ library SafeCast { return uint96(amount); } + function encodeUInt64(uint256 amount) internal pure returns (uint64) { + if (amount > type(uint64).max) { + revert Overflow(amount); + } + return uint64(amount); + } + function encodeUInt48(uint256 amount) internal pure returns (uint48) { if (amount > type(uint48).max) { revert Overflow(amount); diff --git a/src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol b/src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol new file mode 100644 index 000000000..c98b109e4 --- /dev/null +++ b/src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: AGPL-3.0 +/// forge-lint: disable-start(mixed-case-function) +pragma solidity >=0.8.15; + +// Interfaces +import {IPyth} from "src/interfaces/IPyth.sol"; +import {IERC165} from "@openzeppelin-4.8.0/interfaces/IERC165.sol"; +import {IVersioned} from "src/interfaces/IVersioned.sol"; + +// Libraries +import {FullMath} from "src/libraries/FullMath.sol"; +import {SafeCast} from "src/libraries/SafeCast.sol"; + +// Bophades +import {Module} from "src/Kernel.sol"; +import {Submodule, SubKeycode, toSubKeycode} from "src/Submodules.sol"; +import {PriceSubmodule} from "src/modules/PRICE/PRICE.v2.sol"; + +/// @title PythPriceFeeds +/// @author 0xJem +/// @notice Provides prices derived from Pyth Network price feed(s) +contract PythPriceFeeds is PriceSubmodule { + using FullMath for uint256; + + uint256 internal constant _PRICE_DATA_SIZE = 128; + + /// @notice Parameters for a single Pyth price feed + /// + /// @param pyth Address of the Pyth contract + /// @param priceFeedId The Pyth price feed ID + /// @param updateThreshold The maximum number of seconds elapsed since the last price feed update + /// @param maxConfidence The maximum confidence interval allowed (in output decimals scale) + struct OneFeedParams { + address pyth; + bytes32 priceFeedId; + uint48 updateThreshold; + uint256 maxConfidence; + } + + /// @notice Parameters for two Pyth price feeds + /// + /// @param firstPyth First: Address of the Pyth contract + /// @param firstPriceFeedId First: The Pyth price feed ID + /// @param firstUpdateThreshold First: The maximum number of seconds elapsed since the last price feed update + /// @param firstMaxConfidence First: The maximum confidence interval allowed (in output decimals scale) + /// @param secondPyth Second: Address of the Pyth contract + /// @param secondPriceFeedId Second: The Pyth price feed ID + /// @param secondUpdateThreshold Second: The maximum number of seconds elapsed since the last price feed update + /// @param secondMaxConfidence Second: The maximum confidence interval allowed (in output decimals scale) + struct TwoFeedParams { + address firstPyth; + bytes32 firstPriceFeedId; + uint48 firstUpdateThreshold; + uint256 firstMaxConfidence; + address secondPyth; + bytes32 secondPriceFeedId; + uint48 secondUpdateThreshold; + uint256 secondMaxConfidence; + } + + // ========== ERRORS ========== // + + /// @notice A Pyth contract address specified in the parameters is invalid + /// + /// @param paramsIndex_ The index of the parameter + /// @param feed_ The address of the Pyth contract + error Pyth_ParamsPythInvalid(uint8 paramsIndex_, address feed_); + + /// @notice A price feed ID specified in the parameters is invalid + /// + /// @param paramsIndex_ The index of the parameter + /// @param priceFeedId_ The price feed ID + error Pyth_ParamsPriceFeedIdInvalid(uint8 paramsIndex_, bytes32 priceFeedId_); + + /// @notice An update threshold specified in the parameters is invalid + /// @dev This currently occurs if the update threshold is 0 + /// + /// @param paramsIndex_ The index of the parameter + /// @param updateThreshold_ The update threshold + error Pyth_ParamsUpdateThresholdInvalid(uint8 paramsIndex_, uint48 updateThreshold_); + + /// @notice A maximum confidence specified in the parameters is invalid + /// @dev This currently occurs if the maximum confidence is 0 + /// + /// @param paramsIndex_ The index of the parameter + /// @param maxConfidence_ The maximum confidence (in output decimals scale) + error Pyth_ParamsMaxConfidenceInvalid(uint8 paramsIndex_, uint256 maxConfidence_); + + /// @notice The price feed is invalid + /// @dev This is triggered if the Pyth contract call reverts, + /// @dev and indicates that the feed address is not a valid Pyth contract. + /// + /// @param pyth_ The address of the Pyth contract + /// @param priceFeedId_ The price feed ID + error Pyth_FeedInvalid(address pyth_, bytes32 priceFeedId_); + + /// @notice The price returned by the price feed is invalid + /// @dev This could be because: + /// @dev - The price is <= 0 + /// + /// @param pyth_ The address of the Pyth contract + /// @param priceFeedId_ The price feed ID + /// @param price_ The price returned by the price feed + error Pyth_FeedPriceInvalid(address pyth_, bytes32 priceFeedId_, int64 price_); + + /// @notice The data returned by the price feed is stale + /// @dev This could be because: + /// @dev - The price feed was last updated before the update threshold + /// + /// @param pyth_ The address of the Pyth contract + /// @param priceFeedId_ The price feed ID + /// @param publishTime_ The publish time of the price feed + /// @param thresholdTimestamp_ The earliest acceptable timestamp + error Pyth_FeedPublishTimeStale( + address pyth_, + bytes32 priceFeedId_, + uint256 publishTime_, + uint256 thresholdTimestamp_ + ); + + /// @notice The confidence interval exceeds the maximum allowed + /// + /// @param pyth_ The address of the Pyth contract + /// @param priceFeedId_ The price feed ID + /// @param confidence_ The confidence interval returned by the price feed (in Pyth feed scale, i.e., 10^expo) + /// @param maxConfidence_ The maximum confidence interval allowed (in Pyth feed scale, i.e., 10^expo) + error Pyth_FeedConfidenceExcessive( + address pyth_, + bytes32 priceFeedId_, + uint64 confidence_, + uint64 maxConfidence_ + ); + + /// @notice The exponent from the price feed is positive, which results in loss of precision + /// @dev Positive expo values should not be accepted as they cause precision loss + /// + /// @param pyth_ The address of the Pyth contract + /// @param priceFeedId_ The price feed ID + /// @param expo_ The exponent from the price feed (must be <= 0) + error Pyth_ExponentPositive(address pyth_, bytes32 priceFeedId_, int32 expo_); + + // ========== CONSTRUCTOR ========== // + + constructor(Module parent_) Submodule(parent_) {} + + // ========== KERNEL FUNCTIONS =========== // + + /// @inheritdoc Submodule + function SUBKEYCODE() public pure override returns (SubKeycode) { + return toSubKeycode("PRICE.PYTH"); + } + + /// @inheritdoc Submodule + function VERSION() public pure override returns (uint8 major, uint8 minor) { + major = 1; + minor = 0; + } + + // ========== PRICE FEED FUNCTIONS ========== // + + /// @notice Validates the result of the price feed + /// @dev This function will revert if: + /// @dev - Price <= 0 + /// @dev - Publish time is before the update threshold from the current time + /// @dev - Confidence interval exceeds the maximum allowed + /// + /// @param pyth_ Pyth contract address + /// @param priceFeedId_ The price feed ID + /// @param priceData The price data returned by the Pyth contract + /// @param blockTimestamp The current block timestamp + /// @param paramsUpdateThreshold The maximum number of seconds elapsed since the last price feed update + /// @param paramsMaxConfidence The maximum confidence interval allowed (in Pyth feed scale, i.e., 10^expo) + function _validatePriceFeedResult( + address pyth_, + bytes32 priceFeedId_, + IPyth.Price memory priceData, + uint256 blockTimestamp, + uint48 paramsUpdateThreshold, + uint64 paramsMaxConfidence + ) internal pure { + // Price must be positive + if (priceData.price <= 0) + revert Pyth_FeedPriceInvalid(pyth_, priceFeedId_, priceData.price); + + // Publish time must be after the update threshold + // The Pyth contract reverts if the price is stale, but this serves as a backup + if (priceData.publishTime < blockTimestamp - uint256(paramsUpdateThreshold)) + revert Pyth_FeedPublishTimeStale( + pyth_, + priceFeedId_, + priceData.publishTime, + blockTimestamp - uint256(paramsUpdateThreshold) + ); + + if (priceData.conf > paramsMaxConfidence) + revert Pyth_FeedConfidenceExcessive( + pyth_, + priceFeedId_, + priceData.conf, + paramsMaxConfidence + ); + } + + /// @notice Retrieves the latest price returned by the specified Pyth price feed. + /// @dev The result is validated using `_validatePriceFeedResult`, and will revert if invalid + /// + /// @param pyth_ Pyth contract address + /// @param priceFeedId_ The Pyth price feed ID + /// @param updateThreshold_ The maximum number of seconds elapsed since the last price feed update + /// @param maxConfidence_ The maximum confidence interval allowed (in output decimals scale) + /// @param outputDecimals_ The number of decimals to return the price in + /// @return uint256 The validated price in the scale of `outputDecimals_` + function _getFeedPrice( + address pyth_, + bytes32 priceFeedId_, + uint48 updateThreshold_, + uint256 maxConfidence_, + uint8 outputDecimals_ + ) internal view returns (uint256) { + IPyth.Price memory priceData; + { + // Encode function call: getPriceNoOlderThan(bytes32,uint256) + bytes memory callData = abi.encodeWithSelector( + IPyth.getPriceNoOlderThan.selector, + priceFeedId_, + uint256(updateThreshold_) + ); + + // Perform low-level static call (view function) + (bool success, bytes memory returnData) = pyth_.staticcall(callData); + + if (!success) { + // If returnData is empty, it's a call failure (not a contract or function doesn't exist) + if (returnData.length == 0) { + revert Pyth_FeedInvalid(pyth_, priceFeedId_); + } + // Otherwise, bubble up the revert from the function + assembly { + revert(add(returnData, 0x20), mload(returnData)) + } + } + + // Validate return data length matches expected ABI-encoded IPyth.Price struct size + if (returnData.length != _PRICE_DATA_SIZE) { + revert Pyth_FeedInvalid(pyth_, priceFeedId_); + } + + // Decode the return data + priceData = abi.decode(returnData, (IPyth.Price)); + } + + // Although technically possible, a positive exponent has not been seen and would result in loss of precision + // Therefore, it is not supported + if (priceData.expo > 0) { + revert Pyth_ExponentPositive(pyth_, priceFeedId_, priceData.expo); + } + + // Convert maxConfidence from output decimals scale to Pyth feed scale (10^expo) + // Formula: maxConfidenceInPythScale = maxConfidence * 10^expo / 10^outputDecimals + // = maxConfidence * 10^(expo - outputDecimals) + // Note: Result is cast to uint64 since it's compared against priceData.conf (uint64) + uint64 maxConfidenceInPythScale = SafeCast.encodeUInt64( + maxConfidence_.mulDiv( + 10 ** uint256(uint32(-priceData.expo)), + 10 ** uint256(outputDecimals_) + ) + ); + + // Validate raw values from the price feed + _validatePriceFeedResult( + pyth_, + priceFeedId_, + priceData, + block.timestamp, + updateThreshold_, + maxConfidenceInPythScale + ); + + uint256 price = uint256(int256(priceData.price)); + + // Convert price to output decimals + // The PRICE module will handle the zero value + return price.mulDiv(10 ** uint256(outputDecimals_), 10 ** uint256(uint32(-priceData.expo))); + } + + /// @notice Returns the price from a single Pyth feed, as specified in `params_`. + /// @dev This function will revert if: + /// @dev - Any parameter is invalid + /// @dev - The exponent calculation would result in an overflow + /// @dev - The price feed's results are invalid + /// + /// @param outputDecimals_ The number of output decimals (assumed to be the same as PRICE decimals) + /// @param params_ Pyth feed parameters of type `OneFeedParams` + /// @return Price in the scale of `outputDecimals_` + function getOneFeedPrice( + address, + uint8 outputDecimals_, + bytes calldata params_ + ) external view returns (uint256) { + // Decode params + OneFeedParams memory params = abi.decode(params_, (OneFeedParams)); + if (params.pyth == address(0)) revert Pyth_ParamsPythInvalid(0, params.pyth); + if (params.priceFeedId == bytes32(0)) + revert Pyth_ParamsPriceFeedIdInvalid(1, params.priceFeedId); + if (params.updateThreshold == 0) + revert Pyth_ParamsUpdateThresholdInvalid(2, params.updateThreshold); + if (params.maxConfidence == 0) + revert Pyth_ParamsMaxConfidenceInvalid(3, params.maxConfidence); + + uint256 feedPrice = _getFeedPrice( + params.pyth, + params.priceFeedId, + params.updateThreshold, + params.maxConfidence, + outputDecimals_ + ); + + return feedPrice; + } + + /// @notice Returns the result of dividing the price from the first Pyth feed by the price from the second. + /// @dev For example, passing in ETH/USD and DAI/USD will return the ETH/DAI price. + /// + /// @dev This function will revert if: + /// @dev - Any parameter is invalid + /// @dev - The exponent calculation would result in an overflow + /// @dev - Any of the price feeds' results are invalid + /// + /// @param outputDecimals_ The number of output decimals (assumed to be the same as PRICE decimals) + /// @param params_ Pyth feed parameters of type `TwoFeedParams` + /// @return uint256 Price in the scale of `outputDecimals_`. + function getTwoFeedPriceDiv( + address, + uint8 outputDecimals_, + bytes calldata params_ + ) external view returns (uint256) { + // Decode params + TwoFeedParams memory params = abi.decode(params_, (TwoFeedParams)); + if (params.firstPyth == address(0)) revert Pyth_ParamsPythInvalid(0, params.firstPyth); + if (params.firstPriceFeedId == bytes32(0)) + revert Pyth_ParamsPriceFeedIdInvalid(1, params.firstPriceFeedId); + if (params.firstUpdateThreshold == 0) + revert Pyth_ParamsUpdateThresholdInvalid(2, params.firstUpdateThreshold); + if (params.firstMaxConfidence == 0) + revert Pyth_ParamsMaxConfidenceInvalid(3, params.firstMaxConfidence); + if (params.secondPyth == address(0)) revert Pyth_ParamsPythInvalid(4, params.secondPyth); + if (params.secondPriceFeedId == bytes32(0)) + revert Pyth_ParamsPriceFeedIdInvalid(5, params.secondPriceFeedId); + if (params.secondUpdateThreshold == 0) + revert Pyth_ParamsUpdateThresholdInvalid(6, params.secondUpdateThreshold); + if (params.secondMaxConfidence == 0) + revert Pyth_ParamsMaxConfidenceInvalid(7, params.secondMaxConfidence); + + // Get prices from feeds (both already converted to outputDecimals scale) + uint256 numeratorPrice = _getFeedPrice( + params.firstPyth, + params.firstPriceFeedId, + params.firstUpdateThreshold, + params.firstMaxConfidence, + outputDecimals_ + ); + uint256 denominatorPrice = _getFeedPrice( + params.secondPyth, + params.secondPriceFeedId, + params.secondUpdateThreshold, + params.secondMaxConfidence, + outputDecimals_ + ); + + // If denominatorPrice is zero, do an early exit + // The PRICE module will handle the zero value + if (denominatorPrice == 0) return 0; + + // Convert to numerator/denominator price and return + uint256 priceResult = numeratorPrice.mulDiv(10 ** outputDecimals_, denominatorPrice); + + return priceResult; + } + + /// @notice Returns the result of multiplying the price from the first Pyth feed by the price from the second. + /// @dev For example, passing in ETH/DAI and DAI/USD will return the ETH/USD price. + /// + /// @dev This function will revert if: + /// @dev - Any parameter is invalid + /// @dev - The exponent calculation would result in an overflow + /// @dev - Any of the price feeds' results are invalid + /// + /// @param outputDecimals_ The number of output decimals (assumed to be the same as PRICE decimals) + /// @param params_ Pyth feed parameters of type `TwoFeedParams` + /// @return uint256 Price in the scale of `outputDecimals_`. + function getTwoFeedPriceMul( + address, + uint8 outputDecimals_, + bytes calldata params_ + ) external view returns (uint256) { + // Decode params + TwoFeedParams memory params = abi.decode(params_, (TwoFeedParams)); + if (params.firstPyth == address(0)) revert Pyth_ParamsPythInvalid(0, params.firstPyth); + if (params.firstPriceFeedId == bytes32(0)) + revert Pyth_ParamsPriceFeedIdInvalid(1, params.firstPriceFeedId); + if (params.firstUpdateThreshold == 0) + revert Pyth_ParamsUpdateThresholdInvalid(2, params.firstUpdateThreshold); + if (params.firstMaxConfidence == 0) + revert Pyth_ParamsMaxConfidenceInvalid(3, params.firstMaxConfidence); + if (params.secondPyth == address(0)) revert Pyth_ParamsPythInvalid(4, params.secondPyth); + if (params.secondPriceFeedId == bytes32(0)) + revert Pyth_ParamsPriceFeedIdInvalid(5, params.secondPriceFeedId); + if (params.secondUpdateThreshold == 0) + revert Pyth_ParamsUpdateThresholdInvalid(6, params.secondUpdateThreshold); + if (params.secondMaxConfidence == 0) + revert Pyth_ParamsMaxConfidenceInvalid(7, params.secondMaxConfidence); + + // Get prices from feeds (both already converted to outputDecimals scale) + uint256 firstPrice = _getFeedPrice( + params.firstPyth, + params.firstPriceFeedId, + params.firstUpdateThreshold, + params.firstMaxConfidence, + outputDecimals_ + ); + uint256 secondPrice = _getFeedPrice( + params.secondPyth, + params.secondPriceFeedId, + params.secondUpdateThreshold, + params.secondMaxConfidence, + outputDecimals_ + ); + + // Convert to first * second price and return + uint256 priceResult = firstPrice.mulDiv(secondPrice, 10 ** outputDecimals_); + + return priceResult; + } + + // ========== IERC165 FUNCTIONS ========== // + + /// @notice Returns whether the contract supports the given interface + /// + /// @param interfaceId The interface identifier to check + /// @return bool Whether the contract supports the interface + function supportsInterface(bytes4 interfaceId) external view virtual returns (bool) { + return + interfaceId == type(IERC165).interfaceId || interfaceId == type(IVersioned).interfaceId; + } +} +/// forge-lint: disable-end(mixed-case-function) diff --git a/src/test/mocks/MockPyth.sol b/src/test/mocks/MockPyth.sol new file mode 100644 index 000000000..c30704ad2 --- /dev/null +++ b/src/test/mocks/MockPyth.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +/// forge-lint: disable-start(mixed-case-variable) +pragma solidity ^0.8.0; + +import {IPyth} from "interfaces/IPyth.sol"; + +contract MockPyth is IPyth { + mapping(bytes32 => Price) public s_prices; + + // Errors copied from PythErrors.sol + + error PriceFeedNotFound(); + error StalePrice(); + + function setPrice( + bytes32 priceId, + int64 price, + uint64 conf, + int32 expo, + uint256 publishTime + ) public { + s_prices[priceId] = Price({price: price, conf: conf, expo: expo, publishTime: publishTime}); + } + + /// @dev Replicates the errors thrown by the Pyth contract + function getPriceNoOlderThan( + bytes32 priceId, + uint256 age + ) external view override returns (Price memory) { + Price memory priceData = s_prices[priceId]; + + // Check if price exists + if (priceData.publishTime == 0) { + revert PriceFeedNotFound(); + } + + // Check if price is stale + if (priceData.publishTime < block.timestamp - age) { + revert StalePrice(); + } + + return priceData; + } +} +/// forge-lint: disable-end(mixed-case-variable) diff --git a/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/PythPriceFeedsTest.sol b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/PythPriceFeedsTest.sol new file mode 100644 index 000000000..bcd74d775 --- /dev/null +++ b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/PythPriceFeedsTest.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Unlicense +/// forge-lint: disable-start(mixed-case-variable,mixed-case-function) +pragma solidity >=0.8.0; + +// Test +import {Test} from "@forge-std-1.9.6/Test.sol"; +import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; + +// Mocks +import {MockPrice} from "src/test/mocks/MockPrice.v2.sol"; +import {MockPyth} from "src/test/mocks/MockPyth.sol"; + +// Libraries +import {FullMath} from "src/libraries/FullMath.sol"; + +// Bophades +import {Kernel} from "src/Kernel.sol"; +import {PythPriceFeeds} from "src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol"; + +contract PythPriceFeedsTest is Test { + using FullMath for uint256; + using ModuleTestFixtureGenerator for PythPriceFeeds; + + MockPyth internal pyth; + MockPyth internal secondPyth; + + Kernel internal kernel; + MockPrice internal mockPrice; + PythPriceFeeds internal pythSubmodule; + + bytes32 internal constant PRICE_ID_1 = keccak256("PRICE_ID_1"); + bytes32 internal constant PRICE_ID_2 = keccak256("PRICE_ID_2"); + bytes32 internal constant PRICE_ID_3 = keccak256("PRICE_ID_3"); + + // Example: price=123456789, expo=-8 represents 1.23456789 + int64 internal constant PRICE_1 = 123456789; + int32 internal constant EXPO_1 = -8; + uint64 internal constant CONF_1 = 1000000; + uint256 internal constant EXPECTED_PRICE_1_18_DEC = 1234567890000000000; // 1.23456789 * 10^18 + + // Example: price=100000000, expo=8 represents 100000000 * 10^8 + int64 internal constant PRICE_2 = 100000000; + int32 internal constant EXPO_2 = 8; + uint64 internal constant CONF_2 = 2000000; + + // Example: price=500000000, expo=-18 represents 0.0000000005 + int64 internal constant PRICE_3 = 500000000; + int32 internal constant EXPO_3 = -18; + uint64 internal constant CONF_3 = 500000; + + uint8 internal constant PRICE_DECIMALS = 18; + uint48 internal constant UPDATE_THRESHOLD = 100; + // MAX_CONFIDENCE is in output decimals (18 decimals) + // CONF_1 = 1000000 with expo=-8 + // In decimal: 1000000 * 10^-8 = 0.01 + // In output decimals (18): 0.01 * 10^18 = 10000000000000000 (1e16) + // Set MAX_CONFIDENCE to 2e16 to allow CONF_1 but reject higher values + uint64 internal constant MAX_CONFIDENCE = 20000000000000000; // 2 * 10^16 in 18 decimals + + function setUp() public virtual { + vm.warp(51 * 365 * 24 * 60 * 60); // Set timestamp at roughly Jan 1, 2021 + + // Set up the Pyth submodule + { + // Deploy kernel + kernel = new Kernel(); + + // Deploy mockPrice + mockPrice = new MockPrice(kernel, uint8(18), uint32(8 hours)); + mockPrice.setTimestamp(uint48(block.timestamp)); + mockPrice.setPriceDecimals(PRICE_DECIMALS); + + // Deploy Pyth submodule + pythSubmodule = new PythPriceFeeds(mockPrice); + } + + // Set up the Pyth contracts + { + pyth = new MockPyth(); + secondPyth = new MockPyth(); + + // With the default update threshold, these prices will NOT be stale + + // Set up price feed 1: 1.23456789 (expo=-8) + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp - UPDATE_THRESHOLD); + + // Set up price feed 2: 100000000 * 10^8 (expo=8) + pyth.setPrice(PRICE_ID_2, PRICE_2, CONF_2, EXPO_2, block.timestamp - UPDATE_THRESHOLD); + + // Set up price feed 3: 0.0000000005 (expo=-18) + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, block.timestamp - UPDATE_THRESHOLD); + } + } + + // ========= HELPER METHODS ========= // + + function encodeOneFeedParams( + address pyth_, + bytes32 priceFeedId_, + uint48 updateThreshold_, + uint256 maxConfidence_ + ) internal pure returns (bytes memory params) { + return abi.encode(pyth_, priceFeedId_, updateThreshold_, maxConfidence_); + } + + function encodeTwoFeedParams( + address firstPyth_, + bytes32 firstPriceFeedId_, + uint48 firstUpdateThreshold_, + uint256 firstMaxConfidence_, + address secondPyth_, + bytes32 secondPriceFeedId_, + uint48 secondUpdateThreshold_, + uint256 secondMaxConfidence_ + ) internal pure returns (bytes memory params) { + return + abi.encode( + firstPyth_, + firstPriceFeedId_, + firstUpdateThreshold_, + firstMaxConfidence_, + secondPyth_, + secondPriceFeedId_, + secondUpdateThreshold_, + secondMaxConfidence_ + ); + } +} +/// forge-lint: disable-end(mixed-case-variable,mixed-case-function) diff --git a/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getOneFeedPrice.t.sol b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getOneFeedPrice.t.sol new file mode 100644 index 000000000..02ad07653 --- /dev/null +++ b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getOneFeedPrice.t.sol @@ -0,0 +1,678 @@ +// SPDX-License-Identifier: Unlicense +/// forge-lint: disable-start(mixed-case-variable,mixed-case-function) +pragma solidity >=0.8.0; + +// Test +import {PythPriceFeedsTest} from "./PythPriceFeedsTest.sol"; +import {MockPyth} from "src/test/mocks/MockPyth.sol"; + +// Interfaces +import {IPyth} from "src/interfaces/IPyth.sol"; + +// Bophades +import {PythPriceFeeds} from "src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol"; + +contract PythPriceFeedsGetOneFeedPriceTest is PythPriceFeedsTest { + // ========= ONE FEED TESTS ========= // + + // given all parameters are valid + // [X] it returns the correct price in output decimals + function test_success() public view { + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + assertEq( + priceInt, + EXPECTED_PRICE_1_18_DEC, + "Price should match expected value for expo=-8" + ); + } + + // given the pyth contract address is zero + // [X] it reverts with Pyth_ParamsPythInvalid + function test_pythZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPythInvalid.selector, + 0, + address(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(0), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the price feed ID is zero + // [X] it reverts with Pyth_ParamsPriceFeedIdInvalid + function test_priceFeedIdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPriceFeedIdInvalid.selector, + 1, + bytes32(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + bytes32(0), + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the update threshold is zero + // [X] it reverts with Pyth_ParamsUpdateThresholdInvalid + function test_updateThresholdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsUpdateThresholdInvalid.selector, + 2, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams(address(pyth), PRICE_ID_1, 0, MAX_CONFIDENCE); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the max confidence is zero + // [X] it reverts with Pyth_ParamsMaxConfidenceInvalid + function test_maxConfidenceZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsMaxConfidenceInvalid.selector, + 3, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams(address(pyth), PRICE_ID_1, UPDATE_THRESHOLD, 0); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the pyth contract doesn't implement IPyth + // [X] it reverts with Pyth_FeedInvalid + function test_pythContractInvalid_reverts() public { + // Use a contract that doesn't implement IPyth + address invalidPyth = address(0x1234); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedInvalid.selector, + invalidPyth, + PRICE_ID_1 + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + invalidPyth, + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the pyth contract returns data with incorrect length + // [X] it reverts with Pyth_FeedInvalid + function test_returnDataLengthInvalid_reverts() public { + address mockPyth = address(0xDEAD); + + // Mock the call to succeed but return data with wrong length (not 128 bytes) + // The _PRICE_DATA_SIZE is 128 bytes, so we return something smaller + bytes memory wrongLengthData = abi.encode(uint256(1), uint256(2)); // Only 64 bytes + + vm.mockCall( + mockPyth, + abi.encodeWithSelector( + IPyth.getPriceNoOlderThan.selector, + PRICE_ID_1, + uint256(UPDATE_THRESHOLD) + ), + wrongLengthData + ); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedInvalid.selector, + mockPyth, + PRICE_ID_1 + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + mockPyth, + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + vm.clearMockedCalls(); + } + + // given the pyth contract reverts with return data + // [X] it bubbles up the revert + function test_pythRevertsWithData_bubblesUpRevert() public { + address mockPyth = address(0xBEEF); + + // Create a custom error + bytes memory customError = abi.encodeWithSignature( + "CustomError(string)", + "Test revert message" + ); + + // Mock the call to revert with data + vm.mockCallRevert( + mockPyth, + abi.encodeWithSelector( + IPyth.getPriceNoOlderThan.selector, + PRICE_ID_1, + uint256(UPDATE_THRESHOLD) + ), + customError + ); + + // Expect the revert to bubble up + vm.expectRevert(customError); + + bytes memory params = encodeOneFeedParams( + mockPyth, + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the price is <= 0 + // [X] it reverts with Pyth_FeedPriceInvalid + function test_feedPriceInvalid_reverts(int64 price_) public { + price_ = int64(bound(price_, type(int64).min, 0)); + pyth.setPrice(PRICE_ID_1, price_, CONF_1, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedPriceInvalid.selector, + address(pyth), + PRICE_ID_1, + price_ + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the price feed is not found + // [X] it reverts with PriceFeedNotFound + function test_priceFeedNotFound_reverts() public { + bytes memory err = abi.encodeWithSelector(MockPyth.PriceFeedNotFound.selector); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + bytes32(0x0000000000000000000000000000000000000000000000000000000000000001), + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given the publish time is before the update threshold + // [X] it reverts with StalePrice + function test_givenStalePrice_reverts(uint256 publishTime_) public { + // Bound publish time to be stale (publishTime < block.timestamp - UPDATE_THRESHOLD) + publishTime_ = bound(publishTime_, 1, block.timestamp - UPDATE_THRESHOLD - 1); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime_); + + bytes memory err = abi.encodeWithSelector(MockPyth.StalePrice.selector); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given expo is negative (expo = -8, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_maxConfidenceExceeded_reverts(uint64 priceConfidence_) public { + // MAX_CONFIDENCE = 2e16 in output decimals (18) + // CONF_1 = 1000000 with expo=-8 converts to 1e16 in output decimals, so it should pass + // To exceed MAX_CONFIDENCE (2e16), we need conf * 10^10 > 2e16, so conf > 2e6 + priceConfidence_ = uint64(bound(priceConfidence_, 2e6 + 1, type(uint64).max)); + + // Set the price data + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + // Calculate the expected maxConfidence in Pyth scale + // EXPO_1 = -8, PRICE_DECIMALS = 18 + // maxConfidence = 2e16 * 1 / 10^*(-8+18) = 2e16 * 1 / 10^10 = 2e6 + uint64 maxConfInPythScale = 2e6; + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + maxConfInPythScale + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenExpoNegative() public view { + // expo = -8, outputDecimals = 18, totalExponent = 10 + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + // price = 123456789, expo = -8, outputDecimals = 18 + // outputPrice = 123456789 * 10^(10) = 1234567890000000000 + assertEq( + priceInt, + EXPECTED_PRICE_1_18_DEC, + "Price should match expected value for negative expo" + ); + } + + // given expo is positive (expo > 0) + // [X] it reverts with Pyth_ExponentPositive + function test_expoPositive_reverts(int32 expo_) public { + // Bound expo to be positive (> 0) to test Pyth_ExponentPositive error + // Keep expo within reasonable range [1, 32] to avoid overflow in calculations + expo_ = int32(bound(int256(expo_), 1, 32)); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, expo_, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ExponentPositive.selector, + address(pyth), + PRICE_ID_1, + expo_ + ); + vm.expectRevert(err); + + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // given expo is zero (expo = 0, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_expoZero_maxConfidenceExceeded_reverts(uint64 priceConfidence_) public { + // expo = 0, outputDecimals = 18 + // confidenceExponent = 18 + 0 = 18 + // Use a higher maxConfidence to get a meaningful threshold + // maxConfidence = 1e19 -> maxConfidenceInPythScale = 1e19 / 1e18 = 10 + uint256 maxConfidence = 1e19; + uint64 maxConfInPythScale = 10; + priceConfidence_ = uint64( + bound(priceConfidence_, maxConfInPythScale + 1, type(uint64).max) + ); + int32 expo = 0; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + maxConfInPythScale + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + maxConfidence + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_expoZero() public { + // expo = 0, outputDecimals = 18, totalExponent = 18 + int64 price = 123456789; + int32 expo = 0; + pyth.setPrice(PRICE_ID_1, price, 1, expo, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + type(uint64).max // Ensures confidence interval is below the maximum + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + // outputPrice = 123456789 * 10^18 + assertEq(priceInt, 123456789 * 10 ** 18, "Price should match expected value for zero expo"); + } + + // given expo equals negative outputDecimals (expo = -18, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_expoEqualsNegativeOutputDecimals_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -18, outputDecimals = 18 + // confidenceExponent = 18 + (-18) = 0 + // maxConfidenceInPythScale = maxConfidence / 10^0 = 2e16 + priceConfidence_ = uint64(bound(priceConfidence_, 2e16 + 1, type(uint64).max)); + int32 expo = -18; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e16) + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // [X] it returns the price without scaling + function test_expoEqualsNegativeOutputDecimals() public { + // expo = -18, outputDecimals = 18, totalExponent = 0 + int64 price = 123456789; + int32 expo = -18; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + // outputPrice = 123456789 (no scaling needed) + assertEq( + priceInt, + /// forge-lint: disable-next-line(unsafe-typecast) + uint256(uint64(price)), + "Price should match input value when expo equals negative outputDecimals" + ); + } + + // given expo is very negative (expo = -20, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_expoVeryNegative_maxConfidenceExceeded_reverts(uint64 priceConfidence_) public { + // expo = -20, outputDecimals = 18 + // confidenceExponent = 18 + (-20) = -2 + // maxConfidenceInPythScale = maxConfidence * 10^2 = 2e16 * 100 = 2e18 + priceConfidence_ = uint64(bound(priceConfidence_, 2e18 + 1, type(uint64).max)); + int32 expo = -20; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e18) + ); + vm.expectRevert(err); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price by dividing + function test_expoVeryNegative() public { + // expo = -20, outputDecimals = 18, totalExponent = -2 + int64 price = 100000000; + int32 expo = -20; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + // outputPrice = 100000000 / 10^2 = 1000000 + assertEq(priceInt, 1000000, "Price should match expected value for very negative expo"); + } + + // given the confidence interval is at the maximum (boundary case) + // [X] it correctly converts the price + function test_confidenceEqualsMaximum() public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + // Test with confidence exactly at the maximum (2e6) + uint64 priceConfidence_ = 2e6; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + assertEq( + priceInt, + EXPECTED_PRICE_1_18_DEC, + "Price should match expected when confidence equals maximum" + ); + } + + // given the confidence interval is <= the maximum + // [X] it correctly converts the price + function test_confidenceBelowMaximum(uint64 priceConfidence_) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + // Test with confidence below maximum threshold (1 <= confidence <= 2e6) + priceConfidence_ = uint64(bound(priceConfidence_, 1, 2e6)); + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + assertEq( + priceInt, + EXPECTED_PRICE_1_18_DEC, + "Price should be valid when confidence is below maximum" + ); + } + + // given the publish time is >= the threshold boundary + // [X] it returns the correct price + function test_givenPublishTimeAboveThreshold(uint48 publishTime_) public { + // Bound the publish time to a reasonable value + publishTime_ = uint48( + bound(publishTime_, block.timestamp - UPDATE_THRESHOLD, block.timestamp) + ); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime_); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + assertEq( + priceInt, + EXPECTED_PRICE_1_18_DEC, + "Price should be valid when publish time is above threshold" + ); + } + + // given the publish time equals exactly the update threshold boundary + // [X] it returns the correct price + function test_publishTimeExactlyAtThreshold() public { + // Set publish time to exactly block.timestamp - UPDATE_THRESHOLD + // This tests the boundary condition: publishTime == blockTimestamp - updateThreshold + // The validation checks: publishTime < blockTimestamp - updateThreshold (strict <) + // So when publishTime == blockTimestamp - updateThreshold, it should pass + uint256 publishTime = block.timestamp - UPDATE_THRESHOLD; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), PRICE_DECIMALS, params); + + assertEq( + priceInt, + EXPECTED_PRICE_1_18_DEC, + "Price should be valid when publish time exactly equals threshold boundary" + ); + } + + // given outputDecimals is different from default (18) + // [X] it correctly converts prices to the specified output decimals + function test_outputDecimalsFuzz(uint8 outputDecimals_) public { + // Bound output decimals to reasonable range [8, 36] to avoid rounding issues and overflow + // Using >= 8 ensures we don't lose precision (expo = -8) + outputDecimals_ = uint8(bound(outputDecimals_, 8, 36)); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals_) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), outputDecimals_, params); + + // expo = -8, price = 123456789, outputDecimals = outputDecimals_ + // Conversion formula: price * 10^outputDecimals / 10^(-expo) = price * 10^outputDecimals / 10^8 + // = 123456789 * 10^(outputDecimals_ - 8) + /// forge-lint: disable-next-line(unsafe-typecast) + uint256 expected = uint256(uint64(PRICE_1)) * 10 ** (outputDecimals_ - 8); + assertEq(priceInt, expected, "Price should match expected for fuzzed output decimals"); + } + + // given outputDecimals is < the expo + // given the price is < 1 + // [X] the price loses precision + function test_outputDecimalsLessThanExpo_priceLessThanOne() public { + // Bound output decimals to 6, which is less than the expo (8) + uint8 outputDecimals = 6; + // Set the price to 23456789 (0.23456789 * 10^8) + pyth.setPrice(PRICE_ID_1, 23456789, CONF_1, EXPO_1, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), outputDecimals, params); + + // expo = -8, price = 23456789 (0.23456789 * 10^8) + // outputDecimals = 6, price = 234567 (0.234567 * 10^6) + uint256 expected = 234567; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given the price rounds down to 0 + // [X] it returns zero + function test_outputDecimalsLessThanExpo_priceRoundsDownToZero() public { + // Bound output decimals to 6, which is less than the expo (8) + uint8 outputDecimals = 6; + // Set the price to 89 (0.00000089 * 10^8) + // This will round down to 0 when converted to output decimals + pyth.setPrice(PRICE_ID_1, 89, CONF_1, EXPO_1, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), outputDecimals, params); + + // expo = -8, price = 89 (0.00000089 * 10^8) + // outputDecimals = 6, price = 0 (89 * 10^6 / 10^8 is less than 1) + uint256 expected = 0; + assertEq(priceInt, expected, "Price should round down to zero"); + } + + // [X] the price loses precision + function test_outputDecimalsLessThanExpo() public { + // Bound output decimals to 6, which is less than the expo (8) + uint8 outputDecimals = 6; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + + bytes memory params = encodeOneFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getOneFeedPrice(address(0), outputDecimals, params); + + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 6, price = 1234567 (1.234567 * 10^6) + uint256 expected = 1234567; + assertEq(priceInt, expected, "Price should lose precision"); + } +} +/// forge-lint: disable-end(mixed-case-variable,mixed-case-function) diff --git a/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getTwoFeedPriceDiv.t.sol b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getTwoFeedPriceDiv.t.sol new file mode 100644 index 000000000..ddd0d3c53 --- /dev/null +++ b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getTwoFeedPriceDiv.t.sol @@ -0,0 +1,1364 @@ +// SPDX-License-Identifier: Unlicense +/// forge-lint: disable-start(mixed-case-variable,mixed-case-function) +pragma solidity >=0.8.0; + +// Test +import {PythPriceFeedsTest} from "./PythPriceFeedsTest.sol"; +import {MockPyth} from "src/test/mocks/MockPyth.sol"; + +// Libraries +import {FullMath} from "src/libraries/FullMath.sol"; + +// Bophades +import {PythPriceFeeds} from "src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol"; + +contract PythPriceFeedsGetTwoFeedPriceDivTest is PythPriceFeedsTest { + using FullMath for uint256; + + // ========= TWO FEED TESTS - DIV ========= // + + // given all parameters are valid for two feeds + // [X] it returns the correct divided price (first / second) in output decimals + function test_success() public view { + // First feed: 1.23456789 (expo=-8) = 1234567890000000000 in 18 decimals + // Second feed: 0.0000000005 (expo=-18) = 500000000 in 18 decimals + // Result: 1234567890000000000 / 500000000 = 2469135780 + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // (1234567890000000000 * 10^18) / 500000000 = 2469135780000000000000000000 + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq(priceInt, expected, "Divided price should match expected calculation"); + } + + // given the second feed denominator price converts to zero + // [X] it returns zero without reverting + function test_denominatorPriceZero() public { + // Set up a very small price with a very negative expo that will round down to 0 + int64 smallPrice = 1; + int32 veryNegativeExpo = -19; + pyth.setPrice(PRICE_ID_3, smallPrice, CONF_3, veryNegativeExpo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // Should return 0 when denominator price converts to 0 + assertEq(priceInt, 0, "Should return 0 when denominator price converts to 0"); + } + + // given the first pyth contract address is zero + // [X] it reverts with Pyth_ParamsPythInvalid + function test_firstPythZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPythInvalid.selector, + 0, + address(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(0), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first price feed ID is zero + // [X] it reverts with Pyth_ParamsPriceFeedIdInvalid + function test_firstPriceFeedIdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPriceFeedIdInvalid.selector, + 1, + bytes32(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + bytes32(0), + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first update threshold is zero + // [X] it reverts with Pyth_ParamsUpdateThresholdInvalid + function test_firstUpdateThresholdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsUpdateThresholdInvalid.selector, + 2, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + 0, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first max confidence is zero + // [X] it reverts with Pyth_ParamsMaxConfidenceInvalid + function test_firstMaxConfidenceZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsMaxConfidenceInvalid.selector, + 3, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + 0, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second pyth contract address is zero + // [X] it reverts with Pyth_ParamsPythInvalid + function test_secondPythZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPythInvalid.selector, + 4, + address(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(0), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second price feed ID is zero + // [X] it reverts with Pyth_ParamsPriceFeedIdInvalid + function test_secondPriceFeedIdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPriceFeedIdInvalid.selector, + 5, + bytes32(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + bytes32(0), + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second update threshold is zero + // [X] it reverts with Pyth_ParamsUpdateThresholdInvalid + function test_secondUpdateThresholdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsUpdateThresholdInvalid.selector, + 6, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + 0, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second max confidence is zero + // [X] it reverts with Pyth_ParamsMaxConfidenceInvalid + function test_secondMaxConfidenceZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsMaxConfidenceInvalid.selector, + 7, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + 0 + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first feed has invalid price (<= 0) + // [X] it reverts with Pyth_FeedPriceInvalid + function test_firstFeedPriceInvalid_reverts(int64 price_) public { + price_ = int64(bound(price_, type(int64).min, 0)); + pyth.setPrice(PRICE_ID_1, price_, CONF_1, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedPriceInvalid.selector, + address(pyth), + PRICE_ID_1, + price_ + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second feed has invalid price (<= 0) + // [X] it reverts with Pyth_FeedPriceInvalid + function test_secondFeedPriceInvalid_reverts(int64 price_) public { + price_ = int64(bound(price_, type(int64).min, 0)); + pyth.setPrice(PRICE_ID_3, price_, CONF_3, EXPO_3, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedPriceInvalid.selector, + address(pyth), + PRICE_ID_3, + price_ + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first price feed is not found + // [X] it reverts with PriceFeedNotFound + function test_givenFirstFeedPriceFeedNotFound_reverts() public { + bytes memory err = abi.encodeWithSelector(MockPyth.PriceFeedNotFound.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + bytes32(0x0000000000000000000000000000000000000000000000000000000000000001), + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second price feed is not found + // [X] it reverts with PriceFeedNotFound + function test_givenSecondFeedPriceFeedNotFound_reverts() public { + bytes memory err = abi.encodeWithSelector(MockPyth.PriceFeedNotFound.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + bytes32(0x0000000000000000000000000000000000000000000000000000000000000001), + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first feed publish time is < the threshold boundary + // [X] it reverts with StalePrice + function test_givenFirstFeedStalePrice_reverts(uint256 publishTime_) public { + // Bound publish time to be stale (publishTime < block.timestamp - UPDATE_THRESHOLD) + publishTime_ = bound(publishTime_, 1, block.timestamp - UPDATE_THRESHOLD - 1); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime_); + + bytes memory err = abi.encodeWithSelector(MockPyth.StalePrice.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second feed publish time is < the threshold boundary + // [X] it reverts with StalePrice + function test_givenSecondFeedStalePrice_reverts(uint256 publishTime_) public { + // Bound publish time to be stale (publishTime < block.timestamp - UPDATE_THRESHOLD) + publishTime_ = bound(publishTime_, 1, block.timestamp - UPDATE_THRESHOLD - 1); + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, publishTime_); + + bytes memory err = abi.encodeWithSelector(MockPyth.StalePrice.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first feed expo is negative (expo = -8, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + priceConfidence_ = uint64(bound(priceConfidence_, 2e6 + 1, type(uint64).max)); + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e6) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenFirstFeedExpoNegative() public view { + // First feed: expo = -8, price = 123456789 -> 1.23456789 in 18 decimals = 1234567890000000000 + // Second feed: expo = -18, price = 500000000 -> 500000000 in 18 decimals + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq(priceInt, expected, "Price should match expected for negative expo first feed"); + } + + // given the confidence interval is at the maximum (boundary case) + // [X] it correctly converts the price + function test_givenFirstFeedExpoNegative_confidenceEqualsMaximum() public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + // Test with confidence exactly at the maximum (2e6) + uint64 priceConfidence_ = 2e6; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq( + priceInt, + expected, + "Price should match expected when confidence equals maximum for negative expo first feed" + ); + } + + // given the confidence interval is below the maximum + // [X] it correctly converts the price + function test_givenFirstFeedExpoNegative_confidenceBelowMaximum( + uint64 priceConfidence_ + ) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + // Test with confidence below the maximum (1 <= confidence <= 2e6) + priceConfidence_ = uint64(bound(priceConfidence_, 1, 2e6)); + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq( + priceInt, + expected, + "Price should match expected when confidence is below maximum for negative expo first feed" + ); + } + + // given the first feed expo is positive (expo > 0) + // [X] it reverts with Pyth_ExponentPositive + function test_givenFirstFeedExpoPositive_reverts(int32 expo_) public { + // Bound expo to be positive (> 0) to test Pyth_ExponentPositive error + // Keep expo within reasonable range [1, 32] to avoid overflow in calculations + expo_ = int32(bound(int256(expo_), 1, 32)); + int64 price = 100; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo_, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ExponentPositive.selector, + address(pyth), + PRICE_ID_1, + expo_ + ); + vm.expectRevert(err); + + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the first feed expo is zero (expo = 0, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoZero_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = 0, outputDecimals = 18 + // confidenceExponent = 18 + 0 = 18 + // Use a higher maxConfidence to get a meaningful threshold + // maxConfidence = 1e19 -> maxConfidenceInPythScale = 1e19 / 1e18 = 10 + uint256 maxConfidence = 1e19; + uint64 maxConfInPythScale = 10; + priceConfidence_ = uint64( + bound(priceConfidence_, maxConfInPythScale + 1, type(uint64).max) + ); + int32 expo = 0; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + maxConfInPythScale + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + maxConfidence, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenFirstFeedExpoZero() public { + // First feed: expo = 0, price = 100 -> 100 * 10^18 + int64 price = 100; + int32 expo = 0; + pyth.setPrice(PRICE_ID_1, price, 1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + type(uint64).max, // Ensures confidence interval is below the maximum + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 100 * 10^18, Second: 500000000 (18 dec) + // Result: (100 * 10^18 * 10^18) / 500000000 = 100 * 10^36 / 5e8 = 2 * 10^29 + uint256 firstPrice = uint256(100) * 10 ** 18; + uint256 expected = firstPrice.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq(priceInt, expected, "Price should match expected for zero expo first feed"); + } + + // given the first feed expo equals negative outputDecimals (expo = -18, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoNegativeEqualsOutputDecimals_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -18, outputDecimals = 18 + // confidenceExponent = 18 + (-18) = 0 + // maxConfidenceInPythScale = maxConfidence / 10^0 = 2e16 + priceConfidence_ = uint64(bound(priceConfidence_, 2e16 + 1, type(uint64).max)); + int32 expo = -18; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e16) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenFirstFeedExpoNegativeEqualsOutputDecimals() public { + // First feed: expo = -18, price = 1e9 -> 1e9 (no scaling) + int64 price = 1e9; + int32 expo = -18; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 1e9, Second: 500000000 (18 dec) + // Result: (1e9 * 10^18) / 500000000 = 1e27 / 5e8 = 2e18 + uint256 firstPrice = uint256(1e9); + uint256 expected = firstPrice.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq( + priceInt, + expected, + "Price should match expected when first feed expo equals negative outputDecimals" + ); + } + + // given the first feed expo is very negative (expo = -20, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoVeryNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -20, outputDecimals = 18 + // confidenceExponent = 18 + (-20) = -2 + // maxConfidenceInPythScale = maxConfidence * 10^2 = 2e16 * 100 = 2e18 + priceConfidence_ = uint64(bound(priceConfidence_, 2e18 + 1, type(uint64).max)); + int32 expo = -20; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e18) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price by dividing + function test_givenFirstFeedExpoVeryNegative() public { + // First feed: expo = -20, price = 1e10 -> 1e10 / 10^2 = 1e8 + int64 price = 1e10; + int32 expo = -20; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 1e10 / 100 = 1e8, Second: 500000000 (18 dec) + // Result: (1e8 * 10^18) / 500000000 = 1e26 / 5e8 = 2e17 + uint256 firstPrice = uint256(1e10) / 100; + uint256 expected = firstPrice.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq( + priceInt, + expected, + "Price should match expected for very negative expo first feed" + ); + } + + // given the first feed publish time is >= the threshold boundary + // [X] it returns the correct price + function test_givenFirstFeedPublishTimeAboveThreshold(uint256 publishTime_) public { + // Bound the publish time to a reasonable value + publishTime_ = bound(publishTime_, block.timestamp - UPDATE_THRESHOLD, block.timestamp); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime_); + // Second feed keeps publish time as set in setUp() + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq( + priceInt, + expected, + "Divided price should match expected when first feed at threshold" + ); + } + + // given the second feed expo is negative (expo = -8, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + priceConfidence_ = uint64(bound(priceConfidence_, 2e6 + 1, type(uint64).max)); + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + uint64(2e6) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenSecondFeedExpoNegative() public { + // Second feed: expo = -8, price = 123456789 -> 1.23456789 in 18 decimals = 1234567890000000000 + pyth.setPrice(PRICE_ID_3, PRICE_1, CONF_1, EXPO_1, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 1234567890000000000 + // Result: (1234567890000000000 * 10^18) / 1234567890000000000 = 10^18 + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv( + 10 ** PRICE_DECIMALS, + EXPECTED_PRICE_1_18_DEC + ); + assertEq(priceInt, expected, "Price should match expected for negative expo second feed"); + } + + // given the confidence interval is at the maximum (boundary case) + // [X] it correctly converts the price + function test_givenSecondFeedExpoNegative_confidenceEqualsMaximum() public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + // Test with confidence exactly at the maximum (2e6) + uint64 priceConfidence_ = 2e6; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv( + 10 ** PRICE_DECIMALS, + (500000000 * 10 ** PRICE_DECIMALS) / 1e8 + ); + assertEq( + priceInt, + expected, + "Price should match expected when confidence equals maximum for negative expo second feed" + ); + } + + // given the confidence interval is below the maximum + // [X] it correctly converts the price + function test_givenSecondFeedExpoNegative_confidenceBelowMaximum( + uint64 priceConfidence_ + ) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + // Test with confidence below the maximum (1 <= confidence <= 2e6) + priceConfidence_ = uint64(bound(priceConfidence_, 1, 2e6)); + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv( + 10 ** PRICE_DECIMALS, + (500000000 * 10 ** PRICE_DECIMALS) / 1e8 + ); + assertEq( + priceInt, + expected, + "Price should match expected when confidence is below maximum for negative expo second feed" + ); + } + + // given the second feed expo is positive (expo > 0) + // [X] it reverts with Pyth_ExponentPositive + function test_givenSecondFeedExpoPositive_reverts(int32 expo_) public { + // Bound expo to be positive (> 0) to test Pyth_ExponentPositive error + // Keep expo within reasonable range [1, 32] to avoid overflow in calculations + expo_ = int32(bound(int256(expo_), 1, 32)); + int64 price = 100; + pyth.setPrice(PRICE_ID_3, price, CONF_1, expo_, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ExponentPositive.selector, + address(pyth), + PRICE_ID_3, + expo_ + ); + vm.expectRevert(err); + + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // given the second feed expo is zero (expo = 0, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoZero_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = 0, outputDecimals = 18 + // confidenceExponent = 18 + 0 = 18 + // Use a higher maxConfidence to get a meaningful threshold + // maxConfidence = 1e19 -> maxConfidenceInPythScale = 1e19 / 1e18 = 10 + uint256 maxConfidence = 1e19; + uint64 maxConfInPythScale = 10; + priceConfidence_ = uint64( + bound(priceConfidence_, maxConfInPythScale + 1, type(uint64).max) + ); + int32 expo = 0; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + maxConfInPythScale + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + maxConfidence + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenSecondFeedExpoZero() public { + // Second feed: expo = 0, price = 100 -> 100 * 10^18 + int64 price = 100; + int32 expo = 0; + pyth.setPrice(PRICE_ID_3, price, 1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + type(uint64).max // Ensures confidence interval is below the maximum + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 100 * 10^18 + // Result: (1234567890000000000 * 10^18) / (100 * 10^18) = 12345678900000000 + uint256 secondPrice = uint256(100) * 10 ** 18; + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, secondPrice); + assertEq(priceInt, expected, "Price should match expected for zero expo second feed"); + } + + // given the second feed expo equals negative outputDecimals (expo = -18, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoNegativeEqualsOutputDecimals_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -18, outputDecimals = 18 + // confidenceExponent = 18 + (-18) = 0 + // maxConfidenceInPythScale = maxConfidence / 10^0 = 2e16 + priceConfidence_ = uint64(bound(priceConfidence_, 2e16 + 1, type(uint64).max)); + int32 expo = -18; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + uint64(2e16) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenSecondFeedExpoNegativeEqualsOutputDecimals() public { + // Second feed: expo = -18, price = 1e9 -> 1e9 (no scaling) + int64 price = 1e9; + int32 expo = -18; + pyth.setPrice(PRICE_ID_3, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 1e9 + // Result: (1234567890000000000 * 10^18) / 1e9 = 1234567890000000000000000000 + uint256 secondPrice = uint256(1e9); + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, secondPrice); + assertEq( + priceInt, + expected, + "Price should match expected when second feed expo equals negative outputDecimals" + ); + } + + // given the second feed expo is very negative (expo = -20, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoVeryNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -20, outputDecimals = 18 + // confidenceExponent = 18 + (-20) = -2 + // maxConfidenceInPythScale = maxConfidence * 10^2 = 2e16 * 100 = 2e18 + priceConfidence_ = uint64(bound(priceConfidence_, 2e18 + 1, type(uint64).max)); + int32 expo = -20; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + uint64(2e18) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price by dividing + function test_givenSecondFeedExpoVeryNegative() public { + // Second feed: expo = -20, price = 1e10 -> 1e10 / 10^2 = 1e8 + int64 price = 1e10; + int32 expo = -20; + pyth.setPrice(PRICE_ID_3, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 1e8 + // Result: (1234567890000000000 * 10^18) / 1e8 + uint256 secondPrice = uint256(1e10) / 100; + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, secondPrice); + assertEq( + priceInt, + expected, + "Price should match expected for very negative expo second feed" + ); + } + + // given the second feed publish time is >= the threshold boundary + // [X] it returns the correct price + function test_givenSecondFeedPublishTimeAboveThreshold(uint256 publishTime_) public { + // Bound the publish time to a reasonable value + publishTime_ = bound(publishTime_, block.timestamp - UPDATE_THRESHOLD, block.timestamp); + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, publishTime_); + // First feed keeps publish time as set in setUp() + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(10 ** PRICE_DECIMALS, 500000000); + assertEq( + priceInt, + expected, + "Divided price should match expected when second feed at threshold" + ); + } + + // given outputDecimals is >= the default (18) + // [X] it correctly converts prices to the specified output decimals + function test_outputDecimalsFuzz(uint8 outputDecimals_) public { + // Bound output decimals to reasonable range [18, 36] to avoid overflow + outputDecimals_ = uint8(bound(outputDecimals_, 18, 36)); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals_) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals_) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals_, params); + + // First: price = 123456789, expo = -8 -> converts to 123456789 * 10^(outputDecimals_ - 8) + /// forge-lint: disable-next-line(unsafe-typecast) + uint256 firstPrice = uint256(uint64(PRICE_1)) * 10 ** (outputDecimals_ - 8); + // Second: price = 500000000, expo = -18 -> converts to 500000000 * 10^(outputDecimals_ - 18) + /// forge-lint: disable-next-line(unsafe-typecast) + uint256 secondPrice = uint256(uint64(PRICE_3)) * 10 ** (outputDecimals_ - 18); + uint256 expected = firstPrice.mulDiv(10 ** outputDecimals_, secondPrice); + assertEq(priceInt, expected, "Price should match expected for fuzzed output decimals"); + } + + // given outputDecimals is < the expo of price feed one + // given that price one is < 1 + // [X] the price loses precision + function test_outputDecimalsLessThanPriceOneExpo_priceLessThanOne() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed one + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + // The price is also < 1 in 18 decimal scale + pyth.setPrice(PRICE_ID_3, 122222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals, params); + + // First price: + // expo = -18, price = 122222222222222222 + // outputDecimals = 9, price = 122222222.222222222 + // Second price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Expected result: 122222222 * 10^9 / 1234567890 = 99000000 + uint256 expected = 99000000; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given that price one rounds down to 0 + // [X] it returns zero + function test_outputDecimalsLessThanPriceOneExpo_priceRoundsDownToZero() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed one + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This will round down to 0 when converted to output decimals + pyth.setPrice(PRICE_ID_3, 122222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals, params); + + // First price: + // expo = -18, price = 122222222 + // outputDecimals = 9, price = 0 (122222222 * 10^9 / 10^18 is less than 1) + // Expected result: 0 + uint256 expected = 0; + assertEq(priceInt, expected, "Price should round down to zero"); + } + + // [X] the price loses precision + function test_outputDecimalsLessThanPriceOneExpo() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed one + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + pyth.setPrice(PRICE_ID_3, 1222222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals, params); + + // First price: + // expo = -18, price = 1222222222222222222 + // outputDecimals = 9, price = 1222222222.222222222 + // Second price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Expected result: 1222222222 * 10^9 / 1234567890 = 990000008 + uint256 expected = 990000008; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given outputDecimals is < the expo of price feed two + // given that price two is < 1 + // [X] the price loses precision + function test_outputDecimalsLessThanPriceTwoExpo_priceLessThanOne() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed two + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + // The price is also < 1 in 18 decimal scale + pyth.setPrice(PRICE_ID_3, 122222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals, params); + + // First price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Second price: + // expo = -18, price = 122222222222222222 + // outputDecimals = 9, price = 122222222.222222222 + // Expected result: 1234567890 * 10^9 / 122222222 = 10101010027 + uint256 expected = 10101010027; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given that price two rounds down to 0 + // [X] it returns zero + function test_outputDecimalsLessThanPriceTwoExpo_priceRoundsDownToZero() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed two + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This will round down to 0 when converted to output decimals + pyth.setPrice(PRICE_ID_3, 122222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals, params); + + // First price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Second price: + // expo = -18, price = 122222222 + // outputDecimals = 9, price = 0 (122222222 * 10^9 / 10^18 is less than 1) + // Expected result: 0 + uint256 expected = 0; + assertEq(priceInt, expected, "Price should round down to zero"); + } + + // [X] the price loses precision + function test_outputDecimalsLessThanPriceTwoExpo() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed two + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + pyth.setPrice(PRICE_ID_3, 1222222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceDiv(address(0), outputDecimals, params); + + // First price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Second price: + // expo = -18, price = 1222222222222222222 + // outputDecimals = 9, price = 1222222222.222222222 + // Expected result: 1234567890 * 10^9 / 1222222222 = 1010101001 + uint256 expected = 1010101001; + assertEq(priceInt, expected, "Price should lose precision"); + } +} +/// forge-lint: disable-end(mixed-case-variable,mixed-case-function) diff --git a/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getTwoFeedPriceMul.t.sol b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getTwoFeedPriceMul.t.sol new file mode 100644 index 000000000..cfd7c7b11 --- /dev/null +++ b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/getTwoFeedPriceMul.t.sol @@ -0,0 +1,1214 @@ +// SPDX-License-Identifier: Unlicense +/// forge-lint: disable-start(mixed-case-variable,mixed-case-function) +pragma solidity >=0.8.0; + +// Test +import {PythPriceFeedsTest} from "./PythPriceFeedsTest.sol"; +import {MockPyth} from "src/test/mocks/MockPyth.sol"; + +// Libraries +import {FullMath} from "src/libraries/FullMath.sol"; + +// Bophades +import {PythPriceFeeds} from "src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol"; + +contract PythPriceFeedsGetTwoFeedPriceMulTest is PythPriceFeedsTest { + using FullMath for uint256; + + // ========= TWO FEED TESTS - MUL ========= // + + // given all parameters are valid for two feeds + // [X] it returns the correct multiplied price (first * second) in output decimals + function test_success() public view { + // First feed: 1.23456789 (expo=-8) = 1234567890000000000 in 18 decimals + // Second feed: 0.0000000005 (expo=-18) = 500000000 in 18 decimals + // Result: (1234567890000000000 * 500000000) / 10^18 + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // (1234567890000000000 * 500000000) / 10^18 = 617283945000 + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv( + uint256(500000000), + 10 ** uint256(PRICE_DECIMALS) + ); + assertEq(priceInt, expected, "Multiplied price should match expected calculation"); + } + + // given the first pyth contract address is zero + // [X] it reverts with Pyth_ParamsPythInvalid + function test_firstPythZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPythInvalid.selector, + 0, + address(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(0), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second pyth contract address is zero + // [X] it reverts with Pyth_ParamsPythInvalid + function test_secondPythZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPythInvalid.selector, + 4, + address(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(0), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first price feed ID is zero + // [X] it reverts with Pyth_ParamsPriceFeedIdInvalid + function test_firstPriceFeedIdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPriceFeedIdInvalid.selector, + 1, + bytes32(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + bytes32(0), + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second price feed ID is zero + // [X] it reverts with Pyth_ParamsPriceFeedIdInvalid + function test_secondPriceFeedIdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsPriceFeedIdInvalid.selector, + 5, + bytes32(0) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + bytes32(0), + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first update threshold is zero + // [X] it reverts with Pyth_ParamsUpdateThresholdInvalid + function test_firstUpdateThresholdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsUpdateThresholdInvalid.selector, + 2, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + 0, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second update threshold is zero + // [X] it reverts with Pyth_ParamsUpdateThresholdInvalid + function test_secondUpdateThresholdZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsUpdateThresholdInvalid.selector, + 6, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + 0, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first max confidence is zero + // [X] it reverts with Pyth_ParamsMaxConfidenceInvalid + function test_firstMaxConfidenceZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsMaxConfidenceInvalid.selector, + 3, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + 0, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second max confidence is zero + // [X] it reverts with Pyth_ParamsMaxConfidenceInvalid + function test_secondMaxConfidenceZero_reverts() public { + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ParamsMaxConfidenceInvalid.selector, + 7, + 0 + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + 0 + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first feed has invalid price (<= 0) + // [X] it reverts with Pyth_FeedPriceInvalid + function test_firstFeedPriceInvalid_reverts(int64 price_) public { + price_ = int64(bound(price_, type(int64).min, 0)); + pyth.setPrice(PRICE_ID_1, price_, CONF_1, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedPriceInvalid.selector, + address(pyth), + PRICE_ID_1, + price_ + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second feed has invalid price (<= 0) + // [X] it reverts with Pyth_FeedPriceInvalid + function test_secondFeedPriceInvalid_reverts(int64 price_) public { + price_ = int64(bound(price_, type(int64).min, 0)); + pyth.setPrice(PRICE_ID_3, price_, CONF_3, EXPO_3, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedPriceInvalid.selector, + address(pyth), + PRICE_ID_3, + price_ + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first price feed is not found + // [X] it reverts with PriceFeedNotFound + function test_givenFirstFeedPriceFeedNotFound_reverts() public { + bytes memory err = abi.encodeWithSelector(MockPyth.PriceFeedNotFound.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + bytes32(0x0000000000000000000000000000000000000000000000000000000000000001), + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second price feed is not found + // [X] it reverts with PriceFeedNotFound + function test_givenSecondFeedPriceFeedNotFound_reverts() public { + bytes memory err = abi.encodeWithSelector(MockPyth.PriceFeedNotFound.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + bytes32(0x0000000000000000000000000000000000000000000000000000000000000001), + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first feed publish time is < the threshold boundary + // [X] it reverts with StalePrice + function test_givenFirstFeedStalePrice_reverts(uint256 publishTime_) public { + // Bound publish time to be stale (publishTime < block.timestamp - UPDATE_THRESHOLD) + publishTime_ = bound(publishTime_, 1, block.timestamp - UPDATE_THRESHOLD - 1); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime_); + + bytes memory err = abi.encodeWithSelector(MockPyth.StalePrice.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second feed publish time is < the threshold boundary + // [X] it reverts with StalePrice + function test_givenSecondFeedStalePrice_reverts(uint256 publishTime_) public { + // Bound publish time to be stale (publishTime < block.timestamp - UPDATE_THRESHOLD) + publishTime_ = bound(publishTime_, 1, block.timestamp - UPDATE_THRESHOLD - 1); + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, publishTime_); + + bytes memory err = abi.encodeWithSelector(MockPyth.StalePrice.selector); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first feed expo is negative (expo = -8, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + priceConfidence_ = uint64(bound(priceConfidence_, 2e6 + 1, type(uint64).max)); + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e6) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenFirstFeedExpoNegative() public view { + // First feed: expo = -8, price = 123456789 -> 1.23456789 in 18 decimals = 1234567890000000000 + // Second feed: expo = -18, price = 500000000 -> 500000000 in 18 decimals + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // (1234567890000000000 * 500000000) / 10^18 = 617283945000 + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(500000000, 10 ** PRICE_DECIMALS); + assertEq(priceInt, expected, "Price should match expected for negative expo first feed"); + } + + // given the first feed expo is positive (expo > 0) + // [X] it reverts with Pyth_ExponentPositive + function test_givenFirstFeedExpoPositive_reverts(int32 expo_) public { + // Bound expo to be positive (> 0) to test Pyth_ExponentPositive error + // Keep expo within reasonable range [1, 32] to avoid overflow in calculations + expo_ = int32(bound(int256(expo_), 1, 32)); + int64 price = 100; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo_, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ExponentPositive.selector, + address(pyth), + PRICE_ID_1, + expo_ + ); + vm.expectRevert(err); + + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the first feed expo is zero (expo = 0, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoZero_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = 0, outputDecimals = 18 + // confidenceExponent = 18 + 0 = 18 + // Use a higher maxConfidence to get a meaningful threshold + // maxConfidence = 1e19 -> maxConfidenceInPythScale = 1e19 / 1e18 = 10 + uint256 maxConfidence = 1e19; + uint64 maxConfInPythScale = 10; + priceConfidence_ = uint64( + bound(priceConfidence_, maxConfInPythScale + 1, type(uint64).max) + ); + int32 expo = 0; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + maxConfInPythScale + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + maxConfidence, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenFirstFeedExpoZero() public { + // First feed: expo = 0, price = 100 -> 100 * 10^18 + int64 price = 100; + int32 expo = 0; + pyth.setPrice(PRICE_ID_1, price, 1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + type(uint64).max, // Ensures confidence interval is below the maximum + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 100 * 10^18, Second: 500000000 (18 dec) + // Result: (100 * 10^18 * 500000000) / 10^18 = 5 * 10^10 + uint256 firstPrice = uint256(100) * 10 ** 18; + uint256 expected = firstPrice.mulDiv(500000000, 10 ** PRICE_DECIMALS); + assertEq(priceInt, expected, "Price should match expected for zero expo first feed"); + } + + // given the first feed expo equals negative outputDecimals (expo = -18, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoNegativeEqualsOutputDecimals_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -18, outputDecimals = 18 + // confidenceExponent = 18 + (-18) = 0 + // maxConfidenceInPythScale = maxConfidence / 10^0 = 2e16 + priceConfidence_ = uint64(bound(priceConfidence_, 2e16 + 1, type(uint64).max)); + int32 expo = -18; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e16) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenFirstFeedExpoNegativeEqualsOutputDecimals() public { + // First feed: expo = -18, price = 1e9 -> 1e9 (no scaling) + int64 price = 1e9; + int32 expo = -18; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 1e9, Second: 500000000 (18 dec) + // Result: (1e9 * 500000000) / 10^18 = 5e17 / 1e18 = 0 (rounds down) + uint256 firstPrice = uint256(1e9); + uint256 expected = firstPrice.mulDiv(500000000, 10 ** PRICE_DECIMALS); + assertEq( + priceInt, + expected, + "Price should match expected when first feed expo equals negative outputDecimals" + ); + } + + // given the first feed expo is very negative (expo = -20, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenFirstFeedExpoVeryNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -20, outputDecimals = 18 + // confidenceExponent = 18 + (-20) = -2 + // maxConfidenceInPythScale = maxConfidence * 10^2 = 2e16 * 100 = 2e18 + priceConfidence_ = uint64(bound(priceConfidence_, 2e18 + 1, type(uint64).max)); + int32 expo = -20; + pyth.setPrice(PRICE_ID_1, PRICE_1, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_1, + priceConfidence_, + uint64(2e18) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price by dividing + function test_givenFirstFeedExpoVeryNegative() public { + // First feed: expo = -20, price = 1e10 -> 1e10 / 10^2 = 1e8 + int64 price = 1e10; + int32 expo = -20; + pyth.setPrice(PRICE_ID_1, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 1e10 / 100 = 1e8, Second: 500000000 (18 dec) + // Result: (1e8 * 500000000) / 10^18 = 5e16 / 1e18 = 0 (rounds down) + uint256 firstPrice = uint256(1e10) / 100; + uint256 expected = firstPrice.mulDiv(500000000, 10 ** PRICE_DECIMALS); + assertEq( + priceInt, + expected, + "Price should match expected for very negative expo first feed" + ); + } + + // given the first feed publish time is >= the threshold boundary + // [X] it returns the correct price + function test_givenFirstFeedPublishTimeAboveThreshold(uint256 publishTime_) public { + // Bound the publish time to a reasonable value + publishTime_ = bound(publishTime_, block.timestamp - UPDATE_THRESHOLD, block.timestamp); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, publishTime_); + // Second feed keeps publish time as set in setUp() + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(500000000, 10 ** PRICE_DECIMALS); + assertEq( + priceInt, + expected, + "Multiplied price should match expected when first feed at threshold" + ); + } + + // given the second feed expo is negative (expo = -8, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -8, outputDecimals = 18 + // confidenceExponent = 18 + (-8) = 10 + // maxConfidenceInPythScale = maxConfidence / 10^10 = 2e16 / 1e10 = 2e6 + priceConfidence_ = uint64(bound(priceConfidence_, 2e6 + 1, type(uint64).max)); + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, EXPO_1, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + uint64(2e6) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenSecondFeedExpoNegative() public { + // Second feed: expo = -8, price = 123456789 -> 1.23456789 in 18 decimals = 1234567890000000000 + pyth.setPrice(PRICE_ID_3, PRICE_1, CONF_1, EXPO_1, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 1234567890000000000 + // Result: (1234567890000000000 * 1234567890000000000) / 10^18 + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv( + EXPECTED_PRICE_1_18_DEC, + 10 ** PRICE_DECIMALS + ); + assertEq(priceInt, expected, "Price should match expected for negative expo second feed"); + } + + // given the second feed expo is positive (expo > 0) + // [X] it reverts with Pyth_ExponentPositive + function test_givenSecondFeedExpoPositive_reverts(int32 expo_) public { + // Bound expo to be positive (> 0) to test Pyth_ExponentPositive error + // Keep expo within reasonable range [1, 32] to avoid overflow in calculations + expo_ = int32(bound(int256(expo_), 1, 32)); + int64 price = 100; + pyth.setPrice(PRICE_ID_3, price, CONF_1, expo_, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_ExponentPositive.selector, + address(pyth), + PRICE_ID_3, + expo_ + ); + vm.expectRevert(err); + + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // given the second feed expo is zero (expo = 0, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoZero_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = 0, outputDecimals = 18 + // confidenceExponent = 18 + 0 = 18 + // Use a higher maxConfidence to get a meaningful threshold + // maxConfidence = 1e19 -> maxConfidenceInPythScale = 1e19 / 1e18 = 10 + uint256 maxConfidence = 1e19; + uint64 maxConfInPythScale = 10; + priceConfidence_ = uint64( + bound(priceConfidence_, maxConfInPythScale + 1, type(uint64).max) + ); + int32 expo = 0; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + maxConfInPythScale + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + maxConfidence + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenSecondFeedExpoZero() public { + // Second feed: expo = 0, price = 100 -> 100 * 10^18 + int64 price = 100; + int32 expo = 0; + pyth.setPrice(PRICE_ID_3, price, 1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + type(uint64).max // Ensures confidence interval is below the maximum + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 100 * 10^18 + // Result: (1234567890000000000 * 100 * 10^18) / 10^18 = 1234567890000000000 * 100 + uint256 secondPrice = uint256(100) * 10 ** 18; + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(secondPrice, 10 ** PRICE_DECIMALS); + assertEq(priceInt, expected, "Price should match expected for zero expo second feed"); + } + + // given the second feed expo equals negative outputDecimals (expo = -18, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoNegativeEqualsOutputDecimals_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -18, outputDecimals = 18 + // confidenceExponent = 18 + (-18) = 0 + // maxConfidenceInPythScale = maxConfidence / 10^0 = 2e16 + priceConfidence_ = uint64(bound(priceConfidence_, 2e16 + 1, type(uint64).max)); + int32 expo = -18; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + uint64(2e16) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price + function test_givenSecondFeedExpoNegativeEqualsOutputDecimals() public { + // Second feed: expo = -18, price = 1e9 -> 1e9 (no scaling) + int64 price = 1e9; + int32 expo = -18; + pyth.setPrice(PRICE_ID_3, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 1e9 + // Result: (1234567890000000000 * 1e9) / 10^18 = 1234567890 + uint256 secondPrice = uint256(1e9); + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(secondPrice, 10 ** PRICE_DECIMALS); + assertEq( + priceInt, + expected, + "Price should match expected when second feed expo equals negative outputDecimals" + ); + } + + // given the second feed expo is very negative (expo = -20, outputDecimals = 18) + // given the confidence interval is above the maximum + // [X] it reverts with Pyth_FeedConfidenceExcessive + function test_givenSecondFeedExpoVeryNegative_maxConfidenceExceeded_reverts( + uint64 priceConfidence_ + ) public { + // expo = -20, outputDecimals = 18 + // confidenceExponent = 18 + (-20) = -2 + // maxConfidenceInPythScale = maxConfidence * 10^2 = 2e16 * 100 = 2e18 + priceConfidence_ = uint64(bound(priceConfidence_, 2e18 + 1, type(uint64).max)); + int32 expo = -20; + pyth.setPrice(PRICE_ID_3, PRICE_3, priceConfidence_, expo, block.timestamp); + + bytes memory err = abi.encodeWithSelector( + PythPriceFeeds.Pyth_FeedConfidenceExcessive.selector, + address(pyth), + PRICE_ID_3, + priceConfidence_, + uint64(2e18) + ); + vm.expectRevert(err); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + } + + // [X] it correctly converts the price by dividing + function test_givenSecondFeedExpoVeryNegative() public { + // Second feed: expo = -20, price = 1e10 -> 1e10 / 10^2 = 1e8 + int64 price = 1e10; + int32 expo = -20; + pyth.setPrice(PRICE_ID_3, price, CONF_1, expo, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + // First: 1234567890000000000 (18 dec), Second: 1e8 + // Result: (1234567890000000000 * 1e8) / 10^18 = 123456789 + uint256 secondPrice = uint256(1e10) / 100; + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(secondPrice, 10 ** PRICE_DECIMALS); + assertEq( + priceInt, + expected, + "Price should match expected for very negative expo second feed" + ); + } + + // given the second feed publish time is >= the threshold boundary + // [X] it returns the correct price + function test_givenSecondFeedPublishTimeAboveThreshold(uint256 publishTime_) public { + // Bound the publish time to a reasonable value + publishTime_ = bound(publishTime_, block.timestamp - UPDATE_THRESHOLD, block.timestamp); + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, publishTime_); + // First feed keeps publish time as set in setUp() + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + MAX_CONFIDENCE, + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + MAX_CONFIDENCE + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), PRICE_DECIMALS, params); + + uint256 expected = EXPECTED_PRICE_1_18_DEC.mulDiv(500000000, 10 ** PRICE_DECIMALS); + assertEq( + priceInt, + expected, + "Multiplied price should match expected when second feed at threshold" + ); + } + + // given outputDecimals is different from default (18) + // [X] it correctly converts prices to the specified output decimals + function test_outputDecimalsFuzz(uint8 outputDecimals_) public { + // Bound output decimals to reasonable range [18, 36] to avoid overflow + outputDecimals_ = uint8(bound(outputDecimals_, 18, 36)); + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + pyth.setPrice(PRICE_ID_3, PRICE_3, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals_) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals_) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals_, params); + + // First: price = 123456789, expo = -8 -> converts to 123456789 * 10^(outputDecimals_ - 8) + /// forge-lint: disable-next-line(unsafe-typecast) + uint256 firstPrice = uint256(uint64(PRICE_1)) * 10 ** (outputDecimals_ - 8); + // Second: price = 500000000, expo = -18 -> converts to 500000000 * 10^(outputDecimals_ - 18) + /// forge-lint: disable-next-line(unsafe-typecast) + uint256 secondPrice = uint256(uint64(PRICE_3)) * 10 ** (outputDecimals_ - 18); + uint256 expected = firstPrice.mulDiv(secondPrice, 10 ** outputDecimals_); + assertEq(priceInt, expected, "Price should match expected for fuzzed output decimals"); + } + + // given outputDecimals is < the expo of price feed one + // given that price one is < 1 + // [X] the price loses precision + function test_outputDecimalsLessThanPriceOneExpo_priceLessThanOne() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed one + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + // The price is also < 1 in 18 decimal scale + pyth.setPrice(PRICE_ID_3, 122222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals, params); + + // First price: + // expo = -18, price = 122222222222222222 + // outputDecimals = 9, price = 122222222.222222222 + // Second price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Expected result: 122222222 * 1234567890 / 10^9 = 150891630 + uint256 expected = 150891630; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given that price one rounds down to 0 + // [X] it returns zero + function test_outputDecimalsLessThanPriceOneExpo_priceRoundsDownToZero() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed one + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This will round down to 0 when converted to output decimals + pyth.setPrice(PRICE_ID_3, 122222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals, params); + + // First price: + // expo = -18, price = 122222222 + // outputDecimals = 9, price = 0 (122222222 * 10^9 / 10^18 is less than 1) + // Expected result: 0 + uint256 expected = 0; + assertEq(priceInt, expected, "Price should round down to zero"); + } + + // [X] the price loses precision + function test_outputDecimalsLessThanPriceOneExpo() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed one + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + pyth.setPrice(PRICE_ID_3, 1222222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals, params); + + // First price: + // expo = -18, price = 1222222222222222222 + // outputDecimals = 9, price = 1222222222.222222222 + // Second price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Expected result: 1222222222 * 1234567890 / 10^9 = 1508916309 + uint256 expected = 1508916309; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given outputDecimals is < the expo of price feed two + // given that price two is < 1 + // [X] the price loses precision + function test_outputDecimalsLessThanPriceTwoExpo_priceLessThanOne() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed two + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + // The price is also < 1 in 18 decimal scale + pyth.setPrice(PRICE_ID_3, 122222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals, params); + + // First price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Second price: + // expo = -18, price = 122222222222222222 + // outputDecimals = 9, price = 122222222.222222222 + // Expected result: 1234567890 * 122222222 / 10^9 = 150891630 + uint256 expected = 150891630; + assertEq(priceInt, expected, "Price should lose precision"); + } + + // given that price two rounds down to 0 + // [X] it returns zero + function test_outputDecimalsLessThanPriceTwoExpo_priceRoundsDownToZero() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed two + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This will round down to 0 when converted to output decimals + pyth.setPrice(PRICE_ID_3, 122222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals, params); + + // First price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Second price: + // expo = -18, price = 122222222 + // outputDecimals = 9, price = 0 (122222222 * 10^9 / 10^18 is less than 1) + // Expected result: 0 + uint256 expected = 0; + assertEq(priceInt, expected, "Price should round down to zero"); + } + + // [X] the price loses precision + function test_outputDecimalsLessThanPriceTwoExpo() public { + // Bound output decimals to 9, which is less than the expo (18) of price feed two + uint8 outputDecimals = 9; + pyth.setPrice(PRICE_ID_1, PRICE_1, CONF_1, EXPO_1, block.timestamp); + // This price will lose precision when converted to output decimals + pyth.setPrice(PRICE_ID_3, 1222222222222222222, CONF_3, EXPO_3, block.timestamp); + + bytes memory params = encodeTwoFeedParams( + address(pyth), + PRICE_ID_1, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18, // scale max confidence to the new output decimals + address(pyth), + PRICE_ID_3, + UPDATE_THRESHOLD, + (MAX_CONFIDENCE * 10 ** outputDecimals) / 10 ** 18 // scale max confidence to the new output decimals + ); + uint256 priceInt = pythSubmodule.getTwoFeedPriceMul(address(0), outputDecimals, params); + + // First price: + // expo = -8, price = 123456789 (1.23456789 * 10^8) + // outputDecimals = 9, price = 1234567890 (1.23456789 * 10^9) + // Second price: + // expo = -18, price = 1222222222222222222 + // outputDecimals = 9, price = 1222222222.222222222 + // Expected result: 1234567890 * 1222222222 / 10^9 = 1508916309 + uint256 expected = 1508916309; + assertEq(priceInt, expected, "Price should lose precision"); + } +} +/// forge-lint: disable-end(mixed-case-variable,mixed-case-function) diff --git a/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/supportsInterface.t.sol b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/supportsInterface.t.sol new file mode 100644 index 000000000..de8f2b86a --- /dev/null +++ b/src/test/modules/PRICE.v2/submodules/feeds/PythPriceFeeds/supportsInterface.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Unlicense +/// forge-lint: disable-start(mixed-case-variable,mixed-case-function) +pragma solidity >=0.8.0; + +// Test +import {PythPriceFeedsTest} from "./PythPriceFeedsTest.sol"; + +// Interfaces +import {IERC165} from "@openzeppelin-4.8.0/interfaces/IERC165.sol"; +import {IVersioned} from "src/interfaces/IVersioned.sol"; + +contract PythPriceFeedsSupportsInterfaceTest is PythPriceFeedsTest { + // ========= IERC165 FUNCTIONS ========= // + + // given supportsInterface is called with IERC165 interface ID + // [X] it returns true + function test_supportsInterface_IERC165_returnsTrue() public view { + bytes4 interfaceId = type(IERC165).interfaceId; + bool supported = pythSubmodule.supportsInterface(interfaceId); + assertTrue(supported, "Should support IERC165 interface"); + } + + // given supportsInterface is called with IVersioned interface ID + // [X] it returns true + function test_supportsInterface_IVersioned_returnsTrue() public view { + bytes4 interfaceId = type(IVersioned).interfaceId; + bool supported = pythSubmodule.supportsInterface(interfaceId); + assertTrue(supported, "Should support IVersioned interface"); + } + + // given supportsInterface is called with an unsupported interface ID + // [X] it returns false + function test_supportsInterface_unsupported_returnsFalse() public view { + bytes4 interfaceId = 0x12345678; + bool supported = pythSubmodule.supportsInterface(interfaceId); + assertFalse(supported, "Should not support unsupported interface"); + } +} +/// forge-lint: disable-end(mixed-case-variable,mixed-case-function) diff --git a/src/test/modules/PRICE/OlympusPricev1_2Fork.t.sol b/src/test/modules/PRICE/OlympusPricev1_2Fork.t.sol index 7860047f5..110def2f1 100644 --- a/src/test/modules/PRICE/OlympusPricev1_2Fork.t.sol +++ b/src/test/modules/PRICE/OlympusPricev1_2Fork.t.sol @@ -27,6 +27,7 @@ import {PRICEv1} from "src/modules/PRICE/PRICE.v1.sol"; import {OlympusPricev1_2} from "src/modules/PRICE/OlympusPrice.v1_2.sol"; import {OlympusPricev2} from "src/modules/PRICE/OlympusPrice.v2.sol"; import {ChainlinkPriceFeeds} from "modules/PRICE/submodules/feeds/ChainlinkPriceFeeds.sol"; +import {PythPriceFeeds} from "modules/PRICE/submodules/feeds/PythPriceFeeds.sol"; import {SimplePriceFeedStrategy} from "modules/PRICE/submodules/strategies/SimplePriceFeedStrategy.sol"; import {RolesAdmin} from "src/policies/RolesAdmin.sol"; @@ -53,8 +54,12 @@ contract OlympusPricev1_2ForkTest is Test { address public constant TIMELOCK = 0x953EA3223d2dd3c1A91E9D6cca1bf7Af162C9c39; address public constant CONVERTIBLE_DEPOSIT_ACTIVATOR = 0xA0ca0F496B6295f949EddA2DF5FcD3877d5a253E; + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address public constant PYTH = 0x4305FB66699C3B2702D4d05CF36551390A4c69C6; uint256 internal constant OHM_USD_PRICE = 20e18; + bytes32 internal constant ETH_USD_FEED_ID = + 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; // System contracts Kernel public kernel; @@ -69,6 +74,7 @@ contract OlympusPricev1_2ForkTest is Test { // Submodules ChainlinkPriceFeeds public chainlinkPrice; + PythPriceFeeds public pythPrice; SimplePriceFeedStrategy public strategy; // Permissioned addresses @@ -158,6 +164,7 @@ contract OlympusPricev1_2ForkTest is Test { // Deploy submodules chainlinkPrice = new ChainlinkPriceFeeds(price); + pythPrice = new PythPriceFeeds(price); strategy = new SimplePriceFeedStrategy(price); // Upgrade PRICE module to v1.2 @@ -175,14 +182,20 @@ contract OlympusPricev1_2ForkTest is Test { // Install submodules vm.startPrank(moduleWriter); price.installSubmodule(chainlinkPrice); + price.installSubmodule(pythPrice); price.installSubmodule(strategy); vm.stopPrank(); + // TODO remove Chainlink mock price feed + ohmUsdPriceFeed = new MockPriceFeed(); ohmUsdPriceFeed.setDecimals(8); // Configure OHM asset with MA tracking and OHM-ETH/ETH-USD feeds _configureOhmAsset(); + + // Configure WETH asset with Pyth feed + _configureWethAsset(); } // ========== HELPER FUNCTIONS ========== // @@ -228,6 +241,45 @@ contract OlympusPricev1_2ForkTest is Test { vm.stopPrank(); } + function _configureWethAsset() internal { + // Configure WETH with real Pyth price feed for ETH/USD + uint48 updateThreshold = 24 hours; + // Max confidence in absolute price terms (output decimals, 18) + // This value allows for $10 difference + uint256 maxConfidence = 10e18; + + vm.startPrank(priceWriterV2); + + // Configure WETH with Pyth feed + PythPriceFeeds.OneFeedParams memory ethUsdParams = PythPriceFeeds.OneFeedParams( + PYTH, + ETH_USD_FEED_ID, + updateThreshold, + maxConfidence + ); + + IPRICEv2.Component[] memory feeds = new IPRICEv2.Component[](1); + feeds[0] = IPRICEv2.Component( + toSubKeycode("PRICE.PYTH"), + PythPriceFeeds.getOneFeedPrice.selector, + abi.encode(ethUsdParams) + ); + + // This will revert if calling the Pyth feed fails + price.addAsset( + address(WETH), + false, // storeMovingAverage + false, // useMovingAverage + uint32(0), // movingAverageDuration + uint48(0), // lastObservationTime + new uint256[](0), // observations + IPRICEv2.Component(toSubKeycode(bytes20(0)), bytes4(0), abi.encode(0)), // strategy + feeds + ); + + vm.stopPrank(); + } + function _warpToNextHeartbeat() internal { // Warp to the next heartbeat timestamp vm.warp(heart.lastBeat() + heart.frequency());