Skip to content
Open
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
69 changes: 69 additions & 0 deletions scripts/SinvUSDFeedDeploy.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Script, console2} from "forge-std/Script.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {ChainlinkCurveFeed} from "src/feeds/ChainlinkCurveFeed.sol";
import {ClampedPriceFeed} from "src/feeds/ClampedPriceFeed.sol";
import {ERC4626Feed} from "src/feeds/ERC4626Feed.sol";
import {ICurvePool} from "src/interfaces/ICurvePool.sol";

contract SinvUSDFeedDeploy is Script {
error UnderlyingMismatch(address poolCoin, address vaultAsset);
error InvalidPrice(address feed, int256 price);

address internal constant CURVE_POOL = 0xe430e64081a3E7a39D24c5f507D9d4b492b2ED52;
address internal constant SINVUSD_VAULT = 0x3FF361197036Ae1d24B939146D8a449A80F8427d;
address internal constant DOLA_USD_FEED = 0xE33592594f72Cc7ec8a05788be8E8455746C3a32;

int256 internal constant DOLA_USD_CLAMP_PRICE = 1e18;
uint256 internal constant INVUSD_ORACLE_INDEX = 0;
uint256 internal constant INVUSD_TARGET_INDEX = 1;

function run()
external
returns (ClampedPriceFeed dolaUsdFeed, ChainlinkCurveFeed invUsdFeed, ERC4626Feed sinvUsdFeed)
{
vm.createSelectFork(vm.envString("RPC_MAINNET"));

address poolCoin = ICurvePool(CURVE_POOL).coins(INVUSD_TARGET_INDEX);
address vaultAsset = IERC4626(SINVUSD_VAULT).asset();
if (poolCoin != vaultAsset) {
revert UnderlyingMismatch(poolCoin, vaultAsset);
}

uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);

dolaUsdFeed = new ClampedPriceFeed(DOLA_USD_FEED, DOLA_USD_CLAMP_PRICE);
invUsdFeed = new ChainlinkCurveFeed(address(dolaUsdFeed), CURVE_POOL, INVUSD_ORACLE_INDEX, INVUSD_TARGET_INDEX);
sinvUsdFeed = new ERC4626Feed(SINVUSD_VAULT, address(invUsdFeed));

vm.stopBroadcast();

int256 dolaUsdPrice = dolaUsdFeed.latestAnswer();
if (dolaUsdPrice <= 0) {
revert InvalidPrice(address(dolaUsdFeed), dolaUsdPrice);
}

int256 invUsdPrice = invUsdFeed.latestAnswer();
if (invUsdPrice <= 0) {
revert InvalidPrice(address(invUsdFeed), invUsdPrice);
}

int256 sinvUsdPrice = sinvUsdFeed.latestAnswer();
if (sinvUsdPrice <= 0) {
revert InvalidPrice(address(sinvUsdFeed), sinvUsdPrice);
}

console2.log("Clamped DOLA/USD feed deployed to", address(dolaUsdFeed));
console2.log("invUSD feed deployed to", address(invUsdFeed));
console2.log("sinvUSD feed deployed to", address(sinvUsdFeed));
console2.log("DOLA/USD answer:");
console2.logInt(dolaUsdPrice);
console2.log("invUSD/USD answer:");
console2.logInt(invUsdPrice);
console2.log("sinvUSD/USD answer:");
console2.logInt(sinvUsdPrice);
}
}
52 changes: 52 additions & 0 deletions src/feeds/ClampedPriceFeed.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IChainlinkFeed} from "src/interfaces/IChainlinkFeed.sol";

/// @notice Normalizes a Chainlink-compatible feed to 18 decimals and applies an immutable price ceiling.
contract ClampedPriceFeed {
error ZeroAddress();
error InvalidClampPrice(int256 clampPrice);
error UnsupportedFeedDecimals(uint8 decimals);

IChainlinkFeed public immutable feed;
uint8 public immutable feedDecimals;
uint256 public immutable scale;
int256 public immutable clampPrice;

string public description;

constructor(address _feed, int256 _clampPrice) {
if (_feed == address(0)) revert ZeroAddress();
if (_clampPrice <= 0) revert InvalidClampPrice(_clampPrice);

feed = IChainlinkFeed(_feed);
feedDecimals = feed.decimals();
if (feedDecimals > 18) {
revert UnsupportedFeedDecimals(feedDecimals);
}

scale = 10 ** (18 - feedDecimals);
clampPrice = _clampPrice;
description = string.concat("Clamped ", feed.description());
}

function latestRoundData()
public
view
returns (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
(roundId, price, startedAt, updatedAt, answeredInRound) = feed.latestRoundData();
price *= int256(scale);
if (price > clampPrice) price = clampPrice;
}

function latestAnswer() external view returns (int256) {
(, int256 price,,,) = latestRoundData();
return price;
}

function decimals() external pure returns (uint8) {
return 18;
}
}
94 changes: 94 additions & 0 deletions test/ClampedPriceFeed.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Test} from "forge-std/Test.sol";
import {ClampedPriceFeed} from "src/feeds/ClampedPriceFeed.sol";
import {MockFeedDescription} from "test/mocks/MockFeedDescription.sol";

