Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
151 changes: 151 additions & 0 deletions typescript/agentkit/src/action-providers/pendle/constants.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
"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;
2 changes: 2 additions & 0 deletions typescript/agentkit/src/action-providers/pendle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./schemas";
export * from "./pendleActionProvider";
Original file line number Diff line number Diff line change
@@ -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<EvmWalletProvider>;

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<EvmWalletProvider>;

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");
});
});
});
Loading
Loading