diff --git a/src/adapter/pendle/PendleUnifiedOracle.sol b/src/adapter/pendle/PendleUnifiedOracle.sol new file mode 100644 index 00000000..b262324d --- /dev/null +++ b/src/adapter/pendle/PendleUnifiedOracle.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IPMarket} from "@pendle/core-v2/interfaces/IPMarket.sol"; +import {IPPrincipalToken} from "@pendle/core-v2/interfaces/IPPrincipalToken.sol"; +import {IPPYLpOracle} from "@pendle/core-v2/interfaces/IPPYLpOracle.sol"; +import {IStandardizedYield} from "@pendle/core-v2/interfaces/IStandardizedYield.sol"; +import {PendlePYOracleLib} from "@pendle/core-v2/oracles/PtYtLpOracle/PendlePYOracleLib.sol"; +import {PendleLpOracleLib} from "@pendle/core-v2/oracles/PtYtLpOracle/PendleLpOracleLib.sol"; +import {BaseAdapter, Errors, IPriceOracle} from "../BaseAdapter.sol"; +import {ScaleUtils, Scale} from "../../lib/ScaleUtils.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; + +/// @title PendleUnifiedOracle +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Adapter for Pendle PT and LP Oracle. +contract PendleUnifiedOracle is BaseAdapter, Ownable2Step { + /// @inheritdoc IPriceOracle + string public constant name = "PendleUnifiedOracle"; + /// @dev The minimum length of the TWAP window. + uint32 internal constant MIN_TWAP_WINDOW = 5 minutes; + /// @dev The maximum length of the TWAP window. + uint32 internal constant MAX_TWAP_WINDOW = 60 minutes; + /// @notice The decimals of the Pendle Oracle. Fixed to 18. + uint8 internal constant FEED_DECIMALS = 18; + + struct PairParams { + /// @notice The address of the Pendle market. + address pendleMarket; + /// @notice The desired length of the twap window. + uint32 twapWindow; + /// @notice The flag indicating the direction of the price. False when base/quote, true - quote/base + bool inverse; + /// @notice The PendlePYOracleLib function to call. + function (IPMarket, uint32) view returns (uint256) getRate; + /// @notice The scale factors used for decimal conversions. + Scale scale; + } + + mapping(address => mapping(address => PairParams)) private _configuredPairs; + + address public immutable pendleOracle; + + event PairAdded(address indexed pendleMarket, address indexed base, address indexed quote, uint32 twapWindow); + + constructor(address _pendleOracle) { + if (_pendleOracle == address(0)) { + revert Errors.ZeroAddress(); + } + + pendleOracle = _pendleOracle; + } + + /// @dev The oracle can price Pendle PT,LP to SY,Asset. Whether to use SY or Asset depends on the underlying. + /// Consult https://docs.pendle.finance/Developers/Contracts/StandardizedYield#standard-sys for more information. + /// Before deploying this adapter ensure that the oracle is initialized and the observation buffer is filled. + /// Note that this adapter allows specifing any `quote` as the underlying asset. + /// @param _pendleMarket The address of the Pendle market. + /// @param _base The address of the PT or LP token. + /// @param _quote The address of the SY token or the underlying asset. + /// @param _twapWindow The desired length of the twap window. + function addPair(address _pendleMarket, address _base, address _quote, uint32 _twapWindow) external onlyOwner { + //Verify that the pair is not already initialized. + if (_configuredPairs[_base][_quote].pendleMarket != address(0)) { + revert Errors.PriceOracle_AlreadyInitialized(); + } + + // Verify that the TWAP window is sufficiently long. + if (_twapWindow < MIN_TWAP_WINDOW || _twapWindow > MAX_TWAP_WINDOW) { + revert Errors.PriceOracle_InvalidConfiguration(); + } + + // Verify that the observations buffer is adequately sized and populated. + (bool increaseCardinalityRequired,, bool oldestObservationSatisfied) = + IPPYLpOracle(pendleOracle).getOracleState(_pendleMarket, _twapWindow); + if (increaseCardinalityRequired || !oldestObservationSatisfied) { + revert Errors.PriceOracle_InvalidConfiguration(); + } + + (IStandardizedYield sy, IPPrincipalToken pt,) = IPMarket(_pendleMarket).readTokens(); + (, address asset,) = sy.assetInfo(); + + PairParams memory pairParams; + + if (_base == address(pt)) { + if (_quote == address(sy)) { + pairParams.getRate = PendlePYOracleLib.getPtToSyRate; + } else if (asset == _quote) { + // Pendle do not recommend to use this type of price + // https://docs.pendle.finance/Developers/Oracles/HowToIntegratePtAndLpOracle + pairParams.getRate = PendlePYOracleLib.getPtToAssetRate; + } else { + revert Errors.PriceOracle_InvalidConfiguration(); + } + } else if (_base == _pendleMarket) { + if (_quote == address(sy)) { + pairParams.getRate = PendleLpOracleLib.getLpToSyRate; + } else if (asset == _quote) { + pairParams.getRate = PendleLpOracleLib.getLpToAssetRate; + } else { + revert Errors.PriceOracle_InvalidConfiguration(); + } + } else { + revert Errors.PriceOracle_InvalidConfiguration(); + } + + pairParams.pendleMarket = _pendleMarket; + pairParams.twapWindow = _twapWindow; + pairParams.inverse = false; + + // We don't need to worry about decimals base and quote decimals scaling, + // Pendle formula to access LP (rawX) in SY (rawY) + // rawY= rawX × lpToSyRate / 10^18 + // + // https://docs.pendle.finance/Developers/Oracles/HowToIntegratePtAndLpOracle + pairParams.scale = ScaleUtils.calcScale(0, 0, FEED_DECIMALS); + + _configuredPairs[_base][_quote] = pairParams; + + pairParams.inverse = true; + _configuredPairs[_quote][_base] = pairParams; + + emit PairAdded(_pendleMarket, _base, _quote, _twapWindow); + } + + /// @notice Get a quote by calling the Pendle oracle. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @dev Note that the quote does not include instantaneous DEX slippage. + /// @return The converted amount using the Pendle oracle. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + PairParams memory pairParams = _configuredPairs[_base][_quote]; + if (pairParams.pendleMarket == address(0)) { + revert Errors.PriceOracle_InvalidConfiguration(); + } + + uint256 unitPrice = pairParams.getRate(IPMarket(pairParams.pendleMarket), pairParams.twapWindow); + return ScaleUtils.calcOutAmount(inAmount, unitPrice, pairParams.scale, pairParams.inverse); + } + + function getConfiguredPair(address _base, address _quote) + external + view + returns (address pendleMarket, uint32 twapWindow, bool inverse, Scale scale) + { + PairParams memory pairParams = _configuredPairs[_base][_quote]; + return (pairParams.pendleMarket, pairParams.twapWindow, pairParams.inverse, pairParams.scale); + } +} diff --git a/src/lib/Errors.sol b/src/lib/Errors.sol index 8fa36b61..0f2948d7 100644 --- a/src/lib/Errors.sol +++ b/src/lib/Errors.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.0; /// @author Euler Labs (https://www.eulerlabs.com/) /// @notice Collects common errors in PriceOracles. library Errors { + error ZeroAddress(); /// @notice The external feed returned an invalid answer. error PriceOracle_InvalidAnswer(); /// @notice The configuration parameters for the PriceOracle are invalid. @@ -22,4 +23,6 @@ library Errors { error PriceOracle_TooStale(uint256 staleness, uint256 maxStaleness); /// @notice The method can only be called by the governor. error Governance_CallerNotGovernor(); + /// @notice The price oracle has already been initialized. + error PriceOracle_AlreadyInitialized(); } diff --git a/test/adapter/pendle/PendleAddresses.sol b/test/adapter/pendle/PendleAddresses.sol index af13b139..5a81a5f6 100644 --- a/test/adapter/pendle/PendleAddresses.sol +++ b/test/adapter/pendle/PendleAddresses.sol @@ -30,3 +30,7 @@ address constant PENDLE_CORN_UNIBTC_1224_SY = 0xAE754a3B4553EA2EA4794d0171a56Ac1 address constant PENDLE_CORN_SOLVBTCBBN_1224_MARKET = 0xEb4d3057738b9Ed930F451Be473C1CCC42988384; address constant PENDLE_CORN_SOLVBTCBBN_1224_PT = 0x23e479ddcda990E8523494895759bD98cD2fDBF6; address constant PENDLE_CORN_SOLVBTCBBN_1224_SY = 0xEC30E55B51D9518cfcf5e870BCF89c73F5708f72; + +address constant PENDLE_ETHENA_SUSDE_0925_MARKET = 0xA36b60A14A1A5247912584768C6e53E1a269a9F7; +address constant PENDLE_ETHENA_SUSDE_0925_PT = 0x9F56094C450763769BA0EA9Fe2876070c0fD5F77; +address constant PENDLE_ETHENA_SUSDE_0925_SY = 0xC01cde799245a25e6EabC550b36A47F6F83cc0f1; diff --git a/test/adapter/pendle/PendleUnifiedOracle/PendleUnifiedOracle.fork.t.sol b/test/adapter/pendle/PendleUnifiedOracle/PendleUnifiedOracle.fork.t.sol new file mode 100644 index 00000000..b6c2d3a0 --- /dev/null +++ b/test/adapter/pendle/PendleUnifiedOracle/PendleUnifiedOracle.fork.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import { + PENDLE_ORACLE, + PENDLE_ETHENA_SUSDE_0925_MARKET, + PENDLE_ETHENA_SUSDE_0925_PT, + PENDLE_ETHENA_SUSDE_0925_SY +} from "test/adapter/pendle/PendleAddresses.sol"; +import {EETH, EBTC, USDC, USDE, WBTC} from "test/utils/EthereumAddresses.sol"; +import {ForkTest} from "test/utils/ForkTest.sol"; +import {PendleUnifiedOracle} from "src/adapter/pendle/PendleUnifiedOracle.sol"; +import {Errors} from "src/lib/Errors.sol"; + +contract PendleUnifiedOracleForkTest is ForkTest { + PendleUnifiedOracle oracle; + /// @dev 1% + uint256 constant REL_PRECISION = 0.01e18; + + function setUp() public { + _setUpFork(23238500); + oracle = new PendleUnifiedOracle(PENDLE_ORACLE); + } + + /// @dev This market is active. 1 PT-sUSDe0925 = 0.8314 SY-sUSDe. Oracle has no slippage. + function test_GetQuote_ActiveMarket_sUSDe0925_PT_SY() public { + oracle.addPair( + PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_PT, PENDLE_ETHENA_SUSDE_0925_SY, 15 minutes + ); + + uint256 outAmount = oracle.getQuote(1e18, PENDLE_ETHENA_SUSDE_0925_PT, PENDLE_ETHENA_SUSDE_0925_SY); + uint256 outAmount1000 = oracle.getQuote(1000e18, PENDLE_ETHENA_SUSDE_0925_PT, PENDLE_ETHENA_SUSDE_0925_SY); + assertApproxEqRel(outAmount, 0.8314e18, REL_PRECISION); + assertEq(outAmount1000, outAmount * 1000); + + uint256 outAmountInv = oracle.getQuote(outAmount, PENDLE_ETHENA_SUSDE_0925_SY, PENDLE_ETHENA_SUSDE_0925_PT); + assertEq(outAmountInv, 1e18); + uint256 outAmountInv1000 = + oracle.getQuote(outAmount1000, PENDLE_ETHENA_SUSDE_0925_SY, PENDLE_ETHENA_SUSDE_0925_PT); + assertEq(outAmountInv1000, 1000e18); + } + + /// @dev This market is active. 1 PT-sUSDe0925 = 0.9911 USDe. + function test_GetQuote_ActiveMarket_sUSDe0925_PT_Asset() public { + oracle.addPair(PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_PT, USDE, 15 minutes); + + uint256 outAmount = oracle.getQuote(1e18, PENDLE_ETHENA_SUSDE_0925_PT, USDE); + uint256 outAmount1000 = oracle.getQuote(1000e18, PENDLE_ETHENA_SUSDE_0925_PT, USDE); + assertApproxEqRel(outAmount, 0.9911e18, REL_PRECISION); + assertEq(outAmount1000, outAmount * 1000); + + uint256 outAmountInv = oracle.getQuote(outAmount, USDE, PENDLE_ETHENA_SUSDE_0925_PT); + assertEq(outAmountInv, 1e18); + uint256 outAmountInv1000 = oracle.getQuote(outAmount1000, USDE, PENDLE_ETHENA_SUSDE_0925_PT); + assertEq(outAmountInv1000, 1000e18); + } + + /// @dev This market is active. 1 LP-sUSDe0925 = 2.78426 USDe + function test_GetQuote_ActiveMarket_sUSDe0925_LP_Asset() public { + oracle.addPair(PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_MARKET, USDE, 15 minutes); + + uint256 outAmount = oracle.getQuote(1e18, PENDLE_ETHENA_SUSDE_0925_MARKET, USDE); + uint256 outAmount1000 = oracle.getQuote(1000e18, PENDLE_ETHENA_SUSDE_0925_MARKET, USDE); + assertApproxEqRel(outAmount, 2.78426e18, REL_PRECISION); + assertEq(outAmount1000, outAmount * 1000); + + uint256 outAmountInv = oracle.getQuote(outAmount, USDE, PENDLE_ETHENA_SUSDE_0925_MARKET); + assertEq(outAmountInv, 1e18); + uint256 outAmountInv1000 = oracle.getQuote(outAmount1000, USDE, PENDLE_ETHENA_SUSDE_0925_MARKET); + assertEq(outAmountInv1000, 1000e18); + } + + /// @dev This market is active. 1 LP-sUSDe0925 = 2.3353 SY-sUSDe. Oracle has no slippage. + function test_GetQuote_ActiveMarket_sUSDe0925_LP_SY() public { + oracle.addPair( + PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_SY, 15 minutes + ); + + uint256 outAmount = oracle.getQuote(1e18, PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_SY); + uint256 outAmount1000 = oracle.getQuote(1000e18, PENDLE_ETHENA_SUSDE_0925_MARKET, PENDLE_ETHENA_SUSDE_0925_SY); + assertApproxEqRel(outAmount, 2.3353e18, REL_PRECISION); + assertEq(outAmount1000, outAmount * 1000); + + uint256 outAmountInv = oracle.getQuote(outAmount, PENDLE_ETHENA_SUSDE_0925_SY, PENDLE_ETHENA_SUSDE_0925_MARKET); + assertEq(outAmountInv, 1e18); + uint256 outAmountInv1000 = + oracle.getQuote(outAmount1000, PENDLE_ETHENA_SUSDE_0925_SY, PENDLE_ETHENA_SUSDE_0925_MARKET); + assertEq(outAmountInv1000, 1000e18); + } +}