Pendle yield tokenization protocol — split yield-bearing assets into PT (Principal Token) and YT (Yield Token), trade fixed and variable yield on Pendle AMM, SY (Standardized Yield) token wrapping, and market operations. Covers Pendle Router, market math, and yield strategy patterns across Ethereum and Arbitrum.
Pendle is a yield tokenization protocol that splits yield-bearing assets into two components: PT (Principal Token) and YT (Yield Token). PT represents the principal redeemable at maturity, while YT represents the right to all yield generated until maturity. Both trade on Pendle's custom AMM, enabling users to lock in fixed yields (buy PT at a discount) or take leveraged yield exposure (buy YT). All yield-bearing tokens are first wrapped into SY (Standardized Yield), Pendle's unified yield interface.
AI models confuse Pendle's token mechanics with traditional bonds and perpetual yield tokens. These corrections are critical.
PendleRouter for all swaps, mints, redeems, and liquidity operations. The Router handles multi-step operations atomically (e.g., token -> SY -> PT in one tx).minTokenOut or a guessPtOut struct with guessMin/guessMax bounds. The Router's binary search finds the optimal swap amount within these bounds. Setting the guess range too tight causes reverts; too wide wastes gas.npm install viem @pendle/sdk-v2
import { createPublicClient, http, parseAbi } from "viem";
import { mainnet } from "viem/chains";
const publicClient = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL),
});
const PENDLE_MARKET = "0xD0354D4e7bCf345fB117cabe41aCaDb724009CE5" as const;
const marketAbi = parseAbi([
"function readState(address router) view returns (int256 totalPt, int256 totalSy, int256 totalLp, address treasury, int256 scalarRoot, int256 expiry, int256 lnFeeRateRoot, uint256 reserveFeePercent, int256 lastLnImpliedRate)",
"function expiry() view returns (uint256)",
]);
const marketState = await publicClient.readContract({
address: PENDLE_MARKET,
abi: marketAbi,
functionName: "readState",
args: ["0x888888888889758F76e7103c6CbF23ABbF58F946"],
});
// lastLnImpliedRate is ln(1 + impliedRate) scaled by 1e18
// To get implied APY: e^(lastLnImpliedRate / 1e18) - 1
const lnRate = Number(marketState[8]) / 1e18;
const impliedApy = Math.exp(lnRate) - 1;
console.log(`Implied APY: ${(impliedApy * 100).toFixed(2)}%`);
SY wraps any yield-bearing token into a standard interface. It exposes deposit() and redeem() for converting between the underlying yield-bearing asset and SY tokens. The SY contract tracks the exchange rate between itself and the underlying.
Key properties:
PT represents the principal component of a yield-bearing asset. It entitles the holder to redeem 1 unit of the underlying at maturity.
Key properties:
redeemPyToSyYT represents the yield component. Holding YT entitles you to all yield generated by the underlying from now until maturity.
Key properties:
redeemDueInterestAndRewardsA Pendle Market is an AMM pool that trades PT against SY. YT is traded synthetically through PT (since PT + YT = SY, selling PT is equivalent to buying YT).
Pendle markets have a built-in TWAP oracle for the PT/SY implied rate. DeFi protocols use this to price PT as collateral. The oracle must be initialized with a desired observation window before first use.
const PENDLE_ROUTER = "0x888888888889758F76e7103c6CbF23ABbF58F946" as const;
const routerAbi = parseAbi([
"function mintSyFromToken(address receiver, address SY, uint256 minSyOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) input) payable returns (uint256 netSyOut)",
"function redeemSyToToken(address receiver, address SY, uint256 netSyIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) output) returns (uint256 netTokenOut)",
]);
// Mint SY from WETH (for SY-wstETH market)
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" as const;
const SY_WSTETH = "0xcbC72d92b2dc8187414F6734718563898740C0BC" as const;
const mintAmount = 1_000_000_000_000_000_000n; // 1 WETH
const tokenInput = {
tokenIn: WETH,
netTokenIn: mintAmount,
tokenMintSy: WETH,
pendleSwap: "0x0000000000000000000000000000000000000000" as const,
swapData: {
swapType: 0,
extRouter: "0x0000000000000000000000000000000000000000" as const,
extCalldata: "0x" as `0x${string}`,
needScale: false,
},
};
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: routerAbi,
functionName: "mintSyFromToken",
args: [account.address, SY_WSTETH, 0n, tokenInput],
account: account.address,
value: mintAmount,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("mintSyFromToken reverted");
const netSyIn = 950_000_000_000_000_000n; // SY amount to redeem
const tokenOutput = {
tokenOut: WETH,
minTokenOut: 0n, // SET IN PRODUCTION — use oracle rate with slippage
tokenRedeemSy: WETH,
pendleSwap: "0x0000000000000000000000000000000000000000" as const,
swapData: {
swapType: 0,
extRouter: "0x0000000000000000000000000000000000000000" as const,
extCalldata: "0x" as `0x${string}`,
needScale: false,
},
};
const { request: redeemRequest } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: routerAbi,
functionName: "redeemSyToToken",
args: [account.address, SY_WSTETH, netSyIn, tokenOutput],
account: account.address,
});
const redeemHash = await walletClient.writeContract(redeemRequest);
const redeemReceipt = await publicClient.waitForTransactionReceipt({ hash: redeemHash });
if (redeemReceipt.status !== "success") throw new Error("redeemSyToToken reverted");
Minting PT and YT from SY splits the yield-bearing position into its principal and yield components. 1 SY produces 1 PT + 1 YT.
const mintPyAbi = parseAbi([
"function mintPyFromSy(address receiver, address YT, uint256 netSyIn, uint256 minPyOut) returns (uint256 netPyOut)",
]);
const YT_WSTETH = "0x7B6C3e5486D9e6959441ab554A889099ead23c1F" as const;
const netSyToMint = 1_000_000_000_000_000_000n; // 1 SY
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: mintPyAbi,
functionName: "mintPyFromSy",
args: [
account.address,
YT_WSTETH,
netSyToMint,
0n, // minPyOut — SET IN PRODUCTION
],
account: account.address,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("mintPyFromSy reverted");
The Router can handle token -> SY -> PT+YT in a single transaction.
const mintPyFromTokenAbi = parseAbi([
"function mintPyFromToken(address receiver, address YT, uint256 minPyOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) input) payable returns (uint256 netPyOut)",
]);
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: mintPyFromTokenAbi,
functionName: "mintPyFromToken",
args: [
account.address,
YT_WSTETH,
0n, // minPyOut — SET IN PRODUCTION
tokenInput,
],
account: account.address,
value: mintAmount,
});
const redeemPyAbi = parseAbi([
"function redeemPyToSy(address receiver, address YT, uint256 netPyIn, uint256 minSyOut) returns (uint256 netSyOut)",
]);
const netPyIn = 1_000_000_000_000_000_000n;
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: redeemPyAbi,
functionName: "redeemPyToSy",
args: [
account.address,
YT_WSTETH,
netPyIn,
0n, // minSyOut — SET IN PRODUCTION
],
account: account.address,
});
Buying PT at a discount locks in a fixed yield. If implied APY is 5% and maturity is 1 year away, buying PT gives you ~5% guaranteed return at maturity (assuming the underlying asset redeems 1:1).
const swapAbi = parseAbi([
"function swapExactTokenForPt(address receiver, address market, uint256 minPtOut, (uint256 guessMin, uint256 guessMax, uint256 guessOffchain, uint256 maxIteration, uint256 eps) guessPtOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) input) payable returns (uint256 netPtOut, uint256 netSyFee)",
"function swapExactPtForToken(address receiver, address market, uint256 exactPtIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) output) returns (uint256 netTokenOut, uint256 netSyFee)",
]);
const guessPtOut = {
guessMin: 0n,
guessMax: 2_000_000_000_000_000_000n, // upper bound for binary search
guessOffchain: 0n, // 0 = let Router compute; or pass SDK-computed optimal
maxIteration: 256n,
// 1e15 = 0.1% precision — lower eps = more iterations but tighter result
eps: 1_000_000_000_000_000n,
};
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: swapAbi,
functionName: "swapExactTokenForPt",
args: [
account.address,
PENDLE_MARKET,
0n, // minPtOut — SET IN PRODUCTION
guessPtOut,
tokenInput,
],
account: account.address,
value: mintAmount,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("swapExactTokenForPt reverted");
const PT_WSTETH = "0xB253A3370B1Db752D65b890B1fE093A26C398bDE" as const;
const exactPtIn = 1_000_000_000_000_000_000n;
const tokenOutput = {
tokenOut: WETH,
minTokenOut: 0n, // SET IN PRODUCTION
tokenRedeemSy: WETH,
pendleSwap: "0x0000000000000000000000000000000000000000" as const,
swapData: {
swapType: 0,
extRouter: "0x0000000000000000000000000000000000000000" as const,
extCalldata: "0x" as `0x${string}`,
needScale: false,
},
};
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: swapAbi,
functionName: "swapExactPtForToken",
args: [
account.address,
PENDLE_MARKET,
exactPtIn,
tokenOutput,
],
account: account.address,
});
YT is traded synthetically. Buying YT is economically equivalent to minting PT+YT from SY and selling PT. The Router handles this atomically.
const swapYtAbi = parseAbi([
"function swapExactTokenForYt(address receiver, address market, uint256 minYtOut, (uint256 guessMin, uint256 guessMax, uint256 guessOffchain, uint256 maxIteration, uint256 eps) guessYtOut, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) input) payable returns (uint256 netYtOut, uint256 netSyFee)",
"function swapExactYtForToken(address receiver, address market, uint256 exactYtIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) output) returns (uint256 netTokenOut, uint256 netSyFee)",
]);
const guessYtOut = {
guessMin: 0n,
guessMax: 10_000_000_000_000_000_000n,
guessOffchain: 0n,
maxIteration: 256n,
eps: 1_000_000_000_000_000n,
};
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: swapYtAbi,
functionName: "swapExactTokenForYt",
args: [
account.address,
PENDLE_MARKET,
0n, // minYtOut — SET IN PRODUCTION
guessYtOut,
tokenInput,
],
account: account.address,
value: mintAmount,
});
Pendle LPs provide PT + SY liquidity to the AMM. LP rewards include:
const lpAbi = parseAbi([
"function addLiquiditySingleToken(address receiver, address market, uint256 minLpOut, (uint256 guessMin, uint256 guessMax, uint256 guessOffchain, uint256 maxIteration, uint256 eps) guessPtReceivedFromSy, (address tokenIn, uint256 netTokenIn, address tokenMintSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) input) payable returns (uint256 netLpOut, uint256 netSyFee)",
"function removeLiquiditySingleToken(address receiver, address market, uint256 netLpIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) output) returns (uint256 netTokenOut, uint256 netSyFee)",
]);
const guessPtReceivedFromSy = {
guessMin: 0n,
guessMax: 1_000_000_000_000_000_000n,
guessOffchain: 0n,
maxIteration: 256n,
eps: 1_000_000_000_000_000n,
};
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: lpAbi,
functionName: "addLiquiditySingleToken",
args: [
account.address,
PENDLE_MARKET,
0n, // minLpOut — SET IN PRODUCTION
guessPtReceivedFromSy,
tokenInput,
],
account: account.address,
value: mintAmount,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("addLiquiditySingleToken reverted");
const netLpIn = 500_000_000_000_000_000n; // LP tokens to withdraw
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: lpAbi,
functionName: "removeLiquiditySingleToken",
args: [
account.address,
PENDLE_MARKET,
netLpIn,
tokenOutput,
],
account: account.address,
});
Pendle LP impermanent loss is different from standard AMM IL:
The closer to maturity you provide liquidity, the lower your IL risk, but also the fewer fees you earn.
The simplest fixed-yield strategy. Buy PT at a discount, hold to maturity, redeem 1:1.
Example: PT-stETH trading at 0.95 stETH with 6 months to maturity.
Risk: The underlying protocol (e.g., Lido) must remain solvent. PT does NOT guarantee the underlying asset's value.
Buy YT to get leveraged exposure to variable yield.
Example: YT-stETH at 0.05 stETH equivalent, stETH yielding 4% APY.
Risk: If actual yield < implied rate, you lose money. YT value decays to zero at maturity regardless.
Provide liquidity to earn swap fees + SY yield + PENDLE emissions.
const PENDLE_ROUTER_STATIC = "0x263833d47eA3fA4a30d59B2E6C1A0e682eF1C078" as const;
const routerStaticAbi = parseAbi([
"function getMarketState(address market) view returns (address pt, address sy, address yt, int256 impliedYield, uint256 exchangeRate, uint256 totalPt, uint256 totalSy, uint256 totalLp)",
]);
const oracleAbi = parseAbi([
"function getPtToAssetRate(address market, uint32 duration) view returns (uint256)",
"function getYtToAssetRate(address market, uint32 duration) view returns (uint256)",
"function getPtToSyRate(address market, uint32 duration) view returns (uint256)",
]);
const PENDLE_PT_ORACLE = "0x66a1096C6366b2529274dF4f5D8f56DA60a2CacD" as const;
// PT/Asset TWAP rate (e.g., for collateral pricing)
// Duration in seconds — must match initialized observation window
const ptToAssetRate = await publicClient.readContract({
address: PENDLE_PT_ORACLE,
abi: oracleAbi,
functionName: "getPtToAssetRate",
args: [PENDLE_MARKET, 900], // 15-minute TWAP
});
// Rate is scaled to 1e18. A value of 0.95e18 means 1 PT = 0.95 underlying
console.log(`PT/Asset rate: ${Number(ptToAssetRate) / 1e18}`);
const marketOracleAbi = parseAbi([
"function increaseObservationsCardinalityNext(uint16 cardinalityNext) external",
"function observe(uint32[] secondsAgos) view returns (uint216[] lnImpliedRateCumulatives)",
]);
// Must be called on the market contract itself, not the oracle
// Cardinality determines how far back the TWAP can look
// For a 15-minute TWAP, you need enough observations to cover 900 seconds
const { request } = await publicClient.simulateContract({
address: PENDLE_MARKET,
abi: marketOracleAbi,
functionName: "increaseObservationsCardinalityNext",
args: [100], // 100 observation slots
account: account.address,
});
const expiry = await publicClient.readContract({
address: PENDLE_MARKET,
abi: marketAbi,
functionName: "expiry",
});
const expiryDate = new Date(Number(expiry) * 1000);
const isExpired = Date.now() > Number(expiry) * 1000;
console.log(`Market expiry: ${expiryDate.toISOString()}`);
console.log(`Expired: ${isExpired}`);
After maturity, PT redeems 1:1 for the underlying. The redemption path is: PT -> SY -> underlying token.
const redeemAfterMaturityAbi = parseAbi([
"function redeemPyToToken(address receiver, address YT, uint256 netPyIn, (address tokenOut, uint256 minTokenOut, address tokenRedeemSy, address pendleSwap, (uint8 swapType, address extRouter, bytes extCalldata, bool needScale) swapData) output) returns (uint256 netTokenOut)",
]);
// After maturity, you only need PT (YT is worthless). Pass equal PT amount.
// If you don't have matching YT, use redeemPyToSy which handles post-maturity redemption
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: redeemAfterMaturityAbi,
functionName: "redeemPyToToken",
args: [
account.address,
YT_WSTETH,
exactPtIn,
tokenOutput,
],
account: account.address,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Redemption reverted");
YT yield accrues in real-time. Claim it at any point (before or after maturity).
const claimAbi = parseAbi([
"function redeemDueInterestAndRewards(address user, address[] SYs, address[] PTs, address[] YTs, address[] markets) returns (uint256[][] netSyOut, uint256[][] netRewardOut)",
]);
const { request } = await publicClient.simulateContract({
address: PENDLE_ROUTER,
abi: claimAbi,
functionName: "redeemDueInterestAndRewards",
args: [
account.address,
[SY_WSTETH],
[PT_WSTETH],
[YT_WSTETH],
[PENDLE_MARKET],
],
account: account.address,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Claim reverted");
| Action | Before Maturity | After Maturity |
|---|---|---|
| Trade PT on AMM | Yes | No (AMM inactive) |
| Trade YT on AMM | Yes (synthetic) | No |
| Redeem PT for underlying | No (must sell on AMM) | Yes (1:1 via Router) |
| Claim YT yield | Yes (accrued so far) | Yes (all remaining) |
| LP withdrawal | Yes | Yes (mandatory) |
| Mint PT+YT from SY | Yes | No |
Last verified: February 2026
| Contract | Address |
|---|---|
| PendleRouter | 0x888888888889758F76e7103c6CbF23ABbF58F946 |
| PendleRouterStatic | 0x263833d47eA3fA4a30d59B2E6C1A0e682eF1C078 |
| PendleMarketFactoryV3 | 0x1A6fCc85557BC4fB7B534ed835a03EF056c222E2 |
| PendlePtOracle | 0x66a1096C6366b2529274dF4f5D8f56DA60a2CacD |
| vePENDLE | 0x4f30A9D41B80ecC5B94306AB4364951AE3170210 |
| PENDLE token | 0x808507121B80c02388fAd14726482e061B8da827 |
| Contract | Address |
|---|---|
| PendleRouter | 0x888888888889758F76e7103c6CbF23ABbF58F946 |
| PendleRouterStatic | 0x263833d47eA3fA4a30d59B2E6C1A0e682eF1C078 |
| PendleMarketFactoryV3 | 0x2FCb47B58350cD377f94d3821e7373Df60bD9Ced |
| PendlePtOracle | 0x66a1096C6366b2529274dF4f5D8f56DA60a2CacD |
| PENDLE token | 0x0c880f6761F1af8d9Aa9C466984b80DAb9a8c9e8 |
| Error | Cause | Fix |
|---|---|---|
MarketExpired() | Attempting to trade on a market past maturity | Check market.expiry() before trading. Use redeem functions post-maturity |
RouterInsufficientPtOut() | PT received is below minPtOut | Increase slippage tolerance or re-quote with fresh market state |
RouterInsufficientSyOut() | SY received is below minSyOut | Widen slippage. Check if the underlying rate changed significantly |
RouterInsufficientYtOut() | YT received is below minYtOut | Re-quote. YT pricing is more volatile near maturity |
RouterInsufficientLpOut() | LP tokens received below minLpOut | Re-compute expected LP amount with current reserves |
ApproxFail() | Binary search for optimal swap amount failed | Widen guessMin/guessMax range, increase maxIteration, or decrease eps |
MarketProportionTooHigh() | Trade would consume too much of the pool's reserves | Reduce trade size or split into multiple transactions |
SYInvalidTokenIn() | Token passed is not a valid input for this SY contract | Check SY.getTokensIn() for valid deposit tokens |
SYInvalidTokenOut() | Token passed is not a valid output for this SY contract | Check SY.getTokensOut() for valid redemption tokens |
Never set minPtOut, minSyOut, minYtOut, or minLpOut to 0 in production. Always compute expected output first and apply a slippage tolerance.
// Use PendleRouterStatic to preview expected output
const previewAbi = parseAbi([
"function swapExactTokenForPtStatic(address market, address tokenIn, uint256 netTokenIn) view returns (uint256 netPtOut, uint256 netSyFee, uint256 priceImpact)",
]);
const [expectedPtOut] = await publicClient.readContract({
address: PENDLE_ROUTER_STATIC,
abi: previewAbi,
functionName: "swapExactTokenForPtStatic",
args: [PENDLE_MARKET, WETH, mintAmount],
});
// 1% slippage tolerance for Pendle (higher than standard DEX due to binary search)
const slippageBps = 100n;
const minPtOut = expectedPtOut - (expectedPtOut * slippageBps) / 10000n;
When using PT as collateral, always use the TWAP oracle with a sufficient observation window. Instantaneous rates are manipulable.
// Initialize oracle with enough cardinality BEFORE relying on it
// Wait for the observation window to fill before using the TWAP
// 15-minute minimum recommended for lending protocols
// WRONG: Instantaneous rate (manipulable in same tx)
// const rate = await market.readState(router);
// CORRECT: TWAP from dedicated oracle
const rate = await publicClient.readContract({
address: PENDLE_PT_ORACLE,
abi: oracleAbi,
functionName: "getPtToAssetRate",
args: [PENDLE_MARKET, 900], // 15-minute TWAP
});
Approve the Router for SY, PT, and YT tokens. The Router handles all multi-step operations.
const erc20Abi = parseAbi([
"function approve(address spender, uint256 amount) returns (bool)",
]);
// Approve Router to spend SY, PT, and YT
for (const token of [SY_WSTETH, PT_WSTETH, YT_WSTETH]) {
const { request } = await publicClient.simulateContract({
address: token,
abi: erc20Abi,
functionName: "approve",
args: [PENDLE_ROUTER, 2n ** 256n - 1n],
account: account.address,
});
const hash = await walletClient.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash });
}