diff --git a/contracts/interfaces/IRTokenOracle.sol b/contracts/interfaces/IRTokenOracle.sol new file mode 100644 index 000000000..d301be71a --- /dev/null +++ b/contracts/interfaces/IRTokenOracle.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +// RToken Oracle Interface +interface IRTokenOracle { + struct CachedOracleData { + uint192 cachedPrice; + uint256 cachedAtTime; + uint48 cachedAtNonce; + uint48 cachedTradesOpen; + uint256 cachedTradesNonce; + } + + // @returns rTokenPrice {D18} {UoA/tok} The price of the RToken, in UoA + function latestPrice() external returns (uint192 rTokenPrice, uint256 updatedAt); + + // Force recalculate the price of the RToken + function forceUpdatePrice() external; +} diff --git a/contracts/interfaces/ITrading.sol b/contracts/interfaces/ITrading.sol index 2e4b45644..4c40bce0e 100644 --- a/contracts/interfaces/ITrading.sol +++ b/contracts/interfaces/ITrading.sol @@ -61,6 +61,9 @@ interface ITrading is IComponent, IRewardableComponent { /// @return The number of ongoing trades open function tradesOpen() external view returns (uint48); + + /// @return The number of total trades ever opened + function tradesNonce() external view returns (uint256); } interface TestITrading is ITrading { diff --git a/contracts/p0/mixins/Trading.sol b/contracts/p0/mixins/Trading.sol index 56b1ec097..871092986 100644 --- a/contracts/p0/mixins/Trading.sol +++ b/contracts/p0/mixins/Trading.sol @@ -26,6 +26,9 @@ abstract contract TradingP0 is RewardableP0, ITrading { uint192 public minTradeVolume; // {UoA} + // === 3.0.0 === + uint256 public tradesNonce; // to keep track of how many trades have been opened in total + // untestable: // `else` branch of `onlyInitializing` (ie. revert) is currently untestable. // This function is only called inside other `init` functions, each of which is wrapped @@ -68,6 +71,8 @@ abstract contract TradingP0 is RewardableP0, ITrading { trade = broker.openTrade(kind, req); trades[req.sell.erc20()] = trade; tradesOpen++; + tradesNonce++; + emit TradeStarted( trade, req.sell.erc20(), diff --git a/contracts/p1/RTokenOracle.sol b/contracts/p1/RTokenOracle.sol deleted file mode 100644 index 745a35ca0..000000000 --- a/contracts/p1/RTokenOracle.sol +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; - -import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; -import "../interfaces/IBasketHandler.sol"; - -import "hardhat/console.sol"; - -// This interface is here temporarily -interface ModifiedChainlinkInterface { - function latestAnswer() external returns (int256); - - function latestRoundData() - external - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ); -} - -/** - * @title RToken Oracle - * @notice This is just a temporary testing ground for oracle actions!! - * DO NOT USE!! - */ -contract RTokenOracle is ModifiedChainlinkInterface { - IBasketHandler basketHandler; - - AggregatorV3Interface chainlinkOracle; - - int256 cachedPrice; - uint256 cachedAt; - - uint256 CACHE_TIMEOUT; - - constructor(IBasketHandler _basketHandler) { - basketHandler = _basketHandler; // msg.sender - - CACHE_TIMEOUT = 15 minutes; - } - - function _updateCachedPrice() internal { - (uint192 low, uint192 high) = basketHandler.price(); - - // if (low == 0 && high == FIX_MAX) { - // (low, high) = basketHandler.lotPrice(); - // console.log("using lot price"); - // } - - cachedPrice = int256((uint256(low) + uint256(high)) / 2); - cachedAt = block.timestamp; - } - - function forceUpdatePrice() external { - _updateCachedPrice(); - } - - function setChainlinkOracle(address _chainlinkOracle) external { - require(msg.sender == address(basketHandler), "!basketHandler"); // just thinking - - chainlinkOracle = AggregatorV3Interface(_chainlinkOracle); - } - - function latestRoundData() - external - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) - { - if (address(chainlinkOracle) != address(0)) { - return chainlinkOracle.latestRoundData(); - } - - // Situations that require an update, from most common to least common. - if ( - cachedAt + CACHE_TIMEOUT <= block.timestamp // Cache Timeout - // !basketHandler.fullyCollateralized() // Basket is not fully collateralized - // TODO: Nonce difference - // TODO: Should we also check for ready? - // TODO: ..or status for that matter? - ) { - _updateCachedPrice(); - } - - return (0, cachedPrice, 0, cachedAt, 0); - } - - function latestAnswer() external returns (int256 latestPrice) { - (, latestPrice, , , ) = this.latestRoundData(); - } -} diff --git a/contracts/p1/mixins/Trading.sol b/contracts/p1/mixins/Trading.sol index 607cae348..9a84b1d72 100644 --- a/contracts/p1/mixins/Trading.sol +++ b/contracts/p1/mixins/Trading.sol @@ -32,9 +32,11 @@ abstract contract TradingP1 is Multicall, ComponentP1, ReentrancyGuardUpgradeabl // === Governance param === uint192 public maxTradeSlippage; // {%} - uint192 public minTradeVolume; // {UoA} + // === 3.0.0 === + uint256 public tradesNonce; // to keep track of how many trades have been opened in total + // ==== Invariants ==== // tradesOpen = len(values(trades)) // trades[sell] != 0 iff trade[sell] has been opened and not yet settled @@ -111,7 +113,6 @@ abstract contract TradingP1 is Multicall, ComponentP1, ReentrancyGuardUpgradeabl // trades' = trades.set(req.sell, tradeID) // tradesOpen' = tradesOpen + 1 function tryTrade(TradeKind kind, TradeRequest memory req) internal returns (ITrade trade) { - /* */ IERC20 sell = req.sell.erc20(); assert(address(trades[sell]) == address(0)); @@ -121,6 +122,7 @@ abstract contract TradingP1 is Multicall, ComponentP1, ReentrancyGuardUpgradeabl trade = broker.openTrade(kind, req); trades[sell] = trade; tradesOpen++; + tradesNonce++; emit TradeStarted(trade, sell, req.buy.erc20(), req.sellAmount, req.minBuyAmount); } @@ -146,5 +148,5 @@ abstract contract TradingP1 is Multicall, ComponentP1, ReentrancyGuardUpgradeabl * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ - uint256[46] private __gap; + uint256[45] private __gap; } diff --git a/contracts/plugins/assets/RTokenAsset.sol b/contracts/plugins/assets/RTokenAsset.sol index 873589313..b12aea1d4 100644 --- a/contracts/plugins/assets/RTokenAsset.sol +++ b/contracts/plugins/assets/RTokenAsset.sol @@ -4,34 +4,20 @@ pragma solidity 0.8.19; import "../../p1/mixins/RecollateralizationLib.sol"; import "../../interfaces/IMain.sol"; import "../../interfaces/IRToken.sol"; +import "../../interfaces/IRTokenOracle.sol"; import "./Asset.sol"; import "./VersionedAsset.sol"; -// This interface is here temporarily -interface ModifiedChainlinkInterface { - function latestAnswer() external returns (int256); - - function latestRoundData() - external - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ); -} - uint256 constant ORACLE_TIMEOUT = 15 minutes; /// Once an RToken gets large enough to get a price feed, replacing this asset with /// a simpler one will do wonders for gas usage -contract RTokenAsset is IAsset, VersionedAsset, ModifiedChainlinkInterface { +contract RTokenAsset is IAsset, VersionedAsset, IRTokenOracle { using FixLib for uint192; using OracleLib for AggregatorV3Interface; // Component addresses are not mutable in protocol, so it's safe to cache these - // IMain public immutable main; + IMain public immutable main; IBasketHandler public immutable basketHandler; IAssetRegistry public immutable assetRegistry; IBackingManager public immutable backingManager; @@ -43,16 +29,14 @@ contract RTokenAsset is IAsset, VersionedAsset, ModifiedChainlinkInterface { uint192 public immutable maxTradeVolume; // {UoA} // Oracle State - int256 public cachedPrice; - uint256 public cachedAtTime; - uint48 public cachedAtNonce; + CachedOracleData public cachedOracleData; /// @param maxTradeVolume_ {UoA} The max trade volume, in UoA constructor(IRToken erc20_, uint192 maxTradeVolume_) { require(address(erc20_) != address(0), "missing erc20"); require(maxTradeVolume_ > 0, "invalid max trade volume"); - IMain main = erc20_.main(); + main = erc20_.main(); basketHandler = main.basketHandler(); assetRegistry = main.assetRegistry(); backingManager = main.backingManager(); @@ -90,7 +74,7 @@ contract RTokenAsset is IAsset, VersionedAsset, ModifiedChainlinkInterface { function refresh() public virtual override { // No need to save lastPrice; can piggyback off the backing collateral's lotPrice() - cachedAtTime = 0; // force oracle refresh + cachedOracleData.cachedAtTime = 0; // force oracle refresh } // solhint-enable no-empty-blocks @@ -158,44 +142,40 @@ contract RTokenAsset is IAsset, VersionedAsset, ModifiedChainlinkInterface { _updateCachedPrice(); } - // ==== Private ==== - - // Update Oracle price - function _updateCachedPrice() internal { - (uint192 low, uint192 high) = price(); - - require(low != 0 && high != FIX_MAX, "invalid price"); - - cachedPrice = int256((uint256(low) + uint256(high)) / 2); - cachedAtTime = block.timestamp; - cachedAtNonce = basketHandler.nonce(); - } - - function latestRoundData() - external - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) - { + function latestPrice() external returns (uint192 rTokenPrice, uint256 updatedAt) { // Situations that require an update, from most common to least common. if ( - cachedAtTime + ORACLE_TIMEOUT <= block.timestamp || // Cache Timeout - cachedAtNonce != basketHandler.nonce() // Basket nonce was updated - // !basketHandler.fullyCollateralized() // Basket is not fully collateralized - // Basket is recapitalizing, but there's not enough RSR. + cachedOracleData.cachedAtTime + ORACLE_TIMEOUT <= block.timestamp || // Cache Timeout + cachedOracleData.cachedAtNonce != basketHandler.nonce() || // Basket nonce was updated + cachedOracleData.cachedTradesNonce != backingManager.tradesNonce() || // New trades were started.. + cachedOracleData.cachedTradesOpen != backingManager.tradesOpen() // ..or settled (between updates) ) { _updateCachedPrice(); } - return (0, cachedPrice, 0, cachedAtTime, 0); + return (cachedOracleData.cachedPrice, cachedOracleData.cachedAtTime); } - function latestAnswer() external returns (int256 latestPrice) { - (, latestPrice, , , ) = this.latestRoundData(); + // ==== Private ==== + + // Update Oracle Data + function _updateCachedPrice() internal { + if (cachedOracleData.cachedAtTime == block.timestamp) { + // The price was updated in the same block. + return; + } + + (uint192 low, uint192 high) = price(); + + require(low != 0 && high != FIX_MAX, "invalid price"); + + cachedOracleData = CachedOracleData( + (low + high) / 2, + block.timestamp, + basketHandler.nonce(), + backingManager.tradesOpen(), + backingManager.tradesNonce() + ); } /// Computationally expensive basketRange calculation; used in price() & lotPrice() @@ -213,7 +193,6 @@ contract RTokenAsset is IAsset, VersionedAsset, ModifiedChainlinkInterface { // the absence of an external price feed. Any RToken that gets reasonably big // should switch over to an asset with a price feed. - IMain main = backingManager.main(); TradingContext memory ctx; ctx.basketsHeld = basketsHeld;