diff --git a/README.md b/README.md index 708d258..804cb64 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Data Feed Proxy Combinators provide modular and composable smart contracts for o - **`ScaledApi3FeedProxyV1`**: Reads from an underlying `IApi3ReaderProxy`, scales its value to a specified number of decimal places, and exposes the Chainlink `AggregatorV2V3Interface`. This makes an Api3 data feed, with adjusted precision, consumable by systems expecting a Chainlink-compatible interface with arbitrary decimals. - **`ProductApi3ReaderProxyV1`**: Takes two underlying `IApi3ReaderProxy` instances. Its `read()` method returns the product of their values, implementing the `IApi3ReaderProxy` interface. - **`NormalizedApi3ReaderProxyV1`**: Reads from an external data feed implementing the Chainlink-compatible `AggregatorV2V3Interface` and exposes the standard Api3 `IApi3ReaderProxy` interface. This allows dApps expecting an Api3 feed to consume data from other sources, useful for migration. +- **`PriceCappedApi3ReaderProxyV1`**: Wraps an `IApi3ReaderProxy` to enforce price bounds. If the underlying price goes below `lowerBound` or above `upperBound`, the respective bound is returned. Implements `IPriceCappedApi3ReaderProxyV1` (thus `IApi3ReaderProxy` and `AggregatorV2V3Interface`) and includes an `isCapped()` check. Ideal for risk management, like ensuring stablecoin prices remain within a defined range or limiting exposure to extreme volatility. These combinators either consume and expose the Api3 `IApi3ReaderProxy` interface or act as adapters to/from other interfaces like Chainlink's `AggregatorV2V3Interface`. This facilitates integration within the Api3 ecosystem or when bridging with other oracle systems. The output of one combinator can often serve as input for another, enabling complex data transformation pipelines. @@ -108,7 +109,7 @@ The `NETWORK` variable should be set to a chain name as defined by `@api3/contra - `FEED`: Address of the external data feed (e.g., a Chainlink `AggregatorV2V3Interface` compatible feed). - Example: ```bash - NETWORK=polygon-mainnet FEED=0xExternalFeedAddress pnpm deploy:NormalizedApi3ReaderProxyV1 + NETWORK=polygon FEED=0xExternalFeedAddress pnpm deploy:NormalizedApi3ReaderProxyV1 ``` - **`ProductApi3ReaderProxyV1`**: @@ -118,16 +119,44 @@ The `NETWORK` variable should be set to a chain name as defined by `@api3/contra - `PROXY2`: Address of the second `IApi3ReaderProxy` contract. - Example: ```bash - NETWORK=arbitrum-mainnet PROXY1=0xProxy1Address PROXY2=0xProxy2Address pnpm deploy:ProductApi3ReaderProxyV1 + NETWORK=arbitrum PROXY1=0xProxy1Address PROXY2=0xProxy2Address pnpm deploy:ProductApi3ReaderProxyV1 ``` - **`ScaledApi3FeedProxyV1`**: + - `NETWORK`: Target network name. - `PROXY`: Address of the underlying `IApi3ReaderProxy` contract. - `DECIMALS`: The desired number of decimals for the scaled output. - Example: ```bash - NETWORK=base-mainnet PROXY=0xUnderlyingProxyAddress DECIMALS=8 pnpm deploy:ScaledApi3FeedProxyV1 + NETWORK=base PROXY=0xUnderlyingProxyAddress DECIMALS=8 pnpm deploy:ScaledApi3FeedProxyV1 + ``` + +- **`PriceCappedApi3ReaderProxyV1`**: + + - `NETWORK`: Target network name. + - `PROXY`: Address of the underlying `IApi3ReaderProxy` contract. + - `LOWER_BOUND`: The minimum price (inclusive) this proxy will report, as a full integer string (e.g., `"990000000000000000"` for $0.99 with 18 decimals). **Optional: Defaults to `"0"` if not provided (effectively setting only an upper bound).** + - `UPPER_BOUND`: The maximum price (inclusive) this proxy will report, as a full integer string (e.g., `"1010000000000000000"` for $1.01 with 18 decimals). **Optional: Defaults to the maximum `int224` value (`(2**223 - 1)`) if not provided (effectively setting only a lower bound).** To configure a fixed price, set `UPPER_BOUND`to the same value as`LOWER_BOUND`. + - Example (for a stablecoin expected to be around $1.00, with 18 decimals, capped between $0.99 and $1.01): + ```bash + NETWORK=ethereum PROXY=0xUsdcUsdDapiAddress LOWER_BOUND="990000000000000000" UPPER_BOUND="1010000000000000000" pnpm deploy:PriceCappedApi3ReaderProxyV1 + ``` + - Example (upper cap only at $1.05 for an asset, 18 decimals): + ```bash + NETWORK=ethereum PROXY=0xAssetDapiAddress UPPER_BOUND="1050000000000000000" pnpm deploy:PriceCappedApi3ReaderProxyV1 # LOWER_BOUND defaults to "0" + ``` + - Example (fixed price at $1.00 for an asset, 18 decimals): + ```bash + NETWORK=ethereum PROXY=0xStablecoinDapiAddress LOWER_BOUND="1000000000000000000" UPPER_BOUND="1000000000000000000" pnpm deploy:PriceCappedApi3ReaderProxyV1 + ``` + - Example (lower cap only at $0.95 for an asset, 18 decimals): + ```bash + NETWORK=ethereum PROXY=0xAssetDapiAddress LOWER_BOUND="950000000000000000" pnpm deploy:PriceCappedApi3ReaderProxyV1 # UPPER_BOUND defaults to max int224 + ``` + - Example (no effective capping / pass-through, 18 decimals): + ```bash + NETWORK=ethereum PROXY=0xAssetDapiAddress pnpm deploy:PriceCappedApi3ReaderProxyV1 # LOWER_BOUND defaults to "0" (floors negative prices), UPPER_BOUND defaults to max int224 ``` _Note: The specific `pnpm deploy:` scripts for each combinator are defined in the `package.json` file._ diff --git a/contracts/InverseApi3ReaderProxyV1.sol b/contracts/InverseApi3ReaderProxyV1.sol index 59bb4db..18bdb68 100644 --- a/contracts/InverseApi3ReaderProxyV1.sol +++ b/contracts/InverseApi3ReaderProxyV1.sol @@ -29,7 +29,7 @@ contract InverseApi3ReaderProxyV1 is IInverseApi3ReaderProxyV1 { /// `baseValue` is so small (yet non-zero) that the resulting inverted value /// would overflow the `int224` type. /// @return value Inverted value of the underlying proxy - /// @return timestamp Timestamp from the underlying proxy + /// @return timestamp Timestamp of the underlying proxy function read() public view diff --git a/contracts/PriceCappedApi3ReaderProxyV1.sol b/contracts/PriceCappedApi3ReaderProxyV1.sol new file mode 100644 index 0000000..cddc4a2 --- /dev/null +++ b/contracts/PriceCappedApi3ReaderProxyV1.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@api3/contracts/interfaces/IApi3ReaderProxy.sol"; +import "./interfaces/IPriceCappedApi3ReaderProxyV1.sol"; + +/** + * @title An immutable proxy contract that provides a price bounding mechanism. + * It reads the price from the underlying Api3 proxy and if this price falls + * outside a predefined `lowerBound` and `upperBound`, this contract will report + * the respective bound instead. + * This is primarily intended for assets (e.g., stablecoins) where a protocol + * wants to limit the price range it ingests for risk management purposes. + * @dev `lowerBound` and `upperBound` are immutable and set during deployment. + * To set only an upper bound, `lowerBound_` can be set to 0. + * To set only a lower bound, `upperBound_` can be set to `type(int224).max`. + * To configure a fixed price, set `lowerBound_` and `upperBound_` to the + * same desired price. + * If `lowerBound_` is 0 and `upperBound_` is `type(int224).max`, no effective + * capping occurs, though negative prices from the underlying proxy would be + * floored at 0 if `lowerBound_` is 0. + */ +contract PriceCappedApi3ReaderProxyV1 is IPriceCappedApi3ReaderProxyV1 { + /// @notice IApi3ReaderProxy contract address + address public immutable override proxy; + + /// @notice The minimum price (inclusive) that this proxy will report. + int224 public immutable override lowerBound; + + /// @notice The maximum price (inclusive) that this proxy will report. + int224 public immutable override upperBound; + + /// @param proxy_ IApi3ReaderProxy contract address + /// @param lowerBound_ The minimum price (inclusive) this proxy will report + /// @param upperBound_ The maximum price (inclusive) this proxy will report + constructor(address proxy_, int224 lowerBound_, int224 upperBound_) { + if (proxy_ == address(0)) { + revert ZeroProxyAddress(); + } + if (lowerBound_ < 0) { + revert LowerBoundMustBeNonNegative(); + } + if (upperBound_ < lowerBound_) { + revert UpperBoundMustBeGreaterOrEqualToLowerBound(); + } + proxy = proxy_; + lowerBound = lowerBound_; + upperBound = upperBound_; + } + + /// @notice Reads the current value and timestamp from the underlying + /// `IApi3ReaderProxy` and applies the price bounds. + /// @dev If the `baseValue` from the underlying proxy is less than + /// `lowerBound`, then `lowerBound` is returned as the `value`. If + /// `baseValue` is greater than `upperBound`, then `upperBound` is returned. + /// Otherwise, the `baseValue` is returned. The timestamp is passed through + /// unmodified. + /// @return value Value of the underlying proxy, potentially bounded + /// @return timestamp Timestamp of the underlying proxy + function read() + public + view + override + returns (int224 value, uint32 timestamp) + { + (int224 baseValue, uint32 baseTimestamp) = IApi3ReaderProxy(proxy) + .read(); + + timestamp = baseTimestamp; + + if (baseValue < lowerBound) { + value = lowerBound; + } else if (baseValue > upperBound) { + value = upperBound; + } else { + value = baseValue; + } + } + + /// @notice Checks if the current price from the underlying proxy would be + /// capped or floored by the bounds. + /// @return True if the base value is less than `lowerBound` or greater + /// than `upperBound`, false otherwise. + function isCapped() external view returns (bool) { + (int224 baseValue, ) = IApi3ReaderProxy(proxy).read(); + return baseValue < lowerBound || baseValue > upperBound; + } + + /// @dev AggregatorV2V3Interface users are already responsible with + /// validating the values that they receive (e.g., revert if the spot price + /// of an asset is negative). Therefore, this contract omits validation. + function latestAnswer() external view override returns (int256 value) { + (value, ) = read(); + } + + /// @dev A Chainlink feed contract returns the block timestamp at which the + /// feed was last updated. On the other hand, an Api3 feed timestamp + /// denotes the point in time at which the first-party oracles signed the + /// data used to do the last update. We find this to be a reasonable + /// approximation, considering that usually the timestamp is only used to + /// check if the last update is stale. + function latestTimestamp() + external + view + override + returns (uint256 timestamp) + { + (, timestamp) = read(); + } + + /// @dev Api3 feeds are updated asynchronously and not in rounds + function latestRound() external pure override returns (uint256) { + revert FunctionIsNotSupported(); + } + + /// @dev Functions that use the round ID as an argument are not supported + function getAnswer(uint256) external pure override returns (int256) { + revert FunctionIsNotSupported(); + } + + /// @dev Functions that use the round ID as an argument are not supported + function getTimestamp(uint256) external pure override returns (uint256) { + revert FunctionIsNotSupported(); + } + + /// @dev Api3 feeds always use 18 decimals + function decimals() external pure override returns (uint8) { + return 18; + } + + /// @dev Underlying proxy dApp ID and dAPI name act as the description, and + /// this is left empty to save gas on contract deployment + function description() external pure override returns (string memory) { + return ""; + } + + /// @dev A unique version is chosen to easily check if an unverified + /// contract that acts as a Chainlink feed is a PriceCappedApi3ReaderProxyV1 + function version() external pure override returns (uint256) { + return 4918; + } + + /// @dev Functions that use the round ID as an argument are not supported + function getRoundData( + uint80 + ) + external + pure + override + returns (uint80, int256, uint256, uint256, uint80) + { + revert FunctionIsNotSupported(); + } + + /// @dev Rounds IDs are returned as `0` as invalid values. + /// Similar to `latestAnswer()`, we leave the validation of the returned + /// value to the caller. + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + roundId = answeredInRound = 0; + (answer, startedAt) = read(); + updatedAt = startedAt; + } +} diff --git a/contracts/interfaces/IPriceCappedApi3ReaderProxyV1.sol b/contracts/interfaces/IPriceCappedApi3ReaderProxyV1.sol new file mode 100644 index 0000000..7af5f58 --- /dev/null +++ b/contracts/interfaces/IPriceCappedApi3ReaderProxyV1.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import "@api3/contracts/interfaces/IApi3ReaderProxy.sol"; +import "../vendor/@chainlink/contracts@1.2.0/src/v0.8/shared/interfaces/AggregatorV2V3Interface.sol"; + +interface IPriceCappedApi3ReaderProxyV1 is + IApi3ReaderProxy, + AggregatorV2V3Interface +{ + error ZeroProxyAddress(); + + error LowerBoundMustBeNonNegative(); + + error UpperBoundMustBeGreaterOrEqualToLowerBound(); + + error FunctionIsNotSupported(); + + function proxy() external view returns (address proxy); + + function lowerBound() external view returns (int224 lowerBound); + + function upperBound() external view returns (int224 upperBound); + + function isCapped() external view returns (bool); +} diff --git a/deploy/005_deploy_PriceCappedApi3ReaderProxyV1.ts b/deploy/005_deploy_PriceCappedApi3ReaderProxyV1.ts new file mode 100644 index 0000000..7019adf --- /dev/null +++ b/deploy/005_deploy_PriceCappedApi3ReaderProxyV1.ts @@ -0,0 +1,64 @@ +import type { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { getDeploymentName } from '../src'; + +export const CONTRACT_NAME = 'PriceCappedApi3ReaderProxyV1'; + +module.exports = async (hre: HardhatRuntimeEnvironment) => { + const { getUnnamedAccounts, deployments, ethers, network, run } = hre; + const { deploy, log } = deployments; + + const [deployerAddress] = await getUnnamedAccounts(); + if (!deployerAddress) { + throw new Error('No deployer address found.'); + } + log(`Deployer address: ${deployerAddress}`); + + const proxyAddress = process.env.PROXY; + if (!proxyAddress) { + throw new Error('PROXY environment variable not set. Please provide the address of the proxy contract.'); + } + if (!ethers.isAddress(proxyAddress)) { + throw new Error(`Invalid address provided for PROXY: ${proxyAddress}`); + } + log(`Proxy address: ${proxyAddress}`); + + const lowerBound = process.env.LOWER_BOUND ? BigInt(process.env.LOWER_BOUND) : 0n; // Defaults to 0 + log(`Using lower bound: ${lowerBound.toString()}`); + + const upperBound = process.env.UPPER_BOUND ? BigInt(process.env.UPPER_BOUND) : BigInt(2) ** BigInt(223) - BigInt(1); // Defaults to type(int224).max + log(`Using upper bound: ${upperBound.toString()}`); + + const isLocalNetwork = network.name === 'hardhat' || network.name === 'localhost'; + + const confirmations = isLocalNetwork ? 1 : 5; + log(`Deployment confirmations: ${confirmations}`); + + const constructorArgs = [proxyAddress, lowerBound, upperBound]; + const constructorArgTypes = ['address', 'int224', 'int224']; + + const deploymentName = getDeploymentName(CONTRACT_NAME, constructorArgTypes, constructorArgs); + log(`Generated deterministic deployment name for this instance: ${deploymentName}`); + + const deployment = await deploy(deploymentName, { + contract: CONTRACT_NAME, + from: deployerAddress, + args: constructorArgs, + log: true, + waitConfirmations: confirmations, + }); + + if (isLocalNetwork) { + log('Skipping verification on local network.'); + return; + } + + log( + `Attempting verification of ${deploymentName} (contract type ${CONTRACT_NAME}) at ${deployment.address} (already waited for confirmations)...` + ); + await run('verify:verify', { + address: deployment.address, + constructorArguments: deployment.args, + }); +}; +module.exports.tags = [CONTRACT_NAME]; diff --git a/package.json b/package.json index 187b853..3be31b4 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "deploy:InverseApi3ReaderProxyV1": "hardhat deploy --network $NETWORK --tags InverseApi3ReaderProxyV1", "deploy:NormalizedApi3ReaderProxyV1": "hardhat deploy --network $NETWORK --tags NormalizedApi3ReaderProxyV1", "deploy:ProductApi3ReaderProxyV1": "hardhat deploy --network $NETWORK --tags ProductApi3ReaderProxyV1", + "deploy:PriceCappedApi3ReaderProxyV1": "hardhat deploy --network $NETWORK --tags PriceCappedApi3ReaderProxyV1", "deploy:ScaledApi3FeedProxyV1": "hardhat deploy --network $NETWORK --tags ScaledApi3FeedProxyV1", "lint": "pnpm run prettier:check && pnpm run lint:eslint && pnpm run lint:solhint", "lint:solhint": "solhint ./contracts/**/*.sol", diff --git a/test/PriceCappedApi3ReaderProxyV1.sol.ts b/test/PriceCappedApi3ReaderProxyV1.sol.ts new file mode 100644 index 0000000..7f0e8eb --- /dev/null +++ b/test/PriceCappedApi3ReaderProxyV1.sol.ts @@ -0,0 +1,277 @@ +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import * as helpers from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { ethers } from 'hardhat'; + +import * as testUtils from './test-utils'; + +describe('PriceCappedApi3ReaderProxyV1', function () { + async function deploy() { + const roleNames = ['deployer', 'manager', 'airnode', 'auctioneer', 'searcher']; + const accounts = await ethers.getSigners(); + const roles: Record = roleNames.reduce((acc, roleName, index) => { + return { ...acc, [roleName]: accounts[index] }; + }, {}); + + const accessControlRegistryFactory = await ethers.getContractFactory('AccessControlRegistry', roles.deployer); + const accessControlRegistry = await accessControlRegistryFactory.deploy(); + + const api3ServerV1Factory = await ethers.getContractFactory('Api3ServerV1', roles.deployer); + const api3ServerV1 = await api3ServerV1Factory.deploy( + accessControlRegistry.getAddress(), + 'Api3ServerV1 admin', + roles.manager!.address + ); + + const api3ServerV1OevExtensionAdminRoleDescription = 'Api3ServerV1OevExtension admin'; + const api3ServerV1OevExtensionFactory = await ethers.getContractFactory('Api3ServerV1OevExtension', roles.deployer); + const api3ServerV1OevExtension = await api3ServerV1OevExtensionFactory.deploy( + accessControlRegistry.getAddress(), + api3ServerV1OevExtensionAdminRoleDescription, + roles.manager!.address, + api3ServerV1.getAddress() + ); + + const dapiName = ethers.encodeBytes32String('DAI/USD'); + const dappId = 1; + + const api3ReaderProxyV1Factory = await ethers.getContractFactory('Api3ReaderProxyV1', roles.deployer); + const api3ReaderProxyV1 = await api3ReaderProxyV1Factory.deploy( + api3ServerV1OevExtension.getAddress(), + dapiName, + dappId + ); + + const endpointId = testUtils.generateRandomBytes32(); + const templateParameters = testUtils.generateRandomBytes(); + const templateId = ethers.keccak256(ethers.solidityPacked(['bytes32', 'bytes'], [endpointId, templateParameters])); + const beaconId = ethers.keccak256( + ethers.solidityPacked(['address', 'bytes32'], [roles.airnode!.address, templateId]) + ); + await api3ServerV1.connect(roles.manager).setDapiName(dapiName, beaconId); + + const baseBeaconValue = ethers.parseEther('1.0001'); + const baseBeaconTimestamp = await helpers.time.latest(); + const data = ethers.AbiCoder.defaultAbiCoder().encode(['int256'], [baseBeaconValue]); + const signature = await testUtils.signData(roles.airnode! as any, templateId, baseBeaconTimestamp, data); + await api3ServerV1.updateBeaconWithSignedData( + roles.airnode!.address, + templateId, + baseBeaconTimestamp, + data, + signature + ); + + const lowerBound = ethers.parseEther('0.9995'); + const upperBound = ethers.parseEther('1.0005'); + + const priceCappedApi3ReaderProxyV1Factory = await ethers.getContractFactory( + 'PriceCappedApi3ReaderProxyV1', + roles.deployer + ); + const priceCappedApi3ReaderProxyV1 = await priceCappedApi3ReaderProxyV1Factory.deploy( + await api3ReaderProxyV1.getAddress(), + lowerBound, + upperBound + ); + + return { + api3ServerV1, + api3ReaderProxyV1, + priceCappedApi3ReaderProxyV1, + lowerBound, + upperBound, + templateId, + roles, + }; + } + + describe('constructor', function () { + context('proxy is not zero address', function () { + context('lowerBound is not negative', function () { + context('upperBound is greater or equal to lowerBound', function () { + it('constructs', async function () { + const { api3ReaderProxyV1, priceCappedApi3ReaderProxyV1, lowerBound, upperBound } = + await helpers.loadFixture(deploy); + expect(await priceCappedApi3ReaderProxyV1.proxy()).to.equal(await api3ReaderProxyV1.getAddress()); + expect(await priceCappedApi3ReaderProxyV1.lowerBound()).to.equal(lowerBound); + expect(await priceCappedApi3ReaderProxyV1.upperBound()).to.equal(upperBound); + }); + }); + context('upperBound is less than lowerBound', function () { + it('reverts', async function () { + const { api3ReaderProxyV1, lowerBound, upperBound, roles } = await helpers.loadFixture(deploy); + const priceCappedApi3ReaderProxyV1 = await ethers.getContractFactory( + 'PriceCappedApi3ReaderProxyV1', + roles.deployer + ); + await expect(priceCappedApi3ReaderProxyV1.deploy(api3ReaderProxyV1, upperBound, lowerBound)) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'UpperBoundMustBeGreaterOrEqualToLowerBound') + .withArgs(); + }); + }); + }); + context('lowerBound is negative', function () { + it('reverts', async function () { + const { api3ReaderProxyV1, upperBound, roles } = await helpers.loadFixture(deploy); + const priceCappedApi3ReaderProxyV1 = await ethers.getContractFactory( + 'PriceCappedApi3ReaderProxyV1', + roles.deployer + ); + await expect(priceCappedApi3ReaderProxyV1.deploy(api3ReaderProxyV1, ethers.parseEther('-0.9995'), upperBound)) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'LowerBoundMustBeNonNegative') + .withArgs(); + }); + }); + }); + context('proxy is zero address', function () { + it('reverts', async function () { + const { roles, lowerBound, upperBound } = await helpers.loadFixture(deploy); + const priceCappedApi3ReaderProxyV1 = await ethers.getContractFactory( + 'PriceCappedApi3ReaderProxyV1', + roles.deployer + ); + await expect(priceCappedApi3ReaderProxyV1.deploy(ethers.ZeroAddress, lowerBound, upperBound)) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'ZeroProxyAddress') + .withArgs(); + }); + }); + }); + + describe('read', function () { + it('reads the capped rate', async function () { + const { + api3ServerV1, + api3ReaderProxyV1, + priceCappedApi3ReaderProxyV1, + templateId, + lowerBound, + upperBound, + roles, + } = await helpers.loadFixture(deploy); + const dataFeed = await priceCappedApi3ReaderProxyV1.read(); + + const [value, timestamp] = await api3ReaderProxyV1.read(); + expect(dataFeed.value).to.equal(value); + expect(dataFeed.timestamp).to.equal(timestamp); + + let data = ethers.AbiCoder.defaultAbiCoder().encode(['int256'], [ethers.parseEther('0.9991')]); + let beaconTimestamp = await helpers.time.latest(); + let signature = await testUtils.signData(roles.airnode! as any, templateId, beaconTimestamp, data); + await api3ServerV1.updateBeaconWithSignedData( + roles.airnode!.address, + templateId, + beaconTimestamp, + data, + signature + ); + const cappedToLowerBoundDataFeed = await priceCappedApi3ReaderProxyV1.read(); + expect(cappedToLowerBoundDataFeed.value).to.equal(lowerBound); + expect(cappedToLowerBoundDataFeed.timestamp).to.equal(beaconTimestamp); + + data = ethers.AbiCoder.defaultAbiCoder().encode(['int256'], [ethers.parseEther('1.0006')]); + beaconTimestamp = await helpers.time.latest(); + signature = await testUtils.signData(roles.airnode! as any, templateId, beaconTimestamp, data); + await api3ServerV1.updateBeaconWithSignedData( + roles.airnode!.address, + templateId, + beaconTimestamp, + data, + signature + ); + const cappedToUpperBoundDataFeed = await priceCappedApi3ReaderProxyV1.read(); + expect(cappedToUpperBoundDataFeed.value).to.equal(upperBound); + expect(cappedToUpperBoundDataFeed.timestamp).to.equal(beaconTimestamp); + }); + }); + + describe('latestAnswer', function () { + it('returns proxy value', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + const [value] = await priceCappedApi3ReaderProxyV1.read(); + expect(await priceCappedApi3ReaderProxyV1.latestAnswer()).to.be.equal(value); + }); + }); + + describe('latestTimestamp', function () { + it('returns proxy value', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + const [, timestamp] = await priceCappedApi3ReaderProxyV1.read(); + expect(await priceCappedApi3ReaderProxyV1.latestTimestamp()).to.be.equal(timestamp); + }); + }); + + describe('latestRound', function () { + it('reverts', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + await expect(priceCappedApi3ReaderProxyV1.latestRound()) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'FunctionIsNotSupported') + .withArgs(); + }); + }); + + describe('getAnswer', function () { + it('reverts', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + const blockNumber = await ethers.provider.getBlockNumber(); + await expect(priceCappedApi3ReaderProxyV1.getAnswer(blockNumber)) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'FunctionIsNotSupported') + .withArgs(); + }); + }); + + describe('getTimestamp', function () { + it('reverts', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + const blockNumber = await ethers.provider.getBlockNumber(); + await expect(priceCappedApi3ReaderProxyV1.getTimestamp(blockNumber)) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'FunctionIsNotSupported') + .withArgs(); + }); + }); + + describe('decimals', function () { + it('returns 18', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + expect(await priceCappedApi3ReaderProxyV1.decimals()).to.equal(18); + }); + }); + + describe('description', function () { + it('returns empty string', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + expect(await priceCappedApi3ReaderProxyV1.description()).to.equal(''); + }); + }); + + describe('version', function () { + it('returns 4915', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + expect(await priceCappedApi3ReaderProxyV1.version()).to.equal(4918); + }); + }); + + describe('getRoundData', function () { + it('reverts', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + const blockNumber = await ethers.provider.getBlockNumber(); + await expect(priceCappedApi3ReaderProxyV1.getRoundData(blockNumber)) + .to.be.revertedWithCustomError(priceCappedApi3ReaderProxyV1, 'FunctionIsNotSupported') + .withArgs(); + }); + }); + + describe('latestRoundData', function () { + it('returns approximated round data', async function () { + const { priceCappedApi3ReaderProxyV1 } = await helpers.loadFixture(deploy); + const [value, timestamp] = await priceCappedApi3ReaderProxyV1.read(); + const [roundId, answer, startedAt, updatedAt, answeredInRound] = + await priceCappedApi3ReaderProxyV1.latestRoundData(); + expect(roundId).to.equal(0); + expect(answer).to.equal(value); + expect(startedAt).to.equal(timestamp); + expect(updatedAt).to.equal(timestamp); + expect(answeredInRound).to.equal(0); + }); + }); +});