## 概述 在构建基于 Uniswap V4 Hook 的借贷协议 [Licredity](https://github.com/Licredity) 时,我们希望引入 PT 作为担保品,但此前我并没有详细了解过 PT 预言机的开发生态,所以我阅读了目前 Morpho 内几个较大使用 PT 的市场,并阅读了这些市场内的预言机实现。 本文默认读者熟悉 Morpho 的标准预言机实现,假如读者对此不熟悉,可以阅读 [现代 DeFi: 最小化借贷协议 Morpho](https://blog.wssh.trade/posts/morpho-bule/) 一文。 ## [PT-USDe-25SEP2025 / DAI](https://app.morpho.org/ethereum/market/0x45d97c66db5e803b9446802702f087d4293a2f74b370105dc3a88a278bf6bb21/pt-usde-25sep2025-dai?subTab=advanced) 该市场本质上使用了 [0x59CadA9800cc83E68Ba795c8A5982F4e6dB037eC](https://etherscan.io/address/0x59CadA9800cc83E68Ba795c8A5982F4e6dB037eC#code) 作为预言机,源代码如下: ```solidity // SPDX-License-Identifier: SEE LICENSE IN LICENSE pragma solidity ^0.8.19; contract PendleSparkLinearDiscountOracle { uint256 private constant SECONDS_PER_YEAR = 365 days; uint256 private constant ONE = 1e18; address public immutable PT; uint256 public immutable maturity; uint256 public immutable baseDiscountPerYear; // 100% = 1e18 constructor(address _pt, uint256 _baseDiscountPerYear) { require(_baseDiscountPerYear <= 1e18, "invalid discount"); require(_pt != address(0), "zero address"); PT = _pt; maturity = PTExpiry(PT).expiry(); baseDiscountPerYear = _baseDiscountPerYear; } function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) { uint256 timeLeft = (maturity > block.timestamp) ? maturity - block.timestamp : 0; uint256 discount = getDiscount(timeLeft); require(discount <= ONE, "discount overflow"); return (0, int256(ONE - discount), 0, 0, 0); } function decimals() external pure returns (uint8) { return 18; } function getDiscount(uint256 timeLeft) public view returns (uint256) { return (timeLeft * baseDiscountPerYear) / SECONDS_PER_YEAR; } } interface PTExpiry { function expiry() external view returns (uint256); } ``` 直接将 PT-USDe-25SEP2025 视为折价的 USDe,并且假设 `1 USDe = 1 DAI`,并没有处理 USDe 可能的脱锚问题。在 Pendle PT 内部,PT 必须实现以下接口: ```solidity interface IPPrincipalToken is IERC20Metadata { function burnByYT(address user, uint256 amount) external; function mintByYT(address user, uint256 amount) external; function initialize(address _YT) external; function SY() external view returns (address); function YT() external view returns (address); function factory() external view returns (address); function expiry() external view returns (uint256); function isExpired() external view returns (bool); } ``` 但需要注意的,由于在 Licredity Oracle 内部进行 `maxStaleness` 的预言机返回数据过期检验,所以此处我们不能直接使用 `PendleSparkLinearDiscountOracle` 的源代码,此处的 `return (0, int256(ONE - discount), 0, 0, 0);` 无法通过过期检查。 ## [PT-USDe-25SEP2025 / USDC](https://app.morpho.org/ethereum/market/0x7a5d67805cb78fad2596899e0c83719ba89df353b931582eb7d3041fd5a06dc8/pt-usde-25sep2025-usdc?subTab=advanced) ### MetaOracleDeviationTimelock 该预言机并没有使用 Morpho 自己的预言机实现,而是单独实现了一个符合 Morpho 接口的预言机,该预言机地址是 `0xe6aBD3B78Abbb1cc1Ee76c5c3689Aa9646481Fbb`。 Morpho 要求的预言机接口为: ```solidity interface IOracle { /// @notice Returns the price of 1 asset of collateral token quoted in 1 asset of loan token, scaled by 1e36. /// @dev It corresponds to the price of 10**(collateral token decimals) assets of collateral token quoted in /// 10**(loan token decimals) assets of loan token with `36 + loan token decimals - collateral token decimals` /// decimals of precision. function price() external view returns (uint256); } ``` 简单来说,Morpho 要求返回 1 单位担保品以债务资产计价的情况。在 [Morpho](https://github.com/morpho-org/morpho-blue/blob/main/src/Morpho.sol#L511) 内部,我们在 `_isHealthy` 内部使用该价格: ```solidity /// @dev Returns whether the position of `borrower` in the given market `marketParams` is healthy. /// @dev Assumes that the inputs `marketParams` and `id` match. function _isHealthy(MarketParams memory marketParams, Id id, address borrower) internal view returns (bool) { if (position[id][borrower].borrowShares == 0) return true; uint256 collateralPrice = IOracle(marketParams.oracle).price(); return _isHealthy(marketParams, id, borrower, collateralPrice); } /// @dev Returns whether the position of `borrower` in the given market `marketParams` with the given /// `collateralPrice` is healthy. /// @dev Assumes that the inputs `marketParams` and `id` match. /// @dev Rounds in favor of the protocol, so one might not be able to borrow exactly `maxBorrow` but one unit less. function _isHealthy(MarketParams memory marketParams, Id id, address borrower, uint256 collateralPrice) internal view returns (bool) { uint256 borrowed = uint256(position[id][borrower].borrowShares).toAssetsUp( market[id].totalBorrowAssets, market[id].totalBorrowShares ); uint256 maxBorrow = uint256(position[id][borrower].collateral).mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) .wMulDown(marketParams.lltv); return maxBorrow >= borrowed; } ``` 继续阅读 `0xe6aBD3B78Abbb1cc1Ee76c5c3689Aa9646481Fbb` 源代码,该预言机实现了 ```solidity= // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {IMetaOracleDeviationTimelock} from "./interfaces/IMetaOracleDeviationTimelock.sol"; import {IOracle} from "./interfaces/IOracle.sol"; /// @title MetaOracleDeviationTimelock /// @author Steakhouse Financial /// @notice A meta-oracle that selects between a primary and backup oracle based on price deviation and timelocks. /// @dev Switches to backup if primary deviates significantly, switches back when prices reconverge. /// MUST be initialized by calling the `initialize` function. contract MetaOracleDeviationTimelock is IMetaOracleDeviationTimelock, Initializable { // --- Configuration (set during initialization) --- IOracle public primaryOracle; IOracle public backupOracle; uint256 public deviationThreshold; // Scaled by 1e18 (e.g., 0.01e18 for 1%) uint256 public challengeTimelockDuration; // Duration in seconds uint256 public healingTimelockDuration; // Duration in seconds // --- State --- IOracle public currentOracle; // Currently selected oracle uint256 public challengeExpiresAt; // Timestamp when challenge period ends (0 if not challenged) uint256 public healingExpiresAt; // Timestamp when healing period ends (0 if not healing) /// @param _primaryOracle The primary price feed. /// @param _backupOracle The backup price feed. /// @param _deviationThreshold The maximum allowed relative deviation (scaled by 1e18) before a challenge can be initiated. /// @param _challengeTimelockDuration The duration (seconds) a challenge must persist before switching to backup. /// @param _healingTimelockDuration The duration (seconds) prices must remain converged before switching back to primary. function initialize( IOracle _primaryOracle, IOracle _backupOracle, uint256 _deviationThreshold, uint256 _challengeTimelockDuration, uint256 _healingTimelockDuration ) external initializer { require(address(_primaryOracle) != address(0), "Invalid primary oracle"); require(address(_backupOracle) != address(0), "Invalid backup oracle"); require(address(_primaryOracle) != address(_backupOracle), "Oracles must be different"); require(_deviationThreshold > 0, "Deviation threshold must be positive"); primaryOracle = _primaryOracle; backupOracle = _backupOracle; deviationThreshold = _deviationThreshold; challengeTimelockDuration = _challengeTimelockDuration; healingTimelockDuration = _healingTimelockDuration; // Check initial deviation uint256 initialPrimaryPrice = _primaryOracle.price(); uint256 initialBackupPrice = _backupOracle.price(); uint256 initialDeviation; if (initialBackupPrice == 0) { initialDeviation = initialPrimaryPrice == 0 ? 0 : type(uint256).max; } else { uint256 diff; if (initialPrimaryPrice >= initialBackupPrice) { diff = initialPrimaryPrice - initialBackupPrice; } else { diff = initialBackupPrice - initialPrimaryPrice; } initialDeviation = (diff * 10**18) / initialBackupPrice; } require(initialDeviation <= _deviationThreshold, "MODT: Initial deviation too high"); currentOracle = _primaryOracle; // Start with the primary oracle } /// @inheritdoc IOracle function price() public view returns (uint256) { try currentOracle.price() returns (uint256 currentPrice) { return currentPrice; } catch { if (isPrimary()) { return backupOracle.price(); } else { return primaryOracle.price(); } } } /// @inheritdoc IMetaOracleDeviationTimelock function primaryPrice() public view returns (uint256) { return primaryOracle.price(); } /// @inheritdoc IMetaOracleDeviationTimelock function backupPrice() public view returns (uint256) { return backupOracle.price(); } /// @notice Checks if the primary oracle is currently selected. function isPrimary() public view returns (bool) { return currentOracle == primaryOracle; } /// @notice Checks if the backup oracle is currently selected. function isBackup() public view returns (bool) { return currentOracle == backupOracle; } /// @notice Checks if a challenge is currently active. function isChallenged() public view returns (bool) { return challengeExpiresAt > 0; } /// @notice Checks if a healing period is currently active. function isHealing() public view returns (bool) { return healingExpiresAt > 0; } /// @notice Calculates the absolute relative deviation between primary and backup oracles. /// @dev Deviation is calculated as `abs(primaryPrice - backupPrice) * 1e18 / backupPrice`. /// Returns 0 if backupPrice is 0 and primaryPrice is 0. /// Returns type(uint256).max if backupPrice is 0 and primaryPrice is non-zero. function getDeviation() public view returns (uint256) { uint256 currentPrimaryPrice = primaryOracle.price(); uint256 currentBackupPrice = backupOracle.price(); if (currentBackupPrice == 0) { return currentPrimaryPrice == 0 ? 0 : type(uint256).max; } uint256 diff; if (currentPrimaryPrice >= currentBackupPrice) { diff = currentPrimaryPrice - currentBackupPrice; } else { diff = currentBackupPrice - currentPrimaryPrice; } // Use uint256 for intermediate multiplication to avoid overflow before division return (diff * 10**18) / currentBackupPrice; } /// @notice Checks if the deviation exceeds the configured threshold. function isDeviant() public view returns (bool) { return getDeviation() > deviationThreshold; } /// @notice Initiates a challenge if the primary oracle is active and deviation threshold is exceeded. /// @dev Starts a timelock period (`challengeTimelockDuration`). function challenge() external { require(isPrimary(), "MODT: Must be primary oracle"); require(!isChallenged(), "MODT: Already challenged"); require(isDeviant(), "MODT: Deviation threshold not met"); challengeExpiresAt = block.timestamp + challengeTimelockDuration; emit ChallengeStarted(challengeExpiresAt); } /// @notice Revokes an active challenge if the deviation is no longer present. function revokeChallenge() external { require(isPrimary(), "MODT: Must be primary oracle"); // Should still be primary require(isChallenged(), "MODT: Not challenged"); require(!isDeviant(), "MODT: Deviation threshold still met"); challengeExpiresAt = 0; emit ChallengeRevoked(); } /// @notice Checks if the challenge has expired. function hasChallengeExpired() public view returns (bool) { return isChallenged() && block.timestamp >= challengeExpiresAt; } /// @notice Checks if the challenge can be accepted. function canAcceptChallenge() public view returns (bool) { return isPrimary() && isChallenged() && block.timestamp >= challengeExpiresAt && isDeviant(); } /// @notice Accepts the challenge after the timelock expires, switching to the backup oracle. /// @dev Requires the deviation to still be present. function acceptChallenge() external { require(isPrimary(), "MODT: Must be primary oracle"); require(isChallenged(), "MODT: Not challenged"); require(block.timestamp >= challengeExpiresAt, "MODT: Challenge timelock not passed"); require(isDeviant(), "MODT: Deviation resolved"); // Deviation must persist currentOracle = backupOracle; challengeExpiresAt = 0; emit ChallengeAccepted(address(currentOracle)); } /// @notice Initiates the healing process if the backup oracle is active and prices have reconverged. /// @dev Starts a timelock period (`healingTimelockDuration`). function heal() external { require(isBackup(), "MODT: Must be backup oracle"); require(!isHealing(), "MODT: Already healing"); require(!isDeviant(), "MODT: Deviation threshold still met"); healingExpiresAt = block.timestamp + healingTimelockDuration; emit HealingStarted(healingExpiresAt); } /// @notice Revokes an active healing process if the deviation threshold is exceeded again. function revokeHealing() external { require(isBackup(), "MODT: Must be backup oracle"); // Should still be backup require(isHealing(), "MODT: Not healing"); require(isDeviant(), "MODT: Deviation threshold not met"); healingExpiresAt = 0; emit HealingRevoked(); } /// @notice Checks if the healing has expired. function hasHealingExpired() public view returns (bool) { return isHealing() && block.timestamp >= healingExpiresAt; } /// @notice Checks if the healing can be accepted. function canAcceptHealing() public view returns (bool) { return isBackup() && isHealing() && block.timestamp >= healingExpiresAt && !isDeviant(); } /// @notice Accepts the healing after the timelock expires, switching back to the primary oracle. /// @dev Requires the prices to still be converged (not deviant). function acceptHealing() external { require(isBackup(), "MODT: Must be backup oracle"); require(isHealing(), "MODT: Not healing"); require(block.timestamp >= healingExpiresAt, "MODT: Healing timelock not passed"); require(!isDeviant(), "MODT: Deviation occurred"); // Prices must remain converged currentOracle = primaryOracle; healingExpiresAt = 0; emit HealingAccepted(address(currentOracle)); } } ``` 上述预言机的代码逻辑并不复杂。预言机内包含两个预言机,分别被称为: 1. `primaryOracle` 核心预言机,主要负责报价工作 2. `backupOracle` 备用预言机,当主预言机与备用预言机的价差过大时,可以通过挑战将预言机切换为备用预言机 > 代码内的 `currentOracle` 的数值可能是 `primaryOracle` 或是 `backupOracle`。另外需要注意的是,假如 `currentOracle` 的 `price` 函数出现问题,那么会自动切换为另一个预言机请求报价 我们可以将上述预言机视为如下状态机: ```mermaid --- title: MetaOracle Deviation Timelock --- stateDiagram-v2 [*] --> PrimaryOracle PrimaryOracle --> [*] PrimaryOracle --> ChallengeTimeLock ChallengeTimeLock --> RevokeChallenge RevokeChallenge --> PrimaryOracle ChallengeTimeLock --> AcceptChallenge AcceptChallenge --> BackupOracle BackupOracle --> [*] BackupOracle --> HealTimeLock HealTimeLock --> RevokeHealing RevokeHealing --> BackupOracle HealTimeLock --> AcceptHealing AcceptHealing --> PrimaryOracle ``` 其中,`challenge` 开启的条件如下: 1. `currentOracle == primaryOracle` 2. 当前并不属于挑战时间锁(`ChallengeTimeLock`) 内部 3. `primaryOracle` 和 `backupOracle` 价格偏差大于最初设置 当 `challenge` 挑战时间锁内出现`primaryOracle` 和 `backupOracle` 价格偏差恢复的情况,可以调用 `revokeChallenge` 撤销挑战。当挑战时间锁完成并且此时价格偏差仍未恢复,可以调用 `acceptChallenge` 将 `currentOracle` 切换为 `backupOracle`。 当然,我们也可以在 `primaryOracle` 与 `backupOracle` 报价偏差恢复后,将预言机切换为 `primaryOracle`,该流程被称为 `heal` 过程,其过程与 `challenge` 基本类似,此处不再赘述。 简单来说,`MetaOracleDeviationTimelock` 合约允许用户设置 `primaryOracle` 和 `backupOracle` 进行报价。当 `MetaOracleDeviationTimelock` 的 `price` 函数被调用时,`currentOracle` 会返回报价,但假如 `currentOracle` 报价失败,那么会立即使用另一个预言机的报价返回。 同时,该预言机也允许随着 `primaryOracle` 和 `backupOracle` 报价价差的情况,进行预言机切换,当报价价差过大且时间锁完成时,`currentOracle` 会被切换为 `primaryOracle`;反之,当预言机之间的价差恢复时,`currentOracle` 会被切换为 `backupOracle`。 开发者为 `PT-USDe-25SEP2025` 设置了以下预言机: 1. `primaryOracle` 对应的地址是 `0xA5d21a73312221c1C7D94702316c02B2485036B9` 2. `backupOracle` 对应的地址是 `0x0DE0eCde53eA9d38351b2CE0635430506c0AA330` 这两个地址均部署了标准的 `MorphoChainlinkOracleV2` 预言机,我们会在后文分析这两个预言机的配置情况。 ### Primary Oracle 在 Primary Oracle 内部,开发者使用了 Morpho 的标准预言机,并只设置了 `BASE_FEED_1`,设置的地址是 `0x93F4E501ddcB7e8FD18f5839204AeC0234B2B2E0`。该地址内部署了 `PendleChainlinkOracle` 合约,该合约的源代码可以在 [pendle-core-v2-public](https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/oracles/PtYtLpOracle/chainlink/PendleChainlinkOracle.sol) 内找到,该合约的核心部分代码如下: ```solidity= function(IPMarket, uint32) internal view returns (uint256) private immutable _getRawPendlePrice; constructor(address _market, uint32 _twapDuration, PendleOracleType _baseOracleType) { factory = msg.sender; market = _market; twapDuration = _twapDuration; baseOracleType = _baseOracleType; (uint256 fromTokenDecimals, uint256 toTokenDecimals) = _readDecimals(_market, _baseOracleType); (fromTokenScale, toTokenScale) = (10 ** fromTokenDecimals, 10 ** toTokenDecimals); _getRawPendlePrice = _getRawPendlePriceFunc(); } // ================================================================= // PRICING FUNCTIONS // ================================================================= function _getPendleTokenPrice() internal view returns (int256) { return _descalePrice(_getRawPendlePrice(IPMarket(market), twapDuration)); } function _descalePrice(uint256 price) private view returns (int256 unwrappedPrice) { return PMath.Int((price * fromTokenScale) / toTokenScale); } // ================================================================= // USE ONLY AT INITIALIZATION // ================================================================= function _getRawPendlePriceFunc() internal view returns (function(IPMarket, uint32) internal view returns (uint256)) { if (baseOracleType == PendleOracleType.PT_TO_SY) { return PendlePYOracleLib.getPtToSyRate; } else if (baseOracleType == PendleOracleType.PT_TO_ASSET) { return PendlePYOracleLib.getPtToAssetRate; } else { revert("not supported"); } } function _readDecimals( address _market, PendleOracleType _oracleType ) internal view returns (uint8 _fromDecimals, uint8 _toDecimals) { (IStandardizedYield SY, , ) = IPMarket(_market).readTokens(); uint8 syDecimals = SY.decimals(); (, , uint8 assetDecimals) = SY.assetInfo(); if (_oracleType == PendleOracleType.PT_TO_ASSET) { return (assetDecimals, assetDecimals); } else if (_oracleType == PendleOracleType.PT_TO_SY) { return (assetDecimals, syDecimals); } } ``` 在以上代码内,我们可以看到一种并不常见的智能合约编程技术,即 solidity 内的函数式编程方法。在 solidity 内,实际上允许函数返回值是另一个函数。此处在构造过程中调用的 `_getRawPendlePriceFunc` 就是一个这种特殊函数,其返回值仍是一个函数,并且该函数被初始化到了 `_getRawPendlePrice` 内部。此处 `_getRawPendlePrice` 在 `PendleOracleType.PT_TO_ASSET` 情况下的实现如下: ```solidity /** * This function returns the twap rate PT/Asset on market, but take into account the current rate of SY This is to account for special cases where underlying asset becomes insolvent and has decreasing exchangeRate * @param market market to get rate from * @param duration twap duration */ function getPtToAssetRate(IPMarket market, uint32 duration) internal view returns (uint256) { (uint256 syIndex, uint256 pyIndex) = getSYandPYIndexCurrent(market); if (syIndex >= pyIndex) { return getPtToAssetRateRaw(market, duration); } else { return (getPtToAssetRateRaw(market, duration) * syIndex) / pyIndex; } } /// @notice returns the raw rate without taking into account whether SY is solvent function getPtToAssetRateRaw(IPMarket market, uint32 duration) internal view returns (uint256) { uint256 expiry = market.expiry(); if (expiry <= block.timestamp) { return PMath.ONE; } else { uint256 lnImpliedRate = getMarketLnImpliedRate(market, duration); uint256 timeToExpiry = expiry - block.timestamp; uint256 assetToPtRate = MarketMathCore._getExchangeRateFromImpliedRate(lnImpliedRate, timeToExpiry).Uint(); return PMath.ONE.divDown(assetToPtRate); } } function getMarketLnImpliedRate(IPMarket market, uint32 duration) internal view returns (uint256) { uint32[] memory durations = new uint32[](2); durations[0] = duration; uint216[] memory lnImpliedRateCumulative = market.observe(durations); return (lnImpliedRateCumulative[1] - lnImpliedRateCumulative[0]) / duration; } ``` 上述代码使用 `getMarketLnImpliedRate` 函数从 `market` 内读取 TWAP 数据,然后使用 `(lnImpliedRateCumulative[1] - lnImpliedRateCumulative[0]) / duration` 将其转化为该段时间内平均数。 Primary Oracle 存在的问题是 Pendle 预言机返回的实际上是 `PT-USDe-25SEP2025 / USDe` 的报价,所以 Primary Oracle 假设了 `USDe` 和 `USDC` 之间的价格不会脱锚。我们会在 Backup Oracle 内解决此问题 ### Backup Oracle Backup Oracle 也使用了标准的 Morpho 预言机实现,并设置了以下参数: 1. `BASE_FEED_1` 也使用了 Pendle 的 `PT-USDe-25SEP2025 / USDe` 报价预言机,实际上就是上文介绍的 `0x93F4E501ddcB7e8FD18f5839204AeC0234B2B2E0` 2. `BASE_FEED_2` 使用了 Chainlink 的 `USDe / USD ` 预言机,用来处理可能的 USDe 脱锚问题,地址是 `0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961`,返回的报价精度为 8 3. `QUOTE_FEED_1` 使用了 `DummyFeed`,该预言机只用于修正数值精度 为什么需要 `DummyFeed` 修正精度? 在 MorphoChainlinkOracleV2 内存在以下代码: ```solidity SCALE_FACTOR = 10 ** ( 36 + quoteTokenDecimals + quoteFeed1.getDecimals() + quoteFeed2.getDecimals() - baseTokenDecimals - baseFeed1.getDecimals() - baseFeed2.getDecimals() ) * quoteVaultConversionSample / baseVaultConversionSample; } ``` 在此案例中,`quoteTokenDecimals` 是 USDC 的精度,即 `quoteTokenDecimals = 6`,而 `baseTokenDecimals` 是 USDe 的精度,即 `baseTokenDecimals = 18`,而 `baseFeed1.getDecimals` 的返回值是 18,且 `baseFeed2.getDecimals()` 的返回值是 8。 如果不考虑 `QUOTE_FEED_1` 的精度,那么 `SCALE_FACTOR` 的计算结果是 $10^{36 + 6 - 18 - 18 - 8} = 10^{-2}$。显然,该计算由于出现负数会导致计算溢出。解决方案是设置 `quoteFeed1` 的 `decimals`。在此案例中,我们将 `quoteFeed1` 的 `decimals` 设置为 12,由此避免计算 `SCALE_FACTOR` 过程中的下溢。但为了避免 `quoteFeed1` 影响最后的结果,我们将 `quoteFeed1` 的 `price` 设置为 `1e12`。 实际上,我们可以将 `quoteFeed1` 设置的精度设置为大于 2 的某一个数字,该预言机存在只是为了避免计算 `SCALE_FACTOR` 过程中的下溢。 ## [PT-cUSDO-20NOV2025 / USDC](https://app.morpho.org/ethereum/market/0x8a71a66ac828c2b6d4f8accce5859aba0822b502f3833bec4aff09479affffdb/pt-cusdo-20nov2025-usdc?subTab=advanced) 对于 `PT-cUSDO-20NOV2025 / USDC` 市场,开发者使用了 `0xCE84fD399620B7D20e18AB6009Fc8A0cdaad880f` 作为该市场的预言机,该合约是一个可升级合约,其实现代码位于 `0x04236557c2dac11175638a598b28a9b22fdd8d06` 内部,合约被命名为 `EOPendlePTFeedHybrid`。该合约的计算价格使用了 `MAX(1, MIN(LinearDiscount, TWAP PTtoAsset))` 公式,混合了 Pendle 的 TWAP 报价和线性折扣计算。 其中 Pendle TWAP 报价使用了如下函数获取数据: ```solidity function _getPtToAssetTWAPRate() internal view returns (uint256) { return IPTOracle(ptOracle).getPtToAssetRate(ptMarket, twapDuration) * twapNumeratorMultiplier / twapDenominatorMultiplier; } ``` 而线性折扣计算使用了如下代码: ```solidity function _getLinearDiscountRate() internal view returns (uint256) { uint256 timeLeft = (maturity > block.timestamp) ? maturity - block.timestamp : 0; uint256 discount = (timeLeft * baseDiscountPerYear) / SECONDS_PER_YEAR; if (discount > ONE) revert DiscountOverflow(); return ONE - discount; } ``` ## [AAVE PT USDe September 2025](https://app.aave.com/reserve-overview/?underlyingAsset=0xbc6736d346a5ebc0debc997397912cd9b8fae10a&marketName=proto_mainnet_v3) 最近 AAVE 也增加了 PT 作为担保品的选项,此处我们对 `AAVE PT USDe September 2025` 市场对预言机进行简单分析,该预言机的地址是 `0x8B17C02d22EE7D6B8D6829ceB710A458de41E84a`。其核心代码如下: ```solidity /// @inheritdoc ICLSynchronicityPriceAdapter function latestAnswer() external view returns (int256) { int256 currentAssetPrice = ASSET_TO_USD_AGGREGATOR.latestAnswer(); if (currentAssetPrice <= 0) { return 0; } uint256 price = (uint256(currentAssetPrice) * (PERCENTAGE_FACTOR - getCurrentDiscount())) / PERCENTAGE_FACTOR; return int256(price); } /// @inheritdoc IPendlePriceCapAdapter function getCurrentDiscount() public view returns (uint256) { uint256 timeToMaturity = (MATURITY > block.timestamp) ? MATURITY - block.timestamp : 0; return (timeToMaturity * discountRatePerYear) / SECONDS_PER_YEAR; } ``` 与上文出现的预言机类似,该预言机也使用了简单的线性计算当前 PT 的价格,但有趣的是,AAVE 并不假设 PT 的底层资产 USDe 与 USD 不脱锚,所以引入了 `ASSET_TO_USD_AGGREGATOR` 的选项来处理可能的脱锚。但 AAVE 在此处的 `ASSET_TO_USD_AGGREGATOR` 实际上使用是 `0x3E7d1eAB13ad0104d2750B8863b489D65364e32D` 预言机,该预言机是 `USDT / USD` 预言机,也就是说 USDe 脱锚并不会导致 PT 预言机价格下降,而是 USDT 脱锚会导致 PT 预言机价格下降。 > `ASSET_TO_USD_AGGREGATOR` 另一个有趣的特性是该预言机存在报价上限,即 USDT / USD 价格不会大于 1.04 ## 总结 在 Morpho 内,我们可以观察到以下三种不同的预言机配置: 1. 根据利率线性计算 PT 价格,SparkDAO 管理的市场基本都使用了这种类似的预言机 2. 使用 Pendle 官方 TWAP 预言机作为主预言机,同时配置 TWAP 预言机和底层资产价格预言机作为备用预言机,这种方案有效避免了底层资产脱锚的风险,同时没有显著提高 gas 成本,Steakhouse 设计了这套方案 3. 使用 Pendle 官方 TWAP 预言机和利率线性计算,使用较小的数值 在 AAVE 内,我们主要观察到使用了 利率线性计算 PT 价格 的预言机。 一个有趣的观察是很多预言机都没有考虑底层资产脱锚的情况,而 AAVE 奇怪的使用了 USDT 脱锚衡量 USDe 脱锚。