diff --git a/typescript/agentkit/src/action-providers/beefy/beefyActionProvider.test.ts b/typescript/agentkit/src/action-providers/beefy/beefyActionProvider.test.ts new file mode 100644 index 000000000..169c3a1ac --- /dev/null +++ b/typescript/agentkit/src/action-providers/beefy/beefyActionProvider.test.ts @@ -0,0 +1,176 @@ +import { beefyActionProvider, BeefyActionProvider } from "./beefyActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +jest.mock("../../utils", () => ({ + approve: jest.fn().mockResolvedValue("Approval successful"), +})); + +describe("BeefyActionProvider", () => { + let provider: BeefyActionProvider; + let mockWallet: jest.Mocked; + + beforeEach(() => { + provider = beefyActionProvider(); + 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(), + } 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", () => { + expect( + provider.supportsNetwork({ protocolFamily: "svm", networkId: "base-mainnet" }), + ).toBe(false); + }); + }); + + describe("deposit", () => { + it("should successfully deposit into vault", async () => { + // Mock want() -> returns want token address + mockWallet.readContract + .mockResolvedValueOnce("0x4200000000000000000000000000000000000006") // want() + .mockResolvedValueOnce(18); // decimals() + + const result = await provider.deposit(mockWallet, { + vaultAddress: "0x0A2Bc5Bd33bac3C34551C67Af3657451911518Fa", + amount: "1.0", + }); + + expect(result).toContain("Successfully deposited"); + expect(result).toContain("0xmocktxhash"); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + }); + + it("should handle errors", async () => { + mockWallet.readContract.mockRejectedValueOnce(new Error("Contract call failed")); + + const result = await provider.deposit(mockWallet, { + vaultAddress: "0x0A2Bc5Bd33bac3C34551C67Af3657451911518Fa", + amount: "1.0", + }); + + expect(result).toContain("Error"); + }); + }); + + describe("withdraw", () => { + it("should withdraw all when no amount specified", async () => { + const result = await provider.withdraw(mockWallet, { + vaultAddress: "0x0A2Bc5Bd33bac3C34551C67Af3657451911518Fa", + }); + + expect(result).toContain("Successfully withdrew"); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + }); + + it("should withdraw specific amount", async () => { + const result = await provider.withdraw(mockWallet, { + vaultAddress: "0x0A2Bc5Bd33bac3C34551C67Af3657451911518Fa", + amount: "0.5", + }); + + expect(result).toContain("Successfully withdrew"); + }); + }); + + describe("checkPosition", () => { + it("should return position details", async () => { + mockWallet.readContract + .mockResolvedValueOnce(BigInt("1000000000000000000")) // balanceOf (1 moo) + .mockResolvedValueOnce(BigInt("1050000000000000000")) // getPricePerFullShare (1.05) + .mockResolvedValueOnce("mooMorpho-WETH"); // symbol + + const result = await provider.checkPosition(mockWallet, { + vaultAddress: "0x0A2Bc5Bd33bac3C34551C67Af3657451911518Fa", + }); + + expect(result).toContain("Beefy Vault Position"); + expect(result).toContain("mooMorpho-WETH"); + expect(result).toContain("1.05"); + }); + }); + + describe("listVaults", () => { + it("should list active vaults", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + id: "morpho-seamless-weth", + chain: "base", + status: "active", + earnContractAddress: "0xVault1", + tokenAddress: "0xWant1", + earnedToken: "mooMorpho-WETH", + platformId: "morpho", + assets: ["WETH"], + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ "morpho-seamless-weth": 0.076 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ "morpho-seamless-weth": 5000000 }), + }); + + const result = await provider.listVaults(mockWallet, {}); + + expect(result).toContain("Active Beefy Vaults"); + expect(result).toContain("WETH"); + expect(result).toContain("morpho"); + }); + + it("should filter by platform", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { id: "v1", chain: "base", status: "active", earnContractAddress: "0x1", platformId: "morpho", assets: ["WETH"] }, + { id: "v2", chain: "base", status: "active", earnContractAddress: "0x2", platformId: "aerodrome", assets: ["WETH", "USDC"] }, + ], + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ v1: 0.05, v2: 0.1 }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + const result = await provider.listVaults(mockWallet, { platform: "morpho" }); + + expect(result).toContain("WETH"); + expect(result).not.toContain("aerodrome"); + }); + + it("should handle no vaults", async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + const result = await provider.listVaults(mockWallet, {}); + expect(result).toContain("No active Beefy vaults"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/beefy/beefyActionProvider.ts b/typescript/agentkit/src/action-providers/beefy/beefyActionProvider.ts new file mode 100644 index 000000000..95fa6e000 --- /dev/null +++ b/typescript/agentkit/src/action-providers/beefy/beefyActionProvider.ts @@ -0,0 +1,283 @@ +import { z } from "zod"; +import { encodeFunctionData, formatUnits, 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 { BEEFY_API_BASE, BEEFY_VAULT_ABI } from "./constants"; +import { BeefyDepositSchema, BeefyWithdrawSchema, CheckPositionSchema, ListVaultsSchema } from "./schemas"; + +const BEEFY_SUPPORTED_NETWORKS = ["base-mainnet"]; + +/** + * BeefyActionProvider enables interaction with Beefy Finance vaults on Base. + * + * Beefy auto-compounds yield from DeFi protocols (Aerodrome, Morpho, Curve, etc.) + * into mooToken vaults. Users deposit the "want" token and receive mooTokens + * whose value increases as yield is compounded. + */ +export class BeefyActionProvider extends ActionProvider { + constructor() { + super("beefy", []); + } + + @CreateAction({ + name: "deposit", + description: ` +Deposit tokens into a Beefy auto-compounding vault to earn yield. + +Beefy vaults automatically harvest and compound rewards from the underlying +DeFi protocol (Aerodrome, Morpho, Curve, etc.). You deposit the "want" token +and receive mooTokens that increase in value over time. + +It takes: +- vaultAddress: The Beefy vault contract address (find vaults using list_vaults) +- amount: Amount of the underlying want token to deposit, in whole units + +Important: +- You must hold the vault's "want" token before depositing +- For LP vaults, the want token is the LP token (e.g., Aerodrome vAMM LP) +- For single-asset vaults (Morpho, Compound), the want token is the raw asset (WETH, USDC, etc.) +- The vault will be approved to spend your want tokens automatically +`, + schema: BeefyDepositSchema, + }) + async deposit(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + // Get the want token address from the vault + const wantToken = (await wallet.readContract({ + address: args.vaultAddress as Hex, + abi: BEEFY_VAULT_ABI, + functionName: "want", + args: [], + })) as `0x${string}`; + + // Get want token decimals + const decimals = (await wallet.readContract({ + address: wantToken, + abi: erc20Abi, + functionName: "decimals", + args: [], + })) as number; + + const atomicAmount = parseUnits(args.amount, decimals); + + // Approve vault to spend want tokens + const approvalResult = await approve(wallet, wantToken, args.vaultAddress, atomicAmount); + if (approvalResult.startsWith("Error")) { + return `Error approving vault: ${approvalResult}`; + } + + // Deposit into vault + const data = encodeFunctionData({ + abi: BEEFY_VAULT_ABI, + functionName: "deposit", + args: [atomicAmount], + }); + + const txHash = await wallet.sendTransaction({ + to: args.vaultAddress as `0x${string}`, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Successfully deposited ${args.amount} tokens into Beefy vault ${args.vaultAddress}.\nWant token: ${wantToken}\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error depositing into Beefy vault: ${error}`; + } + } + + @CreateAction({ + name: "withdraw", + description: ` +Withdraw tokens from a Beefy vault back to the underlying want token. + +If no amount is specified, withdraws the entire position (withdrawAll). +If an amount is specified, withdraws that many mooTokens (vault shares). + +It takes: +- vaultAddress: The Beefy vault contract address +- amount: (Optional) Amount of mooTokens to withdraw. Leave empty to withdraw all. +`, + schema: BeefyWithdrawSchema, + }) + async withdraw(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + let txHash: `0x${string}`; + + if (!args.amount) { + // Withdraw all + const data = encodeFunctionData({ + abi: BEEFY_VAULT_ABI, + functionName: "withdrawAll", + args: [], + }); + + txHash = await wallet.sendTransaction({ + to: args.vaultAddress as `0x${string}`, + data, + }); + } else { + // Withdraw specific amount of shares + const atomicAmount = parseUnits(args.amount, 18); + + const data = encodeFunctionData({ + abi: BEEFY_VAULT_ABI, + functionName: "withdraw", + args: [atomicAmount], + }); + + txHash = await wallet.sendTransaction({ + to: args.vaultAddress as `0x${string}`, + data, + }); + } + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Successfully withdrew from Beefy vault ${args.vaultAddress}.\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error withdrawing from Beefy vault: ${error}`; + } + } + + @CreateAction({ + name: "check_position", + description: ` +Check your position in a Beefy vault — shows your mooToken balance, +the current price per share, and estimated underlying token value. + +It takes: +- vaultAddress: The Beefy vault contract address to check +`, + schema: CheckPositionSchema, + }) + async checkPosition( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const userAddress = await wallet.getAddress(); + + const [mooBalance, pricePerShare, vaultSymbol] = await Promise.all([ + wallet.readContract({ + address: args.vaultAddress as Hex, + abi: BEEFY_VAULT_ABI, + functionName: "balanceOf", + args: [userAddress as `0x${string}`], + }) as Promise, + wallet.readContract({ + address: args.vaultAddress as Hex, + abi: BEEFY_VAULT_ABI, + functionName: "getPricePerFullShare", + args: [], + }) as Promise, + wallet.readContract({ + address: args.vaultAddress as Hex, + abi: BEEFY_VAULT_ABI, + functionName: "symbol", + args: [], + }) as Promise, + ]); + + const underlyingValue = (mooBalance * pricePerShare) / BigInt(1e18); + const mooFormatted = formatUnits(mooBalance, 18); + const underlyingFormatted = formatUnits(underlyingValue, 18); + const ppfsFormatted = formatUnits(pricePerShare, 18); + + return `Beefy Vault Position:\nVault: ${args.vaultAddress}\nToken: ${vaultSymbol}\nShares: ${mooFormatted}\nPrice per share: ${ppfsFormatted}\nUnderlying value: ~${underlyingFormatted} want tokens`; + } catch (error) { + return `Error checking Beefy position: ${error}`; + } + } + + @CreateAction({ + name: "list_vaults", + description: ` +List active Beefy vaults on Base with their APYs and TVL. + +Returns the top vaults sorted by TVL, including vault address, underlying +assets, APY, and TVL. + +It takes: +- platform: (Optional) Filter by platform — 'aerodrome', 'morpho', 'curve', + 'compound', 'pancakeswap', etc. Leave empty for all platforms. +`, + schema: ListVaultsSchema, + }) + async listVaults( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const [vaultsRes, apyRes, tvlRes] = await Promise.all([ + fetch(`${BEEFY_API_BASE}/vaults`), + fetch(`${BEEFY_API_BASE}/apy`), + fetch(`${BEEFY_API_BASE}/tvl`), + ]); + + if (!vaultsRes.ok || !apyRes.ok || !tvlRes.ok) { + throw new Error("Failed to fetch Beefy API data"); + } + + const vaults = (await vaultsRes.json()) as Array<{ + id: string; + chain: string; + status: string; + earnContractAddress: string; + tokenAddress: string; + earnedToken: string; + platformId: string; + assets: string[]; + }>; + const apys = (await apyRes.json()) as Record; + const tvls = (await tvlRes.json()) as Record>; + + let baseVaults = vaults.filter((v) => v.chain === "base" && v.status === "active"); + + if (args.platform) { + baseVaults = baseVaults.filter( + (v) => v.platformId?.toLowerCase() === args.platform!.toLowerCase(), + ); + } + + // Sort by TVL descending + const sorted = baseVaults + .map((v) => ({ + ...v, + apy: apys[v.id] || 0, + tvl: tvls[v.id]?.[v.earnContractAddress] || tvls[v.id] || 0, + })) + .sort((a, b) => { + const tvlA = typeof a.tvl === "number" ? a.tvl : 0; + const tvlB = typeof b.tvl === "number" ? b.tvl : 0; + return tvlB - tvlA; + }) + .slice(0, 20); + + if (sorted.length === 0) { + return `No active Beefy vaults found on Base${args.platform ? ` for platform "${args.platform}"` : ""}.`; + } + + const lines = sorted.map((v) => { + const apyStr = v.apy ? `${(v.apy * 100).toFixed(2)}%` : "N/A"; + const tvlNum = typeof v.tvl === "number" ? v.tvl : 0; + const tvlStr = tvlNum > 0 ? `$${(tvlNum / 1e6).toFixed(2)}M` : "N/A"; + const assets = v.assets?.join("-") || v.id; + return `- ${assets} (${v.platformId}) | Vault: ${v.earnContractAddress} | APY: ${apyStr} | TVL: ${tvlStr}`; + }); + + return `Active Beefy Vaults on Base (top ${sorted.length}):\n\n${lines.join("\n")}`; + } catch (error) { + return `Error listing Beefy vaults: ${error}`; + } + } + + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && BEEFY_SUPPORTED_NETWORKS.includes(network.networkId!); +} + +export const beefyActionProvider = () => new BeefyActionProvider(); diff --git a/typescript/agentkit/src/action-providers/beefy/constants.ts b/typescript/agentkit/src/action-providers/beefy/constants.ts new file mode 100644 index 000000000..22d4de778 --- /dev/null +++ b/typescript/agentkit/src/action-providers/beefy/constants.ts @@ -0,0 +1,83 @@ +/** + * Beefy Finance contract addresses and ABIs for Base chain. + * + * Beefy auto-compounds yield from various DeFi protocols into + * mooToken vaults. Users deposit the "want" token and receive + * mooTokens whose value increases over time. + */ + +export const BEEFY_API_BASE = "https://api.beefy.finance"; + +/** Standard BeefyVaultV7 ABI — works for all Beefy vaults */ +export const BEEFY_VAULT_ABI = [ + { + inputs: [{ internalType: "uint256", name: "_amount", type: "uint256" }], + name: "deposit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "depositAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_shares", type: "uint256" }], + name: "withdraw", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "withdrawAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "balance", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getPricePerFullShare", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "want", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "totalSupply", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/beefy/index.ts b/typescript/agentkit/src/action-providers/beefy/index.ts new file mode 100644 index 000000000..2162125bf --- /dev/null +++ b/typescript/agentkit/src/action-providers/beefy/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export * from "./beefyActionProvider"; diff --git a/typescript/agentkit/src/action-providers/beefy/schemas.ts b/typescript/agentkit/src/action-providers/beefy/schemas.ts new file mode 100644 index 000000000..c4712197b --- /dev/null +++ b/typescript/agentkit/src/action-providers/beefy/schemas.ts @@ -0,0 +1,60 @@ +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 depositing into a Beefy vault. + */ +export const BeefyDepositSchema = z + .object({ + vaultAddress: ethAddress.describe( + "The Beefy vault contract address (earnContractAddress from the API)", + ), + amount: positiveAmount.describe( + "The amount of the underlying 'want' token to deposit, in whole units", + ), + }) + .describe("Deposit tokens into a Beefy auto-compounding vault to earn yield"); + +/** + * Input schema for withdrawing from a Beefy vault. + */ +export const BeefyWithdrawSchema = z + .object({ + vaultAddress: ethAddress.describe("The Beefy vault contract address to withdraw from"), + amount: positiveAmount + .optional() + .describe( + "Amount of mooTokens (vault shares) to withdraw in whole units. Leave empty to withdraw all", + ), + }) + .describe("Withdraw tokens from a Beefy vault back to the underlying want token"); + +/** + * Input schema for checking a vault position. + */ +export const CheckPositionSchema = z + .object({ + vaultAddress: ethAddress.describe("The Beefy vault contract address to check"), + }) + .describe("Check your position in a Beefy vault — balance, share value, and underlying worth"); + +/** + * Input schema for listing Beefy vaults. + */ +export const ListVaultsSchema = z + .object({ + platform: z + .string() + .optional() + .describe( + "Filter by platform (e.g., 'aerodrome', 'morpho', 'curve'). Leave empty for all", + ), + }) + .describe("List active Beefy vaults on Base with APYs and TVL, sorted by TVL"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..e96af8679 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -7,6 +7,7 @@ export * from "./across"; export * from "./alchemy"; export * from "./baseAccount"; export * from "./basename"; +export * from "./beefy"; export * from "./cdp"; export * from "./clanker"; export * from "./compound"; @@ -18,11 +19,13 @@ export * from "./erc721"; export * from "./erc8004"; export * from "./farcaster"; export * from "./jupiter"; +export * from "./lido"; export * from "./messari"; 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/lido/constants.ts b/typescript/agentkit/src/action-providers/lido/constants.ts new file mode 100644 index 000000000..2e036051f --- /dev/null +++ b/typescript/agentkit/src/action-providers/lido/constants.ts @@ -0,0 +1,68 @@ +/** + * Lido Finance contract addresses and ABIs for Base chain. + * + * wstETH on Base is a bridged ERC20 token (non-rebasing). + * Direct staking is available via Chainlink CCIP Custom Sender. + */ + +/** wstETH bridged token on Base */ +export const WSTETH_BASE = "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452"; + +/** Lido Direct Staking Custom Sender on Base (via Chainlink CCIP) */ +export const LIDO_CUSTOM_SENDER_BASE = "0x328de900860816d29D1367F6903a24D8ed40C997"; + +/** WETH on Base */ +export const WETH_BASE = "0x4200000000000000000000000000000000000006"; + +/** Chainlink wstETH/stETH exchange rate oracle on Base */ +export const WSTETH_RATE_ORACLE_BASE = "0xB88BAc61a4Ca37C43a3725912B1f472c9A5bc061"; + +/** Minimal ABI for Lido Custom Sender — fastStake with native ETH */ +export const LIDO_CUSTOM_SENDER_ABI = [ + { + inputs: [ + { internalType: "uint256", name: "minWstETHAmount", type: "uint256" }, + { internalType: "address", name: "referral", type: "address" }, + ], + name: "fastStake", + outputs: [{ internalType: "uint256", name: "wstETHAmount", type: "uint256" }], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "wethAmount", type: "uint256" }, + { internalType: "uint256", name: "minWstETHAmount", type: "uint256" }, + { internalType: "address", name: "referral", type: "address" }, + ], + name: "fastStakeWETH", + outputs: [{ internalType: "uint256", name: "wstETHAmount", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "ethAmount", type: "uint256" }], + name: "getExpectedWstETH", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + +/** Minimal wstETH ABI (standard ERC20 + exchange rate query) */ +export const WSTETH_ABI = [ + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "totalSupply", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/lido/index.ts b/typescript/agentkit/src/action-providers/lido/index.ts new file mode 100644 index 000000000..c06fc5658 --- /dev/null +++ b/typescript/agentkit/src/action-providers/lido/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export * from "./lidoActionProvider"; diff --git a/typescript/agentkit/src/action-providers/lido/lidoActionProvider.test.ts b/typescript/agentkit/src/action-providers/lido/lidoActionProvider.test.ts new file mode 100644 index 000000000..035e85e53 --- /dev/null +++ b/typescript/agentkit/src/action-providers/lido/lidoActionProvider.test.ts @@ -0,0 +1,109 @@ +import { lidoActionProvider, LidoActionProvider } from "./lidoActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; + +jest.mock("../../utils", () => ({ + approve: jest.fn().mockResolvedValue("Approval successful"), +})); + +describe("LidoActionProvider", () => { + let provider: LidoActionProvider; + let mockWallet: jest.Mocked; + + beforeEach(() => { + provider = lidoActionProvider(); + 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(), + } as unknown as jest.Mocked; + }); + + describe("supportsNetwork", () => { + it("should support base-mainnet", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" }), + ).toBe(true); + }); + + it("should not support ethereum-mainnet", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" }), + ).toBe(false); + }); + + it("should not support non-evm", () => { + expect( + provider.supportsNetwork({ protocolFamily: "svm", networkId: "base-mainnet" }), + ).toBe(false); + }); + }); + + describe("stakeEth", () => { + it("should successfully stake ETH", async () => { + // Mock getExpectedWstETH return + mockWallet.readContract.mockResolvedValueOnce(BigInt("83000000000000000")); // ~0.083 wstETH for 0.1 ETH + + const result = await provider.stakeEth(mockWallet, { + amount: "0.1", + slippage: 0.005, + }); + + expect(result).toContain("Successfully staked 0.1 ETH"); + expect(result).toContain("0xmocktxhash"); + expect(mockWallet.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0x328de900860816d29D1367F6903a24D8ed40C997", + value: BigInt("100000000000000000"), + }), + ); + }); + + it("should handle errors", async () => { + mockWallet.readContract.mockRejectedValueOnce(new Error("Contract call failed")); + + const result = await provider.stakeEth(mockWallet, { + amount: "0.1", + slippage: 0.005, + }); + + expect(result).toContain("Error staking ETH"); + }); + }); + + describe("stakeWeth", () => { + it("should successfully stake WETH with approval", async () => { + mockWallet.readContract.mockResolvedValueOnce(BigInt("83000000000000000")); + + const result = await provider.stakeWeth(mockWallet, { + amount: "0.1", + slippage: 0.005, + }); + + expect(result).toContain("Successfully staked 0.1 WETH"); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + }); + }); + + describe("checkBalance", () => { + it("should return wstETH balance", async () => { + mockWallet.readContract.mockResolvedValueOnce(BigInt("500000000000000000")); // 0.5 wstETH + + const result = await provider.checkBalance(mockWallet); + + expect(result).toContain("0.5"); + expect(result).toContain("wstETH Balance"); + expect(result).toContain("0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452"); + }); + + it("should handle zero balance", async () => { + mockWallet.readContract.mockResolvedValueOnce(BigInt(0)); + + const result = await provider.checkBalance(mockWallet); + + expect(result).toContain("0 wstETH"); + expect(result).toContain("wstETH Balance"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/lido/lidoActionProvider.ts b/typescript/agentkit/src/action-providers/lido/lidoActionProvider.ts new file mode 100644 index 000000000..e66490abb --- /dev/null +++ b/typescript/agentkit/src/action-providers/lido/lidoActionProvider.ts @@ -0,0 +1,187 @@ +import { z } from "zod"; +import { encodeFunctionData, formatEther, Hex, parseEther } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { approve } from "../../utils"; +import { Network } from "../../network"; +import { + WSTETH_BASE, + LIDO_CUSTOM_SENDER_BASE, + WETH_BASE, + LIDO_CUSTOM_SENDER_ABI, + WSTETH_ABI, +} from "./constants"; +import { StakeSchema, StakeWethSchema, CheckBalanceSchema } from "./schemas"; + +const LIDO_SUPPORTED_NETWORKS = ["base-mainnet"]; + +/** + * LidoActionProvider enables staking ETH/WETH to receive wstETH on Base. + * + * Uses Lido's Direct Staking via Chainlink CCIP for instant fastStake + * operations. wstETH is the non-rebasing wrapped version of stETH, + * earning staking yield through increasing exchange rate. + */ +export class LidoActionProvider extends ActionProvider { + constructor() { + super("lido", []); + } + + @CreateAction({ + name: "stake_eth", + description: ` +Stake ETH to receive wstETH on Base via Lido Direct Staking. + +Uses Lido's fastStake powered by Chainlink CCIP — instant wstETH from the +Base liquidity pool. wstETH is the non-rebasing wrapped staked ETH that +earns ~3-4% APY through an increasing exchange rate. + +It takes: +- amount: Amount of ETH to stake in whole units (e.g., '0.1' for 0.1 ETH) +- slippage: Max slippage tolerance (default 0.005 = 0.5%) + +Example: Stake 0.5 ETH to receive ~0.415 wstETH (rate varies with staking rewards). + +Important: The exchange rate is approximately 1 wstETH = 1.2 ETH. You will +receive fewer wstETH tokens than ETH sent, but each wstETH is worth more. +`, + schema: StakeSchema, + }) + async stakeEth(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + const ethAmount = parseEther(args.amount); + + // Get expected wstETH output for slippage calculation + const expectedWstETH = (await wallet.readContract({ + address: LIDO_CUSTOM_SENDER_BASE as Hex, + abi: LIDO_CUSTOM_SENDER_ABI, + functionName: "getExpectedWstETH", + args: [ethAmount], + })) as bigint; + + const slippageBps = BigInt(Math.floor(args.slippage * 10000)); + const minWstETH = expectedWstETH - (expectedWstETH * slippageBps) / 10000n; + + const data = encodeFunctionData({ + abi: LIDO_CUSTOM_SENDER_ABI, + functionName: "fastStake", + args: [minWstETH, "0x0000000000000000000000000000000000000000" as `0x${string}`], + }); + + const txHash = await wallet.sendTransaction({ + to: LIDO_CUSTOM_SENDER_BASE as `0x${string}`, + data, + value: ethAmount, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Successfully staked ${args.amount} ETH via Lido on Base.\nExpected wstETH: ~${formatEther(expectedWstETH)}\nMin wstETH (with ${args.slippage * 100}% slippage): ${formatEther(minWstETH)}\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error staking ETH via Lido: ${error}`; + } + } + + @CreateAction({ + name: "stake_weth", + description: ` +Stake WETH to receive wstETH on Base via Lido Direct Staking. + +Similar to stake_eth but uses WETH instead of native ETH. Requires +WETH approval before staking. + +It takes: +- amount: Amount of WETH to stake in whole units (e.g., '0.1' for 0.1 WETH) +- slippage: Max slippage tolerance (default 0.005 = 0.5%) +`, + schema: StakeWethSchema, + }) + async stakeWeth( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const wethAmount = parseEther(args.amount); + + // Get expected wstETH output + const expectedWstETH = (await wallet.readContract({ + address: LIDO_CUSTOM_SENDER_BASE as Hex, + abi: LIDO_CUSTOM_SENDER_ABI, + functionName: "getExpectedWstETH", + args: [wethAmount], + })) as bigint; + + const slippageBps = BigInt(Math.floor(args.slippage * 10000)); + const minWstETH = expectedWstETH - (expectedWstETH * slippageBps) / 10000n; + + // Approve WETH spending + const approvalResult = await approve( + wallet, + WETH_BASE, + LIDO_CUSTOM_SENDER_BASE, + wethAmount, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving WETH: ${approvalResult}`; + } + + const data = encodeFunctionData({ + abi: LIDO_CUSTOM_SENDER_ABI, + functionName: "fastStakeWETH", + args: [ + wethAmount, + minWstETH, + "0x0000000000000000000000000000000000000000" as `0x${string}`, + ], + }); + + const txHash = await wallet.sendTransaction({ + to: LIDO_CUSTOM_SENDER_BASE as `0x${string}`, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Successfully staked ${args.amount} WETH via Lido on Base.\nExpected wstETH: ~${formatEther(expectedWstETH)}\nTransaction: ${txHash}\nReceipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error staking WETH via Lido: ${error}`; + } + } + + @CreateAction({ + name: "check_wsteth_balance", + description: ` +Check the current wstETH balance of the connected wallet on Base. + +Returns the wstETH balance and an estimate of the underlying ETH value +based on the current exchange rate (~1.2 ETH per wstETH). + +No inputs required. +`, + schema: CheckBalanceSchema, + }) + async checkBalance(wallet: EvmWalletProvider): Promise { + try { + const userAddress = await wallet.getAddress(); + + const balance = (await wallet.readContract({ + address: WSTETH_BASE as Hex, + abi: WSTETH_ABI, + functionName: "balanceOf", + args: [userAddress as `0x${string}`], + })) as bigint; + + const balanceFormatted = formatEther(balance); + + return `wstETH Balance on Base: ${balanceFormatted} wstETH\nWallet: ${userAddress}\nToken: ${WSTETH_BASE}`; + } catch (error) { + return `Error checking wstETH balance: ${error}`; + } + } + + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && LIDO_SUPPORTED_NETWORKS.includes(network.networkId!); +} + +export const lidoActionProvider = () => new LidoActionProvider(); diff --git a/typescript/agentkit/src/action-providers/lido/schemas.ts b/typescript/agentkit/src/action-providers/lido/schemas.ts new file mode 100644 index 000000000..3ae41c2d9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/lido/schemas.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +/** + * Input schema for staking ETH to receive wstETH via Lido on Base. + */ +export const StakeSchema = z + .object({ + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value") + .describe("The amount of ETH to stake, in whole units (e.g., '0.1' for 0.1 ETH)"), + slippage: z + .number() + .min(0.001) + .max(0.1) + .default(0.005) + .describe("Slippage tolerance for minimum wstETH received (default 0.005 = 0.5%)"), + }) + .describe( + "Stake ETH to receive wstETH on Base via Lido Direct Staking (Chainlink CCIP fast stake)", + ); + +/** + * Input schema for staking WETH to receive wstETH via Lido on Base. + */ +export const StakeWethSchema = z + .object({ + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value") + .describe("The amount of WETH to stake, in whole units (e.g., '0.1' for 0.1 WETH)"), + slippage: z + .number() + .min(0.001) + .max(0.1) + .default(0.005) + .describe("Slippage tolerance for minimum wstETH received (default 0.005 = 0.5%)"), + }) + .describe("Stake WETH to receive wstETH on Base via Lido Direct Staking"); + +/** + * Input schema for checking wstETH balance. + */ +export const CheckBalanceSchema = z + .object({}) + .describe("Check the current wstETH balance of the connected wallet on Base"); 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");