Pendle PT maturity marker and redemption-index impairment plan
Settled with Fred 2026-09-02. Two PRs against staging, both HELD from merge until the #715 ledger-truth campaign posts "campaign closed" (its owner has frozen src/lib/portfolio/ for the flip; see §5).
Companion, already shipped or in flight: PR #720 (matured-market history run), the 30-day PT carry runway PR (screener side, feat/pt-carry-30d-runway), issue #703 (coverage line, open), issue #725 (closed as superseded by the 30-day runway).
1. Product decisions (Fred, 2026-09-02)
- A PT position that has reached maturity must tell the user so. For any Pendle PT a tracked wallet holds at or after the PT's maturity, plain in the wallet OR as collateral inside a loop (Aave v3, SparkLend, Morpho Blue), the position row and the Activity feed carry a "matured" marker. For a loop the marker also states that funding keeps accruing against a flat collateral leg (the position bleeds until the PT is redeemed and the debt repaid).
- A write-down of the yield-bearing asset behind a PT must reach the PT's value, on both lines, when it is booked on-chain. Pendle passes such losses to PT holders through its redemption index (§2). Today both the market mark and the accrual (redemption) mark assume the PT pays exactly one unit of its accounting asset at maturity, so the loss only appears on the day the user redeems, as a lump.
- Explicitly OUT of scope (verified unnecessary against Pendle's contracts, 2026-09-02):
- a par cap on the market mark:
MarketMathCore._getExchangeRatereverts on any exchange rate below one, so the pool can never price a PT above par and the on-chain oracle we read is capped by construction. The −100% implied figure on the last day (issue #725) is an artifact of Pendle's API series, not of the pool. - a "shrinking discount band" near maturity: the pool caps PT at 96% of the pool (
MAX_MARKET_PROPORTION) andrateScalar = scalarRoot × 365d / timeToExpiry, so the price can deviate from its glide path by at mostln(0.96/0.04) / rateScalar. For the #703 market (scalarRoot16.58) that is ≈1.6% at 30 days, ≈0.4% at 7 days, ≈0.05% on the last day. Noise near maturity is already bounded by Pendle's own math. - apxUSD / apyUSD book classification: they stay in the Other view (settled).
- the registry stays holdings-driven; no mirror of matured markets nobody held (settled).
- a par cap on the market mark:
2. The mechanism being modelled (verified on-chain, PendleYieldToken v6 / PendleMarketV7)
YT._pyIndexCurrent()=max(SY.exchangeRate(), _pyIndexStored); the stored index is written on every interaction and never decreases.- Redemption (
_calcSyRedeemableFromPY):syToUser = amountPY / indexCurrent(in SY shares). Its value in accounting-asset terms isamountPY × SY.exchangeRate() / indexCurrent, i.e. exactly 1 per PT while the rate is at its peak, less than 1 while it sits below the peak. Post-expiry the same formula holds (postExpiry.firstPYIndexonly routes the post-expiry excess to the treasury; the holder's share still uses the current max index). - YT holders collected interest as the rate rose and give nothing back on a fall.
Worked example (100 PT-sUSDe, accounting asset USDe; sUSDe rate 1.20 at mint):
| path | sUSDe rate | index | PT redeems for | YT keeps |
|---|---|---|---|---|
| normal | 1.20 → 1.26 | 1.26 | 79.4 sUSDe = 100 USDe | 5 USDe interest |
| write-down | 1.20 → 1.26 → 1.10 | 1.26 | 79.4 sUSDe = 87.3 USDe | same 5 USDe |
| recovery | 1.10 → 1.26 | 1.26 | 79.4 sUSDe = 100 USDe | unchanged |
The impairment factor at block b: f(b) = min(1, SY.exchangeRate()@b / YT.pyIndexStored()@b), per market. Both are view calls (pendle_markets.sy_address, pendle_markets.yt_address), readable at any archive block, before and after maturity; neither reverts post-expiry (unlike the TWAP oracle). Caveat, stated in code: pyIndexStored lags the true peak if no on-chain interaction captured it, so a write-down can be slightly UNDERstated between the peak and the next interaction; never overstated.
3. Where the factor applies (both ledgers, both lines)
The PT→accounting-asset rate is the ONE quantity both lines are built on, so f multiplies it once and both lines move together:
- Served ledger (v1):
src/lib/portfolio/snapshot.tsptToAssetRateAtBlock(market mark: TWAP read pre-maturity, par at/after) and the accrual/redemption path insrc/lib/portfolio/assemble.ts(valueRedemptionpull-to-par viapnl.tsptRedemptionMark), plussrc/lib/portfolio/readers/pendle.ts(directly held PT market mark). - Rebuilt ledger (v2):
src/lib/portfolio/derive/marks.tsstep 2 "PT rates, per (market, block)" (par at/after maturity without a read) and the redemption-rate step for PT legs. - Semantics:
fis read in the same per-(market, block) batch as the PT rate (multicall, archiveblockTag). A failed read of either leg leaves the PT mark NULL for that block, never 1, matching the existing "young-TWAP revert → null" rule; post-maturity this introduces a read where there was none, which is accepted (both calls are plain views). fapplies identically to a bare PT and to a PT collateral leg (the PT market attaches by asset, venue-agnostic:derive/marks.tsplanMark).- Dislocation P&L (market − redemption) is unchanged in definition; both inputs carry
f.
4. Matured marker
- Read-time, derived from
pendle_markets.maturity_ts+ an open PT leg (qty > 0) atts ≥ maturity_ts. No new receipt, no ledger write, no engine change: maturity is a calendar event, not a movement. - One shared pure function (registry row + leg → marker | null) used by BOTH the served path and the rebuilt path so the two readers cannot disagree.
- Surfaces: the position row (plain: "Matured, redeem"; loop: "Matured, funding still accruing, unwind") and the Activity feed (one entry at the maturity timestamp per leg, synthesized at read time; it must not be counted as a flow, a capital move, or a marker on the chart).
- The loop variant is keyed off the same leg classification the taxonomy already uses (PT pledged as same-book collateral = carry trade, §"Fixed rate assets" vs "Carry trades").
5. Sequencing and ownership
- Both PRs open against
staging, reviewed, then held until #715 posts "campaign closed". Reason: the campaign's proof runs on frozen inputs andsrc/lib/portfolio/is its area; the factor changes PT marks, which would move figures mid-proof. - After the flip: prod backfill/re-mark needs are stated in each PR body under "Prod (after release)"; the impairment factor changes stored marks only if a market's rate ever sat below its index in the covered history, which the PR must measure read-only on staging and report.
- No prod deploy or data write without Fred's explicit go.