Skip to content

Adds PriceCappedApi3ReaderProxyV1 #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`**:
Expand All @@ -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:<ContractName>` scripts for each combinator are defined in the `package.json` file._
Expand Down
2 changes: 1 addition & 1 deletion contracts/InverseApi3ReaderProxyV1.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
174 changes: 174 additions & 0 deletions contracts/PriceCappedApi3ReaderProxyV1.sol
Original file line number Diff line number Diff line change
@@ -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_) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up not checking the bounds against the current proxy value for 2 reasons:

  1. we are already trusting the deployer to provide a valid proxy value
  2. the proxy value might be outside of the lower/upper range and we still want to deploy to be able to apply the cap and return the either of bounds values.

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;
}
}
26 changes: 26 additions & 0 deletions contracts/interfaces/IPriceCappedApi3ReaderProxyV1.sol
Original file line number Diff line number Diff line change
@@ -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);
}
64 changes: 64 additions & 0 deletions deploy/005_deploy_PriceCappedApi3ReaderProxyV1.ts
Original file line number Diff line number Diff line change
@@ -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];
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading