diff --git a/script/honey/HoneyAddresses.sol b/script/honey/HoneyAddresses.sol index 742000b..45d838d 100644 --- a/script/honey/HoneyAddresses.sol +++ b/script/honey/HoneyAddresses.sol @@ -38,7 +38,7 @@ abstract contract HoneyAddressBook { honeyImpl: 0x96b1a552A97dA5503343d0F9FF2766c616E62905, honeyFactory: 0xA4aFef880F5cE1f63c9fb48F661E27F8B4216401, honeyFactoryReader: 0x285e147060CDc5ba902786d3A471224ee6cE0F91, - honeyFactoryImpl: 0x6331F0a4E0220a14Be27BD31aF091F0a1AC036A1, + honeyFactoryImpl: 0xB587F98c81c37432067060196aC1DdAEC106ED8D, honeyFactoryReaderImpl: 0x91C54526A9f8D0391F64392f24C7E8ff94A5f4fB, collateralVaultImpl: 0xAa4f2Bc7a06c89BEAB5125D82e25D4166b4a4681, honeyFactoryPythWrapper: 0xF5686e448BE103beA465105bEb9d284a34ae7e95 @@ -54,7 +54,7 @@ abstract contract HoneyAddressBook { honeyImpl: 0xD1886E0659Ed88812aeA75862Cc9891097c25542, honeyFactory: 0xA4aFef880F5cE1f63c9fb48F661E27F8B4216401, honeyFactoryReader: 0x285e147060CDc5ba902786d3A471224ee6cE0F91, - honeyFactoryImpl: 0xD38a1fD3E943a61066903889b8e0889EcAc6Dedd, + honeyFactoryImpl: 0xF44F412c1CC4e92aE657aF93740De86048A06b95, honeyFactoryReaderImpl: 0x22ee76216B1b7E4f34CF1417da3E4773F7cbA8E6, collateralVaultImpl: 0xE3689043e7F860FbC0c814839cd7dF5022223172, honeyFactoryPythWrapper: 0xE5Ad9BA751714ec8cdd554b5a9f12BFcA13980cB @@ -68,7 +68,7 @@ abstract contract HoneyAddressBook { honeyImpl: 0x4D4Ff71C50D47a5A45682F42E1E96bdED8fE7516, honeyFactory: 0x2AA7F988284fD04cE83b27d017B89731c67d8F67, honeyFactoryReader: 0xf1CF3467C9508dfa6D1197F5359419856B3A3300, - honeyFactoryImpl: 0xd6E9728eefa526F9B6Cf3c9ba5CbC060F937cDb0, + honeyFactoryImpl: 0xFc5d7EDd77f0b2386f5F960D6868D9022E6603d6, honeyFactoryReaderImpl: 0x52d5848Ab7A304369cd2879DAfd9Ed3349E8ebB1, collateralVaultImpl: 0x5DeDB0F5587F83798245a53189c1A52437A52475, honeyFactoryPythWrapper: 0xA55F4a5D0415bFAaaE8207F77Ab2dc406e7594A2 @@ -82,7 +82,7 @@ abstract contract HoneyAddressBook { honeyImpl: 0x36a10E516452BD2A80Bd5F421B0bC69eB0dFBca9, honeyFactory: 0xeBF958b3b453f76fBE491a7F3ED29e37a509F530, honeyFactoryReader: 0x353F8910914a46b3b971d54A131184fB46B8d7f8, - honeyFactoryImpl: 0x6178832DAC009EDA2e1D97f158cd94545F49c289, + honeyFactoryImpl: 0x22F160db4f4a5C17E985430b25de9B57609D31F1, honeyFactoryReaderImpl: 0x1eD95AF653E0a540fC61AD8d1C7D2C5271855fD5, collateralVaultImpl: 0x149C89732A9e83FDf20CA4AB03A94C3b4eb21C46, honeyFactoryPythWrapper: 0x0E870Ae0ecff7036A7d34F9c571794C4ce1C3d62 diff --git a/script/honey/actions/AddCollateral.s.sol b/script/honey/actions/AddCollateral.s.sol index 22380a3..bc063ac 100644 --- a/script/honey/actions/AddCollateral.s.sol +++ b/script/honey/actions/AddCollateral.s.sol @@ -39,12 +39,9 @@ contract AddCollateralVaultScript is BaseScript, AddressBook { console2.log("Adding collateral %s", IERC20(collateral).symbol()); - // NOTE: the price oracle must have freshly pushed data, otherwise - // the honey factory will consider the asset as depegged. IPriceOracle priceOracle = IPriceOracle(honeyFactory.priceOracle()); - IPriceOracle.Data memory data = priceOracle.getPriceUnsafe(collateral); - require(data.publishTime >= block.timestamp - honeyFactory.priceFeedMaxDelay(), "Price data too old"); - + // check the price is available + priceOracle.priceAvailable(collateral); ERC4626 vault = honeyFactory.createVault(collateral); console2.log("Collateral Vault deployed at:", address(vault)); // Set mint rate to 1:1 diff --git a/script/oracles/OraclesAddresses.sol b/script/oracles/OraclesAddresses.sol index 2321add..9c97c21 100644 --- a/script/oracles/OraclesAddresses.sol +++ b/script/oracles/OraclesAddresses.sol @@ -8,6 +8,7 @@ struct OraclesAddresses { address peggedPriceOracle; address rootPriceOracle; address extPyth; // Third-party dependencies + address redStonePriceOracle; } abstract contract OraclesAddressBook { @@ -30,7 +31,8 @@ abstract contract OraclesAddressBook { pythPriceOracle: 0x5CA67e134c52B1d11E038A5a4eD8Ddcdb1238943, peggedPriceOracle: 0xE72FA7893ec375D82a0ff3078920C39D87F8FC2D, rootPriceOracle: 0xe641aacDf2055F0D20c9ABc8FeF9dFBc5A68600B, - extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43 + extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43, + redStonePriceOracle: 0x28f8171A2561898Cfc9C355a6e20F1B40FeA19CC }); } @@ -39,7 +41,8 @@ abstract contract OraclesAddressBook { pythPriceOracle: 0x5CA67e134c52B1d11E038A5a4eD8Ddcdb1238943, peggedPriceOracle: 0xE72FA7893ec375D82a0ff3078920C39D87F8FC2D, rootPriceOracle: 0xe641aacDf2055F0D20c9ABc8FeF9dFBc5A68600B, - extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43 + extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43, + redStonePriceOracle: 0x678d63074D8dd2eC4C2d7269879e43eaD4BE1338 }); } @@ -48,7 +51,8 @@ abstract contract OraclesAddressBook { pythPriceOracle: 0x8d6864Da39C9Cd4454709Ee55fD383fa2e72200A, peggedPriceOracle: 0x969FA953E1554237357571a2Cf04dF717d8c9ca5, rootPriceOracle: 0x99F8Ff2f48001FaE858C809b61599013F6f5a3d1, - extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43 + extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43, + redStonePriceOracle: 0x5DCC24061c3ccB9A9aA6f16A143bC34bAC2B0C04 }); } @@ -57,7 +61,8 @@ abstract contract OraclesAddressBook { pythPriceOracle: 0x4D7c72253b91B083f44ac0415f2D776861258025, peggedPriceOracle: 0x0811E041eF374591A01cb49E5030D24b4911287e, rootPriceOracle: 0x1EE6c86aeC1307Ef7D1c5144e1A2830BF5bD81Ae, - extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43 + extPyth: 0x2880aB155794e7179c9eE2e38200202908C17B43, + redStonePriceOracle: 0x392282F6241bAce724a6bD493676a2c634EaA3DB }); } } diff --git a/script/oracles/OraclesPredictAddresses.s.sol b/script/oracles/OraclesPredictAddresses.s.sol index 7824a3c..183f6d0 100644 --- a/script/oracles/OraclesPredictAddresses.s.sol +++ b/script/oracles/OraclesPredictAddresses.s.sol @@ -5,6 +5,7 @@ import { BasePredictScript, console2 } from "../base/BasePredict.s.sol"; import { PythPriceOracle } from "src/extras/PythPriceOracle.sol"; import { PeggedPriceOracle } from "src/extras/PeggedPriceOracle.sol"; import { RootPriceOracle } from "src/extras/RootPriceOracle.sol"; +import { RedStonePriceOracle } from "src/extras/RedStonePriceOracle.sol"; contract OraclesPredictAddressesScript is BasePredictScript { function run() public view { @@ -12,5 +13,6 @@ contract OraclesPredictAddressesScript is BasePredictScript { _predictProxyAddress("PythPriceOracle", type(PythPriceOracle).creationCode); _predictAddress("PeggedPriceOracle", type(PeggedPriceOracle).creationCode); _predictAddress("RootPriceOracle", type(RootPriceOracle).creationCode); + _predictProxyAddress("RedStonePriceOracle", type(RedStonePriceOracle).creationCode); } } diff --git a/script/oracles/deployment/5_DeployRedStonePriceOracle.s.sol b/script/oracles/deployment/5_DeployRedStonePriceOracle.s.sol new file mode 100644 index 0000000..f0a16b6 --- /dev/null +++ b/script/oracles/deployment/5_DeployRedStonePriceOracle.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { console2 } from "forge-std/Script.sol"; +import { BaseScript } from "../../base/Base.s.sol"; +import { RBAC } from "../../base/RBAC.sol"; +import { RedStonePriceOracleDeployer } from "src/extras/RedStonePriceOracleDeployer.sol"; +import { RedStonePriceOracle } from "src/extras/RedStonePriceOracle.sol"; +import { AddressBook } from "../../base/AddressBook.sol"; + +contract DeployRedStonePriceOracleScript is RBAC, BaseScript, AddressBook { + function run() public pure { + console2.log("Please run specific function."); + } + + function deployRedStonePriceOracle(address redstonePriceFeedAdapter, address governance) public broadcast { + RedStonePriceOracleDeployer oracleDeployer = new RedStonePriceOracleDeployer( + governance, redstonePriceFeedAdapter, _saltsForProxy(type(RedStonePriceOracle).creationCode) + ); + + RedStonePriceOracle redStonePriceOracle = RedStonePriceOracle(oracleDeployer.oracle()); + _checkDeploymentAddress( + "RedStonePriceOracle", address(redStonePriceOracle), _oraclesAddresses.redStonePriceOracle + ); + + RBAC.RoleDescription memory adminRole = RBAC.RoleDescription({ + contractName: "RedStonePriceOracle", + contractAddr: _oraclesAddresses.redStonePriceOracle, + name: "DEFAULT_ADMIN_ROLE", + role: redStonePriceOracle.DEFAULT_ADMIN_ROLE() + }); + + RBAC.AccountDescription memory deployer = RBAC.AccountDescription({ name: "deployer", addr: governance }); + _requireRole(adminRole, deployer); + } +} diff --git a/script/pol/POLAddresses.sol b/script/pol/POLAddresses.sol index f2385a6..dbf2b88 100644 --- a/script/pol/POLAddresses.sol +++ b/script/pol/POLAddresses.sol @@ -75,7 +75,7 @@ abstract contract POLAddressBook { bgtStaker: 0x44F07Ce5AfeCbCC406e6beFD40cc2998eEb8c7C6, bgtStakerImpl: 0xDD7FA46a1a735DBD7E7eD4B1928176D28002e205, feeCollector: 0x7Bb8DdaC7FbE3FFC0f4B3c73C4F158B06CF82650, - feeCollectorImpl: 0x0fE7B2A78f8c239569ec22cdbdb472694afc289c, + feeCollectorImpl: 0xAc74e54BAAe195Dd88fDC1aCdd967925B3eB220f, bgtIncentiveDistributor: 0x77DA09bC82652f9A14d1b170a001e759640298e6, bgtIncentiveDistributorImpl: 0x5c5BfeFddB6f6A51F1D24A6a99F3BeA53eE59F29, incentivesCollector: 0x1984Baf659607Cc5f206c55BB3B00eb3E180190B, diff --git a/src/extras/IPriceOracle.sol b/src/extras/IPriceOracle.sol index 7c2fa48..b454036 100644 --- a/src/extras/IPriceOracle.sol +++ b/src/extras/IPriceOracle.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.26; /// @title Provide asset prices in USD with WAD precision -/// @dev Generic interface that wraps the Pyth oracle +/// @dev Generic interface that wraps the Pyth oracle and redstone price oracle interface IPriceOracle { /// @dev TBD whether to also return a confidence interval. struct Data { diff --git a/src/extras/RedStonePriceOracle.sol b/src/extras/RedStonePriceOracle.sol new file mode 100644 index 0000000..d999fdc --- /dev/null +++ b/src/extras/RedStonePriceOracle.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { IPriceOracle } from "./IPriceOracle.sol"; +import { Utils } from "../libraries/Utils.sol"; + +/// @title RedStone price feed adapter interface +interface IRedStonePriceFeedAdapter { + /// @dev Get the last update details. + /// @param dataFeedId The data feed ID. + /// @return lastDataTimestamp The timestamp when the data was last updated. + /// @return lastBlockTimestamp The timestamp when the block was last updated. + /// @return lastValue The last value. + function getLastUpdateDetails(bytes32 dataFeedId) + external + view + returns (uint256 lastDataTimestamp, uint256 lastBlockTimestamp, uint256 lastValue); + + /// @dev Get the last update details without any sanity checks. + /// @param dataFeedId The data feed ID. + /// @return lastDataTimestamp The timestamp when the data was last updated. + /// @return lastBlockTimestamp The timestamp when the block was last updated. + /// @return lastValue The last value. + function getLastUpdateDetailsUnsafe(bytes32 dataFeedId) + external + view + returns (uint256 lastDataTimestamp, uint256 lastBlockTimestamp, uint256 lastValue); +} + +/// @title RedStone price oracle +/// @dev Provides price data from RedStone feeds in WAD precision. +contract RedStonePriceOracle is IPriceOracle, AccessControlUpgradeable, UUPSUpgradeable { + using Utils for bytes4; + + /// @notice The RedStone price feed adapter. + IRedStonePriceFeedAdapter public redstonePriceFeedAdapter; + + /// @notice The RedStone data feed IDs mapping + mapping(address asset => bytes32 dataFeedId) public dataFeedIds; + + /// @notice Emitted when a data feed ID is changed. + event DataFeedIdChanged(address indexed asset, bytes32 indexed dataFeedId); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address governance_, address redstonePriceFeedAdapter_) external initializer { + __AccessControl_init(); + __UUPSUpgradeable_init(); + if (governance_ == address(0)) ZeroAddress.selector.revertWith(); + if (redstonePriceFeedAdapter_ == address(0)) ZeroAddress.selector.revertWith(); + redstonePriceFeedAdapter = IRedStonePriceFeedAdapter(redstonePriceFeedAdapter_); + _grantRole(DEFAULT_ADMIN_ROLE, governance_); + } + + function _authorizeUpgrade(address newImplementation) internal virtual override { + // Silent warning + newImplementation; + _checkRole(DEFAULT_ADMIN_ROLE); + } + + /// @notice Set the data feed ID for a given asset. + /// @param asset The asset. + /// @param dataFeedId The RedStone data feed ID. + function setDataFeedId(address asset, bytes32 dataFeedId) external { + _checkRole(DEFAULT_ADMIN_ROLE); + if (asset == address(0) || dataFeedId == bytes32(0)) ZeroAddress.selector.revertWith(); + dataFeedIds[asset] = dataFeedId; + emit DataFeedIdChanged(asset, dataFeedId); + } + + /// @dev Get the latest round data and convert it to the WAD precision. + function _wrapData(uint256 lastValue, uint256 lastBlockTimestamp) + internal + pure + returns (IPriceOracle.Data memory) + { + // redstone feed returns value in 8 decimals, so we need to convert it to 18 decimals. + return IPriceOracle.Data({ price: Utils.changeDecimals(lastValue, 8, 18), publishTime: lastBlockTimestamp }); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* IPriceOracle FUNCTIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @inheritdoc IPriceOracle + /// @dev getLastUpdateDetails has an inbuilt sanity check for 30 hours stale data along with positive value check. + function getPrice(address asset) public view onlyAssetWithDataFeedIdSet(asset) returns (Data memory data) { + (, uint256 lastBlockTimestamp, uint256 lastValue) = + redstonePriceFeedAdapter.getLastUpdateDetails(dataFeedIds[asset]); + return _wrapData(lastValue, lastBlockTimestamp); + } + + /// @inheritdoc IPriceOracle + function getPriceUnsafe(address asset) public view onlyAssetWithDataFeedIdSet(asset) returns (Data memory data) { + (, uint256 lastBlockTimestamp, uint256 lastValue) = + redstonePriceFeedAdapter.getLastUpdateDetailsUnsafe(dataFeedIds[asset]); + return _wrapData(lastValue, lastBlockTimestamp); + } + + /// @inheritdoc IPriceOracle + /// @dev For any number of age less than heartbeat interval, this will mostly revert as redstone feeds are + /// generally updated at heartbeat interval. + function getPriceNoOlderThan( + address asset, + uint256 age + ) + external + view + onlyAssetWithDataFeedIdSet(asset) + returns (Data memory data) + { + data = getPriceUnsafe(asset); + // Throws panic revert if age is greater than block.timestamp + // Revert with UnavailableData if the price is older than the age. + if (data.publishTime < block.timestamp - age) { + UnavailableData.selector.revertWith(asset); + } + return data; + } + + /// @inheritdoc IPriceOracle + function priceAvailable(address asset) external view returns (bool) { + if (dataFeedIds[asset] == bytes32(0)) { + return false; + } + (, uint256 lastBlockTimestamp,) = redstonePriceFeedAdapter.getLastUpdateDetailsUnsafe(dataFeedIds[asset]); + return lastBlockTimestamp != 0; + } + + /// @dev Modifier to check if the data feed ID is set for a given asset. + /// @param asset The asset. + modifier onlyAssetWithDataFeedIdSet(address asset) { + if (dataFeedIds[asset] == bytes32(0)) { + UnavailableData.selector.revertWith(asset); + } + _; + } +} diff --git a/src/extras/RedStonePriceOracleDeployer.sol b/src/extras/RedStonePriceOracleDeployer.sol new file mode 100644 index 0000000..87c5172 --- /dev/null +++ b/src/extras/RedStonePriceOracleDeployer.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { Create2Deployer } from "../base/Create2Deployer.sol"; +import { Salt } from "../base/Salt.sol"; +import { RedStonePriceOracle } from "./RedStonePriceOracle.sol"; + +contract RedStonePriceOracleDeployer is Create2Deployer { + /// @notice The RedStonePriceOracle contract. + // solhint-disable-next-line immutable-vars-naming + RedStonePriceOracle public immutable oracle; + + constructor(address governance, address redstonePriceFeedAdapter, Salt memory oracleSalt) { + // deploy the RedStonePriceOracle implementation + address oracleImpl = deployWithCreate2(oracleSalt.implementation, type(RedStonePriceOracle).creationCode); + // deploy the RedStonePriceOracle proxy + oracle = RedStonePriceOracle(deployProxyWithCreate2(oracleImpl, oracleSalt.proxy)); + + // initialize the contracts + oracle.initialize(governance, redstonePriceFeedAdapter); + } +} diff --git a/src/honey/HoneyFactory.sol b/src/honey/HoneyFactory.sol index 88cbb32..8f7c54f 100644 --- a/src/honey/HoneyFactory.sol +++ b/src/honey/HoneyFactory.sol @@ -36,9 +36,6 @@ contract HoneyFactory is IHoneyFactory, VaultAdmin { /// @dev It's set to 2 cents. uint256 private constant MAX_PEG_OFFSET = 0.02e18; - /// @notice The constant representing the max price feed delay tolerance in seconds allowed. - uint256 private constant MAX_PRICE_FEED_DELAY_TOLERANCE = 120 seconds; - /// @notice The Honey token contract. Honey public honey; @@ -70,7 +67,8 @@ contract HoneyFactory is IHoneyFactory, VaultAdmin { /// @notice The max number of seconds of tolerated staleness /// @dev It's involved into deeming a collateral asset pegged or not - uint256 public priceFeedMaxDelay; + /// @dev Deprecated, not used anymore. + uint256 private _priceFeedMaxDelay; address public referenceCollateral; mapping(address asset => uint256 limit) public relativeCap; @@ -109,7 +107,7 @@ contract HoneyFactory is IHoneyFactory, VaultAdmin { polFeeCollectorFeeRate = ONE_HUNDRED_PERCENT_RATE; // NOTE: based on the average block time of ~2 seconds. - priceFeedMaxDelay = 10 seconds; + _priceFeedMaxDelay = 10 seconds; minSharesToRecapitalize = DEFAULT_MIN_SHARES_TO_RECAPITALIZE; priceOracle = IPriceOracle(_priceOracle); globalCap = ONE_HUNDRED_PERCENT_RATE; @@ -158,18 +156,6 @@ contract HoneyFactory is IHoneyFactory, VaultAdmin { emit BasketModeForced(forced); } - /// @notice Set the max tolerated number of seconds for oracle staleness. - /// @dev It's involved into deeming a collateral asset pegged or not. - /// @dev Only Manager role can call this function. - function setMaxFeedDelay(uint256 maxTolerance) external { - _checkRole(MANAGER_ROLE); - if (maxTolerance > MAX_PRICE_FEED_DELAY_TOLERANCE) { - AmountOutOfRange.selector.revertWith(); - } - priceFeedMaxDelay = maxTolerance; - emit MaxFeedDelaySet(maxTolerance); - } - /// @notice Set lower and upper depeg offset for an asset. /// @dev Only Manager role can call this function. function setDepegOffsets(address asset, uint256 lowerOffset, uint256 upperOffset) external { @@ -580,10 +566,8 @@ contract HoneyFactory is IHoneyFactory, VaultAdmin { if (!priceOracle.priceAvailable(asset)) { return false; } + // Staleness of the feed is monitored offchain and is not considered here. IPriceOracle.Data memory data = priceOracle.getPriceUnsafe(asset); - if (data.publishTime < block.timestamp - priceFeedMaxDelay) { - return false; - } return (1e18 - lowerPegOffsets[asset] <= data.price) && (data.price <= 1e18 + upperPegOffsets[asset]); } @@ -743,7 +727,7 @@ contract HoneyFactory is IHoneyFactory, VaultAdmin { } function _getPrice(address asset) internal view returns (uint256) { - IPriceOracle.Data memory data = priceOracle.getPriceNoOlderThan(asset, priceFeedMaxDelay); + IPriceOracle.Data memory data = priceOracle.getPrice(asset); return data.price; } diff --git a/src/pol/FeeCollector.sol b/src/pol/FeeCollector.sol index 3d6d7d6..5315b25 100644 --- a/src/pol/FeeCollector.sol +++ b/src/pol/FeeCollector.sol @@ -140,4 +140,18 @@ contract FeeCollector is IFeeCollector, PausableUpgradeable, AccessControlUpgrad payoutAmount = queuedPayoutAmount; queuedPayoutAmount = 0; } + + /// @notice Recover ERC20 tokens from the contract + /// @param tokens The tokens to recover + /// @dev Used to recover funds once the contract is deprecated. + function recoverERC20(address[] calldata tokens) external onlyRole(DEFAULT_ADMIN_ROLE) { + for (uint256 i; i < tokens.length;) { + address token = tokens[i]; + uint256 tokenAmount = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransfer(msg.sender, tokenAmount); + unchecked { + ++i; + } + } + } } diff --git a/test/honey/HoneyFactory.t.sol b/test/honey/HoneyFactory.t.sol index 7f7054c..11d2f93 100644 --- a/test/honey/HoneyFactory.t.sol +++ b/test/honey/HoneyFactory.t.sol @@ -286,32 +286,6 @@ contract HoneyFactoryTest is HoneyBaseTest { factory.setDepegOffsets(address(dai), lowerOffset, upperOffset); } - function test_setMaxDelay_failsWithoutManager() external { - uint256 newMaxDelay = 60 seconds; - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), MANAGER_ROLE - ) - ); - factory.setMaxFeedDelay(newMaxDelay); - } - - function test_setMaxDelay_failsOutOfRange() external { - uint256 newMaxDelay = 121 seconds; - vm.prank(manager); - vm.expectRevert(abi.encodeWithSelector(IHoneyErrors.AmountOutOfRange.selector)); - factory.setMaxFeedDelay(newMaxDelay); - } - - function testFuzz_setMaxDelay(uint256 newMaxDelay) public { - newMaxDelay = _bound(newMaxDelay, 0, 60 seconds); - vm.startPrank(manager); - vm.expectEmit(); - emit IHoneyFactory.MaxFeedDelaySet(newMaxDelay); - factory.setMaxFeedDelay(newMaxDelay); - assertEq(factory.priceFeedMaxDelay(), newMaxDelay); - } - function test_ForceBasketModeWhenMint() public { assertFalse(factory.isBasketModeEnabled(true)); vm.prank(manager); @@ -343,62 +317,6 @@ contract HoneyFactoryTest is HoneyBaseTest { factory.setForcedBasketMode(true); } - function test_BasketModeEnabledWhenAllFeedsAreStale_WhenMint() external { - // basket mode is disabled because all the feeds are pegged and the price is not stale - assertFalse(factory.isBasketModeEnabled(true)); - // Increase chain time in order to set the time to the upper bound of the stale price - vm.warp(block.timestamp + factory.priceFeedMaxDelay()); - assertFalse(factory.isBasketModeEnabled(true)); - // Increase time to the upper bound of the stale price + 1 - vm.warp(block.timestamp + 1); - // Because all the feeds are pegged due to the stale price, basket mode should be enabled - assertTrue(factory.isBasketModeEnabled(true)); - } - - function test_BasketModeEnabledWhenStalePriceOnOneFeed_DisabledWhenMint() external { - // basket mode is disabled because all the feeds are pegged and the price is not stale - assertFalse(factory.isBasketModeEnabled(true)); - // Increase chain time in order to set the time to the upper bound of the stale price - vm.warp(block.timestamp + factory.priceFeedMaxDelay()); - assertFalse(factory.isBasketModeEnabled(true)); - // Increase time to the upper bound of the stale price + 1 - vm.warp(block.timestamp + 1); - // Because all the feeds are not pegged due to the stale price, basket mode should be enabled - assertTrue(factory.isBasketModeEnabled(true)); - - // Change price feed to a non-stale price - pyth.setData(daiFeed, int64(99_993_210), uint64(31_155), int32(-8), block.timestamp); - pyth.setData(usdtFeed, int64(99_993_210), uint64(31_155), int32(-8), block.timestamp); - // If there is one stale price, basket mode should be disable on mint because of 2 good feeds - assertFalse(factory.isBasketModeEnabled(true)); - // Make the last price feed non-stale - pyth.setData(dummyFeed, int64(99_993_210), uint64(31_155), int32(-8), block.timestamp); - // If all price feeds are non-stale, basket mode should be disabled - assertFalse(factory.isBasketModeEnabled(true)); - } - - function test_BasketModeEnabledWhenStalePriceOnOneFeed_WhenRedeem() external { - _factoryMint(dai, daiBalance, receiver, false); - // basket mode is disabled because all the feeds are pegged and the price is not stale - assertFalse(factory.isBasketModeEnabled(false)); - // Increase chain time in order to set the time to the upper bound of the stale price - vm.warp(block.timestamp + factory.priceFeedMaxDelay()); - assertFalse(factory.isBasketModeEnabled(false)); - // Increase time to the upper bound of the stale price + 1 - vm.warp(block.timestamp + 1); - assertTrue(factory.isBasketModeEnabled(false)); - - // Change price feed to a non-stale price - pyth.setData(dummyFeed, int64(99_993_210), uint64(31_155), int32(-8), block.timestamp); - pyth.setData(usdtFeed, int64(99_993_210), uint64(31_155), int32(-8), block.timestamp); - // Basket mode is enabled because of the dai collateral is used and it's still stale. - assertTrue(factory.isBasketModeEnabled(false)); - // Make the last price feed non-stale - pyth.setData(daiFeed, int64(99_993_210), uint64(31_155), int32(-8), block.timestamp); - // If all price feeds are non-stale, basket mode should be disabled - assertFalse(factory.isBasketModeEnabled(false)); - } - function testFuzz_BasketModeDisabledWhenAnAssetDepegUnderOneDollar_WhenMint(uint256 pegOffset) public { pegOffset = _bound(pegOffset, PEG_OFFSET + 0.1e18, 1e18 - 0.1e18); // basket mode is disabled because all the feeds are pegged and the price is not stale diff --git a/test/integration/RedStonePriceOracle.t.sol b/test/integration/RedStonePriceOracle.t.sol new file mode 100644 index 0000000..304a2d8 --- /dev/null +++ b/test/integration/RedStonePriceOracle.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { Create2Deployer } from "src/base/Create2Deployer.sol"; +import { Salt } from "src/base/Salt.sol"; +import { IPriceOracle } from "src/extras/IPriceOracle.sol"; +import { RedStonePriceOracle, IRedStonePriceFeedAdapter } from "src/extras/RedStonePriceOracle.sol"; +import { RedStonePriceOracleDeployer } from "src/extras/RedStonePriceOracleDeployer.sol"; + +/// @title RedStonePriceOracleIntegrationTest +/// @notice Fork tests for RedStonePriceOracle on Berachain mainnet. +contract RedStonePriceOracleIntegrationTest is Create2Deployer, Test { + /// @dev Mainnet RedStone price feed adapter (multi-feed) — sourced from + /// `script/oracles/deployment/5_DeployRedStonePriceOracle.s.sol`. + address constant REDSTONE_FEED_ADAPTER = 0x24c8964338Deb5204B096039147B8e8C3AEa42Cc; + + address constant USDT = 0x779Ded0c9e1022225f8E0630b35a9b54bE713736; + address constant USDC = 0x549943e04f40284185054145c6E4e9568C1D3241; + address constant USDe = 0x5d3a1Ff2b6BAb83b63cd9AD0787074081a52ef34; + + bytes32 constant USDT_FEED = bytes32("USDT"); + bytes32 constant USDC_FEED = bytes32("USDC"); + bytes32 constant USDe_FEED = bytes32("USDe"); + + address governance = 0xD13948F99525FB271809F45c268D72a3C00a568D; + uint256 forkBlock = 21_990_674; + + RedStonePriceOracle oracle; + + function setUp() public virtual { + vm.createSelectFork("berachain"); + vm.rollFork(forkBlock); + + // Deploy via the deployer contract (matches production deployment path). + RedStonePriceOracleDeployer deployer = + new RedStonePriceOracleDeployer(governance, REDSTONE_FEED_ADAPTER, Salt({ implementation: 0, proxy: 0 })); + oracle = deployer.oracle(); + + // Register feeds for the three stablecoins as governance (DEFAULT_ADMIN_ROLE). + vm.startPrank(governance); + oracle.setDataFeedId(USDT, USDT_FEED); + oracle.setDataFeedId(USDC, USDC_FEED); + oracle.setDataFeedId(USDe, USDe_FEED); + vm.stopPrank(); + } + + function test_Fork() public view { + assertEq(block.chainid, 80_094); + assertEq(block.number, forkBlock); + assertEq(block.timestamp, 1_780_996_064); + } + + function test_Deployment() public view { + assertEq(address(oracle.redstonePriceFeedAdapter()), REDSTONE_FEED_ADAPTER); + assertTrue(oracle.hasRole(oracle.DEFAULT_ADMIN_ROLE(), governance)); + assertEq(oracle.dataFeedIds(USDT), USDT_FEED); + assertEq(oracle.dataFeedIds(USDC), USDC_FEED); + assertEq(oracle.dataFeedIds(USDe), USDe_FEED); + } + + function test_GetPrice_USDT() public view { + _assertStablecoinPrice(USDT, USDT_FEED); + } + + function test_GetPrice_USDC() public view { + _assertStablecoinPrice(USDC, USDC_FEED); + } + + function test_GetPrice_USDe() public view { + _assertStablecoinPrice(USDe, USDe_FEED); + } + + function test_GetPriceUnsafe_AllFeeds() public view { + _assertUnsafeMatchesAdapter(USDT, USDT_FEED); + _assertUnsafeMatchesAdapter(USDC, USDC_FEED); + _assertUnsafeMatchesAdapter(USDe, USDe_FEED); + } + + function test_PriceAvailable() public view { + assertTrue(oracle.priceAvailable(USDT)); + assertTrue(oracle.priceAvailable(USDC)); + assertTrue(oracle.priceAvailable(USDe)); + // Unregistered asset has no feed + assertFalse(oracle.priceAvailable(address(0xdead))); + } + + /// @dev `getPriceUnsafe` and `getPrice` wrap the same underlying value — they must agree. + function test_GetPriceUnsafe_EqualsGetPrice() public view { + _assertUnsafeEqualsSafe(USDT); + _assertUnsafeEqualsSafe(USDC); + _assertUnsafeEqualsSafe(USDe); + } + + /// @dev RedStone stablecoin feeds typically update on a multi-hour heartbeat, so requesting + /// a price no older than 1 hour should revert for all three stablecoins at this fork block. + function test_GetPriceNoOlderThan_RevertsAt1Hour() public { + _assertNoOlderThanReverts(USDT, 1 hours); + _assertNoOlderThanReverts(USDC, 1 hours); + _assertNoOlderThanReverts(USDe, 1 hours); + } + + /// @dev Same as above but with a 6 hour staleness tolerance — still expected to revert at this + /// fork block because the feeds were last pushed more than 6 hours ago. + function test_GetPriceNoOlderThan_RevertsAt6Hours() public { + _assertNoOlderThanReverts(USDT, 6 hours); + _assertNoOlderThanReverts(USDC, 6 hours); + _assertNoOlderThanReverts(USDe, 6 hours); + } + + /// @dev With a large tolerance (2 days), the call should succeed and return the same + /// data as `getPriceUnsafe`. + function test_GetPriceNoOlderThan_SucceedsWithLargeAge() public view { + for (uint256 i = 0; i < 3; ++i) { + address asset = [USDT, USDC, USDe][i]; + IPriceOracle.Data memory withAge = oracle.getPriceNoOlderThan(asset, 2 days); + IPriceOracle.Data memory unsafe = oracle.getPriceUnsafe(asset); + assertEq(withAge.price, unsafe.price); + assertEq(withAge.publishTime, unsafe.publishTime); + } + } + + /// @dev Verifies that the oracle returns the price in WAD (18 decimals) and that the value + /// is approximately $1 (within +/- 2%) — appropriate for a USD-pegged stablecoin feed. + function _assertStablecoinPrice(address asset, bytes32 feed) internal view { + IPriceOracle.Data memory data = oracle.getPrice(asset); + + // Raw adapter value is 8 decimals; oracle returns 18 decimals (factor of 1e10). + (, uint256 lastBlockTimestamp, uint256 rawValue) = + IRedStonePriceFeedAdapter(REDSTONE_FEED_ADAPTER).getLastUpdateDetails(feed); + assertEq(data.price, rawValue * 1e10, "price not scaled to 18 decimals"); + assertEq(data.publishTime, lastBlockTimestamp, "publishTime mismatch"); + + // Sanity: the returned 18-decimal price must be in the same order of magnitude as 1e18, + // and clearly larger than what a 8-decimal value would be. + assertGt(data.price, 0.98e18, "stablecoin price too low"); + assertLt(data.price, 1.02e18, "stablecoin price too high"); + assertGt(data.price, 1e9, "price not scaled up from 8 decimals"); + } + + /// @dev Verifies `getPriceUnsafe` against the adapter's unsafe getter with 1e10 scaling. + function _assertUnsafeMatchesAdapter(address asset, bytes32 feed) internal view { + IPriceOracle.Data memory data = oracle.getPriceUnsafe(asset); + (, uint256 lastBlockTimestamp, uint256 rawValue) = + IRedStonePriceFeedAdapter(REDSTONE_FEED_ADAPTER).getLastUpdateDetailsUnsafe(feed); + assertEq(data.price, rawValue * 1e10); + assertEq(data.publishTime, lastBlockTimestamp); + } + + function _assertUnsafeEqualsSafe(address asset) internal view { + IPriceOracle.Data memory safe = oracle.getPrice(asset); + IPriceOracle.Data memory unsafe = oracle.getPriceUnsafe(asset); + assertEq(safe.price, unsafe.price, "price mismatch between safe and unsafe"); + assertEq(safe.publishTime, unsafe.publishTime, "publishTime mismatch between safe and unsafe"); + } + + function _assertNoOlderThanReverts(address asset, uint256 age) internal { + vm.expectRevert(abi.encodeWithSelector(IPriceOracle.UnavailableData.selector, asset)); + oracle.getPriceNoOlderThan(asset, age); + } +} diff --git a/test/oracles/RedStonePriceOracle.t.sol b/test/oracles/RedStonePriceOracle.t.sol new file mode 100644 index 0000000..3d8d338 --- /dev/null +++ b/test/oracles/RedStonePriceOracle.t.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import { RedStonePriceOracle, IRedStonePriceFeedAdapter } from "src/extras/RedStonePriceOracle.sol"; +import { Create2Deployer } from "src/base/Create2Deployer.sol"; +import { IPriceOracle } from "src/extras/IPriceOracle.sol"; + +contract MockRedStonePriceFeedAdapter is IRedStonePriceFeedAdapter { + struct Update { + uint256 lastDataTimestamp; + uint256 lastBlockTimestamp; + uint256 lastValue; + } + + mapping(bytes32 dataFeedId => Update update) public updates; + + function setUpdate( + bytes32 dataFeedId, + uint256 lastDataTimestamp, + uint256 lastBlockTimestamp, + uint256 lastValue + ) + external + { + updates[dataFeedId] = Update(lastDataTimestamp, lastBlockTimestamp, lastValue); + } + + function getLastUpdateDetails(bytes32 dataFeedId) + external + view + returns (uint256 lastDataTimestamp, uint256 lastBlockTimestamp, uint256 lastValue) + { + Update memory u = updates[dataFeedId]; + return (u.lastDataTimestamp, u.lastBlockTimestamp, u.lastValue); + } + + function getLastUpdateDetailsUnsafe(bytes32 dataFeedId) + external + view + returns (uint256 lastDataTimestamp, uint256 lastBlockTimestamp, uint256 lastValue) + { + Update memory u = updates[dataFeedId]; + return (u.lastDataTimestamp, u.lastBlockTimestamp, u.lastValue); + } +} + +contract RedStonePriceOracleTest is Test, Create2Deployer { + RedStonePriceOracle _redStonePriceOracle; + MockRedStonePriceFeedAdapter _mockedAdapter; + address _governance = makeAddr("governance"); + + function setUp() public { + RedStonePriceOracle _redStonePriceOracleImpl = new RedStonePriceOracle(); + _redStonePriceOracle = RedStonePriceOracle(deployProxyWithCreate2(address(_redStonePriceOracleImpl), 0)); + _mockedAdapter = new MockRedStonePriceFeedAdapter(); + } + + modifier initialize() { + _redStonePriceOracle.initialize(_governance, address(_mockedAdapter)); + assert(_redStonePriceOracle.hasRole(_redStonePriceOracle.DEFAULT_ADMIN_ROLE(), _governance)); + _; + } + + function test_initialize_zeroAddress() public { + // Governance address cannot be zero + vm.expectRevert(IPriceOracle.ZeroAddress.selector); + _redStonePriceOracle.initialize(address(0), address(_mockedAdapter)); + + // RedStone price feed adapter address cannot be zero + vm.expectRevert(IPriceOracle.ZeroAddress.selector); + _redStonePriceOracle.initialize(_governance, address(0)); + } + + function testFuzz_initialize(address governance_, address adapter_) public { + assumeNotZeroAddress(governance_); + assumeNotZeroAddress(adapter_); + + _redStonePriceOracle.initialize(governance_, adapter_); + + assertEq(address(_redStonePriceOracle.redstonePriceFeedAdapter()), adapter_); + assertTrue(_redStonePriceOracle.hasRole(_redStonePriceOracle.DEFAULT_ADMIN_ROLE(), governance_)); + } + + function test_setDataFeedId() public initialize { + address asset = makeAddr("USDC"); + bytes32 dataFeedId = "USDC"; + + _setDataFeedId(asset, dataFeedId); + + assertEq(_redStonePriceOracle.dataFeedIds(asset), dataFeedId); + } + + function test_setDataFeedId_ZeroAddress() public initialize { + // Asset cannot be zero + vm.prank(_governance); + vm.expectRevert(IPriceOracle.ZeroAddress.selector); + _redStonePriceOracle.setDataFeedId(address(0), "USDC"); + + // Data feed ID cannot be zero + vm.prank(_governance); + vm.expectRevert(IPriceOracle.ZeroAddress.selector); + _redStonePriceOracle.setDataFeedId(makeAddr("USDC"), bytes32(0)); + } + + function test_setDataFeedId_NotAdmin() public initialize { + // Only DEFAULT_ADMIN_ROLE can set data feed IDs + vm.expectRevert(); + _redStonePriceOracle.setDataFeedId(makeAddr("USDC"), "USDC"); + } + + function test_getPrice() public initialize { + address asset = address(0x1); + bytes32 dataFeedId = "USDC"; + + // RedStone reports prices in 8 decimals. Source value 1e8 represents $1 and + // should be returned as 1e18 in WAD precision. + _mockedAdapter.setUpdate(dataFeedId, block.timestamp, block.timestamp, 1e8); + _setDataFeedId(asset, dataFeedId); + + IPriceOracle.Data memory priceData = _redStonePriceOracle.getPrice(asset); + assertEq(priceData.price, 1e18); + assertEq(priceData.publishTime, block.timestamp); + } + + function test_getPrice_UnavailableAsset() public initialize { + // No data feed ID set for the asset, should revert with UnavailableData. + vm.expectRevert(abi.encodeWithSelector(IPriceOracle.UnavailableData.selector, address(0x1))); + _redStonePriceOracle.getPrice(address(0x1)); + } + + function test_getPriceUnsafe() public initialize { + address asset = address(0x1); + bytes32 dataFeedId = "USDC"; + + _mockedAdapter.setUpdate(dataFeedId, block.timestamp, block.timestamp, 2e8); + _setDataFeedId(asset, dataFeedId); + + IPriceOracle.Data memory priceData = _redStonePriceOracle.getPriceUnsafe(asset); + assertEq(priceData.price, 2e18); + assertEq(priceData.publishTime, block.timestamp); + } + + function test_getPriceNoOlderThan() public initialize { + address asset = address(0x1); + bytes32 dataFeedId = "USDC"; + + // Warp ahead so we can travel back for the publish time + vm.warp(1_000_000); + uint256 publishTime = block.timestamp - 100; + + _mockedAdapter.setUpdate(dataFeedId, publishTime, publishTime, 1e8); + _setDataFeedId(asset, dataFeedId); + + // Within age tolerance + IPriceOracle.Data memory priceData = _redStonePriceOracle.getPriceNoOlderThan(asset, 200); + assertEq(priceData.price, 1e18); + assertEq(priceData.publishTime, publishTime); + + // Stale data, beyond age tolerance + vm.expectRevert(abi.encodeWithSelector(IPriceOracle.UnavailableData.selector, asset)); + _redStonePriceOracle.getPriceNoOlderThan(asset, 50); + } + + function test_priceAvailable() public initialize { + address asset = address(0x1); + bytes32 dataFeedId = "USDC"; + + // No data feed ID set + assertFalse(_redStonePriceOracle.priceAvailable(asset)); + + // Data feed ID set, but adapter returns zero timestamp + _setDataFeedId(asset, dataFeedId); + assertFalse(_redStonePriceOracle.priceAvailable(asset)); + + // Adapter has data + _mockedAdapter.setUpdate(dataFeedId, block.timestamp, block.timestamp, 1e8); + assertTrue(_redStonePriceOracle.priceAvailable(asset)); + } + + function _setDataFeedId(address asset, bytes32 dataFeedId) internal { + vm.prank(_governance); + _redStonePriceOracle.setDataFeedId(asset, dataFeedId); + } +} diff --git a/test/pol/FeeCollector.t.sol b/test/pol/FeeCollector.t.sol index 6f75af7..c2b4d4a 100644 --- a/test/pol/FeeCollector.t.sol +++ b/test/pol/FeeCollector.t.sol @@ -265,4 +265,26 @@ contract FeeCollectorTest is POLTest { feeCollector.grantRole(pauserRole, newVaultPauser); assert(feeCollector.hasRole(pauserRole, newVaultPauser)); } + + function test_RecoverERC20() public { + _addFees(); + address[] memory tokens = new address[](1); + tokens[0] = address(feeToken); + vm.prank(governance); + feeCollector.recoverERC20(tokens); + assertEq(feeToken.balanceOf(governance), 10 ether); + assertEq(feeToken.balanceOf(address(feeCollector)), 0); + } + + function test_RecoverERC20_FailIfNotOwner() public { + _addFees(); + address[] memory tokens = new address[](1); + tokens[0] = address(feeToken); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), defaultAdminRole + ) + ); + feeCollector.recoverERC20(tokens); + } }