A Pendle PT token trades below the value of whatever it’s redeemable for, on purpose, and the gap is the entire point: it’s the fixed yield a buyer locked in by holding the discount instead of the variable rate. Price that position correctly and you need the live discount, not the face value. Pendle doesn’t expose that number through a plain balanceOf or a DEX quote, it ships its own onchain oracle for it, and the oracle takes an argument most integration snippets get wrong on the first try.
TL;DR
Call getPtToAssetRate(market, duration) on Pendle’s PendlePYLpOracle
(0x5542be50420E88dd7D5B4a3D488FA6ED82F6DAc2, identical address on
Ethereum, Base, and BNB Chain) to get a PT’s current exchange rate to its
underlying asset. duration is a TWAP window in seconds, not a plain
integer, call getOracleState first to confirm the market has enough
history to support it.
What you’re actually reading: a PT’s discount to its underlying
Pendle splits a yield-bearing asset into two tokens with an expiry date: a Principal Token (PT), redeemable 1:1 for the underlying at maturity, and a Yield Token (YT), which claims the yield earned until then. Because the PT gives up yield in exchange for a fixed, known redemption value later, it trades at a discount today, and that discount is exactly the fixed rate the buyer is locking in.
The discount isn’t a constant. It shrinks as the market approaches expiry, since there’s less time left for yield to accrue to the YT side, and it moves with market demand for fixed-rate exposure in between. A PT’s “exchange rate” is this discount expressed as a ratio, how many units of the underlying one PT is currently worth, somewhere under 1.0 until maturity.
You won’t find this ratio by calling balanceOf or by polling a DEX pool price. Pendle publishes it directly through a dedicated oracle contract, built for exactly this read.
The oracle: PendlePYLpOracle and getPtToAssetRate
PendlePYLpOracle is deployed at the identical address on every chain Pendle supports except Polygon (there’s no Pendle deployment there at all): 0x5542be50420E88dd7D5B4a3D488FA6ED82F6DAc2 on Ethereum, Base, and BNB Chain.
The method you want is:
function getPtToAssetRate(address market, uint32 duration) external view returns (uint256)market is the address of the specific Pendle market (each maturity date for each underlying asset is its own market contract, not a shared registry). duration is a TWAP window in seconds, Pendle reads a time-weighted average over that window rather than a single-block spot price, the same defense Uniswap v3’s own TWAP oracle uses against single-block manipulation. 900 (15 minutes) is a reasonable default for most reads.
The return value is the PT-to-asset rate scaled to 18 decimals, regardless of the underlying asset’s own decimals.
Reading the live rate via API
Here’s a real market: the wstETH PT on Ethereum, maturing 2027-12-30, at 0x34280882267fFa6383b363E278B027Be083bBe3B:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "oracle": { "address": "0x5542be50420E88dd7D5B4a3D488FA6ED82F6DAc2" } }, "context": { "market": "sol_address", "duration": "sol_int" } }, "context": { "market": "0x34280882267fFa6383b363E278B027Be083bBe3B", "duration": 900 }, "expression": "cel.bind(rate, oracle.getPtToAssetRate(market, duration), formatUnits(rate, 18))" }' | python3 -m json.toolRun live against mainnet, this returns:
{ "result": { "value": 0.9746466266484894, "type": "double" }, "meta": { "blockNumber": "26274398", "totalCalls": 1, "totalRounds": 1 }}One PT-wstETH is worth about 0.9746 wstETH as of block 26274398, over a year before maturity. That discount is the fixed yield baked into the position: hold the PT to expiry and redeem it 1:1, and the roughly 2.5% gap closes entirely.
Why duration has to be a typed context variable, not a literal
duration is a uint32 on the Solidity side, and this is the one call shape in this guide that actually breaks if you skip the schema. Pass it as a bare number inline in the expression instead of declaring it in schema.context:
"expression": "oracle.getPtToAssetRate(market, 900)"and evmquery rejects it before it ever reaches the chain:
{ "errors": [ { "code": "QUERY__EXPRESSION_INVALID", "detail": "Expression invalid:\n- error: found no matching overload for 'SEL_Contract_oracle.getPtToAssetRate(sol_address, int)'" } ]}Fixed-width integers need a typed context variable
A plain 900 literal in a CEL expression types as an untyped int, which
doesn’t unify with a Solidity uint32 parameter the way it does with
uint256. The fix used above, declaring "duration": "sol_int" in
schema.context and passing 900 as a runtime value, works immediately.
Expect the same requirement on any ABI method with a fixed-width integer
argument (uint8, uint16, uint32, and so on), not just this one.
Confirm the oracle has enough history before you trust it
A TWAP oracle needs a minimum number of stored price observations to cover the window you ask for. If a market hasn’t accumulated enough yet, the returned rate can be based on a shorter, less reliable window than the duration you requested, silently. getOracleState(market, duration) tells you before you trust the number:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "oracle": { "address": "0x5542be50420E88dd7D5B4a3D488FA6ED82F6DAc2" } }, "context": { "market": "sol_address", "duration": "sol_int" } }, "context": { "market": "0x34280882267fFa6383b363E278B027Be083bBe3B", "duration": 900 }, "expression": "oracle.getOracleState(market, duration)" }' | python3 -m json.tool{ "result": { "value": { "increaseCardinalityRequired": false, "cardinalityRequired": "83", "oldestObservationSatisfied": true }, "type": "SEL_Struct_oracle_getOracleState" }, "meta": { "blockNumber": "26274398", "totalCalls": 1, "totalRounds": 1 }}oldestObservationSatisfied: true means the market’s observation buffer already covers the requested window, this market is safe to read at a 900-second duration. If increaseCardinalityRequired came back true, the market needs more stored observations before that duration is trustworthy, and you’d fall back to a shorter one or wait. Worth noting for anyone cross-referencing evmquery’s own meta: null quirk on raw struct returns elsewhere on this blog: this particular struct call returns full blockNumber/totalCalls/totalRounds metadata, not null, so that quirk isn’t a blanket rule for every struct-shaped response.
Same oracle, any chain, any market
Because the oracle address doesn’t change across chains, swapping chain and market is the only edit needed to price a completely different asset. Here’s the hBTC PT on Base, maturing 2027-02-25, at 0xe27C94805B0c4D7eA9DCE3c787eaE3430AcFcbe9:
{ "result": { "value": 0.9852151656247905, "type": "double" }, "meta": { "blockNumber": "53099347" }}0.9852 is closer to 1.0 than the wstETH market’s 0.9746, and that’s not a coincidence: the hBTC market matures roughly ten months sooner, so there’s less time value left to discount away. Watching the same ratio converge toward 1.0 as a market nears expiry is a useful sanity check that you’re reading the right number.
The oracle is also live at the same address on BNB Chain. Polygon has no Pendle deployment at all, don’t point this contract address at it.
Pitfalls worth knowing before you ship this
- There’s no global PT registry contract. Every maturity, for every underlying asset, is its own market address. Source market addresses from Pendle’s own app or API rather than guessing a pattern.
- The rate approaches 1.0, it doesn’t jump there. Near expiry the discount compresses toward zero gradually; don’t expect a step change until the PT actually matures and becomes redeemable.
- This guide covers pre-expiry reads only. Behavior right at or after a market’s maturity wasn’t exercised here, both markets used above have months to a year left. Treat post-expiry reads as untested until you’ve confirmed them against a market that’s actually matured.
formatUnits(rate, 18)is correct regardless of the underlying’s own decimals. Pendle’s oracle normalizes its output to 18 decimals internally, you don’t need to look up the underlying token’sdecimals()first the way you would for a raw balance read. See Multicall3: batch EVM contract reads if you’re pricing several PT markets in one request and want to batch the calls.
If you want to confirm a market’s full read-only method set yourself before writing an expression against it, evmquery’s Contract Inspector resolves any address’s ABI, which is also how the method signatures in this post were confirmed before publishing. For a broader look at what else the API reads besides fixed-rate positions, see evmquery for developers.
Next steps
- Contract Inspector: paste any address and see its full resolved read methods before writing a query
- Lido stETH Exchange Rate: How to Read It Correctly via API: the same wstETH address used in this post’s worked example, read a different way
- ERC-4626 Price: Read Vault Share Price With convertToAssets: another exchange rate computed from internal accounting rather than a market price
- evmquery for developers: what else the API can read besides fixed-rate positions



