diff --git a/src/generated/FlareFtsoWords.pointers.sol b/src/generated/FlareFtsoWords.pointers.sol index 6f6a67f..0cf0db6 100644 --- a/src/generated/FlareFtsoWords.pointers.sol +++ b/src/generated/FlareFtsoWords.pointers.sol @@ -10,7 +10,7 @@ pragma solidity ^0.8.25; // file needs the contract to exist so that it can be compiled. /// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0x69ffc4160d6551b77f501f51e6e53d7825dcd7318519779f877261ebd37b7da0); +bytes32 constant BYTECODE_HASH = bytes32(0x6a26d65b8bf482f013658fbdfe21f4c1df2a439f1b71d1437ad8bc72dd33e225); /// @dev The hash of the meta that describes the contract. bytes32 constant DESCRIBED_BY_META_HASH = bytes32(0x4ccb316fd35a2abbee0016f38926a4c95b6270b229c85a9acf8a3a52b36bfedc); @@ -48,13 +48,13 @@ bytes constant SUB_PARSER_WORD_PARSERS = hex"093509550967"; /// @dev Every two bytes is a function pointer for an operand handler. /// These positional indexes all map to the same indexes looked up in the parse /// meta. -bytes constant OPERAND_HANDLER_FUNCTION_POINTERS = hex"0e430e430e43"; +bytes constant OPERAND_HANDLER_FUNCTION_POINTERS = hex"0e040e040e04"; /// @dev The function pointers for the integrity check fns. -bytes constant INTEGRITY_FUNCTION_POINTERS = hex"0e200e2c0e38"; +bytes constant INTEGRITY_FUNCTION_POINTERS = hex"0de10ded0df9"; /// @dev The function pointers known to the interpreter for dynamic dispatch. /// By setting these as a constant they can be inlined into the interpreter /// and loaded at eval time for very low gas (~100) due to the compiler /// optimising it to a single `codecopy` to build the in memory bytes array. -bytes constant OPCODE_FUNCTION_POINTERS = hex"0a090ac70b1c"; +bytes constant OPCODE_FUNCTION_POINTERS = hex"0a090a880add"; diff --git a/src/lib/op/LibOpFtsoCurrentPriceUsd.sol b/src/lib/op/LibOpFtsoCurrentPriceUsd.sol index 05a8b69..916c240 100644 --- a/src/lib/op/LibOpFtsoCurrentPriceUsd.sol +++ b/src/lib/op/LibOpFtsoCurrentPriceUsd.sol @@ -5,7 +5,6 @@ pragma solidity ^0.8.19; import {OperandV2, StackItem} from "rain-interpreter-interface-0.1.0/src/interface/IInterpreterV4.sol"; import {LibIntOrAString, IntOrAString} from "rain-intorastring-0.1.0/src/lib/LibIntOrAString.sol"; import {LibFtsoCurrentPriceUsd} from "../price/LibFtsoCurrentPriceUsd.sol"; -import {DecimalsTooLarge} from "../../err/ErrFtso.sol"; import {LibDecimalFloat, Float} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; @@ -26,9 +25,8 @@ library LibOpFtsoCurrentPriceUsd { /// address. This registry is used to find the FTSO contract for the symbol /// and then the price is fetched from the FTSO. The price is returned as a /// Rain Float, normalized from the FTSO's native decimals. - /// @dev Propagates InactiveFtso, PriceNotFinalized, InconsistentFtso, and - /// StalePrice from LibFtsoCurrentPriceUsd. Additionally reverts with - /// DecimalsTooLarge if the FTSO reports more than 255 decimals. + /// @dev Propagates InactiveFtso, PriceNotFinalized, InconsistentFtso, + /// StalePrice and DecimalsTooLarge from LibFtsoCurrentPriceUsd. /// @param inputs The inputs to the operation. Always 2 items. /// 0. The symbol of the asset to fetch the price of, encoded as an /// unwrapped `IntOrAString` (i.e. a `uint256`). @@ -47,10 +45,7 @@ library LibOpFtsoCurrentPriceUsd { (uint256 price, uint256 decimals) = LibFtsoCurrentPriceUsd.ftsoCurrentPriceUsd( symbol.toStringV3(), LibDecimalFloat.toFixedDecimalLossless(timeout, 0) ); - if (decimals > type(uint8).max) { - revert DecimalsTooLarge(decimals); - } - // Check above ensures safe downcast. + // Library guarantees decimals <= type(uint8).max; downcast is safe. //forge-lint: disable-next-line(unsafe-typecast) Float priceFloat = LibDecimalFloat.fromFixedDecimalLosslessPacked(price, uint8(decimals)); diff --git a/src/lib/price/LibFtsoCurrentPriceUsd.sol b/src/lib/price/LibFtsoCurrentPriceUsd.sol index 8274572..87981bb 100644 --- a/src/lib/price/LibFtsoCurrentPriceUsd.sol +++ b/src/lib/price/LibFtsoCurrentPriceUsd.sol @@ -3,14 +3,13 @@ pragma solidity ^0.8.19; import {IFtsoRegistry, LibFlareContractRegistry} from "../registry/LibFlareContractRegistry.sol"; -import {InactiveFtso, PriceNotFinalized, StalePrice, InconsistentFtso} from "../../err/ErrFtso.sol"; +import {InactiveFtso, PriceNotFinalized, StalePrice, InconsistentFtso, DecimalsTooLarge} from "../../err/ErrFtso.sol"; import {IFtso} from "../../vendor/flare-smart-contracts/userInterfaces/IFtso.sol"; library LibFtsoCurrentPriceUsd { /// @notice Fetches the current FTSO USD price for a symbol and returns the /// raw price together with its native decimal count. The caller is - /// responsible for normalising to 18 decimals and enforcing the - /// DecimalsTooLarge guard. + /// responsible for normalising to 18 decimals. /// @dev Reverts with InactiveFtso if the FTSO is not active. /// Reverts with PriceNotFinalized if the finalization type is not /// WEIGHTED_MEDIAN or TRUSTED_ADDRESSES. @@ -18,11 +17,13 @@ library LibFtsoCurrentPriceUsd { /// values (indicates an FTSO bug). /// Reverts with StalePrice(priceTimestamp, timeout) if the price is older /// than timeout seconds. + /// Reverts with DecimalsTooLarge(decimals) if the FTSO reports more + /// decimals than fit in a uint8. /// @param symbol The FTSO symbol string (e.g. "FLR", "ETH"). /// @param timeout Max age in seconds; prices older than this revert. /// @return price The raw FTSO price in the FTSO's native unit. /// @return decimals The decimal precision of the returned price (typically - /// 5 for Flare FTSO v1, but not guaranteed). + /// 5 for Flare FTSO v1, but not guaranteed). Always <= type(uint8).max. function ftsoCurrentPriceUsd(string memory symbol, uint256 timeout) internal view returns (uint256, uint256) { // Fetch the FTSO from the registry. IFtsoRegistry ftsoRegistry = LibFlareContractRegistry.getFtsoRegistry(); @@ -67,6 +68,12 @@ library LibFtsoCurrentPriceUsd { revert StalePrice(priceTimestamp, timeout); } + // The FTSO is untrusted so the decimals bound is enforced here, at the + // trust boundary, rather than at each call site. + if (decimals > type(uint8).max) { + revert DecimalsTooLarge(decimals); + } + return (price, decimals); } } diff --git a/src/lib/sflr/LibSceptreStakedFlare.sol b/src/lib/sflr/LibSceptreStakedFlare.sol index e971c95..565c912 100644 --- a/src/lib/sflr/LibSceptreStakedFlare.sol +++ b/src/lib/sflr/LibSceptreStakedFlare.sol @@ -12,6 +12,7 @@ library LibSceptreStakedFlare { /// Fixed 18 decimal place ratio of sFLR to FLR. /// For each 1e18 FLR pooled, this is how many sFLR shares it is worth /// according to the sFLR contract's current exchange rate. + /// Reverts with ZeroSFLRRate if the sFLR contract returns 0. //forge-lint: disable-next-line(mixed-case-function) function getSFLRPerFLR18() internal view returns (uint256) { uint256 rate = SFLR_CONTRACT.getSharesByPooledFlr(1e18); diff --git a/test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol b/test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol new file mode 100644 index 0000000..e7f8b70 --- /dev/null +++ b/test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {FtsoTest, OperandV2, StackItem} from "../../../abstract/FtsoTest.sol"; +import {LibFtsoCurrentPriceUsd} from "src/lib/price/LibFtsoCurrentPriceUsd.sol"; +import {DecimalsTooLarge, StalePrice} from "src/err/ErrFtso.sol"; +import {LibIntOrAString, IntOrAString} from "rain-intorastring-0.1.0/src/lib/LibIntOrAString.sol"; + +/// @title LibFtsoCurrentPriceUsdTest +/// @notice Exercises `LibFtsoCurrentPriceUsd.ftsoCurrentPriceUsd` DIRECTLY, +/// without going through `LibOpFtsoCurrentPriceUsd`. The FTSO is untrusted, so +/// the bounds it enforces on FTSO-reported values MUST hold for every caller of +/// the library, not just the one op that happens to call it today. +contract LibFtsoCurrentPriceUsdTest is FtsoTest { + using LibIntOrAString for IntOrAString; + + /// External wrapper so `vm.expectRevert` monitors the correct call frame. + /// The library function is `internal`, so an inline call would revert in the + /// test frame itself rather than in a call `vm.expectRevert` intercepts. + function externalFtsoCurrentPriceUsd(string memory symbol, uint256 timeout) + external + view + returns (uint256, uint256) + { + return LibFtsoCurrentPriceUsd.ftsoCurrentPriceUsd(symbol, timeout); + } + + /// Satisfies `FtsoTest` so the inherited registry-failure tests exercise the + /// library directly. Outputs are `[price, decimals]`. + function externalRun(OperandV2, StackItem[] memory inputs) external view override returns (StackItem[] memory) { + IntOrAString symbol; + uint256 timeout; + assembly ("memory-safe") { + symbol := mload(add(inputs, 0x20)) + timeout := mload(add(inputs, 0x40)) + } + (uint256 price, uint256 decimals) = LibFtsoCurrentPriceUsd.ftsoCurrentPriceUsd(symbol.toStringV3(), timeout); + StackItem[] memory outputs = new StackItem[](2); + outputs[0] = StackItem.wrap(bytes32(price)); + outputs[1] = StackItem.wrap(bytes32(decimals)); + return outputs; + } + + /// Mocks a well formed, finalized, non-stale FTSO reporting `currentPrice`. + function mockHealthyFtso(string memory symbol, PriceDetails memory priceDetails, CurrentPrice memory currentPrice) + internal + { + conformPriceDetails(priceDetails, currentPrice); + finalizePrice(priceDetails); + + mockRegistry(); + mockFtsoRegistry(FTSO, symbol); + activateFtso(); + mockPriceDetails(priceDetails); + mockPrice(FTSO, currentPrice); + } + + /// In bound decimals are returned to the caller unchanged, alongside the raw + /// price. This is the oracle the guard is protecting: the library never + /// rescales, it only bounds. + function testFtsoCurrentPriceUsdHappy( + string memory symbol, + uint256 timeout, + uint256 currentTime, + PriceDetails memory priceDetails, + CurrentPrice memory currentPrice + ) external { + currentPrice.price = bound(currentPrice.price, 0, uint256(int256(type(int224).max))); + currentPrice.decimals = bound(currentPrice.decimals, 0, type(uint8).max); + timeout = bound(timeout, 0, uint256(int256(type(int224).max))); + currentTime = warpNotStale(currentPrice, timeout, currentTime); + + mockHealthyFtso(symbol, priceDetails, currentPrice); + + (uint256 price, uint256 decimals) = this.externalFtsoCurrentPriceUsd(symbol, timeout); + assertEq(price, currentPrice.price, "price"); + assertEq(decimals, currentPrice.decimals, "decimals"); + } + + /// An FTSO reporting more decimals than fit in a uint8 MUST be rejected by + /// the library itself, with the offending value in the error data. Without + /// this bound a direct caller would silently downcast to `uint8` and + /// mis-scale the price by orders of magnitude. + function testFtsoCurrentPriceUsdDecimalsTooLargeReverts( + string memory symbol, + uint256 timeout, + uint256 currentTime, + PriceDetails memory priceDetails, + CurrentPrice memory currentPrice + ) external { + currentPrice.price = bound(currentPrice.price, 0, uint256(int256(type(int224).max))); + currentPrice.decimals = + bound(currentPrice.decimals, uint256(type(uint8).max) + 1, uint256(int256(type(int32).max))); + timeout = bound(timeout, 0, uint256(int256(type(int224).max))); + currentTime = warpNotStale(currentPrice, timeout, currentTime); + + mockHealthyFtso(symbol, priceDetails, currentPrice); + + vm.expectRevert(abi.encodeWithSelector(DecimalsTooLarge.selector, currentPrice.decimals)); + this.externalFtsoCurrentPriceUsd(symbol, timeout); + } + + /// The exact boundary, low side: `type(uint8).max` decimals is the largest + /// value that survives the downcast, so it MUST be accepted and returned + /// verbatim. Pins the `>` in the guard against being weakened to `>=`. + function testFtsoCurrentPriceUsdDecimalsBoundaryMaxAccepted() external { + string memory symbol = "ETH"; + uint256 timeout = 3600; + + CurrentPrice memory currentPrice; + currentPrice.price = 98765; + currentPrice.decimals = uint256(type(uint8).max); + currentPrice.timestamp = 50000; + vm.warp(currentPrice.timestamp); + + PriceDetails memory priceDetails; + mockHealthyFtso(symbol, priceDetails, currentPrice); + + (uint256 price, uint256 decimals) = this.externalFtsoCurrentPriceUsd(symbol, timeout); + assertEq(price, 98765, "price"); + assertEq(decimals, uint256(type(uint8).max), "decimals"); + } + + /// The exact boundary, high side: one more than `type(uint8).max` MUST + /// revert, carrying the unclamped value. Pins the `>` in the guard against + /// being widened to `type(uint8).max + 1` or dropped entirely. + function testFtsoCurrentPriceUsdDecimalsBoundaryOverMaxReverts() external { + string memory symbol = "ETH"; + uint256 timeout = 3600; + + CurrentPrice memory currentPrice; + currentPrice.price = 98765; + currentPrice.decimals = uint256(type(uint8).max) + 1; + currentPrice.timestamp = 50000; + vm.warp(currentPrice.timestamp); + + PriceDetails memory priceDetails; + mockHealthyFtso(symbol, priceDetails, currentPrice); + + vm.expectRevert(abi.encodeWithSelector(DecimalsTooLarge.selector, uint256(type(uint8).max) + 1)); + this.externalFtsoCurrentPriceUsd(symbol, timeout); + } + + /// Staleness is checked before the decimals bound, so a stale price with + /// oversized decimals reports StalePrice rather than DecimalsTooLarge. Pins + /// the guard's position at the end of the checks, where it cannot mask an + /// earlier failure. + function testFtsoCurrentPriceUsdStaleTakesPrecedenceOverDecimals() external { + string memory symbol = "ETH"; + uint256 timeout = 3600; + + CurrentPrice memory currentPrice; + currentPrice.price = 98765; + currentPrice.decimals = uint256(type(uint8).max) + 1; + currentPrice.timestamp = 50000; + // One second past the staleness boundary. + vm.warp(currentPrice.timestamp + timeout + 1); + + PriceDetails memory priceDetails; + mockHealthyFtso(symbol, priceDetails, currentPrice); + + vm.expectRevert(abi.encodeWithSelector(StalePrice.selector, currentPrice.timestamp, timeout)); + this.externalFtsoCurrentPriceUsd(symbol, timeout); + } +}