概述

在构建基于 Uniswap V4 Hook 的借贷协议 Licredity 时,我们希望引入 PT 作为担保品,但此前我并没有详细了解过 PT 预言机的开发生态,所以我阅读了目前 Morpho 内几个较大使用 PT 的市场,并阅读了这些市场内的预言机实现。

本文默认读者熟悉 Morpho 的标准预言机实现,假如读者对此不熟悉,可以阅读 现代 DeFi: 最小化借贷协议 Morpho 一文。

PT-USDe-25SEP2025 / DAI

该市场本质上使用了 0x59CadA9800cc83E68Ba795c8A5982F4e6dB037eC 作为预言机,源代码如下:

// 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 必须实现以下接口:

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

MetaOracleDeviationTimelock

该预言机并没有使用 Morpho 自己的预言机实现,而是单独实现了一个符合 Morpho 接口的预言机,该预言机地址是 0xe6aBD3B78Abbb1cc1Ee76c5c3689Aa9646481Fbb

Morpho 要求的预言机接口为:

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 内部,我们在 _isHealthy 内部使用该价格:

/// @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 源代码,该预言机实现了

// 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。另外需要注意的是,假如 currentOracleprice 函数出现问题,那么会自动切换为另一个预言机请求报价

我们可以将上述预言机视为如下状态机:

MetaOracle Deviation Timelock

其中,challenge 开启的条件如下:

  1. currentOracle == primaryOracle
  2. 当前并不属于挑战时间锁(ChallengeTimeLock) 内部
  3. primaryOraclebackupOracle 价格偏差大于最初设置

challenge 挑战时间锁内出现primaryOraclebackupOracle 价格偏差恢复的情况,可以调用 revokeChallenge 撤销挑战。当挑战时间锁完成并且此时价格偏差仍未恢复,可以调用 acceptChallengecurrentOracle 切换为 backupOracle

当然,我们也可以在 primaryOraclebackupOracle 报价偏差恢复后,将预言机切换为 primaryOracle,该流程被称为 heal 过程,其过程与 challenge 基本类似,此处不再赘述。

简单来说,MetaOracleDeviationTimelock 合约允许用户设置 primaryOraclebackupOracle 进行报价。当 MetaOracleDeviationTimelockprice 函数被调用时,currentOracle 会返回报价,但假如 currentOracle 报价失败,那么会立即使用另一个预言机的报价返回。

同时,该预言机也允许随着 primaryOraclebackupOracle 报价价差的情况,进行预言机切换,当报价价差过大且时间锁完成时,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 内找到,该合约的核心部分代码如下:

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 内部。此处 _getRawPendlePricePendleOracleType.PT_TO_ASSET 情况下的实现如下:

/**
 * 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 假设了 USDeUSDC 之间的价格不会脱锚。我们会在 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 内存在以下代码:

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}$。显然,该计算由于出现负数会导致计算溢出。解决方案是设置 quoteFeed1decimals。在此案例中,我们将 quoteFeed1decimals 设置为 12,由此避免计算 SCALE_FACTOR 过程中的下溢。但为了避免 quoteFeed1 影响最后的结果,我们将 quoteFeed1price 设置为 1e12

实际上,我们可以将 quoteFeed1 设置的精度设置为大于 2 的某一个数字,该预言机存在只是为了避免计算 SCALE_FACTOR 过程中的下溢。

PT-cUSDO-20NOV2025 / USDC

对于 PT-cUSDO-20NOV2025 / USDC 市场,开发者使用了 0xCE84fD399620B7D20e18AB6009Fc8A0cdaad880f 作为该市场的预言机,该合约是一个可升级合约,其实现代码位于 0x04236557c2dac11175638a598b28a9b22fdd8d06 内部,合约被命名为 EOPendlePTFeedHybrid。该合约的计算价格使用了 MAX(1, MIN(LinearDiscount, TWAP PTtoAsset)) 公式,混合了 Pendle 的 TWAP 报价和线性折扣计算。

其中 Pendle TWAP 报价使用了如下函数获取数据:

function _getPtToAssetTWAPRate() internal view returns (uint256) {
    return IPTOracle(ptOracle).getPtToAssetRate(ptMarket, twapDuration) * twapNumeratorMultiplier
        / twapDenominatorMultiplier;
}

而线性折扣计算使用了如下代码:

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

最近 AAVE 也增加了 PT 作为担保品的选项,此处我们对 AAVE PT USDe September 2025 市场对预言机进行简单分析,该预言机的地址是 0x8B17C02d22EE7D6B8D6829ceB710A458de41E84a。其核心代码如下:

  /// @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 脱锚。