diff --git a/contracts/contracts/interfaces/IOTokenVaultOracle.sol b/contracts/contracts/interfaces/IOTokenVaultOracle.sol new file mode 100644 index 0000000000..2b1483b091 --- /dev/null +++ b/contracts/contracts/interfaces/IOTokenVaultOracle.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { AggregatorV3Interface } from "./chainlink/AggregatorV3Interface.sol"; +import { IOToken } from "./IOToken.sol"; +import { IVault } from "./IVault.sol"; + +/** + * @title OToken Vault Oracle Interface + * @author Origin Protocol Inc + */ +interface IOTokenVaultOracle is AggregatorV3Interface { + /** + * @notice Returns the Vault used to calculate the OToken price. + * @return The Vault contract. + */ + function vault() external view returns (IVault); + + /** + * @notice Returns the OToken whose price is reported. + * @return The OToken contract. + */ + function oToken() external view returns (IOToken); + + /** + * @notice Returns the value of one OToken in the Vault's underlying asset. + * @return The price with 18 decimals. + */ + function price() external view returns (uint256); + + /** + * @notice Returns the latest price using the legacy Chainlink interface. + * @return The price with 18 decimals. + */ + function latestAnswer() external view returns (int256); +} diff --git a/contracts/contracts/oracle/OTokenVaultOracle.sol b/contracts/contracts/oracle/OTokenVaultOracle.sol new file mode 100644 index 0000000000..74a0892c2a --- /dev/null +++ b/contracts/contracts/oracle/OTokenVaultOracle.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IOToken } from "../interfaces/IOToken.sol"; +import { IOTokenVaultOracle } from "../interfaces/IOTokenVaultOracle.sol"; +import { IVault } from "../interfaces/IVault.sol"; + +/** + * @title OToken Vault Oracle + * @notice Reports the value of one OToken in the Vault's underlying asset. + * @dev The price has 18 decimals and is calculated from the Vault's total value + * divided by the OToken's total supply. + * @author Origin Protocol Inc + */ +contract OTokenVaultOracle is IOTokenVaultOracle { + /// @notice The number of decimals in the reported price. + uint8 public constant override decimals = 18; + + /// @notice The Chainlink-compatible oracle version. + uint256 public constant override version = 1; + + /// @notice The Vault used to calculate the OToken price. + IVault public immutable vault; + + /// @notice The OToken whose price is reported. + IOToken public immutable oToken; + + /// @notice A human-readable description of the price pair. + string public override description; + + /** + * @notice Constructs an OToken Vault Oracle. + * @param _vault The Vault used to calculate the price and resolve the OToken. + * @param _description A human-readable description of the price pair. + */ + constructor(address _vault, string memory _description) { + require(_vault != address(0), "Vault is zero address"); + + vault = IVault(_vault); + address _oToken = vault.oToken(); + require(_oToken != address(0), "OToken is zero address"); + oToken = IOToken(_oToken); + description = _description; + } + + /** + * @notice Returns the value of one OToken in the Vault's underlying asset. + * @return The price with 18 decimals. + */ + function price() public view override returns (uint256) { + uint256 supply = oToken.totalSupply(); + require(supply > 0, "No data present"); + + return (vault.totalValue() * 1e18) / supply; + } + + /** + * @notice Legacy Chainlink-compatible latest price accessor. + * @return The latest price with 18 decimals. + */ + function latestAnswer() external view override returns (int256) { + return int256(price()); + } + + /** + * @notice Returns data for the current synthetic round. + * @dev This is a live calculated feed, so only round 1 exists. + * @param _roundId The requested round ID, which must be 1. + * @return roundId The synthetic round ID. + * @return answer The current price with 18 decimals. + * @return startedAt The timestamp at which the price was read. + * @return updatedAt The timestamp at which the price was read. + * @return answeredInRound The synthetic round ID in which the answer was calculated. + */ + function getRoundData(uint80 _roundId) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + require(_roundId == 1, "No data present"); + return _latestRoundData(); + } + + /** + * @notice Returns the latest calculated price as Chainlink round data. + * @return roundId The synthetic round ID. + * @return answer The current price with 18 decimals. + * @return startedAt The timestamp at which the price was read. + * @return updatedAt The timestamp at which the price was read. + * @return answeredInRound The synthetic round ID in which the answer was calculated. + */ + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return _latestRoundData(); + } + + function _latestRoundData() + internal + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return (1, int256(price()), block.timestamp, block.timestamp, 1); + } +} diff --git a/contracts/scripts/deploy/base/001_DeploySuperOETHVaultOracle.s.sol b/contracts/scripts/deploy/base/001_DeploySuperOETHVaultOracle.s.sol new file mode 100644 index 0000000000..b12fbf6329 --- /dev/null +++ b/contracts/scripts/deploy/base/001_DeploySuperOETHVaultOracle.s.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { OTokenVaultOracle } from "contracts/oracle/OTokenVaultOracle.sol"; +import { AbstractDeployScript } from "scripts/deploy/helpers/AbstractDeployScript.s.sol"; + +/** + * @title 001_DeploySuperOETHVaultOracle + * @notice Deploys the superOETHb/WETH Vault Oracle on Base. + * @author Origin Protocol Inc + */ +contract $001_DeploySuperOETHVaultOracle is + AbstractDeployScript("001_DeploySuperOETHVaultOracle") +{ + function _execute() internal override { + OTokenVaultOracle superOETHVaultOracle = new OTokenVaultOracle( + resolver.resolve("OETHBASE_VAULT_PROXY"), + "superOETHb / WETH" + ); + + _recordDeployment( + "OETHBASE_VAULT_ORACLE", + address(superOETHVaultOracle) + ); + } + + function _fork() internal override { + OTokenVaultOracle oracle = OTokenVaultOracle( + resolver.resolve("OETHBASE_VAULT_ORACLE") + ); + + require( + address(oracle.vault()) == resolver.resolve("OETHBASE_VAULT_PROXY"), + "Unexpected Vault" + ); + require( + address(oracle.oToken()) == resolver.resolve("OETHBASE_PROXY"), + "Unexpected OToken" + ); + require(oracle.decimals() == 18, "Unexpected decimals"); + require(oracle.version() == 1, "Unexpected version"); + require( + keccak256(bytes(oracle.description())) == + keccak256(bytes("superOETHb / WETH")), + "Unexpected description" + ); + require(oracle.price() > 0, "Invalid price"); + } +} diff --git a/contracts/scripts/deploy/mainnet/004_DeployOTokenVaultOracles.s.sol b/contracts/scripts/deploy/mainnet/004_DeployOTokenVaultOracles.s.sol new file mode 100644 index 0000000000..7c47338415 --- /dev/null +++ b/contracts/scripts/deploy/mainnet/004_DeployOTokenVaultOracles.s.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { OTokenVaultOracle } from "contracts/oracle/OTokenVaultOracle.sol"; +import { AbstractDeployScript } from "scripts/deploy/helpers/AbstractDeployScript.s.sol"; + +/** + * @title 004_DeployOTokenVaultOracles + * @notice Deploys the OETH/WETH and OUSD/USDC Vault Oracles on mainnet. + * @author Origin Protocol Inc + */ +contract $004_DeployOTokenVaultOracles is + AbstractDeployScript("004_DeployOTokenVaultOracles") +{ + function _execute() internal override { + OTokenVaultOracle oethVaultOracle = new OTokenVaultOracle( + resolver.resolve("OETH_VAULT_PROXY"), + "OETH / WETH" + ); + OTokenVaultOracle ousdVaultOracle = new OTokenVaultOracle( + resolver.resolve("OUSD_VAULT_PROXY"), + "OUSD / USDC" + ); + + _recordDeployment("OETH_VAULT_ORACLE", address(oethVaultOracle)); + _recordDeployment("OUSD_VAULT_ORACLE", address(ousdVaultOracle)); + } + + function _fork() internal override { + _verifyOracle( + resolver.resolve("OETH_VAULT_ORACLE"), + resolver.resolve("OETH_VAULT_PROXY"), + resolver.resolve("OETH_PROXY"), + "OETH / WETH" + ); + _verifyOracle( + resolver.resolve("OUSD_VAULT_ORACLE"), + resolver.resolve("OUSD_VAULT_PROXY"), + resolver.resolve("OUSD_PROXY"), + "OUSD / USDC" + ); + } + + function _verifyOracle( + address oracleAddress, + address vaultAddress, + address oTokenAddress, + string memory expectedDescription + ) internal view { + OTokenVaultOracle oracle = OTokenVaultOracle(oracleAddress); + + require(address(oracle.vault()) == vaultAddress, "Unexpected Vault"); + require(address(oracle.oToken()) == oTokenAddress, "Unexpected OToken"); + require(oracle.decimals() == 18, "Unexpected decimals"); + require(oracle.version() == 1, "Unexpected version"); + require( + keccak256(bytes(oracle.description())) == + keccak256(bytes(expectedDescription)), + "Unexpected description" + ); + require(oracle.price() > 0, "Invalid price"); + } +} diff --git a/contracts/tasks/lib/network.ts b/contracts/tasks/lib/network.ts index 33c8317c12..ab68dd6d80 100644 --- a/contracts/tasks/lib/network.ts +++ b/contracts/tasks/lib/network.ts @@ -1,5 +1,4 @@ import { ethers } from "ethers"; -import { resolveChain, getRpcEnvVar } from "@oplabs/talos-client"; /** * Ambient network context for the standalone (hardhat-free) action runtime. @@ -31,6 +30,10 @@ export function rpcUrlFor(nameOrId: string | number): { networkName: string; url: string; } { + // Talos is an optional peer dependency and is only installed in the action + // runner image. Load it lazily so Hardhat can import shared utilities without + // requiring the private package. + const { resolveChain, getRpcEnvVar } = require("@oplabs/talos-client"); const chain = resolveChain(nameOrId); let url: string | undefined; if (process.env.FORK === "true" && process.env.LOCAL_PROVIDER_URL) { diff --git a/contracts/tests/mocks/MockOTokenVaultOracleDependencies.sol b/contracts/tests/mocks/MockOTokenVaultOracleDependencies.sol new file mode 100644 index 0000000000..162ed07b1d --- /dev/null +++ b/contracts/tests/mocks/MockOTokenVaultOracleDependencies.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +contract MockOTokenVaultOracleVault { + uint256 public totalValue; + address public oToken; + + function setTotalValue(uint256 _totalValue) external { + totalValue = _totalValue; + } + + function setOToken(address _oToken) external { + oToken = _oToken; + } +} + +contract MockOTokenVaultOracleToken { + uint256 public totalSupply; + + function setTotalSupply(uint256 _totalSupply) external { + totalSupply = _totalSupply; + } +} diff --git a/contracts/tests/unit/oracle/OTokenVaultOracle/concrete/ViewFunctions.t.sol b/contracts/tests/unit/oracle/OTokenVaultOracle/concrete/ViewFunctions.t.sol new file mode 100644 index 0000000000..e86cdcf95e --- /dev/null +++ b/contracts/tests/unit/oracle/OTokenVaultOracle/concrete/ViewFunctions.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Unit_OTokenVaultOracle_Shared_Test } from "../shared/Shared.t.sol"; + +contract Unit_Concrete_OTokenVaultOracle_ViewFunctions_Test is + Unit_OTokenVaultOracle_Shared_Test +{ + function test_constructor_setsConfiguration() public view { + assertEq(address(oracle.vault()), address(mockVault)); + assertEq(address(oracle.oToken()), address(mockOToken)); + assertEq(oracle.decimals(), 18); + assertEq(oracle.description(), "OToken / underlying asset"); + assertEq(oracle.version(), 1); + } + + function test_price_isOneAtRebase() public view { + assertEq(oracle.price(), 1e18); + } + + function test_price_increasesWithUnrebasedYield() public { + mockVault.setTotalValue(105e18); + assertEq(oracle.price(), 1.05e18); + } + + function test_price_returnsFractionalPrice() public { + mockVault.setTotalValue(101e18); + mockOToken.setTotalSupply(100e18); + assertEq(oracle.price(), 1.01e18); + } + + function test_price_RevertWhen_supplyIsZero() public { + mockOToken.setTotalSupply(0); + vm.expectRevert("No data present"); + oracle.price(); + } + + function test_latestAnswer_returnsPrice() public view { + assertEq(oracle.latestAnswer(), int256(1e18)); + } + + function test_latestRoundData_returnsCurrentPrice() public view { + ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = oracle.latestRoundData(); + + assertEq(roundId, 1); + assertEq(answer, int256(1e18)); + assertEq(startedAt, block.timestamp); + assertEq(updatedAt, block.timestamp); + assertEq(answeredInRound, 1); + } + + function test_getRoundData_returnsCurrentPriceForSyntheticRound() + public + view + { + (uint80 roundId, int256 answer, , , uint80 answeredInRound) = oracle + .getRoundData(1); + + assertEq(roundId, 1); + assertEq(answer, int256(1e18)); + assertEq(answeredInRound, 1); + } + + function test_getRoundData_RevertWhen_roundDoesNotExist() public { + vm.expectRevert("No data present"); + oracle.getRoundData(2); + } +} diff --git a/contracts/tests/unit/oracle/OTokenVaultOracle/fuzz/ViewFunctions.fuzz.t.sol b/contracts/tests/unit/oracle/OTokenVaultOracle/fuzz/ViewFunctions.fuzz.t.sol new file mode 100644 index 0000000000..f4c50ddde8 --- /dev/null +++ b/contracts/tests/unit/oracle/OTokenVaultOracle/fuzz/ViewFunctions.fuzz.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Unit_OTokenVaultOracle_Shared_Test } from "../shared/Shared.t.sol"; + +contract Unit_Fuzz_OTokenVaultOracle_ViewFunctions_Test is + Unit_OTokenVaultOracle_Shared_Test +{ + /// @notice The oracle always reports total value divided by total supply with 18 decimals. + function testFuzz_price_reportsAssetValuePerOToken( + uint256 totalValue, + uint256 totalSupply + ) public { + totalValue = bound(totalValue, 0, 1e40); + totalSupply = bound(totalSupply, 1, 1e40); + + mockVault.setTotalValue(totalValue); + mockOToken.setTotalSupply(totalSupply); + + assertEq(oracle.price(), (totalValue * 1e18) / totalSupply); + } +} diff --git a/contracts/tests/unit/oracle/OTokenVaultOracle/shared/Shared.t.sol b/contracts/tests/unit/oracle/OTokenVaultOracle/shared/Shared.t.sol new file mode 100644 index 0000000000..33a3e90f81 --- /dev/null +++ b/contracts/tests/unit/oracle/OTokenVaultOracle/shared/Shared.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Base } from "tests/Base.t.sol"; +import { Oracles } from "tests/utils/artifacts/Oracles.sol"; + +import { IOTokenVaultOracle } from "contracts/interfaces/IOTokenVaultOracle.sol"; +import { + MockOTokenVaultOracleToken, + MockOTokenVaultOracleVault +} from "tests/mocks/MockOTokenVaultOracleDependencies.sol"; + +abstract contract Unit_OTokenVaultOracle_Shared_Test is Base { + IOTokenVaultOracle internal oracle; + MockOTokenVaultOracleVault internal mockVault; + MockOTokenVaultOracleToken internal mockOToken; + + function setUp() public virtual override { + super.setUp(); + vm.warp(7 days); + _deployMockContracts(); + _deployContracts(); + _configureContracts(); + _fundInitialUsers(); + label(); + } + + function _deployMockContracts() internal { + mockVault = new MockOTokenVaultOracleVault(); + mockOToken = new MockOTokenVaultOracleToken(); + mockVault.setOToken(address(mockOToken)); + } + + function _deployContracts() internal { + oracle = IOTokenVaultOracle( + vm.deployCode( + Oracles.O_TOKEN_VAULT_ORACLE, + abi.encode( + address(mockVault), + "OToken / underlying asset" + ) + ) + ); + } + + function _configureContracts() internal { + mockVault.setTotalValue(100e18); + mockOToken.setTotalSupply(100e18); + } + + function _fundInitialUsers() internal {} + + function label() public { + vm.label(address(oracle), "OTokenVaultOracle"); + vm.label(address(mockVault), "MockVault"); + vm.label(address(mockOToken), "MockOToken"); + } +} diff --git a/contracts/tests/utils/artifacts/Oracles.sol b/contracts/tests/utils/artifacts/Oracles.sol new file mode 100644 index 0000000000..7b0d3bb60f --- /dev/null +++ b/contracts/tests/utils/artifacts/Oracles.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +library Oracles { + string internal constant O_TOKEN_VAULT_ORACLE = + "contracts/oracle/OTokenVaultOracle.sol:OTokenVaultOracle"; +}