Splitting Time: Inside Pendle V2's Yield Tokenization Engine
What if you could strip the yield off a stETH position and sell it separately? Not as a derivative on some centralized exchange, but as an ERC20 token on-chain, priced by a custom AMM that mathematically forces convergence at maturity?
That is the core idea behind Pendle Finance V2. It takes any yield-bearing asset, wraps it into a standardized interface, and splits it into two tokens: one representing the principal, one representing the future yield. Then it provides an AMM specifically designed to trade these time-decaying instruments.
I read all 206 Solidity files (~21,454 lines) in the public repository. Here is how it works.
The Split: SY to PT + YT
The tokenization operates through a three-layer abstraction:
Underlying Asset (e.g., stETH, aUSDC)
|
v
Standardized Yield (SY) -- wraps any yield-bearing asset into a common interface
|
v
PT + YT -- 1 SY mints 1 PT + 1 YT at the current exchange rateStandardized Yield (SY) is the adapter layer. Each SY contract wraps one yield-bearing asset and exposes a uniform interface: deposit(), redeem(), exchangeRate(). The exchange rate is the SY-to-underlying conversion factor, and it should be monotonically non-decreasing for well-behaved underlyings. The conversion math is straightforward:
function syToAsset(uint256 exchangeRate, uint256 syAmount) internal pure returns (uint256) {
return (syAmount * exchangeRate) / ONE;
}Principal Token (PT) is a minimal ERC20 with an expiry timestamp. At maturity, 1 PT is redeemable for 1 unit of underlying. Before maturity, it trades at a discount that reflects the market’s implied yield rate. PT can only be minted and burned by its paired YT contract.
Yield Token (YT) is where the complexity lives. When you mint, the YT contract receives SY and issues both PT and YT:
function _calcPYToMint(uint256 amountSy, uint256 indexCurrent) internal pure returns (uint256 amountPY) {
return SYUtils.syToAsset(indexCurrent, amountSy);
}YT holders collect all yield generated by the underlying SY between purchase time and expiry. The interest accrual formula tracks per-user index snapshots:
function _distributeInterestPrivate(address user, uint256 currentIndex) private {
uint256 prevIndex = userInterest[user].index;
uint256 principal = _YTbalance(user);
uint256 interestFromYT = (principal * (currentIndex - prevIndex))
.divDown(prevIndex * currentIndex);
userInterest[user].accrued += interestFromYT.Uint128();
userInterest[user].index = currentIndex.Uint128();
}This computes principal * (1/prevIndex - 1/currentIndex) – the SY interest accrued between two exchange rate snapshots. After expiry, YT becomes worthless for redemption (only PT is needed), but users can still claim their pre-expiry accrued interest. All post-expiry yield flows to the protocol treasury.
The AMM: Logit Curve with Time Decay
Pendle does not use a constant-product AMM. It uses a logit-based exchange rate curve inspired by Notional Finance’s fCash model, and it only trades PT against SY. YT trading is synthesized by the router through flash swaps (more on that later).
The core pricing function in MarketMathCore.sol:
function _getExchangeRate(
int256 totalPt, int256 totalAsset, int256 rateScalar,
int256 rateAnchor, int256 netPtToAccount
) internal pure returns (int256 exchangeRate) {
int256 numerator = totalPt.subNoNeg(netPtToAccount);
int256 proportion = numerator.divDown(totalPt + totalAsset);
int256 lnProportion = _logProportion(proportion);
exchangeRate = lnProportion.divDown(rateScalar) + rateAnchor;
}Where _logProportion computes the logit function – ln(p / (1-p)):
function _logProportion(int256 proportion) internal pure returns (int256) {
int256 logitP = proportion.divDown(IONE - proportion);
return logitP.ln();
}This creates a sigmoid-shaped curve that provides concentrated liquidity around the equilibrium rate, with natural bounds as the PT proportion approaches 0 or 1. A hard cap at MAX_MARKET_PROPORTION = 96% prevents extreme pool imbalance.
The critical innovation is time decay. The rateScalar parameter increases as expiry approaches:
function _getRateScalar(MarketState memory market, uint256 timeToExpiry)
internal pure returns (int256 rateScalar) {
rateScalar = (market.scalarRoot * IMPLIED_RATE_TIME.Int()) / timeToExpiry.Int();
}Where IMPLIED_RATE_TIME = 365 days. As timeToExpiry approaches zero, rateScalar grows toward infinity, which flattens the exchange rate curve and forces the PT price toward 1:1 with the underlying. This is how PT converges to its face value at maturity – not through governance or keeper bots, but through the math itself.
Market state is loaded into a memory struct at the start of each operation, manipulated entirely in memory, and written back in a single _writeState call. This minimizes expensive SSTORE operations. Storage is tightly packed: totalPt (int128) + totalSy (int128) share one slot; lastLnImpliedRate (uint96) + oracle metadata share another.
The Router: Diamond-Like Proxy
PendleRouterV4 is 24 lines of code. It inherits from OpenZeppelin’s Proxy and dispatches every call based on the function selector:
contract PendleRouterV4 is Proxy, RouterStorage {
function _implementation() internal view override returns (address) {
RouterStorage.CoreStorage storage $ = _getCoreStorage();
address facet = $.selectorToFacet[msg.sig];
require(facet != address(0), "INVALID_SELECTOR");
return facet;
}
}Storage uses ERC-7201 namespaced slots at a deterministic location, mapping bytes4 selectors to facet addresses. The router delegates to six action facets:
| Facet | Purpose |
|---|---|
ActionSwapPTV3 |
PT swaps against SY |
ActionSwapYTV3 |
YT swaps (synthesized via flash swap + mint/redeem) |
ActionAddRemoveLiqV3 |
Liquidity management |
ActionMiscV3 |
Miscellaneous operations |
ActionCallbackV3 |
Swap callback handling |
ActionSimple |
On-chain approximation variants |
YT swaps are the most interesting. Since the market only trades PT/SY, buying YT requires a multi-step synthesis: send SY to the YT contract, flash-swap PT out of the market (PT goes to YT), mint PY from the combined SY+PT, then settle the flash swap in the callback. Selling YT is the reverse: send YT to the YT contract, flash-swap PT from the market to YT, redeem PT+YT for SY, return SY to settle. All of this happens atomically in a single transaction through the callback mechanism.
A Reflector helper contract handles integrations where exact input amounts are unknown until execution time. It receives tokens, scales the input amounts to match actual received balances, forwards the adjusted call to the hardcoded router at 0x888888888889758F76e7103c6CbF23ABbF58F946, and sweeps any dust back to the receiver.
The Oracle: TWAP on Implied Rates
The oracle system is adapted directly from Uniswap V3, but instead of tracking price, it tracks lnImpliedRateCumulative – the cumulative sum of the log implied rate over time:
function transform(Observation memory last, uint32 blockTimestamp, uint96 lnImpliedRate)
public pure returns (Observation memory) {
return Observation({
blockTimestamp: blockTimestamp,
lnImpliedRateCumulative: last.lnImpliedRateCumulative
+ uint216(lnImpliedRate) * (blockTimestamp - last.blockTimestamp),
initialized: true
});
}Observations are stored in a ring buffer of up to 65,535 slots, one per block. The TWAP over any duration is (cumulative[now] - cumulative[now - duration]) / duration. Binary search finds the surrounding observations, with linear interpolation for timestamps between entries.
From the TWAP implied rate, PT and YT prices are derived: PT_price = 1 / e^(TWAP_lnImpliedRate * timeToExpiry / 365 days) and YT_price = 1 - PT_price (since PT + YT = 1 underlying). LP token valuation is more involved – it computes a hypothetical trade that would move the pool to the oracle rate and values the resulting composition at oracle prices, making it manipulation-resistant.
The oracle includes a reentrancy guard check (_checkMarketReentrancy) that verifies the market is not in the middle of a state-modifying call, protecting against read-only reentrancy attacks on oracle consumers.
Security Posture
The codebase has been audited 9 times by 6 independent firms: Ackee, ChainSecurity, CMichel, Dedaub, Dingbats, Spearbit, 0xleastwood, and WatchPug. Our independent analysis found 0 critical findings, 0 high findings, and 2 medium findings:
| ID | Finding | Location | Description |
|---|---|---|---|
| M-1 | Chainlink Oracle Staleness Not Validated | FixedPricePTAMM.sol |
latestRoundData() ignores updatedAt, answeredInRound, and startedAt. Stale or zero prices could lead to incorrect cross-chain PT swap amounts. |
| M-2 | govExecuteMessage Governance Bypass | PendleMsgReceiveEndpointUpg.sol |
Owner can inject arbitrary cross-chain messages bypassing LayerZero source validation. No timelock protection. A compromised owner key could forge vePENDLE data across chains. |
The M-1 finding is a common Chainlink integration oversight:
(, int256 rawPrice,,,) = oracle.latestRoundData();All five return values from latestRoundData() are available, but only rawPrice is used. The fix is straightforward: check updatedAt > 0, enforce a maximum staleness window, verify rawPrice > 0, and on L2 deployments, add a sequencer uptime feed check.
Beyond these two findings, the security fundamentals are solid. Every state-modifying external function uses nonReentrant. The checks-effects-interactions pattern is followed consistently – state is written before external callbacks, with post-callback balance verification. The custom PendleERC20 packs the reentrancy guard status byte alongside _totalSupply (uint248) in a single storage slot, saving ~2,100 gas per guard check. EIP-712 typed signatures with replay protection secure the limit order system. Two-step ownership transfer is used across all ownable contracts.
Six additional low-severity findings were identified, including a silent catch in the oracle reentrancy check (forward-compatibility for older markets), unlimited persistent token approvals in the Reflector, and a theoretical overflow edge case in the interest calculation.
Code Quality: B+
The protocol earns a B+ overall. The math libraries are battle-tested – LogExpMath is adapted from Balancer V2 with 18-decimal fixed-point precision, using power-of-2 decomposition with 12-term Taylor series. PMath provides clean fixed-point arithmetic with a PYIndex user-defined value type for type safety. Storage packing is excellent throughout.
The main deductions: NatSpec documentation is sparse (only 78 NatSpec comments across the entire /contracts/core/ directory), no test files exist in the public repository (tests are in a private internal repo), and the codebase mixes custom errors with legacy require() strings. The MarketMathCore.sol file – arguably the most critical code in the protocol – has only 3 NatSpec comments for 14 functions.
| Area | Assessment |
|---|---|
| Architecture & Organization | A- |
| Storage Packing & Gas Optimization | A |
| Security Patterns | A- |
| Math Library Robustness | A |
| Audit Coverage | A- |
| Maintainability | B+ |
| Documentation | C+ |
| Test Visibility (public repo) | D |
Summary Assessment
Pendle V2 is a mathematically rigorous protocol built around a genuinely novel primitive. The logit-based AMM with time decay is not a fork of anything – it solves a real problem (pricing time-decaying fixed-income instruments on-chain) with an approach that guarantees convergence at maturity through pure math rather than governance. The diamond-like router keeps the entry point stable while allowing the team to ship new action facets. The Uniswap V3-style TWAP oracle, adapted for implied rates rather than prices, provides manipulation-resistant pricing for downstream integrations.
The security posture is strong for a protocol deployed across 12+ EVM chains with significant TVL. Nine audits from six firms, zero critical findings in our independent review, and consistent defensive patterns throughout the codebase. The two medium findings (Chainlink staleness, governance message bypass) are both in peripheral components rather than the core AMM or yield tokenization logic.
The main gaps are in transparency rather than engineering: no public tests, sparse documentation on the most critical math functions. For a protocol asking other DeFi protocols to integrate its oracle, this matters. The code is good. Showing your work would make it better.
Analysis based on pendle-core-v2-public (commit 1a9ef17, v6.5.0). 206 Solidity files, ~21,454 lines of code. Full architecture, quality, and security reports available from the research team.