diff --git a/scripts/SinvUSDFeedDeploy.s.sol b/scripts/SinvUSDFeedDeploy.s.sol new file mode 100644 index 00000000..b492fc8d --- /dev/null +++ b/scripts/SinvUSDFeedDeploy.s.sol @@ -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); + } +} diff --git a/src/feeds/ClampedPriceFeed.sol b/src/feeds/ClampedPriceFeed.sol new file mode 100644 index 00000000..4328814d --- /dev/null +++ b/src/feeds/ClampedPriceFeed.sol @@ -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; + } +} diff --git a/test/ClampedPriceFeed.t.sol b/test/ClampedPriceFeed.t.sol new file mode 100644 index 00000000..c31df407 --- /dev/null +++ b/test/ClampedPriceFeed.t.sol @@ -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); + } +}