From fc21012cfafe46b1cc197b95e9345dbe7932f99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santiago=20Ace=C3=B1olaza?= Date: Thu, 22 May 2025 09:15:40 -0300 Subject: [PATCH 1/3] Remvoes ProxyUtils.sol and inlines the feed scaling --- contracts/NormalizedApi3ReaderProxyV1.sol | 14 ++++--- contracts/ProxyUtils.sol | 25 ------------ contracts/adapters/ScaledApi3FeedProxyV1.sol | 41 ++++++++++++-------- 3 files changed, 34 insertions(+), 46 deletions(-) delete mode 100644 contracts/ProxyUtils.sol diff --git a/contracts/NormalizedApi3ReaderProxyV1.sol b/contracts/NormalizedApi3ReaderProxyV1.sol index 5761583..45dc0e1 100644 --- a/contracts/NormalizedApi3ReaderProxyV1.sol +++ b/contracts/NormalizedApi3ReaderProxyV1.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.27; import "./interfaces/INormalizedApi3ReaderProxyV1.sol"; -import "./ProxyUtils.sol"; /// @title An immutable proxy contract that converts a Chainlink /// AggregatorV2V3Interface feed output to 18 decimals to conform with @@ -13,8 +12,6 @@ import "./ProxyUtils.sol"; /// Refer to https://github.com/api3dao/migrate-from-chainlink-to-api3 for more /// information about the Chainlink interface implementation. contract NormalizedApi3ReaderProxyV1 is INormalizedApi3ReaderProxyV1 { - using ProxyUtils for int256; - /// @notice Chainlink AggregatorV2V3Interface contract address address public immutable override feed; @@ -36,7 +33,6 @@ contract NormalizedApi3ReaderProxyV1 is INormalizedApi3ReaderProxyV1 { /// @notice Returns the price of the underlying Chainlink feed normalized to /// 18 decimals - /// of underlying Chainlink feed /// @return value The normalized signed fixed-point value with 18 decimals /// @return timestamp The updatedAt timestamp of the feed function read() @@ -48,7 +44,15 @@ contract NormalizedApi3ReaderProxyV1 is INormalizedApi3ReaderProxyV1 { (, int256 answer, , uint256 updatedAt, ) = AggregatorV2V3Interface(feed) .latestRoundData(); - value = int224(answer.scaleValue(feedDecimals, 18)); + if (feedDecimals != 18) { + uint8 delta = feedDecimals > 18 + ? feedDecimals - 18 + : 18 - feedDecimals; + int256 factor = int256(10 ** uint256(delta)); + answer = feedDecimals < 18 ? answer * factor : answer / factor; + } + + value = int224(answer); timestamp = uint32(updatedAt); } diff --git a/contracts/ProxyUtils.sol b/contracts/ProxyUtils.sol deleted file mode 100644 index 747d6ca..0000000 --- a/contracts/ProxyUtils.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -/// @title Proxy Utilities Library -/// @notice Provides utility functions for proxy contracts -library ProxyUtils { - /// @notice Scales an integer value between decimal representations - /// @param value The value to scale - /// @param fromDecimals The number of decimals in the original value - /// @param toDecimals The target number of decimals - /// @return The scaled integer value - function scaleValue( - int256 value, - uint8 fromDecimals, - uint8 toDecimals - ) internal pure returns (int256) { - if (fromDecimals == toDecimals) return value; - uint8 delta = fromDecimals > toDecimals - ? fromDecimals - toDecimals - : toDecimals - fromDecimals; - - int256 factor = int256(10 ** uint256(delta)); - return fromDecimals < toDecimals ? value * factor : value / factor; - } -} diff --git a/contracts/adapters/ScaledApi3FeedProxyV1.sol b/contracts/adapters/ScaledApi3FeedProxyV1.sol index 08f996e..c26d77b 100644 --- a/contracts/adapters/ScaledApi3FeedProxyV1.sol +++ b/contracts/adapters/ScaledApi3FeedProxyV1.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.27; import "@api3/contracts/interfaces/IApi3ReaderProxy.sol"; -import "../ProxyUtils.sol"; import "./interfaces/IScaledApi3FeedProxyV1.sol"; /// @title An immutable Chainlink AggregatorV2V3Interface feed contract that @@ -11,8 +10,6 @@ import "./interfaces/IScaledApi3FeedProxyV1.sol"; /// @dev This contract assumes the source proxy always returns values with /// 18 decimals (as all IApi3ReaderProxy-compatible proxies do) contract ScaledApi3FeedProxyV1 is IScaledApi3FeedProxyV1 { - using ProxyUtils for int256; - /// @notice IApi3ReaderProxy contract address address public immutable override proxy; @@ -118,19 +115,31 @@ contract ScaledApi3FeedProxyV1 is IScaledApi3FeedProxyV1 { updatedAt = startedAt; } - /// @notice Reads from the IApi3ReaderProxy value and scales it to target - /// decimals - /// @dev Casting the scaled value to int224 might cause an overflow but this - /// is preferable to checking the value for overflows in every read due to - /// gas overhead - function _read() - internal - view - returns (int256 scaledValue, uint32 timestamp) - { - (int256 value, uint32 proxyTimestamp) = IApi3ReaderProxy(proxy).read(); - - scaledValue = int224(value.scaleValue(18, targetDecimals)); + /// @notice Reads a value from the underlying `IApi3ReaderProxy` and + /// scales it to `targetDecimals`. + /// @dev Reads an `int224` value (assumed to be 18 decimals) from the + /// underlying `IApi3ReaderProxy` and scales it to `targetDecimals`. + /// The initial `int224` proxy value is widened to `int256` before scaling. + /// The scaling arithmetic (`value * factor` or `value / factor`) is then + /// performed using `int256` types. This allows the scaled result to exceed + /// the `int224` range, provided it fits within `int256`. + /// Arithmetic operations will revert on overflow or underflow + /// (e.g., if `value * factor` exceeds `type(int256).max`). + /// @return value The scaled signed fixed-point value with `targetDecimals`. + /// @return timestamp The timestamp from the underlying proxy. + function _read() internal view returns (int256 value, uint32 timestamp) { + (int224 proxyValue, uint32 proxyTimestamp) = IApi3ReaderProxy(proxy) + .read(); + + value = proxyValue; timestamp = proxyTimestamp; + + if (18 != targetDecimals) { + uint8 delta = 18 > targetDecimals + ? 18 - targetDecimals + : targetDecimals - 18; + int256 factor = int256(10 ** uint256(delta)); + value = 18 < targetDecimals ? value * factor : value / factor; + } } } From 8e4d0c9e506e38fb167351bc407e40b706f06343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santiago=20Ace=C3=B1olaza?= Date: Thu, 22 May 2025 09:51:46 -0300 Subject: [PATCH 2/3] Optimizes for read on NormalizedApi3ReaderProxyV1 and ScaledApi3FeedProxyV1 while scaling feeds --- contracts/NormalizedApi3ReaderProxyV1.sol | 31 +++++++++++-------- contracts/adapters/ScaledApi3FeedProxyV1.sol | 31 ++++++++++++------- .../interfaces/IScaledApi3FeedProxyV1.sol | 2 ++ .../INormalizedApi3ReaderProxyV1.sol | 2 ++ test/NormalizedApi3ReaderProxyV1.sol.ts | 21 +++++++++++-- test/adapters/ScaledApi3FeedProxyV1.sol.ts | 17 ++++++++-- 6 files changed, 74 insertions(+), 30 deletions(-) diff --git a/contracts/NormalizedApi3ReaderProxyV1.sol b/contracts/NormalizedApi3ReaderProxyV1.sol index 45dc0e1..59569c3 100644 --- a/contracts/NormalizedApi3ReaderProxyV1.sol +++ b/contracts/NormalizedApi3ReaderProxyV1.sol @@ -15,24 +15,35 @@ contract NormalizedApi3ReaderProxyV1 is INormalizedApi3ReaderProxyV1 { /// @notice Chainlink AggregatorV2V3Interface contract address address public immutable override feed; - uint8 internal immutable feedDecimals; + /// @dev Pre-calculated factor for scaling to 18 decimals. + int256 private immutable scalingFactor; + + /// @dev True for upscaling (multiply by scalingFactor), else downscaling + /// (divide by scalingFactor). + bool private immutable isUpscaling; /// @param feed_ The address of the Chainlink AggregatorV2V3Interface feed constructor(address feed_) { if (feed_ == address(0)) { revert ZeroProxyAddress(); } - uint8 feedDecimals_ = AggregatorV2V3Interface(feed_).decimals(); if (feedDecimals_ == 0 || feedDecimals_ > 36) { revert UnsupportedFeedDecimals(); } + if (feedDecimals_ == 18) { + revert NoNormalizationNeeded(); + } feed = feed_; - feedDecimals = feedDecimals_; + uint8 delta = feedDecimals_ > 18 + ? feedDecimals_ - 18 + : 18 - feedDecimals_; + scalingFactor = int256(10 ** uint256(delta)); + isUpscaling = feedDecimals_ < 18; } /// @notice Returns the price of the underlying Chainlink feed normalized to - /// 18 decimals + /// 18 decimals. /// @return value The normalized signed fixed-point value with 18 decimals /// @return timestamp The updatedAt timestamp of the feed function read() @@ -44,15 +55,9 @@ contract NormalizedApi3ReaderProxyV1 is INormalizedApi3ReaderProxyV1 { (, int256 answer, , uint256 updatedAt, ) = AggregatorV2V3Interface(feed) .latestRoundData(); - if (feedDecimals != 18) { - uint8 delta = feedDecimals > 18 - ? feedDecimals - 18 - : 18 - feedDecimals; - int256 factor = int256(10 ** uint256(delta)); - answer = feedDecimals < 18 ? answer * factor : answer / factor; - } - - value = int224(answer); + value = isUpscaling + ? int224(answer * scalingFactor) + : int224(answer / scalingFactor); timestamp = uint32(updatedAt); } diff --git a/contracts/adapters/ScaledApi3FeedProxyV1.sol b/contracts/adapters/ScaledApi3FeedProxyV1.sol index c26d77b..d5cc809 100644 --- a/contracts/adapters/ScaledApi3FeedProxyV1.sol +++ b/contracts/adapters/ScaledApi3FeedProxyV1.sol @@ -13,8 +13,16 @@ contract ScaledApi3FeedProxyV1 is IScaledApi3FeedProxyV1 { /// @notice IApi3ReaderProxy contract address address public immutable override proxy; + /// @dev Target decimals for the scaled value. uint8 private immutable targetDecimals; + /// @dev Pre-calculated factor for scaling from 18 decimals. + int256 private immutable scalingFactor; + + /// @dev True for upscaling (multiply by scalingFactor), else downscaling + /// (divide by scalingFactor). + bool private immutable isUpscaling; + /// @param proxy_ IApi3ReaderProxy contract address /// @param targetDecimals_ Decimals used to scale the IApi3ReaderProxy value constructor(address proxy_, uint8 targetDecimals_) { @@ -24,9 +32,16 @@ contract ScaledApi3FeedProxyV1 is IScaledApi3FeedProxyV1 { if (targetDecimals_ == 0 || targetDecimals_ > 36) { revert InvalidDecimals(); } - + if (targetDecimals_ == 18) { + revert NoScalingNeeded(); + } proxy = proxy_; targetDecimals = targetDecimals_; + uint8 delta = targetDecimals_ > 18 + ? targetDecimals_ - 18 + : 18 - targetDecimals_; + scalingFactor = int256(10 ** uint256(delta)); + isUpscaling = targetDecimals_ > 18; } /// @dev AggregatorV2V3Interface users are already responsible with @@ -124,22 +139,16 @@ contract ScaledApi3FeedProxyV1 is IScaledApi3FeedProxyV1 { /// performed using `int256` types. This allows the scaled result to exceed /// the `int224` range, provided it fits within `int256`. /// Arithmetic operations will revert on overflow or underflow - /// (e.g., if `value * factor` exceeds `type(int256).max`). + /// (e.g., if `value * scalingFactor` exceeds `type(int256).max`). /// @return value The scaled signed fixed-point value with `targetDecimals`. /// @return timestamp The timestamp from the underlying proxy. function _read() internal view returns (int256 value, uint32 timestamp) { (int224 proxyValue, uint32 proxyTimestamp) = IApi3ReaderProxy(proxy) .read(); - value = proxyValue; + value = isUpscaling + ? proxyValue * scalingFactor + : proxyValue / scalingFactor; timestamp = proxyTimestamp; - - if (18 != targetDecimals) { - uint8 delta = 18 > targetDecimals - ? 18 - targetDecimals - : targetDecimals - 18; - int256 factor = int256(10 ** uint256(delta)); - value = 18 < targetDecimals ? value * factor : value / factor; - } } } diff --git a/contracts/adapters/interfaces/IScaledApi3FeedProxyV1.sol b/contracts/adapters/interfaces/IScaledApi3FeedProxyV1.sol index 6cf2efe..96f6405 100644 --- a/contracts/adapters/interfaces/IScaledApi3FeedProxyV1.sol +++ b/contracts/adapters/interfaces/IScaledApi3FeedProxyV1.sol @@ -8,6 +8,8 @@ interface IScaledApi3FeedProxyV1 is AggregatorV2V3Interface { error InvalidDecimals(); + error NoScalingNeeded(); + error FunctionIsNotSupported(); function proxy() external view returns (address proxy); diff --git a/contracts/interfaces/INormalizedApi3ReaderProxyV1.sol b/contracts/interfaces/INormalizedApi3ReaderProxyV1.sol index 8788596..f8cfcb3 100644 --- a/contracts/interfaces/INormalizedApi3ReaderProxyV1.sol +++ b/contracts/interfaces/INormalizedApi3ReaderProxyV1.sol @@ -12,6 +12,8 @@ interface INormalizedApi3ReaderProxyV1 is error UnsupportedFeedDecimals(); + error NoNormalizationNeeded(); + error FunctionIsNotSupported(); function feed() external view returns (address feed); diff --git a/test/NormalizedApi3ReaderProxyV1.sol.ts b/test/NormalizedApi3ReaderProxyV1.sol.ts index dbf6bb0..6443908 100644 --- a/test/NormalizedApi3ReaderProxyV1.sol.ts +++ b/test/NormalizedApi3ReaderProxyV1.sol.ts @@ -42,9 +42,24 @@ describe('NormalizedApi3ReaderProxyV1', function () { describe('constructor', function () { context('feed is not zero address', function () { - it('constructs', async function () { - const { feed, normalizedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); - expect(await normalizedApi3ReaderProxyV1.feed()).to.equal(await feed.getAddress()); + context('feed does not have 18 decimals', function () { + it('constructs', async function () { + const { feed, normalizedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + expect(await normalizedApi3ReaderProxyV1.feed()).to.equal(await feed.getAddress()); + }); + }); + context('feed has 18 decimals', function () { + it('reverts', async function () { + const { roles, mockAggregatorV2V3Factory } = await helpers.loadFixture(deploy); + const feed = await mockAggregatorV2V3Factory.deploy(18, ethers.parseEther('1'), await helpers.time.latest()); + const normalizedApi3ReaderProxyV1Factory = await ethers.getContractFactory( + 'NormalizedApi3ReaderProxyV1', + roles.deployer + ); + await expect(normalizedApi3ReaderProxyV1Factory.deploy(feed)) + .to.be.revertedWithCustomError(normalizedApi3ReaderProxyV1Factory, 'NoNormalizationNeeded') + .withArgs(); + }); }); }); context('feed is zero address', function () { diff --git a/test/adapters/ScaledApi3FeedProxyV1.sol.ts b/test/adapters/ScaledApi3FeedProxyV1.sol.ts index 944b09e..d0b8859 100644 --- a/test/adapters/ScaledApi3FeedProxyV1.sol.ts +++ b/test/adapters/ScaledApi3FeedProxyV1.sol.ts @@ -88,9 +88,20 @@ describe('ScaledApi3FeedProxyV1', function () { describe('constructor', function () { context('proxy is not zero address', function () { context('targetDecimals is not invalid', function () { - it('constructs', async function () { - const { api3ReaderProxyV1, scaledApi3FeedProxyV1 } = await helpers.loadFixture(deploy); - expect(await scaledApi3FeedProxyV1.proxy()).to.equal(await api3ReaderProxyV1.getAddress()); + context('targetDecimals is not 18', function () { + it('constructs', async function () { + const { api3ReaderProxyV1, scaledApi3FeedProxyV1 } = await helpers.loadFixture(deploy); + expect(await scaledApi3FeedProxyV1.proxy()).to.equal(await api3ReaderProxyV1.getAddress()); + }); + }); + context('targetDecimals is 18', function () { + it('reverts', async function () { + const { api3ReaderProxyV1, roles } = await helpers.loadFixture(deploy); + const scaledApi3FeedProxyV1 = await ethers.getContractFactory('ScaledApi3FeedProxyV1', roles.deployer); + await expect(scaledApi3FeedProxyV1.deploy(await api3ReaderProxyV1.getAddress(), 18)) + .to.be.revertedWithCustomError(scaledApi3FeedProxyV1, 'NoScalingNeeded') + .withArgs(); + }); }); }); context('targetDecimals is invalid', function () { From 76780c00859e36759f88e6e0f77014ec468dafd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santiago=20Ace=C3=B1olaza?= Date: Thu, 22 May 2025 10:22:33 -0300 Subject: [PATCH 3/3] Minor natspec comments improvements --- contracts/NormalizedApi3ReaderProxyV1.sol | 11 ++++++++++- contracts/adapters/ScaledApi3FeedProxyV1.sol | 15 ++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/contracts/NormalizedApi3ReaderProxyV1.sol b/contracts/NormalizedApi3ReaderProxyV1.sol index 59569c3..f211346 100644 --- a/contracts/NormalizedApi3ReaderProxyV1.sol +++ b/contracts/NormalizedApi3ReaderProxyV1.sol @@ -5,7 +5,7 @@ import "./interfaces/INormalizedApi3ReaderProxyV1.sol"; /// @title An immutable proxy contract that converts a Chainlink /// AggregatorV2V3Interface feed output to 18 decimals to conform with -/// IApi3ReaderProxy decimal standard +/// IApi3ReaderProxy decimal convention /// @dev This contract implements the AggregatorV2V3Interface to be compatible /// with Chainlink aggregators. This allows the contract to be used as a drop-in /// replacement for Chainlink aggregators in existing dApps. @@ -44,6 +44,15 @@ contract NormalizedApi3ReaderProxyV1 is INormalizedApi3ReaderProxyV1 { /// @notice Returns the price of the underlying Chainlink feed normalized to /// 18 decimals. + /// @dev Fetches an `int256` answer from the Chainlink feed and scales it + /// to 18 decimals using pre-calculated factors. The result is cast to + /// `int224` to conform to the `IApi3ReaderProxy` interface. + /// IMPORTANT: If the normalized `int256` value is outside the `int224` + /// range, this cast causes silent truncation and data loss. Deployers + /// must verify that the source feed's characteristics (value magnitude + /// and original decimals) ensure the 18-decimal normalized value fits + /// `int224`. Scaling arithmetic (prior to cast) reverts on `int256` + /// overflow. /// @return value The normalized signed fixed-point value with 18 decimals /// @return timestamp The updatedAt timestamp of the feed function read() diff --git a/contracts/adapters/ScaledApi3FeedProxyV1.sol b/contracts/adapters/ScaledApi3FeedProxyV1.sol index d5cc809..9d8a344 100644 --- a/contracts/adapters/ScaledApi3FeedProxyV1.sol +++ b/contracts/adapters/ScaledApi3FeedProxyV1.sol @@ -133,13 +133,14 @@ contract ScaledApi3FeedProxyV1 is IScaledApi3FeedProxyV1 { /// @notice Reads a value from the underlying `IApi3ReaderProxy` and /// scales it to `targetDecimals`. /// @dev Reads an `int224` value (assumed to be 18 decimals) from the - /// underlying `IApi3ReaderProxy` and scales it to `targetDecimals`. - /// The initial `int224` proxy value is widened to `int256` before scaling. - /// The scaling arithmetic (`value * factor` or `value / factor`) is then - /// performed using `int256` types. This allows the scaled result to exceed - /// the `int224` range, provided it fits within `int256`. - /// Arithmetic operations will revert on overflow or underflow - /// (e.g., if `value * scalingFactor` exceeds `type(int256).max`). + /// underlying `IApi3ReaderProxy`. This value is then scaled to + /// `targetDecimals` using pre-calculated factors. The scaling arithmetic + /// (e.g., `proxyValue * scalingFactor`) involves an `int224` (`proxyValue`) + /// and an `int256` (`scalingFactor`). `proxyValue` is implicitly promoted + /// to `int256` for this operation, resulting in an `int256` value. + /// This allows the scaled result to exceed the `int224` range, provided + /// it fits within `int256`. Arithmetic operations will revert on `int256` + /// overflow. The function returns the scaled value as an `int256`. /// @return value The scaled signed fixed-point value with `targetDecimals`. /// @return timestamp The timestamp from the underlying proxy. function _read() internal view returns (int256 value, uint32 timestamp) {