From 7fa9a9ad4bad3fc17ae688bf9bce1b9254befee5 Mon Sep 17 00:00:00 2001 From: Kubudak90 Date: Sun, 29 Mar 2026 23:59:59 +0300 Subject: [PATCH] feat: add Pendle Finance action provider for Base Add a new action provider for Pendle Finance, enabling AI agents to interact with Pendle's yield tokenization protocol on Base. Actions: - buy_pt: Buy Principal Tokens to lock in fixed yield - sell_pt: Sell PT back to underlying tokens - buy_yt: Buy Yield Tokens to speculate on yield - sell_yt: Sell YT back to underlying tokens - add_liquidity: Provide liquidity to Pendle markets - remove_liquidity: Remove liquidity from markets - claim_rewards: Claim accrued interest, yield, and PENDLE rewards - list_markets: List active Pendle markets with APYs and TVL Uses Pendle Hosted SDK API for optimal execution (aggregator routing, limit order matching, gas-efficient offchain params). Falls back to direct Router V4 calls for claim_rewards. Addresses WISHLIST.md item: "Pendle interactions (LP, PT, YT)" --- .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/pendle/constants.ts | 151 ++++++ .../src/action-providers/pendle/index.ts | 2 + .../pendle/pendleActionProvider.test.ts | 202 +++++++ .../pendle/pendleActionProvider.ts | 495 ++++++++++++++++++ .../src/action-providers/pendle/schemas.ts | 150 ++++++ 6 files changed, 1001 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/pendle/constants.ts create mode 100644 typescript/agentkit/src/action-providers/pendle/index.ts create mode 100644 typescript/agentkit/src/action-providers/pendle/pendleActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/pendle/pendleActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/pendle/schemas.ts diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..c0b555f6f 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -23,6 +23,7 @@ export * from "./pyth"; export * from "./moonwell"; export * from "./morpho"; export * from "./opensea"; +export * from "./pendle"; export * from "./spl"; export * from "./superfluid"; export * from "./sushi"; diff --git a/typescript/agentkit/src/action-providers/pendle/constants.ts b/typescript/agentkit/src/action-providers/pendle/constants.ts new file mode 100644 index 000000000..b4ae68831 --- /dev/null +++ b/typescript/agentkit/src/action-providers/pendle/constants.ts @@ -0,0 +1,151 @@ +/** + * Pendle Finance contract addresses and ABIs for Base chain. + * + * Pendle uses a Diamond Proxy (EIP-2535) Router V4 that delegates + * to multiple facets for swaps, liquidity, and misc actions. + */ + +export const PENDLE_ROUTER_V4 = "0x888888888889758F76e7103c6CbF23ABbF58F946"; +export const PENDLE_ROUTER_STATIC = "0xB4205a645c7e920BD8504181B1D7f2c5C955C3e7"; + +export const PENDLE_API_BASE = "https://api-v2.pendle.finance/core"; + +export const SUPPORTED_CHAIN_IDS: Record = { + "base-mainnet": 8453, +}; + +/** + * Minimal Router V4 ABI — only the "Simple" functions that don't require + * ApproxParams or LimitOrderData, plus redeem for claiming rewards. + */ +export const PENDLE_ROUTER_ABI = [ + // swapExactTokenForPt (full version for calldata from API) + { + inputs: [ + { internalType: "address", name: "receiver", type: "address" }, + { internalType: "address", name: "market", type: "address" }, + { internalType: "uint256", name: "minPtOut", type: "uint256" }, + { + components: [ + { internalType: "uint256", name: "guessMin", type: "uint256" }, + { internalType: "uint256", name: "guessMax", type: "uint256" }, + { internalType: "uint256", name: "guessOffchain", type: "uint256" }, + { internalType: "uint256", name: "maxIteration", type: "uint256" }, + { internalType: "uint256", name: "eps", type: "uint256" }, + ], + internalType: "struct ApproxParams", + name: "guessPtOut", + type: "tuple", + }, + { + components: [ + { internalType: "address", name: "tokenIn", type: "address" }, + { internalType: "uint256", name: "netTokenIn", type: "uint256" }, + { internalType: "address", name: "tokenMintSy", type: "address" }, + { internalType: "address", name: "pendleSwap", type: "address" }, + { + components: [ + { internalType: "enum SwapType", name: "swapType", type: "uint8" }, + { internalType: "address", name: "extRouter", type: "address" }, + { internalType: "bytes", name: "extCalldata", type: "bytes" }, + { internalType: "bool", name: "needScale", type: "bool" }, + ], + internalType: "struct SwapData", + name: "swapData", + type: "tuple", + }, + ], + internalType: "struct TokenInput", + name: "input", + type: "tuple", + }, + { + components: [ + { internalType: "address", name: "limitRouter", type: "address" }, + { internalType: "uint256", name: "epsSkipMarket", type: "uint256" }, + { + components: [ + { + components: [ + { internalType: "uint256", name: "salt", type: "uint256" }, + { internalType: "uint256", name: "expiry", type: "uint256" }, + { internalType: "uint256", name: "nonce", type: "uint256" }, + { internalType: "enum IPLimitRouter.OrderType", name: "orderType", type: "uint8" }, + { internalType: "address", name: "token", type: "address" }, + { internalType: "address", name: "YT", type: "address" }, + { internalType: "address", name: "maker", type: "address" }, + { internalType: "address", name: "receiver", type: "address" }, + { internalType: "uint256", name: "makingAmount", type: "uint256" }, + { internalType: "uint256", name: "lnImpliedRate", type: "uint256" }, + { internalType: "uint256", name: "failSafeRate", type: "uint256" }, + { internalType: "bytes", name: "permit", type: "bytes" }, + ], + internalType: "struct IPLimitRouter.Order", + name: "order", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + { internalType: "uint256", name: "makingAmount", type: "uint256" }, + ], + internalType: "struct IPLimitRouter.FillOrderParams[]", + name: "normalFills", + type: "tuple[]", + }, + { + components: [ + { + components: [ + { internalType: "uint256", name: "salt", type: "uint256" }, + { internalType: "uint256", name: "expiry", type: "uint256" }, + { internalType: "uint256", name: "nonce", type: "uint256" }, + { internalType: "enum IPLimitRouter.OrderType", name: "orderType", type: "uint8" }, + { internalType: "address", name: "token", type: "address" }, + { internalType: "address", name: "YT", type: "address" }, + { internalType: "address", name: "maker", type: "address" }, + { internalType: "address", name: "receiver", type: "address" }, + { internalType: "uint256", name: "makingAmount", type: "uint256" }, + { internalType: "uint256", name: "lnImpliedRate", type: "uint256" }, + { internalType: "uint256", name: "failSafeRate", type: "uint256" }, + { internalType: "bytes", name: "permit", type: "bytes" }, + ], + internalType: "struct IPLimitRouter.Order", + name: "order", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + { internalType: "uint256", name: "makingAmount", type: "uint256" }, + ], + internalType: "struct IPLimitRouter.FillOrderParams[]", + name: "flashFills", + type: "tuple[]", + }, + { internalType: "bytes", name: "optData", type: "bytes" }, + ], + internalType: "struct LimitOrderData", + name: "limit", + type: "tuple", + }, + ], + name: "swapExactTokenForPt", + outputs: [ + { internalType: "uint256", name: "netPtOut", type: "uint256" }, + { internalType: "uint256", name: "netSyFee", type: "uint256" }, + { internalType: "uint256", name: "netSyInterm", type: "uint256" }, + ], + stateMutability: "payable", + type: "function", + }, + // redeemDueInterestAndRewards + { + inputs: [ + { internalType: "address", name: "user", type: "address" }, + { internalType: "address[]", name: "sys", type: "address[]" }, + { internalType: "address[]", name: "yts", type: "address[]" }, + { internalType: "address[]", name: "markets", type: "address[]" }, + ], + name: "redeemDueInterestAndRewards", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/pendle/index.ts b/typescript/agentkit/src/action-providers/pendle/index.ts new file mode 100644 index 000000000..0b86f8670 --- /dev/null +++ b/typescript/agentkit/src/action-providers/pendle/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export * from "./pendleActionProvider"; diff --git a/typescript/agentkit/src/action-providers/pendle/pendleActionProvider.test.ts b/typescript/agentkit/src/action-providers/pendle/pendleActionProvider.test.ts new file mode 100644 index 000000000..d8a780597 --- /dev/null +++ b/typescript/agentkit/src/action-providers/pendle/pendleActionProvider.test.ts @@ -0,0 +1,202 @@ +import { pendleActionProvider, PendleActionProvider } from "./pendleActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; + +// Mock global fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +// Mock approve utility +jest.mock("../../utils", () => ({ + approve: jest.fn().mockResolvedValue("Approval successful"), +})); + +describe("PendleActionProvider", () => { + let provider: PendleActionProvider; + let mockWallet: jest.Mocked; + + beforeEach(() => { + provider = pendleActionProvider(); + mockWallet = { + getAddress: jest.fn().mockReturnValue("0x1234567890abcdef1234567890abcdef12345678"), + getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet" }), + sendTransaction: jest.fn().mockResolvedValue("0xmocktxhash" as `0x${string}`), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: 1, blockNumber: 123456 }), + readContract: jest.fn().mockResolvedValue(18), + } as unknown as jest.Mocked; + + mockFetch.mockReset(); + }); + + describe("supportsNetwork", () => { + it("should support base-mainnet", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" }), + ).toBe(true); + }); + + it("should not support unsupported networks", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" }), + ).toBe(false); + }); + + it("should not support non-evm networks", () => { + expect( + provider.supportsNetwork({ protocolFamily: "svm", networkId: "base-mainnet" }), + ).toBe(false); + }); + }); + + describe("buyPt", () => { + it("should successfully buy PT", async () => { + // Mock market info fetch + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + pt: "0xPtAddress0000000000000000000000000000000", + yt: "0xYtAddress0000000000000000000000000000000", + sy: "0xSyAddress0000000000000000000000000000000", + }), + }) + // Mock convert API + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + tx: { to: "0xRouterAddress", data: "0xcalldata", value: "0" }, + requiredApprovals: [], + }), + }); + + const result = await provider.buyPt(mockWallet, { + market: "0x829a0d0b0261a3b96208631c19d5380422e2ca54", + tokenIn: "0x4200000000000000000000000000000000000006", + amount: "0.1", + slippage: 0.01, + }); + + expect(result).toContain("Successfully bought PT"); + expect(result).toContain("0xmocktxhash"); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + }); + + it("should handle API errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => "Bad request", + }); + + const result = await provider.buyPt(mockWallet, { + market: "0x829a0d0b0261a3b96208631c19d5380422e2ca54", + tokenIn: "0x4200000000000000000000000000000000000006", + amount: "0.1", + slippage: 0.01, + }); + + expect(result).toContain("Error"); + }); + }); + + describe("sellPt", () => { + it("should successfully sell PT", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + pt: "0xPtAddress0000000000000000000000000000000", + yt: "0xYtAddress0000000000000000000000000000000", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + tx: { to: "0xRouterAddress", data: "0xcalldata", value: "0" }, + requiredApprovals: [], + }), + }); + + const result = await provider.sellPt(mockWallet, { + market: "0x829a0d0b0261a3b96208631c19d5380422e2ca54", + tokenOut: "0x4200000000000000000000000000000000000006", + amount: "1.0", + slippage: 0.01, + }); + + expect(result).toContain("Successfully sold PT"); + }); + }); + + describe("addLiquidity", () => { + it("should successfully add liquidity", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + tx: { to: "0xRouterAddress", data: "0xcalldata", value: "100000000000000" }, + requiredApprovals: [], + }), + }); + + const result = await provider.addLiquidity(mockWallet, { + market: "0x829a0d0b0261a3b96208631c19d5380422e2ca54", + tokenIn: "0x0000000000000000000000000000000000000000", + amount: "0.1", + slippage: 0.01, + }); + + expect(result).toContain("Successfully added liquidity"); + }); + }); + + describe("claimRewards", () => { + it("should successfully claim rewards", async () => { + const result = await provider.claimRewards(mockWallet, { + syAddresses: [], + ytAddresses: ["0x829a0d0b0261a3b96208631c19d5380422e2ca54"], + marketAddresses: [], + }); + + expect(result).toContain("Successfully claimed Pendle rewards"); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + }); + }); + + describe("listMarkets", () => { + it("should list active markets", async () => { + const futureDate = new Date(Date.now() + 86400000 * 30).toISOString(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + results: [ + { + address: "0xMarket1", + name: "yoETH-Dec2026", + expiry: futureDate, + pt: { address: "0xPt1" }, + yt: { address: "0xYt1" }, + underlyingAsset: { symbol: "ETH", address: "0xUnderlying1" }, + impliedApy: 0.0568, + tvl: { usd: 956000 }, + }, + ], + }), + }); + + const result = await provider.listMarkets(mockWallet); + + expect(result).toContain("Active Pendle Markets"); + expect(result).toContain("yoETH-Dec2026"); + expect(result).toContain("5.68%"); + }); + + it("should handle empty markets", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [] }), + }); + + const result = await provider.listMarkets(mockWallet); + expect(result).toContain("No active Pendle markets"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/pendle/pendleActionProvider.ts b/typescript/agentkit/src/action-providers/pendle/pendleActionProvider.ts new file mode 100644 index 000000000..e4e610913 --- /dev/null +++ b/typescript/agentkit/src/action-providers/pendle/pendleActionProvider.ts @@ -0,0 +1,495 @@ +import { z } from "zod"; +import { encodeFunctionData, Hex, parseUnits } from "viem"; +import { erc20Abi } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { approve } from "../../utils"; +import { Network } from "../../network"; +import { + PENDLE_ROUTER_V4, + PENDLE_API_BASE, + PENDLE_ROUTER_ABI, + SUPPORTED_CHAIN_IDS, +} from "./constants"; +import { + BuyPtSchema, + SellPtSchema, + BuyYtSchema, + SellYtSchema, + AddLiquiditySchema, + RemoveLiquiditySchema, + ClaimRewardsSchema, + ListMarketsSchema, +} from "./schemas"; + +const NATIVE_TOKEN = "0x0000000000000000000000000000000000000000"; + +const PENDLE_SUPPORTED_NETWORKS = ["base-mainnet"]; + +/** + * PendleActionProvider enables interaction with Pendle Finance on Base. + * + * Pendle splits yield-bearing tokens into PT (Principal Token) and YT (Yield Token), + * enabling fixed-yield and yield-speculation strategies. This provider uses the + * Pendle Hosted SDK API for optimal execution (better gas, aggregator routing, + * limit order matching). + */ +export class PendleActionProvider extends ActionProvider { + constructor() { + super("pendle", []); + } + + /** + * Calls the Pendle Convert API to get transaction calldata. + */ + private async callConvertApi( + chainId: number, + receiver: string, + slippage: number, + inputs: Array<{ tokenAddress: string; amount: string }>, + outputs: Array<{ tokenAddress: string }>, + ): Promise<{ + tx: { to: string; data: string; value: string }; + requiredApprovals: Array<{ tokenAddress: string; spender: string; amount: string }>; + }> { + const url = `${PENDLE_API_BASE}/v3/sdk/${chainId}/convert`; + const body = { + receiver, + slippage, + enableAggregator: true, + inputs, + outputs, + }; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Pendle API error (${response.status}): ${text}`); + } + + return response.json(); + } + + /** + * Resolves token decimals — returns 18 for native ETH. + */ + private async getDecimals(wallet: EvmWalletProvider, tokenAddress: string): Promise { + if (tokenAddress.toLowerCase() === NATIVE_TOKEN) return 18; + return (await wallet.readContract({ + address: tokenAddress as Hex, + abi: erc20Abi, + functionName: "decimals", + args: [], + })) as number; + } + + /** + * Gets the chain ID for the current network. + */ + private getChainId(wallet: EvmWalletProvider): number { + const network = wallet.getNetwork(); + const chainId = SUPPORTED_CHAIN_IDS[network.networkId!]; + if (!chainId) throw new Error(`Unsupported network: ${network.networkId}`); + return chainId; + } + + /** + * Fetches market info from Pendle API to resolve PT/YT/LP addresses. + */ + private async getMarketInfo( + chainId: number, + marketAddress: string, + ): Promise<{ + pt: string; + yt: string; + sy: string; + underlyingAsset: string; + expiry: string; + name: string; + }> { + const url = `${PENDLE_API_BASE}/v1/sdk/${chainId}/markets/${marketAddress}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch market info: ${response.status}`); + } + return response.json(); + } + + /** + * Executes a convert operation via the Pendle API and submits the transaction. + */ + private async executeConvert( + wallet: EvmWalletProvider, + slippage: number, + inputs: Array<{ tokenAddress: string; amount: string }>, + outputs: Array<{ tokenAddress: string }>, + ): Promise<{ txHash: string; receipt: unknown }> { + const chainId = this.getChainId(wallet); + const receiver = await wallet.getAddress(); + + const result = await this.callConvertApi(chainId, receiver, slippage, inputs, outputs); + + // Handle required approvals + for (const approval of result.requiredApprovals || []) { + const approvalResult = await approve( + wallet, + approval.tokenAddress, + approval.spender, + BigInt(approval.amount), + ); + if (approvalResult.startsWith("Error")) { + throw new Error(`Token approval failed: ${approvalResult}`); + } + } + + // Submit the transaction + const txHash = await wallet.sendTransaction({ + to: result.tx.to as `0x${string}`, + data: result.tx.data as `0x${string}`, + value: BigInt(result.tx.value || "0"), + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + return { txHash, receipt }; + } + + // ─── Actions ──────────────────────────────────────────────── + + @CreateAction({ + name: "buy_pt", + description: ` +Buy PT (Principal Token) on Pendle Finance to lock in a fixed yield. + +PT represents the principal portion of a yield-bearing token. It trades at a discount +before maturity and is redeemable 1:1 for the underlying asset at maturity. +The discount = your fixed yield. + +It takes: +- market: The Pendle market address (find markets using the list_markets action) +- tokenIn: The token to swap from (e.g., WETH, USDC address). Use 0x0000000000000000000000000000000000000000 for native ETH +- amount: Amount of input tokens in whole units (e.g., '0.1' for 0.1 WETH) +- slippage: Slippage tolerance (default 0.01 = 1%) + +Example: Buy PT with 0.1 ETH on the yoETH market to lock in ~5% fixed yield. +`, + schema: BuyPtSchema, + }) + async buyPt(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + const chainId = this.getChainId(wallet); + const decimals = await this.getDecimals(wallet, args.tokenIn); + const atomicAmount = parseUnits(args.amount, decimals).toString(); + const marketInfo = await this.getMarketInfo(chainId, args.market); + + const { txHash, receipt } = await this.executeConvert( + wallet, + args.slippage, + [{ tokenAddress: args.tokenIn, amount: atomicAmount }], + [{ tokenAddress: marketInfo.pt }], + ); + + return `Successfully bought PT on Pendle market ${args.market}.\nInput: ${args.amount} tokens\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error buying PT on Pendle: ${error}`; + } + } + + @CreateAction({ + name: "sell_pt", + description: ` +Sell PT (Principal Token) on Pendle Finance back to an underlying token. + +Use this to exit a fixed-yield position before maturity, or to redeem after maturity. + +It takes: +- market: The Pendle market address +- tokenOut: The token to receive (e.g., WETH, USDC address). Use 0x0000000000000000000000000000000000000000 for native ETH +- amount: Amount of PT to sell, in whole units +- slippage: Slippage tolerance (default 0.01 = 1%) +`, + schema: SellPtSchema, + }) + async sellPt(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + const chainId = this.getChainId(wallet); + const marketInfo = await this.getMarketInfo(chainId, args.market); + + // PT tokens have 18 decimals + const atomicAmount = parseUnits(args.amount, 18).toString(); + + const { txHash, receipt } = await this.executeConvert( + wallet, + args.slippage, + [{ tokenAddress: marketInfo.pt, amount: atomicAmount }], + [{ tokenAddress: args.tokenOut }], + ); + + return `Successfully sold PT on Pendle market ${args.market}.\nSold: ${args.amount} PT\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error selling PT on Pendle: ${error}`; + } + } + + @CreateAction({ + name: "buy_yt", + description: ` +Buy YT (Yield Token) on Pendle Finance to speculate on yield going up. + +YT holders receive all yield generated by the underlying asset until maturity. +If you expect yields to increase, buying YT lets you profit from that. + +It takes: +- market: The Pendle market address (find markets using the list_markets action) +- tokenIn: The token to swap from. Use 0x0000000000000000000000000000000000000000 for native ETH +- amount: Amount of input tokens in whole units +- slippage: Slippage tolerance (default 0.01 = 1%) +`, + schema: BuyYtSchema, + }) + async buyYt(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + const chainId = this.getChainId(wallet); + const decimals = await this.getDecimals(wallet, args.tokenIn); + const atomicAmount = parseUnits(args.amount, decimals).toString(); + const marketInfo = await this.getMarketInfo(chainId, args.market); + + const { txHash, receipt } = await this.executeConvert( + wallet, + args.slippage, + [{ tokenAddress: args.tokenIn, amount: atomicAmount }], + [{ tokenAddress: marketInfo.yt }], + ); + + return `Successfully bought YT on Pendle market ${args.market}.\nInput: ${args.amount} tokens\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error buying YT on Pendle: ${error}`; + } + } + + @CreateAction({ + name: "sell_yt", + description: ` +Sell YT (Yield Token) on Pendle Finance back to an underlying token. + +Use this to exit a yield speculation position. + +It takes: +- market: The Pendle market address +- tokenOut: The token to receive. Use 0x0000000000000000000000000000000000000000 for native ETH +- amount: Amount of YT to sell, in whole units +- slippage: Slippage tolerance (default 0.01 = 1%) +`, + schema: SellYtSchema, + }) + async sellYt(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + const chainId = this.getChainId(wallet); + const marketInfo = await this.getMarketInfo(chainId, args.market); + const atomicAmount = parseUnits(args.amount, 18).toString(); + + const { txHash, receipt } = await this.executeConvert( + wallet, + args.slippage, + [{ tokenAddress: marketInfo.yt, amount: atomicAmount }], + [{ tokenAddress: args.tokenOut }], + ); + + return `Successfully sold YT on Pendle market ${args.market}.\nSold: ${args.amount} YT\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error selling YT on Pendle: ${error}`; + } + } + + @CreateAction({ + name: "add_liquidity", + description: ` +Add liquidity to a Pendle market to earn swap fees and PENDLE rewards. + +Pendle LP positions earn from three sources: swap fees, underlying yield, and PENDLE incentives. + +It takes: +- market: The Pendle market address +- tokenIn: The token to provide as liquidity. Use 0x0000000000000000000000000000000000000000 for native ETH +- amount: Amount of tokens to add, in whole units +- slippage: Slippage tolerance (default 0.01 = 1%) +`, + schema: AddLiquiditySchema, + }) + async addLiquidity( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(wallet); + const decimals = await this.getDecimals(wallet, args.tokenIn); + const atomicAmount = parseUnits(args.amount, decimals).toString(); + + // LP token address is the market address itself + const { txHash, receipt } = await this.executeConvert( + wallet, + args.slippage, + [{ tokenAddress: args.tokenIn, amount: atomicAmount }], + [{ tokenAddress: args.market }], + ); + + return `Successfully added liquidity to Pendle market ${args.market}.\nInput: ${args.amount} tokens\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error adding liquidity on Pendle: ${error}`; + } + } + + @CreateAction({ + name: "remove_liquidity", + description: ` +Remove liquidity from a Pendle market back to a single token. + +It takes: +- market: The Pendle market address +- tokenOut: The token to receive back. Use 0x0000000000000000000000000000000000000000 for native ETH +- amount: Amount of LP tokens to remove, in whole units +- slippage: Slippage tolerance (default 0.01 = 1%) +`, + schema: RemoveLiquiditySchema, + }) + async removeLiquidity( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const atomicAmount = parseUnits(args.amount, 18).toString(); + + const { txHash, receipt } = await this.executeConvert( + wallet, + args.slippage, + [{ tokenAddress: args.market, amount: atomicAmount }], + [{ tokenAddress: args.tokenOut }], + ); + + return `Successfully removed liquidity from Pendle market ${args.market}.\nRemoved: ${args.amount} LP\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error removing liquidity on Pendle: ${error}`; + } + } + + @CreateAction({ + name: "claim_rewards", + description: ` +Claim accrued interest, yield, and PENDLE rewards from Pendle positions. + +Pendle positions accrue rewards over time: +- YT holders earn yield from the underlying asset +- LP providers earn swap fees and PENDLE incentives +- SY holders may earn interest + +Provide the addresses of the positions you want to claim from. + +It takes: +- syAddresses: Array of SY token addresses (optional) +- ytAddresses: Array of YT token addresses (optional) +- marketAddresses: Array of market/LP addresses (optional) +`, + schema: ClaimRewardsSchema, + }) + async claimRewards( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const userAddress = await wallet.getAddress(); + + const data = encodeFunctionData({ + abi: PENDLE_ROUTER_ABI, + functionName: "redeemDueInterestAndRewards", + args: [ + userAddress as `0x${string}`, + (args.syAddresses || []) as `0x${string}`[], + (args.ytAddresses || []) as `0x${string}`[], + (args.marketAddresses || []) as `0x${string}`[], + ], + }); + + const txHash = await wallet.sendTransaction({ + to: PENDLE_ROUTER_V4 as `0x${string}`, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Successfully claimed Pendle rewards.\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error claiming Pendle rewards: ${error}`; + } + } + + @CreateAction({ + name: "list_markets", + description: ` +List active Pendle markets on Base with their current APYs, TVL, and maturity dates. + +Returns a formatted list of markets including: +- Market name and address +- Underlying asset +- Fixed APY (implied from PT price) +- TVL +- Maturity date + +No inputs required. +`, + schema: ListMarketsSchema, + }) + async listMarkets(wallet: EvmWalletProvider): Promise { + try { + const chainId = this.getChainId(wallet); + const url = `${PENDLE_API_BASE}/v2/markets/all?chainId=${chainId}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Pendle API error: ${response.status}`); + } + + const data = await response.json(); + const markets = (data.results || data) as Array<{ + address: string; + name: string; + expiry: string; + pt: { address: string }; + yt: { address: string }; + underlyingAsset: { symbol: string; address: string }; + aggregatedApy: number; + impliedApy: number; + tvl: { usd: number }; + liquidity: { usd: number }; + }>; + + if (!markets || markets.length === 0) { + return "No active Pendle markets found on Base."; + } + + const lines = markets + .filter((m) => new Date(m.expiry) > new Date()) + .sort((a, b) => (b.tvl?.usd || 0) - (a.tvl?.usd || 0)) + .slice(0, 15) + .map((m) => { + const expiry = new Date(m.expiry).toLocaleDateString(); + const tvl = m.tvl?.usd ? `$${(m.tvl.usd / 1e6).toFixed(2)}M` : "N/A"; + const fixedApy = m.impliedApy ? `${(m.impliedApy * 100).toFixed(2)}%` : "N/A"; + return `- ${m.name} | Market: ${m.address} | Fixed APY: ${fixedApy} | TVL: ${tvl} | Expires: ${expiry}`; + }); + + return `Active Pendle Markets on Base:\n\n${lines.join("\n")}`; + } catch (error) { + return `Error listing Pendle markets: ${error}`; + } + } + + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && PENDLE_SUPPORTED_NETWORKS.includes(network.networkId!); +} + +export const pendleActionProvider = () => new PendleActionProvider(); diff --git a/typescript/agentkit/src/action-providers/pendle/schemas.ts b/typescript/agentkit/src/action-providers/pendle/schemas.ts new file mode 100644 index 000000000..65f404a10 --- /dev/null +++ b/typescript/agentkit/src/action-providers/pendle/schemas.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +const ethAddress = z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format"); + +const positiveAmount = z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value"); + +/** + * Input schema for buying PT (Principal Token) on Pendle. + */ +export const BuyPtSchema = z + .object({ + market: ethAddress.describe("The Pendle market address to trade on"), + tokenIn: ethAddress.describe( + "The address of the input token (e.g., WETH, USDC). Use 0x0000000000000000000000000000000000000000 for native ETH", + ), + amount: positiveAmount.describe("The amount of input tokens to swap, in whole units (e.g., '0.1' for 0.1 WETH)"), + slippage: z + .number() + .min(0.001) + .max(0.5) + .default(0.01) + .describe("Slippage tolerance as a decimal (e.g., 0.01 for 1%). Default: 0.01"), + }) + .describe("Buy PT (Principal Token) on Pendle to lock in a fixed yield until maturity"); + +/** + * Input schema for selling PT back to a token. + */ +export const SellPtSchema = z + .object({ + market: ethAddress.describe("The Pendle market address to trade on"), + tokenOut: ethAddress.describe( + "The address of the output token to receive. Use 0x0000000000000000000000000000000000000000 for native ETH", + ), + amount: positiveAmount.describe("The amount of PT tokens to sell, in whole units"), + slippage: z + .number() + .min(0.001) + .max(0.5) + .default(0.01) + .describe("Slippage tolerance as a decimal (e.g., 0.01 for 1%). Default: 0.01"), + }) + .describe("Sell PT (Principal Token) on Pendle back to an underlying token"); + +/** + * Input schema for buying YT (Yield Token) on Pendle. + */ +export const BuyYtSchema = z + .object({ + market: ethAddress.describe("The Pendle market address to trade on"), + tokenIn: ethAddress.describe( + "The address of the input token. Use 0x0000000000000000000000000000000000000000 for native ETH", + ), + amount: positiveAmount.describe("The amount of input tokens to swap, in whole units"), + slippage: z + .number() + .min(0.001) + .max(0.5) + .default(0.01) + .describe("Slippage tolerance as a decimal (e.g., 0.01 for 1%). Default: 0.01"), + }) + .describe("Buy YT (Yield Token) on Pendle to speculate on yield increases"); + +/** + * Input schema for selling YT back to a token. + */ +export const SellYtSchema = z + .object({ + market: ethAddress.describe("The Pendle market address to trade on"), + tokenOut: ethAddress.describe( + "The address of the output token to receive. Use 0x0000000000000000000000000000000000000000 for native ETH", + ), + amount: positiveAmount.describe("The amount of YT tokens to sell, in whole units"), + slippage: z + .number() + .min(0.001) + .max(0.5) + .default(0.01) + .describe("Slippage tolerance as a decimal (e.g., 0.01 for 1%). Default: 0.01"), + }) + .describe("Sell YT (Yield Token) on Pendle back to an underlying token"); + +/** + * Input schema for adding liquidity to a Pendle market. + */ +export const AddLiquiditySchema = z + .object({ + market: ethAddress.describe("The Pendle market address to provide liquidity to"), + tokenIn: ethAddress.describe( + "The address of the input token. Use 0x0000000000000000000000000000000000000000 for native ETH", + ), + amount: positiveAmount.describe("The amount of tokens to add as liquidity, in whole units"), + slippage: z + .number() + .min(0.001) + .max(0.5) + .default(0.01) + .describe("Slippage tolerance as a decimal (e.g., 0.01 for 1%). Default: 0.01"), + }) + .describe("Add liquidity to a Pendle market to earn swap fees and PENDLE rewards"); + +/** + * Input schema for removing liquidity from a Pendle market. + */ +export const RemoveLiquiditySchema = z + .object({ + market: ethAddress.describe("The Pendle market address to remove liquidity from"), + tokenOut: ethAddress.describe( + "The address of the output token to receive. Use 0x0000000000000000000000000000000000000000 for native ETH", + ), + amount: positiveAmount.describe("The amount of LP tokens to remove, in whole units"), + slippage: z + .number() + .min(0.001) + .max(0.5) + .default(0.01) + .describe("Slippage tolerance as a decimal (e.g., 0.01 for 1%). Default: 0.01"), + }) + .describe("Remove liquidity from a Pendle market back to a single token"); + +/** + * Input schema for claiming rewards from Pendle positions. + */ +export const ClaimRewardsSchema = z + .object({ + syAddresses: z + .array(ethAddress) + .default([]) + .describe("SY token addresses to claim interest from"), + ytAddresses: z + .array(ethAddress) + .default([]) + .describe("YT token addresses to claim yield from"), + marketAddresses: z + .array(ethAddress) + .default([]) + .describe("Market (LP) addresses to claim rewards from"), + }) + .describe("Claim accrued interest, yield, and rewards from Pendle positions"); + +/** + * Input schema for listing available Pendle markets. + */ +export const ListMarketsSchema = z + .object({}) + .describe("List active Pendle markets on Base with their APYs, TVL, and maturity dates");