diff --git a/contracts/external-interfaces/IPendleV2MarketFactory.sol b/contracts/external-interfaces/IPendleV2MarketFactory.sol deleted file mode 100644 index 21c4e3b3e..000000000 --- a/contracts/external-interfaces/IPendleV2MarketFactory.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -/* - This file is part of the Enzyme Protocol. - - (c) Enzyme Council - - For the full license information, please view the LICENSE - file that was distributed with this source code. -*/ - -pragma solidity >=0.6.0 <0.9.0; - -/// @title IPendleV2MarketFactory Interface -/// @author Enzyme Council -interface IPendleV2MarketFactory { - function isValidMarket(address _market) external view returns (bool isValidMarket_); -} diff --git a/contracts/external-interfaces/IPendleV2PtOracle.sol b/contracts/external-interfaces/IPendleV2PtAndLpOracle.sol similarity index 75% rename from contracts/external-interfaces/IPendleV2PtOracle.sol rename to contracts/external-interfaces/IPendleV2PtAndLpOracle.sol index a59d95d36..658c010ec 100644 --- a/contracts/external-interfaces/IPendleV2PtOracle.sol +++ b/contracts/external-interfaces/IPendleV2PtAndLpOracle.sol @@ -11,11 +11,9 @@ pragma solidity >=0.6.0 <0.9.0; -/// @title IPendleV2PtOracle Interface +/// @title IPendleV2PtAndLpOracle Interface /// @author Enzyme Council -interface IPendleV2PtOracle { - function getPtToAssetRate(address _market, uint32 _duration) external view returns (uint256 ptToAssetRate_); - +interface IPendleV2PtAndLpOracle { function getOracleState(address _market, uint32 _duration) external view diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/IPendleV2Position.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/IPendleV2Position.sol index b2e74cc48..1895b0a29 100644 --- a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/IPendleV2Position.sol +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/IPendleV2Position.sol @@ -22,16 +22,6 @@ interface IPendleV2Position is IExternalPosition { ClaimRewards } - function getMarketForPrincipalToken(address _principalTokenAddress) - external - view - returns (address marketAddress_); - - function getOraclePricingDurationForMarket(address _marketAddress) - external - view - returns (uint32 pricingDuration_); - function getLPTokens() external view returns (address[] memory lpTokenAddresses_); function getPrincipalTokens() external view returns (address[] memory principalTokenAddresses_); diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionDataDecoder.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionDataDecoder.sol index 429f019e9..37e413b1e 100644 --- a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionDataDecoder.sol +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionDataDecoder.sol @@ -22,18 +22,14 @@ abstract contract PendleV2PositionDataDecoder { internal pure returns ( - address principalTokenAddress_, IPendleV2Market market_, - uint32 pricingDuration_, address depositTokenAddress_, uint256 depositAmount_, IPendleV2Router.ApproxParams memory guessPtOut_, uint256 minPtOut_ ) { - return abi.decode( - _actionArgs, (address, IPendleV2Market, uint32, address, uint256, IPendleV2Router.ApproxParams, uint256) - ); + return abi.decode(_actionArgs, (IPendleV2Market, address, uint256, IPendleV2Router.ApproxParams, uint256)); } /// @dev Helper to decode args used during the SellPrincipalToken action @@ -41,14 +37,13 @@ abstract contract PendleV2PositionDataDecoder { internal pure returns ( - IPendleV2PrincipalToken principalTokenAddress_, IPendleV2Market market_, address withdrawalTokenAddress_, uint256 withdrawalAmount_, uint256 minIncomingAmount_ ) { - return abi.decode(_actionArgs, (IPendleV2PrincipalToken, IPendleV2Market, address, uint256, uint256)); + return abi.decode(_actionArgs, (IPendleV2Market, address, uint256, uint256)); } /// @dev Helper to decode args used during the AddLiquidity action @@ -57,15 +52,13 @@ abstract contract PendleV2PositionDataDecoder { pure returns ( IPendleV2Market market_, - uint32 pricingDuration_, address depositTokenAddress_, uint256 depositAmount_, IPendleV2Router.ApproxParams memory guessPtReceived_, uint256 minLpOut_ ) { - return - abi.decode(_actionArgs, (IPendleV2Market, uint32, address, uint256, IPendleV2Router.ApproxParams, uint256)); + return abi.decode(_actionArgs, (IPendleV2Market, address, uint256, IPendleV2Router.ApproxParams, uint256)); } /// @dev Helper to decode args used during the AddLiquidity action diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionLib.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionLib.sol index 5911d0ced..539c5a969 100644 --- a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionLib.sol +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionLib.sol @@ -11,13 +11,11 @@ pragma solidity 0.8.19; import {IERC20} from "../../../../../external-interfaces/IERC20.sol"; import {IPendleV2Market} from "../../../../../external-interfaces/IPendleV2Market.sol"; -import {IPendleV2MarketFactory} from "../../../../../external-interfaces/IPendleV2MarketFactory.sol"; import {IPendleV2PrincipalToken} from "../../../../../external-interfaces/IPendleV2PrincipalToken.sol"; -import {IPendleV2PtOracle} from "../../../../../external-interfaces/IPendleV2PtOracle.sol"; import {IPendleV2Router} from "../../../../../external-interfaces/IPendleV2Router.sol"; import {IPendleV2StandardizedYield} from "../../../../../external-interfaces/IPendleV2StandardizedYield.sol"; import {IWETH} from "../../../../../external-interfaces/IWETH.sol"; -import {IAddressListRegistry} from "../../../../../persistent/address-list-registry/IAddressListRegistry.sol"; +import {IExternalPositionProxy} from "../../../../../persistent/external-positions/IExternalPositionProxy.sol"; import {AddressArrayLib} from "../../../../../utils/0.8.19/AddressArrayLib.sol"; import {AssetHelpers} from "../../../../../utils/0.8.19/AssetHelpers.sol"; import {PendleLpOracleLib} from "../../../../../utils/0.8.19/pendle/adapted-libs/PendleLpOracleLib.sol"; @@ -26,13 +24,22 @@ import {IPendleV2Market as IOracleLibPendleMarket} from "../../../../../utils/0.8.19/pendle/adapted-libs/interfaces/IPendleV2Market.sol"; import {WrappedSafeERC20 as SafeERC20} from "../../../../../utils/0.8.19/open-zeppelin/WrappedSafeERC20.sol"; import {PendleV2PositionLibBase1} from "./bases/PendleV2PositionLibBase1.sol"; +import {IPendleV2MarketRegistry} from "./markets-registry/IPendleV2MarketRegistry.sol"; import {IPendleV2Position} from "./IPendleV2Position.sol"; import {PendleV2PositionDataDecoder} from "./PendleV2PositionDataDecoder.sol"; /// @title PendleV2PositionLib Contract /// @author Enzyme Council /// @notice An External Position library contract for Pendle V2 Positions -/// @dev See "POSITION VALUE" section for notes on pricing mechanism that must be considered by funds +/// @dev In order to take a particular Pendle V2 position (PT or LP), +/// the fund owner must first register it on the PendleV2MarketsRegistry contract, via the VaultProxy, +/// i.e., by calling ComptrollerProxy.vaultCallOnContract(). +/// The actions allowed in this position follow the following rules based on the registry: +/// - Can buy PT from MarketA: MarketA is the market oracle for PT +/// - Can sell PT on MarketA: MarketA is the market oracle for PT +/// - Can provide liquidity to MarketA: MarketA has a non-zero TWAP duration +/// - Can remove liquidity from MarketA: always allowed if LP token was acquired via this contract +/// See "POSITION VALUE" section for notes on pricing mechanism that must be considered by funds. contract PendleV2PositionLib is IPendleV2Position, PendleV2PositionDataDecoder, @@ -46,28 +53,16 @@ contract PendleV2PositionLib is uint256 internal constant ORACLE_RATE_PRECISION = 1e18; address internal constant PENDLE_NATIVE_ASSET_ADDRESS = address(0); - IAddressListRegistry internal immutable ADDRESS_LIST_REGISTRY; - uint32 internal immutable MINIMUM_PRICING_DURATION; - uint32 internal immutable MAXIMUM_PRICING_DURATION; - uint256 internal immutable PENDLE_MARKET_FACTORIES_LIST_ID; + IPendleV2MarketRegistry internal immutable PENDLE_MARKET_REGISTRY; IPendleV2Router internal immutable PENDLE_ROUTER; - IPendleV2PtOracle internal immutable PRINCIPAL_TOKEN_ORACLE; IWETH private immutable WRAPPED_NATIVE_ASSET; constructor( - address _addressListRegistry, - uint32 _minimumPricingDuration, - uint32 _maximumPricingDuration, - uint256 _pendleMarketFactoriesListId, - address _pendlePtOracleAddress, + address _pendleMarketsRegistryAddress, address _pendleRouterAddress, address _wrappedNativeAssetAddress ) { - ADDRESS_LIST_REGISTRY = IAddressListRegistry(_addressListRegistry); - MINIMUM_PRICING_DURATION = _minimumPricingDuration; - MAXIMUM_PRICING_DURATION = _maximumPricingDuration; - PENDLE_MARKET_FACTORIES_LIST_ID = _pendleMarketFactoriesListId; - PRINCIPAL_TOKEN_ORACLE = IPendleV2PtOracle(_pendlePtOracleAddress); + PENDLE_MARKET_REGISTRY = IPendleV2MarketRegistry(_pendleMarketsRegistryAddress); PENDLE_ROUTER = IPendleV2Router(_pendleRouterAddress); WRAPPED_NATIVE_ASSET = IWETH(_wrappedNativeAssetAddress); } @@ -97,19 +92,22 @@ contract PendleV2PositionLib is function __buyPrincipalToken(bytes memory _actionArgs) private { // Decode the actionArgs ( - address principalTokenAddress, IPendleV2Market market, - uint32 pricingDuration, address depositTokenAddress, uint256 depositAmount, IPendleV2Router.ApproxParams memory guessPtOut, uint256 minPtOut ) = __decodeBuyPrincipalTokenActionArgs(_actionArgs); - __handlePrincipalTokenInput({_principalTokenAddress: principalTokenAddress, _marketAddress: address(market)}); - __handleMarketAndDurationInput({_marketAddress: address(market), _duration: pricingDuration}); + (IPendleV2StandardizedYield syToken, IPendleV2PrincipalToken principalToken,) = market.readTokens(); - (IPendleV2StandardizedYield syToken,,) = market.readTokens(); + __validateMarketForPt({_ptAddress: address(principalToken), _marketAddress: address(market)}); + + // Add principal token to storage as-needed + if (!principalTokens.contains(address(principalToken))) { + principalTokens.push(address(principalToken)); + emit PrincipalTokenAdded(address(principalToken)); + } // We can safely pass in 0 for minIncomingShares since we validate the final minPtOut. (uint256 syTokenAmount) = __mintSYToken({ @@ -143,19 +141,13 @@ contract PendleV2PositionLib is /// @dev Helper to sell a Pendle principal token for an underlying token function __sellPrincipalToken(bytes memory _actionArgs) private { // Decode the actionArgs - ( - IPendleV2PrincipalToken principalToken, - IPendleV2Market market, - address withdrawalTokenAddress, - uint256 withdrawalAmount, - uint256 minIncomingAmount - ) = __decodeSellPrincipalTokenActionArgs(_actionArgs); + (IPendleV2Market market, address withdrawalTokenAddress, uint256 withdrawalAmount, uint256 minIncomingAmount) = + __decodeSellPrincipalTokenActionArgs(_actionArgs); - // Validate that the principal token is in storage - require( - getMarketForPrincipalToken(address(principalToken)) == address(market), - "__sellPrincipalToken: invalid market address" - ); + (IPendleV2StandardizedYield syToken, IPendleV2PrincipalToken principalToken, address yieldTokenAddress) = + IPendleV2Market(market).readTokens(); + + __validateMarketForPt({_ptAddress: address(principalToken), _marketAddress: address(market)}); // Approve the principal token to be spent by the market __approveAssetMaxAsNeeded({ @@ -164,8 +156,6 @@ contract PendleV2PositionLib is _neededAmount: withdrawalAmount }); - (IPendleV2StandardizedYield syToken,, address yieldTokenAddress) = IPendleV2Market(market).readTokens(); - // Convert PT to SY // We can safely pass 0 as _minSyOut because we validate the final minIncomingAmount uint256 netSyOut; @@ -198,10 +188,8 @@ contract PendleV2PositionLib is }); if (IERC20(address(principalToken)).balanceOf(address(this)) == 0) { - // Remove the principal token from storage - // Also clears the mapping to enable its use in verifying storage presence + // Remove the principal token from storage if no balance remains principalTokens.removeStorageItem(address(principalToken)); - principalTokenToMarket[address(principalToken)] = address(0); emit PrincipalTokenRemoved(address(principalToken)); } @@ -212,14 +200,18 @@ contract PendleV2PositionLib is // Decode the actionArgs ( IPendleV2Market market, - uint32 pricingDuration, address depositTokenAddress, uint256 depositAmount, IPendleV2Router.ApproxParams memory guessPtReceived, uint256 minLpOut ) = __decodeAddLiquidityActionArgs(_actionArgs); - __handleMarketAndDurationInput({_marketAddress: address(market), _duration: pricingDuration}); + // Validate that the market has a non-zero duration (i.e, is registered) + require( + PENDLE_MARKET_REGISTRY.getMarketOracleDurationForUser({_user: msg.sender, _marketAddress: address(market)}) + > 0, + "__addLiquidity: Unsupported market" + ); (IPendleV2StandardizedYield syToken,,) = market.readTokens(); @@ -270,7 +262,8 @@ contract PendleV2PositionLib is uint256 minIncomingAmount ) = __decodeRemoveLiquidityActionArgs(_actionArgs); - __validatePendleMarket(market); + // Validate that the LP token is tracked in this position (i.e., was acquired via this contract) + require(lpTokens.contains(address(market)), "__removeLiquidity: Unsupported market"); // Approve the router to spend the LP token __approveAssetMaxAsNeeded({ @@ -323,45 +316,15 @@ contract PendleV2PositionLib is } // Send the rewards back to the vault. - __pushFullAssetBalances(msg.sender, rewardTokenAddresses); - } - - /// @dev Helper to handle market and duration input - function __handleMarketAndDurationInput(address _marketAddress, uint32 _duration) private { - // Check whether or not the market is already in storage. - // If market is in storage, make sure that the provided duration matches the stored duration. - uint32 storedDuration = getOraclePricingDurationForMarket(_marketAddress); + // Ignore any PT and LP tokens held by this contract, as a precaution. + for (uint256 i; i < rewardTokenAddresses.length; i++) { + IERC20 rewardToken = IERC20(rewardTokenAddresses[i]); - if (storedDuration != 0) { - require(storedDuration == _duration, "__handleMarketAndDurationInput: stored duration mismatch"); - } else { - // If market is not in storage, validate market/duration and add it to storage. - __validateMarketAndDuration({_marketAddress: _marketAddress, _duration: _duration}); + if (principalTokens.contains(address(rewardToken)) || lpTokens.contains(address(rewardToken))) { + continue; + } - marketToOraclePricingDuration[_marketAddress] = _duration; - emit OracleDurationForMarketAdded(_marketAddress, _duration); - } - } - - /// @dev Helper to handle principal token input - function __handlePrincipalTokenInput(address _principalTokenAddress, address _marketAddress) private { - // Check whether or not the principalToken is already in storage. - // If PT is in storage, make sure that the provided config matches the stored config. - address storedMarket = getMarketForPrincipalToken(_principalTokenAddress); - - if (storedMarket != address(0)) { - require(storedMarket == _marketAddress, "__handlePrincipalTokenInput: stored market address mismatch"); - } else { - // If PT is not in storage, validate that it matches the Pendle market. - (, IPendleV2PrincipalToken retrievedPrincipalToken,) = IPendleV2Market(_marketAddress).readTokens(); - require( - address(retrievedPrincipalToken) == _principalTokenAddress, - "__handlePrincipalTokenInput: principal token and market mismatch" - ); - - principalTokens.push(_principalTokenAddress); - principalTokenToMarket[_principalTokenAddress] = _marketAddress; - emit PrincipalTokenAdded(_principalTokenAddress, _marketAddress); + rewardToken.safeTransfer(msg.sender, rewardToken.balanceOf(address(this))); } } @@ -419,41 +382,12 @@ contract PendleV2PositionLib is }); } - /// @dev Helper to validate the market and duration - /// Throws if invalid - function __validateMarketAndDuration(address _marketAddress, uint32 _duration) private view { - __validatePendleMarket({_market: IPendleV2Market(_marketAddress)}); - - // For safety, we require that the specified duration falls between some boundaries. + /// @dev Helper to validate the market a PT can be traded on + function __validateMarketForPt(address _ptAddress, address _marketAddress) private view { require( - MINIMUM_PRICING_DURATION <= _duration && _duration <= MAXIMUM_PRICING_DURATION, - "__validateMarketAndDuration: out-of-bounds duration" - ); - - // We validate that the oracle duration is valid as recommended by the Pendle docs. - // src: https://docs.pendle.finance/Developers/Integration/PTOracle#oracle-preparation - (bool increaseCardinalityRequired,, bool oldestObservationSatisfied) = - PRINCIPAL_TOKEN_ORACLE.getOracleState({_market: _marketAddress, _duration: _duration}); - require( - increaseCardinalityRequired == false && oldestObservationSatisfied == true, - "__validateMarketAndDuration: invalid pricing duration" - ); - } - - /// @dev Helper to validate that the market is a canonical Pendle market - /// Also checks that the market's rate asset is in the list of supported assets - /// Throws if invalid - function __validatePendleMarket(IPendleV2Market _market) private view { - IPendleV2MarketFactory pendleMarketFactory = IPendleV2MarketFactory(_market.factory()); - - require( - ADDRESS_LIST_REGISTRY.isInList(PENDLE_MARKET_FACTORIES_LIST_ID, address(pendleMarketFactory)), - "__validatePendleMarket: invalid market factory" - ); - - require( - pendleMarketFactory.isValidMarket({_market: address(_market)}), - "__validatePendleMarket: invalid market address" + _marketAddress + == PENDLE_MARKET_REGISTRY.getPtOracleMarketForUser({_user: msg.sender, _ptAddress: _ptAddress}), + "__validateMarketForPt: Unsupported market" ); } @@ -463,10 +397,11 @@ contract PendleV2PositionLib is // CONSIDERATIONS FOR FUND MANAGERS: // 1. The pricing of Pendle Principal Tokens and LP tokens is TWAP-based. - // Managers can specify which on-chain market and duration they want to use for pricing. - // The market and duration is stored in the EP. Once set, a market/duration pair cannot be changed. - // For more information on Pendle Principal Tokens pricing, see https://docs.pendle.finance/Developers/Integration/PTOracle - // For more information on Pendle LP Tokens pricing, see https://docs.pendle.finance/Developers/Integration/LPOracle + // Fund owners provide the TWAP duration to use for each position, via a registry contract (see contract-level natspec). + // Position pricing security and correctness will vary according to market liquidity and TWAP duration. + // Fund owners must consider these factors along with their fund's risk tolerance for share price deviations. + // For more information on Pendle Principal Tokens pricing, see https://docs.pendle.finance/Developers/Integration/IntroductionOfPtOracle + // For more information on Pendle LP Tokens pricing, see https://docs.pendle.finance/Developers/Integration/IntroductionOfLpOracle // 2. The valuation of the External Positions fully excludes accrued rewards. // To prevent significant underpricing, managers should claim rewards regularly. @@ -484,6 +419,8 @@ contract PendleV2PositionLib is /// 1. Principal token (PT) holdings /// 2. LP token holdings function getManagedAssets() external view override returns (address[] memory assets_, uint256[] memory amounts_) { + address vaultProxyAddress = IExternalPositionProxy(address(this)).getVaultProxy(); + address[] memory principalTokensMem = principalTokens; uint256 principalTokensLength = principalTokensMem.length; @@ -499,13 +436,17 @@ contract PendleV2PositionLib is uint256[] memory rawAmounts = new uint256[](principalTokensLength + lpTokensLength); for (uint256 i; i < principalTokensLength; i++) { - (rawAssets[i], rawAmounts[i]) = __getPrincipalTokenValue(principalTokensMem[i]); + (rawAssets[i], rawAmounts[i]) = __getPrincipalTokenValue({ + _vaultProxyAddress: vaultProxyAddress, + _principalTokenAddress: principalTokensMem[i] + }); } for (uint256 i; i < lpTokensLength; i++) { // Start assigning from the subarray that follows the assigned principalTokens uint256 nextEmptyIndex = principalTokensLength + i; - (rawAssets[nextEmptyIndex], rawAmounts[nextEmptyIndex]) = __getLpTokenValue(lpTokensMem[i]); + (rawAssets[nextEmptyIndex], rawAmounts[nextEmptyIndex]) = + __getLpTokenValue({_vaultProxyAddress: vaultProxyAddress, _lpTokenAddress: lpTokensMem[i]}); } // Does not remove 0-amount items @@ -513,7 +454,7 @@ contract PendleV2PositionLib is } /// @dev Helper to get the value, in the underlying asset, of a lpToken holding - function __getLpTokenValue(address _lpTokenAddress) + function __getLpTokenValue(address _vaultProxyAddress, address _lpTokenAddress) private view returns (address underlyingToken_, uint256 value_) @@ -529,16 +470,21 @@ contract PendleV2PositionLib is underlyingToken_ = address(WRAPPED_NATIVE_ASSET); } - uint256 rate = PendleLpOracleLib.getLpToAssetRate({ - market: IOracleLibPendleMarket(_lpTokenAddress), - duration: getOraclePricingDurationForMarket(_lpTokenAddress) + // Retrieve the registered oracle duration for the market + uint32 duration = PENDLE_MARKET_REGISTRY.getMarketOracleDurationForUser({ + _user: _vaultProxyAddress, + _marketAddress: _lpTokenAddress }); + require(duration > 0, "__getLpTokenValue: Duration not registered"); + + uint256 rate = + PendleLpOracleLib.getLpToAssetRate({market: IOracleLibPendleMarket(_lpTokenAddress), duration: duration}); value_ = lpTokenBalance * rate / ORACLE_RATE_PRECISION; } /// @dev Helper to get the value, in the underlying asset, of a principal token holding - function __getPrincipalTokenValue(address _principalTokenAddress) + function __getPrincipalTokenValue(address _vaultProxyAddress, address _principalTokenAddress) private view returns (address underlyingToken_, uint256 value_) @@ -554,12 +500,19 @@ contract PendleV2PositionLib is underlyingToken_ = address(WRAPPED_NATIVE_ASSET); } - // Retrieve the stored market and its duration - IOracleLibPendleMarket market = IOracleLibPendleMarket(getMarketForPrincipalToken(_principalTokenAddress)); + // Retrieve the registered oracle market and its duration + (address marketAddress, uint32 duration) = PENDLE_MARKET_REGISTRY.getPtOracleMarketAndDurationForUser({ + _ptAddress: _principalTokenAddress, + _user: _vaultProxyAddress + }); + require(duration > 0, "__getPrincipalTokenValue: Duration not registered"); uint256 rate = PendlePtOracleLib.getPtToAssetRate({ - market: market, - duration: getOraclePricingDurationForMarket(address(market)) + market: IOracleLibPendleMarket(marketAddress), + duration: PENDLE_MARKET_REGISTRY.getMarketOracleDurationForUser({ + _user: _vaultProxyAddress, + _marketAddress: marketAddress + }) }); value_ = principalTokenBalance * rate / ORACLE_RATE_PRECISION; @@ -577,30 +530,6 @@ contract PendleV2PositionLib is return lpTokens; } - /// @notice Gets the market used for pricing a particular Principal Token - /// @param _principalTokenAddress The Principal token address - /// @return marketAddress_ The market address for a Pendle principal token address - function getMarketForPrincipalToken(address _principalTokenAddress) - public - view - override - returns (address marketAddress_) - { - return principalTokenToMarket[_principalTokenAddress]; - } - - /// @notice Gets the oracle duration for pricing tokens of a particular Pendle market - /// @param _marketAddress The address of the Pendle market - /// @return pricingDuration_ The oracle duration - function getOraclePricingDurationForMarket(address _marketAddress) - public - view - override - returns (uint32 pricingDuration_) - { - return marketToOraclePricingDuration[_marketAddress]; - } - /// @notice Gets the Principal Tokens held /// @return principalTokenAddresses_ The Pendle Principal token addresses function getPrincipalTokens() public view override returns (address[] memory principalTokenAddresses_) { diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionParser.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionParser.sol index b846359a5..ca0578195 100644 --- a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionParser.sol +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/PendleV2PositionParser.sol @@ -43,7 +43,7 @@ contract PendleV2PositionParser is PendleV2PositionDataDecoder, IExternalPositio ) { if (_actionId == uint256(IPendleV2Position.Actions.BuyPrincipalToken)) { - (,,, address depositTokenAddress, uint256 depositAmount,,) = + (, address depositTokenAddress, uint256 depositAmount,,) = __decodeBuyPrincipalTokenActionArgs(_encodedActionArgs); assetsToTransfer_ = new address[](1); @@ -51,12 +51,12 @@ contract PendleV2PositionParser is PendleV2PositionDataDecoder, IExternalPositio amountsToTransfer_ = new uint256[](1); amountsToTransfer_[0] = depositAmount; } else if (_actionId == uint256(IPendleV2Position.Actions.SellPrincipalToken)) { - (,, address withdrawalTokenAddress,,) = __decodeSellPrincipalTokenActionArgs(_encodedActionArgs); + (, address withdrawalTokenAddress,,) = __decodeSellPrincipalTokenActionArgs(_encodedActionArgs); assetsToReceive_ = new address[](1); assetsToReceive_[0] = __parseTokenAddressInput(withdrawalTokenAddress); } else if (_actionId == uint256(IPendleV2Position.Actions.AddLiquidity)) { - (,, address depositTokenAddress, uint256 depositAmount,,) = + (, address depositTokenAddress, uint256 depositAmount,,) = __decodeAddLiquidityActionArgs(_encodedActionArgs); assetsToTransfer_ = new address[](1); diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/bases/PendleV2PositionLibBase1.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/bases/PendleV2PositionLibBase1.sol index 75a681184..f34d073c5 100644 --- a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/bases/PendleV2PositionLibBase1.sol +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/bases/PendleV2PositionLibBase1.sol @@ -19,7 +19,7 @@ pragma solidity 0.8.19; /// a numbered PendleV2PositionLibBaseXXX that inherits the previous base. /// e.g., `PendleV2PositionLibBase2 is PendleV2PositionLibBase1` abstract contract PendleV2PositionLibBase1 { - event PrincipalTokenAdded(address indexed principalToken, address indexed market); + event PrincipalTokenAdded(address indexed principalToken); event PrincipalTokenRemoved(address indexed principalToken); @@ -27,13 +27,7 @@ abstract contract PendleV2PositionLibBase1 { event LpTokenRemoved(address indexed lpToken); - event OracleDurationForMarketAdded(address indexed market, uint32 indexed pricingDuration); - address[] internal principalTokens; address[] internal lpTokens; - - mapping(address principalToken => address market) internal principalTokenToMarket; - - mapping(address market => uint32 pricingDuration) internal marketToOraclePricingDuration; } diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/markets-registry/IPendleV2MarketRegistry.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/markets-registry/IPendleV2MarketRegistry.sol new file mode 100644 index 000000000..b7b34f91c --- /dev/null +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/markets-registry/IPendleV2MarketRegistry.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0 + +/* + This file is part of the Enzyme Protocol. + + (c) Enzyme Council + + For the full license information, please view the LICENSE + file that was distributed with this source code. +*/ + +pragma solidity >=0.6.0 <0.9.0; + +/// @title IPendleV2MarketRegistry Interface +/// @author Enzyme Council +interface IPendleV2MarketRegistry { + /// @param marketAddress The Pendle market address to register + /// @param duration The TWAP duration to use for marketAddress + struct UpdateMarketInput { + address marketAddress; + uint32 duration; + } + + function getMarketOracleDurationForUser(address _user, address _marketAddress) + external + view + returns (uint32 duration_); + + function getPtOracleMarketAndDurationForUser(address _user, address _ptAddress) + external + view + returns (address marketAddress_, uint32 duration_); + + function getPtOracleMarketForUser(address _user, address _ptAddress) + external + view + returns (address marketAddress_); + + function updateMarketsForCaller(UpdateMarketInput[] calldata _updateMarketInputs, bool _skipValidation) external; +} diff --git a/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/markets-registry/PendleV2MarketRegistry.sol b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/markets-registry/PendleV2MarketRegistry.sol new file mode 100644 index 000000000..cd858a243 --- /dev/null +++ b/contracts/release/extensions/external-position-manager/external-positions/pendle-v2/markets-registry/PendleV2MarketRegistry.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0 + +/* + This file is part of the Enzyme Protocol. + + (c) Enzyme Council + + For the full license information, please view the LICENSE + file that was distributed with this source code. +*/ + +pragma solidity 0.8.19; + +import {IDispatcher} from "../../../../../../persistent/dispatcher/IDispatcher.sol"; +import {IPendleV2Market} from "../../../../../../external-interfaces/IPendleV2Market.sol"; +import {IPendleV2PrincipalToken} from "../../../../../../external-interfaces/IPendleV2PrincipalToken.sol"; +import {IPendleV2PtAndLpOracle} from "../../../../../../external-interfaces/IPendleV2PtAndLpOracle.sol"; +import {IPendleV2MarketRegistry} from "./IPendleV2MarketRegistry.sol"; + +/// @title PendleV2MarketRegistry Contract +/// @author Enzyme Council +/// @notice A contract for the per-user registration of Pendle v2 markets +contract PendleV2MarketRegistry is IPendleV2MarketRegistry { + event MarketForUserUpdated(address indexed user, address indexed marketAddress, uint32 duration); + + event PtForUserUpdated(address indexed user, address indexed ptAddress, address indexed marketAddress); + + error InsufficientOracleState(bool increaseCardinalityRequired, bool oldestObservationSatisfied); + + IPendleV2PtAndLpOracle private immutable PENDLE_PT_AND_LP_ORACLE; + + mapping(address => mapping(address => uint32)) private userToMarketToOracleDuration; + mapping(address => mapping(address => address)) private userToPtToLinkedMarket; + + constructor(IPendleV2PtAndLpOracle _pendlePtAndLpOracle) { + PENDLE_PT_AND_LP_ORACLE = _pendlePtAndLpOracle; + } + + /// @notice Updates the market registry specific to the caller + /// @param _updateMarketInputs An array of market config inputs to set + /// @param _skipValidation True to skip optional validation of _updateMarketInputs + /// @dev See UpdateMarketInput definition for struct param details + function updateMarketsForCaller(UpdateMarketInput[] calldata _updateMarketInputs, bool _skipValidation) + external + override + { + address user = msg.sender; + + for (uint256 i; i < _updateMarketInputs.length; i++) { + UpdateMarketInput memory marketInput = _updateMarketInputs[i]; + + // Does not validate zero-duration, which is a valid oracle deactivation + if (marketInput.duration > 0 && !_skipValidation) { + __validateMarketConfig({_marketAddress: marketInput.marketAddress, _duration: marketInput.duration}); + } + + // Store the market duration + userToMarketToOracleDuration[user][marketInput.marketAddress] = marketInput.duration; + emit MarketForUserUpdated(user, marketInput.marketAddress, marketInput.duration); + + // Handle PT-market link + (, IPendleV2PrincipalToken pt,) = IPendleV2Market(marketInput.marketAddress).readTokens(); + bool ptIsLinkedToMarket = + getPtOracleMarketForUser({_user: user, _ptAddress: address(pt)}) == marketInput.marketAddress; + + if (marketInput.duration > 0) { + // If new duration is non-zero, cache PT-market link (i.e., always follow the last active market) + + if (!ptIsLinkedToMarket) { + userToPtToLinkedMarket[user][address(pt)] = marketInput.marketAddress; + emit PtForUserUpdated(user, address(pt), marketInput.marketAddress); + } + } else if (ptIsLinkedToMarket) { + // If the PT's linked market duration is being set to 0, remove link to the market + + // Unlink the PT from the market + userToPtToLinkedMarket[user][address(pt)] = address(0); + emit PtForUserUpdated(user, address(pt), address(0)); + } + } + } + + /// @dev Helper to validate user-input market config. + /// Only validates the recommended oracle state, + /// not whether duration provides a sufficiently secure TWAP price. + /// src: https://docs.pendle.finance/Developers/Integration/HowToIntegratePtAndLpOracle. + function __validateMarketConfig(address _marketAddress, uint32 _duration) private view { + (bool increaseCardinalityRequired,, bool oldestObservationSatisfied) = + PENDLE_PT_AND_LP_ORACLE.getOracleState({_market: _marketAddress, _duration: _duration}); + + if (increaseCardinalityRequired || !oldestObservationSatisfied) { + revert InsufficientOracleState(increaseCardinalityRequired, oldestObservationSatisfied); + } + } + + /////////////////// + // STATE GETTERS // + /////////////////// + + // EXTERNAL + + /// @notice Gets the oracle market and its duration for a principal token, as-registered by the given user + /// @param _user The user + /// @param _ptAddress The principal token + /// @return marketAddress_ The market + /// @return duration_ The duration + function getPtOracleMarketAndDurationForUser(address _user, address _ptAddress) + external + view + returns (address marketAddress_, uint32 duration_) + { + marketAddress_ = getPtOracleMarketForUser({_user: _user, _ptAddress: _ptAddress}); + duration_ = getMarketOracleDurationForUser({_user: _user, _marketAddress: marketAddress_}); + + return (marketAddress_, duration_); + } + + // PUBLIC + + /// @notice Gets the oracle duration for a market, as-registered by the given user + /// @param _user The user + /// @param _marketAddress The market + /// @return duration_ The duration + function getMarketOracleDurationForUser(address _user, address _marketAddress) + public + view + returns (uint32 duration_) + { + return userToMarketToOracleDuration[_user][_marketAddress]; + } + + /// @notice Gets the linked market for a principal token, as-registered by the given user + /// @param _user The user + /// @param _ptAddress The principal token + /// @return marketAddress_ The market + function getPtOracleMarketForUser(address _user, address _ptAddress) public view returns (address marketAddress_) { + return userToPtToLinkedMarket[_user][_ptAddress]; + } +} diff --git a/tests/interfaces/external/IPendleV2PtOracle.sol b/tests/interfaces/external/IPendleV2PtAndLpOracle.sol similarity index 84% rename from tests/interfaces/external/IPendleV2PtOracle.sol rename to tests/interfaces/external/IPendleV2PtAndLpOracle.sol index b6341954a..1618e42e8 100644 --- a/tests/interfaces/external/IPendleV2PtOracle.sol +++ b/tests/interfaces/external/IPendleV2PtAndLpOracle.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.6.0 <0.9.0; -/// @title IPendleV2PtOracle Interface +/// @title IPendleV2PtAndLpOracle Interface /// @author Enzyme Council -interface IPendleV2PtOracle { +interface IPendleV2PtAndLpOracle { function getPtToAssetRate(address _market, uint32 _duration) external view returns (uint256 ptToAssetRate_); function getOracleState(address _market, uint32 _duration) diff --git a/tests/interfaces/interfaces.txt b/tests/interfaces/interfaces.txt index 3a613220f..3977ac4bd 100644 --- a/tests/interfaces/interfaces.txt +++ b/tests/interfaces/interfaces.txt @@ -157,6 +157,7 @@ IUniswapV3LiquidityPositionLib.sol: UniswapV3LiquidityPositionLib.abi.json IUniswapV3LiquidityPositionParser.sol: UniswapV3LiquidityPositionParser.abi.json ITermFinanceV1LendingPositionLib.sol: TermFinanceV1LendingPositionLib.abi.json ITermFinanceV1LendingPositionParser.sol: TermFinanceV1LendingPositionParser.abi.json +IPendleV2MarketRegistry.sol: PendleV2MarketRegistry.abi.json IPendleV2PositionLib.sol: PendleV2PositionLib.abi.json IPendleV2PositionParser.sol: PendleV2PositionParser.abi.json IMorphoBluePositionLib.sol: MorphoBluePositionLib.abi.json diff --git a/tests/tests/protocols/pendle/PendleV2MarketRegistry.t.sol b/tests/tests/protocols/pendle/PendleV2MarketRegistry.t.sol new file mode 100644 index 000000000..2100193d7 --- /dev/null +++ b/tests/tests/protocols/pendle/PendleV2MarketRegistry.t.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.19; + +import {UnitTest} from "tests/bases/UnitTest.sol"; +import {IPendleV2Market} from "tests/interfaces/external/IPendleV2Market.sol"; +import {IPendleV2PtAndLpOracle} from "tests/interfaces/external/IPendleV2PtAndLpOracle.sol"; +import {IPendleV2MarketRegistry} from "tests/interfaces/internal/IPendleV2MarketRegistry.sol"; +import {PendleV2Utils} from "./PendleV2Utils.sol"; + +contract PendleV2MarketRegistryTest is UnitTest, PendleV2Utils { + event MarketForUserUpdated(address indexed user, address indexed marketAddress, uint32 duration); + event PtForUserUpdated(address indexed user, address indexed ptAddress, address indexed marketAddress); + + IPendleV2MarketRegistry registry; + address mockPendlePtAndLpOracleAddress = makeAddr("MockPendlePtAndLpOracle"); + address mockMarketAddress = makeAddr("MockMarket"); + address mockMarketAddress2 = makeAddr("MockMarket2"); + address mockPtAddress = makeAddr("MockPT"); + + function setUp() public { + // Set the same mock PT on both mock Markets + vm.mockCall({ + callee: mockMarketAddress, + data: abi.encodeWithSelector(IPendleV2Market.readTokens.selector), + returnData: abi.encode(address(0), mockPtAddress, address(0)) + }); + vm.mockCall({ + callee: mockMarketAddress2, + data: abi.encodeWithSelector(IPendleV2Market.readTokens.selector), + returnData: abi.encode(address(0), mockPtAddress, address(0)) + }); + + // Set the mock PendlePtAndLpOracle to return a valid oracle state for any inputs + __updateOracleState({increaseCardinalityRequired: false, oldestObservationSatisfied: true}); + + // Deploy the registry with a mock oracle + registry = __deployPendleV2MarketRegistry({_pendlePtAndLpOracleAddress: mockPendlePtAndLpOracleAddress}); + } + + // MISC HELPERS + + function __updateOracleState(bool increaseCardinalityRequired, bool oldestObservationSatisfied) internal { + vm.mockCall({ + callee: mockPendlePtAndLpOracleAddress, + data: abi.encodeWithSelector(IPendleV2PtAndLpOracle.getOracleState.selector), + returnData: abi.encode(increaseCardinalityRequired, false, oldestObservationSatisfied) + }); + } + + // TESTS + + function test_updateMarketsForCaller_failsWithInsufficientOracleState() public { + // Define an arbitrary market input + IPendleV2MarketRegistry.UpdateMarketInput[] memory updateMarketInputs = + __encodePendleV2MarketRegistryUpdate({_marketAddress: mockMarketAddress, _duration: 1}); + + // Set the oracle to return a bad increaseCardinalityRequired only + __updateOracleState({increaseCardinalityRequired: true, oldestObservationSatisfied: true}); + + // Should fail with the expected error values + vm.expectRevert(abi.encodeWithSelector(IPendleV2MarketRegistry.InsufficientOracleState.selector, true, true)); + registry.updateMarketsForCaller({_updateMarketInputs: updateMarketInputs, _skipValidation: false}); + + // Set the oracle to return a bad oldestObservationSatisfied only + __updateOracleState({increaseCardinalityRequired: false, oldestObservationSatisfied: false}); + + // Should fail with the expected error values + vm.expectRevert(abi.encodeWithSelector(IPendleV2MarketRegistry.InsufficientOracleState.selector, false, false)); + registry.updateMarketsForCaller({_updateMarketInputs: updateMarketInputs, _skipValidation: false}); + + // Set the oracle to return good values again, and the call should succeed + __updateOracleState({increaseCardinalityRequired: false, oldestObservationSatisfied: true}); + registry.updateMarketsForCaller({_updateMarketInputs: updateMarketInputs, _skipValidation: false}); + } + + function test_updateMarketsForCaller_successWithRemovingMarketDuration() public { + address marketAddress = mockMarketAddress; + uint32 duration = 123; + + // Set market duration + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddress, + _duration: duration, + _skipValidation: false, + _expectedLinkedMarketForPt: marketAddress + }); + + // Remove market duration + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddress, + _duration: 0, + _skipValidation: false, + _expectedLinkedMarketForPt: address(0) + }); + } + + function test_updateMarketsForCaller_successWithMultipleMarketsForPt() public { + address marketAddressA = mockMarketAddress; + address marketAddressB = mockMarketAddress2; + uint32 durationA = 123; + uint32 durationB = 456; + + // Set MarketA duration (PT linked to MarketA) + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddressA, + _duration: durationA, + _skipValidation: false, + _expectedLinkedMarketForPt: marketAddressA + }); + + // Set MarketB duration (PT linked to MarketB) + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddressB, + _duration: durationB, + _skipValidation: false, + _expectedLinkedMarketForPt: marketAddressB + }); + + // Remove MarketA duration (PT still linked to MarketB) + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddressA, + _duration: 0, + _skipValidation: false, + _expectedLinkedMarketForPt: marketAddressB + }); + + // Remove MarketB duration (PT unlinked) + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddressB, + _duration: 0, + _skipValidation: false, + _expectedLinkedMarketForPt: address(0) + }); + } + + function test_updateMarketsForCaller_successWithSkipValidation() public { + address marketAddress = mockMarketAddress; + + // Set the oracle to return a bad increaseCardinalityRequired + __updateOracleState({increaseCardinalityRequired: true, oldestObservationSatisfied: true}); + + // Should succeed with skipValidation + __test_updateMarketsForCaller_success({ + _marketAddress: marketAddress, + _duration: 123, + _skipValidation: true, + _expectedLinkedMarketForPt: marketAddress + }); + } + + function __test_updateMarketsForCaller_success( + address _marketAddress, + uint32 _duration, + bool _skipValidation, + address _expectedLinkedMarketForPt + ) internal { + address caller = makeAddr("Caller"); + address ptAddress = mockPtAddress; + + IPendleV2MarketRegistry.UpdateMarketInput[] memory updateMarketInputs = + __encodePendleV2MarketRegistryUpdate({_marketAddress: _marketAddress, _duration: _duration}); + + // PT link should be updated if: + // A. _duration > 0, and the market is not its previous market + // B. _duration == 0, and the PT is linked to the market + bool prevLinkToMarket = + _marketAddress == registry.getPtOracleMarketForUser({_user: caller, _ptAddress: ptAddress}); + bool expectPtLinkUpdate = (_duration > 0 && !prevLinkToMarket) || (_duration == 0 && prevLinkToMarket); + + // Pre-assert the expected events + expectEmit(address(registry)); + emit MarketForUserUpdated(caller, _marketAddress, _duration); + + if (expectPtLinkUpdate) { + expectEmit(address(registry)); + emit PtForUserUpdated(caller, ptAddress, _expectedLinkedMarketForPt); + } + + // Register the market + vm.prank(caller); + registry.updateMarketsForCaller({_updateMarketInputs: updateMarketInputs, _skipValidation: _skipValidation}); + + // Assert storage + { + address linkedMarketForPt = registry.getPtOracleMarketForUser({_user: caller, _ptAddress: ptAddress}); + uint32 marketDuration = + registry.getMarketOracleDurationForUser({_user: caller, _marketAddress: _marketAddress}); + assertEq(linkedMarketForPt, _expectedLinkedMarketForPt, "Incorrect linked market for PT"); + assertEq(marketDuration, _duration, "Incorrect duration for market"); + } + + // Combined getter: getPtOracleMarketAndDurationForUser + { + (address combinedLinkedMarketForPt, uint32 combinedMarketDurationForPt) = + registry.getPtOracleMarketAndDurationForUser({_user: caller, _ptAddress: ptAddress}); + assertEq( + combinedLinkedMarketForPt, _expectedLinkedMarketForPt, "Incorrect combined getter: linked market for PT" + ); + + // Duration should be that of the linked market + assertEq( + combinedMarketDurationForPt, + registry.getMarketOracleDurationForUser({_user: caller, _marketAddress: _expectedLinkedMarketForPt}), + "Incorrect combined getter: incorrect duration" + ); + } + } +} diff --git a/tests/tests/protocols/pendle/PendleV2Position.t.sol b/tests/tests/protocols/pendle/PendleV2Position.t.sol index d35579aa5..da8db738a 100644 --- a/tests/tests/protocols/pendle/PendleV2Position.t.sol +++ b/tests/tests/protocols/pendle/PendleV2Position.t.sol @@ -1,27 +1,27 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.19; -import {IAddressListRegistry as IAddressListRegistryProd} from - "contracts/persistent/address-list-registry/IAddressListRegistry.sol"; import {IPendleV2Position as IPendleV2PositionProd} from "contracts/release/extensions/external-position-manager/external-positions/pendle-v2/IPendleV2Position.sol"; -import {IPendleV2Market as IPendleV2MarketProd} from "contracts/external-interfaces/IPendleV2Market.sol"; import {PendleLpOracleLib} from "contracts/utils/0.8.19/pendle/adapted-libs/PendleLpOracleLib.sol"; import {IPendleV2Market as IOracleLibPendleMarket} from "contracts/utils/0.8.19/pendle/adapted-libs/interfaces/IPendleV2Market.sol"; import {IntegrationTest} from "tests/bases/IntegrationTest.sol"; - import {IERC20} from "tests/interfaces/external/IERC20.sol"; import {IPendleV2Market} from "tests/interfaces/external/IPendleV2Market.sol"; import {IPendleV2PrincipalToken} from "tests/interfaces/external/IPendleV2PrincipalToken.sol"; -import {IPendleV2PtOracle} from "tests/interfaces/external/IPendleV2PtOracle.sol"; +import {IPendleV2PtAndLpOracle} from "tests/interfaces/external/IPendleV2PtAndLpOracle.sol"; import {IPendleV2StandardizedYield} from "tests/interfaces/external/IPendleV2StandardizedYield.sol"; import {IPendleV2Router} from "tests/interfaces/external/IPendleV2Router.sol"; - +import {IComptrollerLib} from "tests/interfaces/internal/IComptrollerLib.sol"; import {IExternalPositionManager} from "tests/interfaces/internal/IExternalPositionManager.sol"; +import {IFundDeployer} from "tests/interfaces/internal/IFundDeployer.sol"; +import {IPendleV2MarketRegistry} from "tests/interfaces/internal/IPendleV2MarketRegistry.sol"; import {IPendleV2PositionLib} from "tests/interfaces/internal/IPendleV2PositionLib.sol"; import {IPendleV2PositionParser} from "tests/interfaces/internal/IPendleV2PositionParser.sol"; +import {AddressArrayLib} from "tests/utils/libs/AddressArrayLib.sol"; +import {PendleV2Utils} from "./PendleV2Utils.sol"; // ETHEREUM MAINNET CONSTANTS address constant ETHEREUM_MARKET_FACTORY_V1 = 0x27b1dAcd74688aF24a64BD3C9C1B143118740784; @@ -29,16 +29,16 @@ address constant ETHEREUM_MARKET_FACTORY_V3 = 0x1A6fCc85557BC4fB7B534ed835a03EF0 address constant ETHEREUM_PT_ORACLE = 0xbbd487268A295531d299c125F3e5f749884A3e30; address constant ETHEREUM_ROUTER = 0x00000000005BBB0EF59571E58418F9a4357b68A0; address constant ETHEREUM_STETH_26DEC2025_MARKET_ADDRESS = 0xC374f7eC85F8C7DE3207a10bB1978bA104bdA3B2; -address constant ETHEREUM_EZETH_25APR2024_MARKET_ADDRESS = 0xDe715330043799D7a80249660d1e6b61eB3713B3; address constant ETHEREUM_WEETH_27JUN2024_MARKET_ADDRESS = 0xF32e58F92e60f4b0A37A69b95d642A471365EAe8; -uint32 constant MINIMUM_PRICING_DURATION = uint32(60 * 15); // 15 minutes -uint32 constant MAXIMUM_PRICING_DURATION = uint32(60 * 30); // 30 minutes uint256 constant ORACLE_RATE_PRECISION = 1e18; address constant PENDLE_NATIVE_ASSET_ADDRESS = address(0); -abstract contract PendleTestBase is IntegrationTest { - event PrincipalTokenAdded(address indexed principalToken, address indexed market); +// TODO: Add test instance for a market that uses the native asset as its oracle rate asset +abstract contract PendleTestBase is IntegrationTest, PendleV2Utils { + using AddressArrayLib for address[]; + + event PrincipalTokenAdded(address indexed principalToken); event PrincipalTokenRemoved(address indexed principalToken); @@ -46,10 +46,8 @@ abstract contract PendleTestBase is IntegrationTest { event LpTokenRemoved(address indexed lpToken); - event OracleDurationForMarketAdded(address indexed market, uint32 indexed pricingDuration); - uint256 internal pendleV2TypeId; - uint256 internal pendleMarketFactoriesListId; + IPendleV2MarketRegistry internal pendleV2MarketRegistry; IPendleV2PositionLib internal pendleV2PositionLib; IPendleV2PositionParser internal pendleV2PositionParser; IPendleV2PositionLib internal pendleV2ExternalPosition; @@ -57,7 +55,7 @@ abstract contract PendleTestBase is IntegrationTest { IERC20 internal underlyingAsset; IPendleV2Market internal market; IPendleV2PrincipalToken internal principalToken; - IPendleV2PtOracle internal pendlePtOracle; + IPendleV2PtAndLpOracle internal pendlePtAndLpOracle; IPendleV2Router internal pendleRouter; IPendleV2Router.ApproxParams internal guessPtOut; IPendleV2StandardizedYield internal syToken; @@ -73,41 +71,31 @@ abstract contract PendleTestBase is IntegrationTest { function __initialize( EnzymeVersion _version, - address[] memory _pendleMarketFactoryAddresses, - address _pendlePtOracleAddress, + address _pendlePtAndLpOracleAddress, address _pendleRouterAddress, address _pendleMarketAddress, uint32 _pricingDuration ) internal { + // Assign vars from inputs version = _version; - - setUpMainnetEnvironment(ETHEREUM_BLOCK_PENDLE_TIME_SENSITIVE); - - pendlePtOracle = IPendleV2PtOracle(_pendlePtOracleAddress); + pendlePtAndLpOracle = IPendleV2PtAndLpOracle(_pendlePtAndLpOracleAddress); pendleRouter = IPendleV2Router(_pendleRouterAddress); + market = IPendleV2Market(_pendleMarketAddress); + pricingDuration = _pricingDuration; - // Create a new AddressListRegistry list for Pendle Market factories - pendleMarketFactoriesListId = core.persistent.addressListRegistry.createList({ - _owner: makeAddr("PendleMarketFactoriesListOwner"), - _updateType: formatAddressListRegistryUpdateType(IAddressListRegistryProd.UpdateType.AddAndRemove), - _initialItems: _pendleMarketFactoryAddresses - }); + // Validate that the market has at least one reward token + // @dev This can be moved to specific market setup, we just need at least one market per version with reward tokens + require(market.getRewardTokens().length > 0, "__initialize: Market has no reward tokens"); + // Assign other misc vars externalPositionManager = IExternalPositionManager(getExternalPositionManagerAddressForVersion(version)); - (pendleV2PositionLib, pendleV2PositionParser, pendleV2TypeId) = deployPendleV2({ - _addressListRegistry: address(core.persistent.addressListRegistry), - _minimumPricingDuration: MINIMUM_PRICING_DURATION, - _maximumPricingDuration: MAXIMUM_PRICING_DURATION, - _pendleMarketFactoriesListId: pendleMarketFactoriesListId, - _pendlePtOracleAddress: _pendlePtOracleAddress, - _pendleRouterAddress: _pendleRouterAddress, - _wrappedNativeAssetAddress: address(wrappedNativeToken) - }); - - market = IPendleV2Market(_pendleMarketAddress); - pricingDuration = _pricingDuration; (syToken, principalToken,) = market.readTokens(); - + (, address underlyingAssetAddress,) = syToken.assetInfo(); + // If underlyingAssetAddress is the 0 address, this indicates that the NATIVE_ASSET is the reference asset + if (underlyingAssetAddress == PENDLE_NATIVE_ASSET_ADDRESS) { + underlyingAssetAddress = address(wrappedNativeToken); + } + underlyingAsset = IERC20(underlyingAssetAddress); // Default generic guessPtOut. In a production setting, these settings can be calculated offchain to reduce gas usage. // src: https://docs.pendle.finance/Developers/Contracts/PendleRouter#approxparams guessPtOut = IPendleV2Router.ApproxParams({ @@ -118,8 +106,22 @@ abstract contract PendleTestBase is IntegrationTest { eps: 1e15 }); - (comptrollerProxyAddress, vaultProxyAddress, fundOwner) = createTradingFundForVersion(version); + // Add the market's underlyingAsset to the asset universe + addPrimitiveWithTestAggregator({ + _valueInterpreter: core.release.valueInterpreter, + _tokenAddress: underlyingAssetAddress, + _skipIfRegistered: true + }); + // Deploy and register all Pendle V2 contracts + (pendleV2MarketRegistry, pendleV2PositionLib, pendleV2PositionParser, pendleV2TypeId) = deployPendleV2({ + _pendlePtAndLpOracleAddress: _pendlePtAndLpOracleAddress, + _pendleRouterAddress: _pendleRouterAddress, + _wrappedNativeAssetAddress: address(wrappedNativeToken) + }); + + // Create a fund and add an empty Pendle position + (comptrollerProxyAddress, vaultProxyAddress, fundOwner) = createTradingFundForVersion(version); vm.prank(fundOwner); pendleV2ExternalPosition = IPendleV2PositionLib( createExternalPositionForVersion({ @@ -130,59 +132,48 @@ abstract contract PendleTestBase is IntegrationTest { }) ); - // Increase the wrapped native token balance to allow testing native token deposits - increaseTokenBalance({_token: wrappedNativeToken, _to: vaultProxyAddress, _amount: 100 ether}); - - // Seed the vault with the pendle syToken underlying - (, address underlyingAssetAddress,) = syToken.assetInfo(); - - // If underlyingAssetAddress is the 0 address, this indicates that the NATIVE_ASSET is the reference asset - if (underlyingAssetAddress == PENDLE_NATIVE_ASSET_ADDRESS) { - underlyingAssetAddress = address(wrappedNativeToken); - } - - underlyingAsset = IERC20(underlyingAssetAddress); - - // Add the underlyingAsset to the asset universe - addPrimitiveWithTestAggregator({ - _valueInterpreter: core.release.valueInterpreter, - _tokenAddress: underlyingAssetAddress, - _skipIfRegistered: true + // Register the PT and market for the fund (call directly from vault) + vm.prank(vaultProxyAddress); + pendleV2MarketRegistry.updateMarketsForCaller({ + _updateMarketInputs: __encodePendleV2MarketRegistryUpdate({ + _marketAddress: address(market), + _duration: pricingDuration + }), + _skipValidation: false }); + // Increase the vault's balances of tokens to use in Pendle actions + increaseTokenBalance({_token: wrappedNativeToken, _to: vaultProxyAddress, _amount: 100 ether}); increaseTokenBalance({ _token: underlyingAsset, _to: vaultProxyAddress, _amount: 100 * assetUnit(underlyingAsset) }); + // Set a deposit amount to be used for the tests depositAmount = underlyingAsset.balanceOf(vaultProxyAddress) / 7; } // DEPLOYMENT HELPERS function deployPendleV2( - address _addressListRegistry, - uint32 _minimumPricingDuration, - uint32 _maximumPricingDuration, - uint256 _pendleMarketFactoriesListId, - address _pendlePtOracleAddress, + address _pendlePtAndLpOracleAddress, address _pendleRouterAddress, address _wrappedNativeAssetAddress ) public returns ( + IPendleV2MarketRegistry pendleV2MarketRegistry_, IPendleV2PositionLib pendleV2PositionLib_, IPendleV2PositionParser pendleV2PositionParser_, uint256 typeId_ ) { + pendleV2MarketRegistry_ = + __deployPendleV2MarketRegistry({_pendlePtAndLpOracleAddress: _pendlePtAndLpOracleAddress}); + pendleV2PositionLib_ = deployPendleV2PositionLib({ - _addressListRegistry: _addressListRegistry, - _minimumPricingDuration: _minimumPricingDuration, - _maximumPricingDuration: _maximumPricingDuration, - _pendleMarketFactoriesListId: _pendleMarketFactoriesListId, - _pendlePtOracleAddress: _pendlePtOracleAddress, + _pendleMarketRegistryAddress: address(pendleV2MarketRegistry_), _pendleRouterAddress: _pendleRouterAddress, _wrappedNativeAssetAddress: _wrappedNativeAssetAddress }); @@ -196,27 +187,15 @@ abstract contract PendleTestBase is IntegrationTest { _parser: address(pendleV2PositionParser_) }); - return (pendleV2PositionLib_, pendleV2PositionParser_, typeId_); + return (pendleV2MarketRegistry_, pendleV2PositionLib_, pendleV2PositionParser_, typeId_); } function deployPendleV2PositionLib( - address _addressListRegistry, - uint32 _minimumPricingDuration, - uint32 _maximumPricingDuration, - uint256 _pendleMarketFactoriesListId, - address _pendlePtOracleAddress, + address _pendleMarketRegistryAddress, address _pendleRouterAddress, address _wrappedNativeAssetAddress ) public returns (IPendleV2PositionLib) { - bytes memory args = abi.encode( - _addressListRegistry, - _minimumPricingDuration, - _maximumPricingDuration, - _pendleMarketFactoriesListId, - _pendlePtOracleAddress, - _pendleRouterAddress, - _wrappedNativeAssetAddress - ); + bytes memory args = abi.encode(_pendleMarketRegistryAddress, _pendleRouterAddress, _wrappedNativeAssetAddress); address addr = deployCode("PendleV2PositionLib.sol", args); return IPendleV2PositionLib(addr); } @@ -232,18 +211,8 @@ abstract contract PendleTestBase is IntegrationTest { // ACTION HELPERS - function __buyPrincipalTokenVerbose( - IPendleV2PrincipalToken _principalToken, - IPendleV2Market _market, - uint32 _pricingDuration, - address _depositTokenAddress, - uint256 _depositAmount, - IPendleV2Router.ApproxParams memory _guessPtOut, - uint256 _minPtOut - ) private { - bytes memory actionArgs = abi.encode( - _principalToken, _market, _pricingDuration, _depositTokenAddress, _depositAmount, _guessPtOut, _minPtOut - ); + function __buyPrincipalToken(address _depositTokenAddress) private { + bytes memory actionArgs = abi.encode(market, _depositTokenAddress, depositAmount, guessPtOut, 0); vm.prank(fundOwner); @@ -256,27 +225,8 @@ abstract contract PendleTestBase is IntegrationTest { }); } - function __buyPrincipalToken(address _depositTokenAddress) private { - __buyPrincipalTokenVerbose({ - _principalToken: principalToken, - _market: market, - _pricingDuration: pricingDuration, - _depositTokenAddress: _depositTokenAddress, - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minPtOut: 0 - }); - } - - function __sellPrincipalTokenVerbose( - IPendleV2PrincipalToken _principalToken, - IPendleV2Market _market, - address _underlyingAsset, - uint256 _withdrawalAmount, - uint256 _minIncomingAmount - ) private { - bytes memory actionArgs = - abi.encode(_principalToken, _market, _underlyingAsset, _withdrawalAmount, _minIncomingAmount); + function __sellPrincipalToken(address _withdrawalTokenAddress, uint256 _withdrawalAmount) private { + bytes memory actionArgs = abi.encode(market, _withdrawalTokenAddress, _withdrawalAmount, 0); vm.prank(fundOwner); @@ -289,26 +239,8 @@ abstract contract PendleTestBase is IntegrationTest { }); } - function __sellPrincipalToken(address _withdrawalTokenAddress, uint256 _withdrawalAmount) private { - __sellPrincipalTokenVerbose({ - _principalToken: principalToken, - _market: market, - _underlyingAsset: _withdrawalTokenAddress, - _withdrawalAmount: _withdrawalAmount, - _minIncomingAmount: 0 - }); - } - - function __addLiquidityVerbose( - IPendleV2Market _market, - uint32 _pricingDuration, - IERC20 _underlyingAsset, - uint256 _depositAmount, - IPendleV2Router.ApproxParams memory _guessPtOut, - uint256 _minLpOut - ) private { - bytes memory actionArgs = - abi.encode(_market, _pricingDuration, _underlyingAsset, _depositAmount, _guessPtOut, _minLpOut); + function __addLiquidity() private { + bytes memory actionArgs = abi.encode(market, underlyingAsset, depositAmount, guessPtOut, 0); vm.prank(fundOwner); @@ -321,26 +253,8 @@ abstract contract PendleTestBase is IntegrationTest { }); } - function __addLiquidity() private { - __addLiquidityVerbose({ - _market: market, - _pricingDuration: pricingDuration, - _underlyingAsset: underlyingAsset, - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minLpOut: 0 - }); - } - - function __removeLiquidityVerbose( - IPendleV2Market _market, - IERC20 _withdrawalToken, - uint256 _withdrawalAmount, - uint256 _minSyOut, - uint256 _minIncomingAmount - ) private { - bytes memory actionArgs = - abi.encode(_market, _withdrawalToken, _withdrawalAmount, _minSyOut, _minIncomingAmount); + function __removeLiquidity(uint256 _withdrawalAmount) private { + bytes memory actionArgs = abi.encode(market, underlyingAsset, _withdrawalAmount, 0, 0); vm.prank(fundOwner); @@ -353,16 +267,6 @@ abstract contract PendleTestBase is IntegrationTest { }); } - function __removeLiquidity(uint256 _withdrawalAmount) private { - __removeLiquidityVerbose({ - _market: market, - _withdrawalToken: underlyingAsset, - _withdrawalAmount: _withdrawalAmount, - _minSyOut: 0, - _minIncomingAmount: 0 - }); - } - function __claimRewards(address[] memory marketAddresses) private { bytes memory actionArgs = abi.encode(marketAddresses); vm.prank(fundOwner); @@ -383,11 +287,7 @@ abstract contract PendleTestBase is IntegrationTest { // Assert that the AddPrincipalToken event has been emitted expectEmit(address(pendleV2ExternalPosition)); - emit PrincipalTokenAdded(address(principalToken), address(market)); - - // Assert that the AddMarket event has been emitted - expectEmit(address(pendleV2ExternalPosition)); - emit OracleDurationForMarketAdded(address(market), pricingDuration); + emit PrincipalTokenAdded(address(principalToken)); __buyPrincipalToken({_depositTokenAddress: _depositTokenAddress}); @@ -400,9 +300,6 @@ abstract contract PendleTestBase is IntegrationTest { // Assert that the principalToken has been added to the external position assertEq(toArray(address(principalToken)), pendleV2ExternalPosition.getPrincipalTokens()); - // Assert that the market has been added to the external position with the proper duration - assertEq(pricingDuration, pendleV2ExternalPosition.getOraclePricingDurationForMarket(address(market))); - // Assert that the PrincipalToken value is accounted for in the EP (, address expectedAsset,) = syToken.assetInfo(); uint256 principalTokenBalance = IERC20(address(principalToken)).balanceOf(address(pendleV2ExternalPosition)); @@ -411,7 +308,7 @@ abstract contract PendleTestBase is IntegrationTest { assertGt(principalTokenBalance, 0, "Incorrect principalToken balance"); uint256 expectedAssetAmount = principalTokenBalance - * pendlePtOracle.getPtToAssetRate({_market: address(market), _duration: pricingDuration}) + * pendlePtAndLpOracle.getPtToAssetRate({_market: address(market), _duration: pricingDuration}) / ORACLE_RATE_PRECISION; // Assert that the EP holds the principalToken @@ -444,105 +341,24 @@ abstract contract PendleTestBase is IntegrationTest { } } - // Test buying a principalToken for which the rate asset is the native asset - // TODO: Uncomment this when the oracle is ready for usage, or when we find a way to mock its validity - // function test_buyPrincipalToken_nativeRateAsset_success() public { - // IPendleV2Market marketNativeRateAsset = IPendleV2Market(ETHEREUM_EZETH_25APR2024_MARKET_ADDRESS); - // (, IPendleV2PrincipalToken principalTokenNativeRateAsset,) = marketNativeRateAsset.readTokens(); - // __buyPrincipalTokenVerbose({ - // _principalToken: principalTokenNativeRateAsset, - // _market: marketNativeRateAsset, - // _pricingDuration: 900, - // _depositTokenAddress: NATIVE_ASSET_ADDRESS, - // _depositAmount: depositAmount, - // _guessPtOut: guessPtOut, - // _minPtOut: 0 - // }); - // } - - // Test that the function reverts if the market mismatches the previously set market for the principal token - function test_buyPrincipalToken_inconsistentMarket_failure() public { - __buyPrincipalToken({_depositTokenAddress: address(underlyingAsset)}); + function test_buyPrincipalToken_failsWithDifferentPtMarket() public { + // Clone the market + address altMarketForPtAddress = makeAddr("AltMarketForPt"); + vm.etch(altMarketForPtAddress, address(market).code); - vm.expectRevert(formatError("__handlePrincipalTokenInput: stored market address mismatch")); - - // Attempt to buy the same principalToken with a different market - __buyPrincipalTokenVerbose({ - _principalToken: principalToken, - _market: IPendleV2Market(makeAddr("Invalid market")), - _pricingDuration: pricingDuration, - _depositTokenAddress: address(underlyingAsset), - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minPtOut: 0 + // Link PT to the cloned market + vm.prank(vaultProxyAddress); + pendleV2MarketRegistry.updateMarketsForCaller({ + _updateMarketInputs: __encodePendleV2MarketRegistryUpdate({ + _marketAddress: altMarketForPtAddress, + _duration: pricingDuration + }), + _skipValidation: true }); - vm.expectRevert(formatError("__handleMarketAndDurationInput: stored duration mismatch")); - - // Attempt to buy the same principalToken with the same market but a different duration - __buyPrincipalTokenVerbose({ - _principalToken: principalToken, - _market: market, - _pricingDuration: pricingDuration + 1, - _depositTokenAddress: address(underlyingAsset), - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minPtOut: 0 - }); - } - - // Test that the function reverts if the pricing duration is not supported by the market - function test_buyPrincipalToken_badMarketPricingDuration_failure() public { - IPendleV2Market marketBadMarketPricingDuration = IPendleV2Market(ETHEREUM_EZETH_25APR2024_MARKET_ADDRESS); - (IPendleV2StandardizedYield sy, IPendleV2PrincipalToken pt,) = marketBadMarketPricingDuration.readTokens(); - - // Use the first available deposit token as the deposit token - address depositTokenAddress = sy.getTokensIn()[0]; - - // Seed the deposit token - increaseTokenBalance({_token: IERC20(depositTokenAddress), _to: vaultProxyAddress, _amount: depositAmount}); - - vm.expectRevert(formatError("__validateMarketAndDuration: invalid pricing duration")); - - __buyPrincipalTokenVerbose({ - _principalToken: pt, - _market: marketBadMarketPricingDuration, - _pricingDuration: 900, - _depositTokenAddress: depositTokenAddress, - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minPtOut: 0 - }); - } - - // Test that the function reverts if the pricing duration falls outside of the EP's supported range - function test_buyPrincipalToken_badPricingDuration_failure() public { - uint32 tooSmallDuration = MINIMUM_PRICING_DURATION - 1; - uint32 tooBigDuration = MAXIMUM_PRICING_DURATION + 1; - - vm.expectRevert(formatError("__validateMarketAndDuration: out-of-bounds duration")); - - __buyPrincipalTokenVerbose({ - _principalToken: principalToken, - _market: market, - _pricingDuration: tooSmallDuration, - _depositTokenAddress: address(underlyingAsset), - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minPtOut: 0 - }); - - vm.expectRevert(formatError("__validateMarketAndDuration: out-of-bounds duration")); - - __buyPrincipalTokenVerbose({ - _principalToken: principalToken, - _market: market, - _pricingDuration: tooBigDuration, - _depositTokenAddress: address(underlyingAsset), - _depositAmount: depositAmount, - _guessPtOut: guessPtOut, - _minPtOut: 0 - }); + // Should fail + vm.expectRevert(formatError("__validateMarketForPt: Unsupported market")); + __buyPrincipalToken({_depositTokenAddress: address(underlyingAsset)}); } function __test_sellPrincipalToken(bool _sellAll, bool _expiredPrincipalToken) private { @@ -558,7 +374,7 @@ abstract contract PendleTestBase is IntegrationTest { } uint256 expectedUnderlyingDelta = withdrawalAmount - * pendlePtOracle.getPtToAssetRate({_market: address(market), _duration: pricingDuration}) + * pendlePtAndLpOracle.getPtToAssetRate({_market: address(market), _duration: pricingDuration}) / ORACLE_RATE_PRECISION; if (_sellAll) { @@ -621,9 +437,34 @@ abstract contract PendleTestBase is IntegrationTest { } } + function test_sellPrincipalToken_failsWithDifferentPtMarket() public { + // Acquire PT + __buyPrincipalToken({_depositTokenAddress: address(underlyingAsset)}); + uint256 principalTokenBalance = IERC20(address(principalToken)).balanceOf(address(pendleV2ExternalPosition)); + + // Clone the market + address altMarketForPtAddress = makeAddr("AltMarketForPt"); + vm.etch(altMarketForPtAddress, address(market).code); + + // Link PT to the cloned market + vm.prank(vaultProxyAddress); + pendleV2MarketRegistry.updateMarketsForCaller({ + _updateMarketInputs: __encodePendleV2MarketRegistryUpdate({ + _marketAddress: altMarketForPtAddress, + _duration: pricingDuration + }), + _skipValidation: true + }); + + // Should fail + vm.expectRevert(formatError("__validateMarketForPt: Unsupported market")); + __sellPrincipalToken({ + _withdrawalTokenAddress: address(underlyingAsset), + _withdrawalAmount: principalTokenBalance + }); + } + function test_addLiquidity_success() public { - vm.expectEmit(); - emit OracleDurationForMarketAdded(address(market), pricingDuration); vm.expectEmit(); emit LpTokenAdded(address(market)); @@ -654,6 +495,19 @@ abstract contract PendleTestBase is IntegrationTest { assertApproxEqRel(depositAmount, amounts[0], WEI_ONE_PERCENT / 5); } + function test_addLiquidity_failsWithZeroMarketDuration() public { + // Set market duration to 0 + vm.prank(vaultProxyAddress); + pendleV2MarketRegistry.updateMarketsForCaller({ + _updateMarketInputs: __encodePendleV2MarketRegistryUpdate({_marketAddress: address(market), _duration: 0}), + _skipValidation: false + }); + + // Should fail + vm.expectRevert(formatError("__addLiquidity: Unsupported market")); + __addLiquidity(); + } + function __test_removeLiquidity(bool _removeAll) private { __addLiquidity(); @@ -702,6 +556,12 @@ abstract contract PendleTestBase is IntegrationTest { __test_removeLiquidity({_removeAll: false}); } + function test_removeLiquidity_failsWithUnheldLpToken() public { + // Should fail + vm.expectRevert(formatError("__removeLiquidity: Unsupported market")); + __removeLiquidity({_withdrawalAmount: 1}); + } + function test_claimRewards_success() public { address[] memory rewardTokens = market.getRewardTokens(); @@ -741,6 +601,53 @@ abstract contract PendleTestBase is IntegrationTest { } } + function test_claimRewards_successWithPtAndLpAsRewards() public { + // Acquire PT and LP so that: (1) rewards accrue and (2) we can test that PT and LP rewards will not be sent to the vault + __buyPrincipalToken({_depositTokenAddress: address(underlyingAsset)}); + __addLiquidity(); + + address[] memory originalRewardTokens = market.getRewardTokens(); + + // Add PT and LP to the list of reward tokens and mock the rewardTokens callback + address[] memory rewardTokensPlusPtAndLp = + originalRewardTokens.mergeArray(toArray(address(principalToken), address(market))); + vm.mockCall({ + callee: address(market), + data: abi.encodeWithSelector(IPendleV2Market.getRewardTokens.selector), + returnData: abi.encode(rewardTokensPlusPtAndLp) + }); + assertEq(market.getRewardTokens(), rewardTokensPlusPtAndLp); + + // Checkpoint pre-claim balances of normal reward tokens + uint256[] memory originalRewardTokenBalancesPreClaim = new uint256[](originalRewardTokens.length); + for (uint256 i; i < originalRewardTokens.length; i++) { + originalRewardTokenBalancesPreClaim[i] = IERC20(originalRewardTokens[i]).balanceOf(vaultProxyAddress); + } + + // Warp the time to allow rewards to accrue + skip(50 days); + + // Update user rewards as per Pendle technical docs: + // https://docs.pendle.finance/Developers/Contracts/TechnicalDetails#getting-up-to-dateaccruedrewardson-chain-applicable-to-sy-yt--lp + IERC20(address(market)).transfer(address(pendleV2ExternalPosition), 0); + + __claimRewards(toArray(address(market))); + + // TODO: blocked since rewards don't accrue in these tests + // Assert that normal rewards tokens were sent to the vault + // for (uint256 i; i < originalRewardTokens.length; i++) { + // assertGt( + // IERC20(originalRewardTokens[i]).balanceOf(vaultProxyAddress), + // originalRewardTokenBalancesPreClaim[i], + // "Incorrect reward token balance" + // ); + // } + + // Assert that the PT and LP tokens have not been sent to the vault + assertEq(IERC20(address(principalToken)).balanceOf(vaultProxyAddress), 0); + assertEq(IERC20(address(market)).balanceOf(vaultProxyAddress), 0); + } + function test_multiplePositions_success() public { __buyPrincipalToken({_depositTokenAddress: address(underlyingAsset)}); __addLiquidity(); @@ -778,16 +685,59 @@ abstract contract PendleTestBase is IntegrationTest { // Value of the EP should be roughly equal 2 deposit amounts (2x PT) assertApproxEqRel(depositAmount * 2, amountsThird[0], WEI_ONE_PERCENT / 2); } + + function test_positionValue_failsWithZeroDurationForLpToken() public { + __addLiquidity(); + + // Set market duration to 0 + vm.prank(vaultProxyAddress); + pendleV2MarketRegistry.updateMarketsForCaller({ + _updateMarketInputs: __encodePendleV2MarketRegistryUpdate({_marketAddress: address(market), _duration: 0}), + _skipValidation: false + }); + + // Should fail + vm.expectRevert("__getLpTokenValue: Duration not registered"); + pendleV2ExternalPosition.getManagedAssets(); + } + + function test_positionValue_failsWithZeroDurationForPt() public { + __buyPrincipalToken({_depositTokenAddress: address(underlyingAsset)}); + + // Set market duration to 0 + vm.prank(vaultProxyAddress); + pendleV2MarketRegistry.updateMarketsForCaller({ + _updateMarketInputs: __encodePendleV2MarketRegistryUpdate({_marketAddress: address(market), _duration: 0}), + _skipValidation: false + }); + + // Should fail + vm.expectRevert("__getPrincipalTokenValue: Duration not registered"); + pendleV2ExternalPosition.getManagedAssets(); + } +} + +abstract contract PendleTestEthereum is PendleTestBase { + function __initializeEthereum(EnzymeVersion _version, address _pendleMarketAddress, uint32 _pricingDuration) + internal + { + setUpMainnetEnvironment(ETHEREUM_BLOCK_PENDLE_TIME_SENSITIVE); + + __initialize({ + _version: _version, + _pendlePtAndLpOracleAddress: ETHEREUM_PT_ORACLE, + _pendleRouterAddress: ETHEREUM_ROUTER, + _pendleMarketAddress: _pendleMarketAddress, + _pricingDuration: _pricingDuration + }); + } } // Pendle weETH is a v3 market -contract PendleWeEthTestEthereum is PendleTestBase { +contract PendleWeEthTestEthereum is PendleTestEthereum { function setUp() public override { - __initialize({ + __initializeEthereum({ _version: EnzymeVersion.Current, - _pendleMarketFactoryAddresses: toArray(ETHEREUM_MARKET_FACTORY_V1, ETHEREUM_MARKET_FACTORY_V3), - _pendlePtOracleAddress: ETHEREUM_PT_ORACLE, - _pendleRouterAddress: ETHEREUM_ROUTER, _pendleMarketAddress: ETHEREUM_WEETH_27JUN2024_MARKET_ADDRESS, _pricingDuration: 900 // 15 minutes }); @@ -795,13 +745,10 @@ contract PendleWeEthTestEthereum is PendleTestBase { } // Pendle weETH is a v3 market -contract PendleWeEthTestEthereumV4 is PendleTestBase { +contract PendleWeEthTestEthereumV4 is PendleTestEthereum { function setUp() public override { - __initialize({ + __initializeEthereum({ _version: EnzymeVersion.V4, - _pendleMarketFactoryAddresses: toArray(ETHEREUM_MARKET_FACTORY_V1, ETHEREUM_MARKET_FACTORY_V3), - _pendlePtOracleAddress: ETHEREUM_PT_ORACLE, - _pendleRouterAddress: ETHEREUM_ROUTER, _pendleMarketAddress: ETHEREUM_WEETH_27JUN2024_MARKET_ADDRESS, _pricingDuration: 900 // 15 minutes }); @@ -809,13 +756,10 @@ contract PendleWeEthTestEthereumV4 is PendleTestBase { } // Pendle steth is a v1 market -contract PendleStethTestEthereum is PendleTestBase { +contract PendleStethTestEthereum is PendleTestEthereum { function setUp() public override { - __initialize({ + __initializeEthereum({ _version: EnzymeVersion.Current, - _pendleMarketFactoryAddresses: toArray(ETHEREUM_MARKET_FACTORY_V1, ETHEREUM_MARKET_FACTORY_V3), - _pendlePtOracleAddress: ETHEREUM_PT_ORACLE, - _pendleRouterAddress: ETHEREUM_ROUTER, _pendleMarketAddress: ETHEREUM_STETH_26DEC2025_MARKET_ADDRESS, _pricingDuration: 900 // 15 minutes }); @@ -823,13 +767,10 @@ contract PendleStethTestEthereum is PendleTestBase { } // Pendle steth is a v1 market -contract PendleStethTestEthereumV4 is PendleTestBase { +contract PendleStethTestEthereumV4 is PendleTestEthereum { function setUp() public override { - __initialize({ + __initializeEthereum({ _version: EnzymeVersion.V4, - _pendleMarketFactoryAddresses: toArray(ETHEREUM_MARKET_FACTORY_V1, ETHEREUM_MARKET_FACTORY_V3), - _pendlePtOracleAddress: ETHEREUM_PT_ORACLE, - _pendleRouterAddress: ETHEREUM_ROUTER, _pendleMarketAddress: ETHEREUM_STETH_26DEC2025_MARKET_ADDRESS, _pricingDuration: 900 // 15 minutes }); diff --git a/tests/tests/protocols/pendle/PendleV2Utils.sol b/tests/tests/protocols/pendle/PendleV2Utils.sol new file mode 100644 index 000000000..7a99d65b2 --- /dev/null +++ b/tests/tests/protocols/pendle/PendleV2Utils.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.19; + +import {AddOnUtilsBase} from "tests/utils/bases/AddOnUtilsBase.sol"; +import {IPendleV2MarketRegistry} from "tests/interfaces/internal/IPendleV2MarketRegistry.sol"; + +abstract contract PendleV2Utils is AddOnUtilsBase { + function __deployPendleV2MarketRegistry(address _pendlePtAndLpOracleAddress) + internal + returns (IPendleV2MarketRegistry) + { + bytes memory args = abi.encode(_pendlePtAndLpOracleAddress); + return IPendleV2MarketRegistry(deployCode("PendleV2MarketRegistry.sol", args)); + } + + function __encodePendleV2MarketRegistryUpdate(address _marketAddress, uint32 _duration) + internal + pure + returns (IPendleV2MarketRegistry.UpdateMarketInput[] memory updateMarketInputs_) + { + updateMarketInputs_ = new IPendleV2MarketRegistry.UpdateMarketInput[](1); + updateMarketInputs_[0] = + IPendleV2MarketRegistry.UpdateMarketInput({marketAddress: _marketAddress, duration: _duration}); + + return updateMarketInputs_; + } +}