contract ClampedPriceFeedTest is Test {
int256 internal constant CLAMP_PRICE = 1e18;

MockFeedDescription internal sourceFeed;
ClampedPriceFeed internal clampedFeed;

function setUp() public {
sourceFeed = new MockFeedDescription(8, 99_607_083, "DOLA / USD");
clampedFeed = new ClampedPriceFeed(address(sourceFeed), CLAMP_PRICE);
}

function test_constructor() public view {
assertEq(address(clampedFeed.feed()), address(sourceFeed));
assertEq(clampedFeed.feedDecimals(), 8);
assertEq(clampedFeed.scale(), 1e10);
assertEq(clampedFeed.clampPrice(), CLAMP_PRICE);
assertEq(clampedFeed.decimals(), 18);
assertEq(clampedFeed.description(), "Clamped DOLA / USD");
}

function test_latestRoundData_normalizesPriceBelowClampAndPreservesMetadata() public {
vm.warp(2 hours);
uint256 updatedAt = block.timestamp - 1 hours;
sourceFeed.changeUpdatedAt(updatedAt);

(uint80 roundId, int256 price, uint256 startedAt, uint256 returnedUpdatedAt, uint80 answeredInRound) =
clampedFeed.latestRoundData();

assertEq(roundId, 0);
assertEq(price, 996_070_830_000_000_000);
assertEq(startedAt, 0);
assertEq(returnedUpdatedAt, updatedAt);
assertEq(answeredInRound, 0);
}

function test_latestRoundData_clampsPriceAboveClamp() public {
sourceFeed.changeAnswer(101_000_000);

(, int256 price,,,) = clampedFeed.latestRoundData();

assertEq(price, CLAMP_PRICE);
}

function test_latestRoundData_preservesPriceEqualToClamp() public {
sourceFeed.changeAnswer(100_000_000);

(, int256 price,,,) = clampedFeed.latestRoundData();

assertEq(price, CLAMP_PRICE);
}

function test_latestRoundData_preservesNegativePrice() public {
MockFeedDescription negativeSourceFeed = new MockFeedDescription(8, -1, "DOLA / USD");
ClampedPriceFeed negativeClampedFeed = new ClampedPriceFeed(address(negativeSourceFeed), CLAMP_PRICE);

(, int256 price,,,) = negativeClampedFeed.latestRoundData();

assertEq(price, -1e10);
}

function test_latestAnswer_matchesLatestRoundData() public view {
(, int256 price,,,) = clampedFeed.latestRoundData();
assertEq(clampedFeed.latestAnswer(), price);
}

function test_constructor_revertsForZeroAddress() public {
vm.expectRevert(ClampedPriceFeed.ZeroAddress.selector);
new ClampedPriceFeed(address(0), CLAMP_PRICE);
}

function test_constructor_revertsForZeroClampPrice() public {
vm.expectRevert(abi.encodeWithSelector(ClampedPriceFeed.InvalidClampPrice.selector, 0));
new ClampedPriceFeed(address(sourceFeed), 0);
}

function test_constructor_revertsForNegativeClampPrice() public {
vm.expectRevert(abi.encodeWithSelector(ClampedPriceFeed.InvalidClampPrice.selector, -1));
new ClampedPriceFeed(address(sourceFeed), -1);
}

function test_constructor_revertsForMoreThan18Decimals() public {
MockFeedDescription unsupportedFeed = new MockFeedDescription(19, 1, "Unsupported");

vm.expectRevert(abi.encodeWithSelector(ClampedPriceFeed.UnsupportedFeedDecimals.selector, 19));
new ClampedPriceFeed(address(unsupportedFeed), CLAMP_PRICE);
}
}
Loading