diff --git a/typescript/agentkit/src/action-providers/aerodrome/README.md b/typescript/agentkit/src/action-providers/aerodrome/README.md new file mode 100644 index 000000000..4c7b8fc87 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/README.md @@ -0,0 +1,33 @@ +# Aerodrome Action Provider + +The Aerodrome Action Provider enables AI agents to interact with [Aerodrome Finance](https://aerodrome.finance/) on the Base Network. + +## Features + +### veAERO Management +- **createLock**: Lock AERO tokens to create veAERO NFTs for governance voting + +### Governance +- **vote**: Cast votes for liquidity pool emissions with a veAERO NFT + +### V2 Pools (Stable & Volatile) +- **swapExactTokens**: Swap tokens through Aerodrome V2 pools (stable or volatile) + +## Usage + +Import and initialize the action provider: + +```typescript +import { aerodromeActionProvider } from '@coinbase/agentkit'; + +// Initialize AgentKit +const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [ + // ... other providers + aerodromeActionProvider(), + ], +}); +``` + +The provider currently supports Base Mainnet only. \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts new file mode 100644 index 000000000..593b64606 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts @@ -0,0 +1,560 @@ +/** + * AerodromeActionProvider Tests + */ + +import { encodeFunctionData, parseUnits, ReadContractParameters, Abi, Hex } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { approve } from "../../utils"; +import { AerodromeActionProvider } from "./aerodromeActionProvider"; +import { Network } from "../../network"; +import { + VOTING_ESCROW_ABI, + VOTER_ABI, + ROUTER_ABI, + AERO_ADDRESS, + VOTING_ESCROW_ADDRESS, + VOTER_ADDRESS, + ROUTER_ADDRESS, + ZERO_ADDRESS, +} from "./constants"; +import * as utilsModule from "./utils"; + +const MOCK_ADDRESS = "0x1234567890123456789012345678901234567890"; +const MOCK_POOL_ADDRESS_1 = "0xaaaa567890123456789012345678901234567890"; +const MOCK_POOL_ADDRESS_2 = "0xbbbb567890123456789012345678901234567890"; +const MOCK_TOKEN_IN = "0xcccc567890123456789012345678901234567890"; +const MOCK_TOKEN_OUT = "0xdddd567890123456789012345678901234567890"; +const MOCK_TX_HASH = "0xabcdef1234567890"; +const MOCK_DECIMALS = 18; +const MOCK_RECEIPT = { gasUsed: 100000n }; + +jest.mock("../../utils"); +jest.mock("./utils"); +const mockApprove = approve as jest.MockedFunction; + +describe("AerodromeActionProvider", () => { + const provider = new AerodromeActionProvider(); + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet" }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH as `0x${string}`), + waitForTransactionReceipt: jest.fn().mockResolvedValue(MOCK_RECEIPT), + readContract: jest.fn().mockImplementation((params: ReadContractParameters) => { + if (params.functionName === "decimals") return Promise.resolve(MOCK_DECIMALS); + if (params.functionName === "symbol") { + if (params.address && params.address.toLowerCase() === MOCK_TOKEN_IN.toLowerCase()) { + return Promise.resolve("TOKEN_IN"); + } + if (params.address && params.address.toLowerCase() === MOCK_TOKEN_OUT.toLowerCase()) { + return Promise.resolve("TOKEN_OUT"); + } + return Promise.resolve("AERO"); + } + if (params.functionName === "lastVoted") return Promise.resolve(0n); + if (params.functionName === "gauges") return Promise.resolve(MOCK_POOL_ADDRESS_1); + if (params.functionName === "balanceOf") { + return Promise.resolve(parseUnits("1000000", MOCK_DECIMALS)); + } + if (params.functionName === "getAmountsOut") { + const amount = params.args?.[0] as bigint; + return Promise.resolve([amount, amount * 2n]); + } + if (params.functionName === "ownerOf") { + return Promise.resolve(MOCK_ADDRESS); + } + return Promise.resolve(0); + }), + } as unknown as jest.Mocked; + + mockApprove.mockResolvedValue("Approval successful"); + + jest + .spyOn(utilsModule, "getTokenInfo") + .mockImplementation(async (wallet: EvmWalletProvider, address: Hex) => { + if (address?.toLowerCase() === MOCK_TOKEN_IN.toLowerCase()) { + return { decimals: MOCK_DECIMALS, symbol: "TOKEN_IN" }; + } + if (address?.toLowerCase() === MOCK_TOKEN_OUT.toLowerCase()) { + return { decimals: MOCK_DECIMALS, symbol: "TOKEN_OUT" }; + } + return { decimals: MOCK_DECIMALS, symbol: "AERO" }; + }); + + jest + .spyOn(utilsModule, "formatTransactionResult") + .mockImplementation( + ( + action: string, + details: string, + txHash: Hex, + receipt: { gasUsed?: bigint | string }, + ): string => { + return `Successfully ${action}. ${details}\nTransaction: ${txHash}. Gas used: ${receipt.gasUsed}`; + }, + ); + + jest + .spyOn(utilsModule, "handleTransactionError") + .mockImplementation((action: string, error: unknown): string => { + if (error instanceof Error) { + if (error.message.includes("NotApprovedOrOwner")) { + return `Error ${action}: Wallet ${MOCK_ADDRESS} does not own or is not approved for veAERO token ID`; + } + if (error.message.includes("INSUFFICIENT_OUTPUT_AMOUNT")) { + return `Error ${action}: Insufficient output amount. Slippage may be too high or amountOutMin too strict for current market conditions.`; + } + if (error.message.includes("INSUFFICIENT_LIQUIDITY")) { + return `Error ${action}: Insufficient liquidity for this trade pair and amount.`; + } + if (error.message.includes("Expired")) { + return `Error ${action}: Transaction deadline likely passed during execution.`; + } + return `Error ${action}: ${error.message}`; + } + return `Error ${action}: ${String(error)}`; + }); + + jest.spyOn(utilsModule, "formatDuration").mockImplementation((/* _seconds: number */) => { + return "1 week"; + }); + + jest.spyOn(utilsModule, "getCurrentEpochStart").mockImplementation(() => 1680739200n); + + jest.spyOn(Date, "now").mockImplementation(() => 1681315200000); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Network Support", () => { + it("should support base-mainnet network", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-mainnet", + } as Network), + ).toBe(true); + }); + + it("should not support other evm networks", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "ethereum-mainnet", + } as Network), + ).toBe(false); + }); + + it("should not support other protocol families", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "other-protocol-family", + networkId: "base-mainnet", + } as Network), + ).toBe(false); + }); + + it("should handle invalid network objects", () => { + expect(provider.supportsNetwork({ protocolFamily: "invalid-protocol" } as Network)).toBe( + false, + ); + expect(provider.supportsNetwork({} as Network)).toBe(false); + }); + }); + + describe("createLock", () => { + it("should successfully create a veAERO lock", async () => { + const args = { + aeroAmount: "100.5", + lockDurationSeconds: "604800", + }; + + const atomicAmount = parseUnits(args.aeroAmount, MOCK_DECIMALS); + + const response = await provider.createLock(mockWallet, args); + + expect(mockApprove).toHaveBeenCalledWith( + mockWallet, + AERO_ADDRESS, + VOTING_ESCROW_ADDRESS, + atomicAmount, + ); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: VOTING_ESCROW_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: VOTING_ESCROW_ABI, + functionName: "create_lock", + args: [atomicAmount, BigInt(args.lockDurationSeconds)], + }), + }); + + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); + expect(response).toContain(`Successfully created veAERO lock`); + expect(response).toContain(MOCK_TX_HASH); + }); + + it("should return error if lock duration is too short", async () => { + const args = { + aeroAmount: "100.5", + lockDurationSeconds: "604799", + }; + + const response = await provider.createLock(mockWallet, args); + expect(response).toContain("Error: Lock duration"); + expect(response).toContain("must be at least 1 week"); + }); + + it("should return error if lock duration is too long", async () => { + const args = { + aeroAmount: "100.5", + lockDurationSeconds: "126144001", + }; + + const response = await provider.createLock(mockWallet, args); + expect(response).toContain("Error: Lock duration"); + expect(response).toContain("cannot exceed 4 years"); + }); + + it("should return error if AERO amount is not positive", async () => { + const args = { + aeroAmount: "0", + lockDurationSeconds: "604800", + }; + + const response = await provider.createLock(mockWallet, args); + expect(response).toContain("Error: AERO amount must be greater than 0"); + }); + + it("should handle approval errors", async () => { + const args = { + aeroAmount: "100.5", + lockDurationSeconds: "604800", + }; + + mockApprove.mockResolvedValue("Error: Insufficient balance"); + + const response = await provider.createLock(mockWallet, args); + expect(response).toContain("Error approving VotingEscrow contract"); + }); + + it("should handle transaction errors", async () => { + const args = { + aeroAmount: "100.5", + lockDurationSeconds: "604800", + }; + + mockWallet.sendTransaction.mockRejectedValue(new Error("Transaction failed")); + + const response = await provider.createLock(mockWallet, args); + expect(response).toContain("Error creating veAERO lock"); + }); + }); + + describe("vote", () => { + it("should successfully cast votes", async () => { + const args = { + veAeroTokenId: "1", + poolAddresses: [MOCK_POOL_ADDRESS_1, MOCK_POOL_ADDRESS_2], + weights: ["100", "50"], + }; + + const response = await provider.vote(mockWallet, args); + + expect(mockWallet.readContract).toHaveBeenCalledWith({ + address: VOTER_ADDRESS as `0x${string}`, + abi: VOTER_ABI, + functionName: "lastVoted", + args: [1n], + }); + + expect(mockWallet.readContract).toHaveBeenCalled(); + const callArgs = mockWallet.readContract.mock.calls.flat(); + + const gaugeCalls = callArgs.filter( + (call: ReadContractParameters) => + call?.functionName === "gauges" && + typeof call?.args?.[0] === "string" && + (call?.args?.[0].toLowerCase() === MOCK_POOL_ADDRESS_1.toLowerCase() || + call?.args?.[0].toLowerCase() === MOCK_POOL_ADDRESS_2.toLowerCase()), + ); + expect(gaugeCalls.length).toBeGreaterThanOrEqual(2); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: VOTER_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: VOTER_ABI, + functionName: "vote", + args: [1n, [MOCK_POOL_ADDRESS_1, MOCK_POOL_ADDRESS_2], [100n, 50n]], + }), + }); + + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); + expect(response).toContain(`Successfully cast votes`); + }); + + it("should return error if already voted in current epoch", async () => { + const args = { + veAeroTokenId: "1", + poolAddresses: [MOCK_POOL_ADDRESS_1], + weights: ["100"], + }; + + mockWallet.readContract = jest + .fn() + .mockImplementation((params: ReadContractParameters) => { + if (params.functionName === "lastVoted") return Promise.resolve(1681315200n); + if (params.functionName === "gauges") return Promise.resolve(MOCK_POOL_ADDRESS_1); + if (params.functionName === "ownerOf") return Promise.resolve(MOCK_ADDRESS); + return Promise.resolve(MOCK_DECIMALS); + }); + + const response = await provider.vote(mockWallet, args); + expect(response).toContain("Error: Already voted with token ID"); + expect(response).toContain("in the current epoch"); + }); + + it("should return error if pool does not have a registered gauge", async () => { + const args = { + veAeroTokenId: "1", + poolAddresses: [MOCK_POOL_ADDRESS_1], + weights: ["100"], + }; + + mockWallet.readContract = jest + .fn() + .mockImplementation((params: ReadContractParameters) => { + if (params.functionName === "gauges") return Promise.resolve(ZERO_ADDRESS); + if (params.functionName === "ownerOf") return Promise.resolve(MOCK_ADDRESS); + return Promise.resolve(0); + }); + + const response = await provider.vote(mockWallet, args); + expect(response).toContain("Error: Pool"); + expect(response).toContain("does not have a registered gauge"); + }); + + it("should handle transaction errors", async () => { + const args = { + veAeroTokenId: "1", + poolAddresses: [MOCK_POOL_ADDRESS_1], + weights: ["100"], + }; + + mockWallet.sendTransaction.mockRejectedValue(new Error("Transaction failed")); + + const response = await provider.vote(mockWallet, args); + expect(response).toContain("Error casting votes"); + }); + + it("should correctly handle NotApprovedOrOwner errors", async () => { + const args = { + veAeroTokenId: "1", + poolAddresses: [MOCK_POOL_ADDRESS_1], + weights: ["100"], + }; + + mockWallet.readContract = jest + .fn() + .mockImplementation((params: ReadContractParameters) => { + if (params.functionName === "lastVoted") return Promise.resolve(0n); + if (params.functionName === "gauges") return Promise.resolve(MOCK_POOL_ADDRESS_1); + if (params.functionName === "ownerOf") return Promise.resolve(MOCK_ADDRESS); + return Promise.resolve(0); + }); + + const notApprovedError = new Error("execution reverted: NotApprovedOrOwner"); + mockWallet.sendTransaction.mockRejectedValue(notApprovedError); + + const response = await provider.vote(mockWallet, args); + expect(response).toContain("Error casting votes"); + expect(response).toContain("does not own or is not approved for veAERO token ID"); + }); + }); + + describe("swapExactTokens", () => { + it("should successfully swap tokens", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1.5", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + const atomicAmountIn = parseUnits(args.amountIn, MOCK_DECIMALS); + + const response = await provider.swapExactTokens(mockWallet, args); + + expect(mockApprove).toHaveBeenCalledWith( + mockWallet, + expect.stringMatching(new RegExp(MOCK_TOKEN_IN, "i")), + ROUTER_ADDRESS, + atomicAmountIn, + ); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: ROUTER_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: ROUTER_ABI, + functionName: "swapExactTokensForTokens", + args: [ + atomicAmountIn, + BigInt(args.amountOutMin), + [ + { + from: MOCK_TOKEN_IN, + to: MOCK_TOKEN_OUT, + stable: false, + factory: ZERO_ADDRESS as `0x${string}`, + }, + ], + MOCK_ADDRESS, + BigInt(args.deadline), + ], + }), + }); + + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); + expect(response).toContain(`Successfully completed swap`); + }); + + it("should return error if deadline has already passed", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1.5", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1681315199", + useStablePool: false, + }; + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error: Deadline"); + expect(response).toContain("has already passed"); + }); + + it("should return error if swap amount is not positive", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "0", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error: Swap amount must be greater than 0"); + }); + + it("should handle approval errors", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1.5", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + mockApprove.mockResolvedValue("Error: Insufficient balance"); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error approving Router contract"); + }); + + it("should handle transaction errors", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1.5", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + mockWallet.sendTransaction.mockReset(); + mockWallet.sendTransaction.mockRejectedValue(new Error("Transaction failed")); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error swapping tokens"); + }); + + it("should handle INSUFFICIENT_OUTPUT_AMOUNT errors", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1.5", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + mockWallet.sendTransaction.mockReset(); + const slippageError = new Error("execution reverted: INSUFFICIENT_OUTPUT_AMOUNT"); + mockWallet.sendTransaction.mockRejectedValue(slippageError); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error swapping tokens"); + expect(response).toContain("Slippage may be too high"); + }); + + it("should handle INSUFFICIENT_LIQUIDITY errors", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1000000", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + mockWallet.sendTransaction.mockReset(); + const liquidityError = new Error("execution reverted: INSUFFICIENT_LIQUIDITY"); + mockWallet.sendTransaction.mockRejectedValue(liquidityError); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error swapping tokens"); + expect(response).toContain("Insufficient liquidity"); + }); + + it("should handle Expired errors", async () => { + const args = { + tokenInAddress: MOCK_TOKEN_IN, + tokenOutAddress: MOCK_TOKEN_OUT, + amountIn: "1.5", + amountOutMin: "1000000000", + to: MOCK_ADDRESS, + deadline: "1691315200", + useStablePool: false, + }; + + mockWallet.sendTransaction.mockReset(); + const expiredError = new Error("execution reverted: Expired"); + mockWallet.sendTransaction.mockRejectedValue(expiredError); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error swapping tokens"); + expect(response).toContain("deadline"); + }); + }); + + describe("getCurrentEpochStart function", () => { + it("should correctly calculate epoch start time", () => { + expect(utilsModule.getCurrentEpochStart(BigInt(604800))).toBe(1680739200n); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts new file mode 100644 index 000000000..d1717da7a --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts @@ -0,0 +1,401 @@ +import { z } from "zod"; +import { encodeFunctionData, Hex, parseUnits, getAddress, formatUnits } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { approve } from "../../utils"; +import { + ERC20_ABI, + VOTING_ESCROW_ABI, + VOTER_ABI, + ROUTER_ABI, + AERO_ADDRESS, + VOTING_ESCROW_ADDRESS, + VOTER_ADDRESS, + ROUTER_ADDRESS, + ZERO_ADDRESS, + MIN_LOCK_DURATION, + MAX_LOCK_DURATION, + WEEK_SECONDS, +} from "./constants"; +import { CreateLockSchema, VoteSchema, SwapExactTokensSchema } from "./schemas"; +import { Network } from "../../network"; +import { + getTokenInfo, + formatTransactionResult, + handleTransactionError, + formatDuration, + getCurrentEpochStart, +} from "./utils"; + +export const SUPPORTED_NETWORKS = ["base-mainnet"]; +const SECONDS_IN_WEEK = BigInt(WEEK_SECONDS); + +/** + * AerodromeActionProvider enables AI agents to interact with Aerodrome Finance on Base Mainnet. + * Supported actions: create veAERO locks, vote for gauge emissions, swap tokens. + */ +export class AerodromeActionProvider extends ActionProvider { + /** + * Constructor for the AerodromeActionProvider class. + */ + constructor() { + super("aerodrome", []); + } + + /** + * Creates a new veAERO lock by depositing AERO tokens for a specified duration on Base Mainnet. + * + * @param wallet - The EVM wallet provider used to interact with the blockchain + * @param args - The parameters for creating a lock, including aeroAmount and lockDurationSeconds + * @returns A string containing the transaction result or error message + */ + @CreateAction({ + name: "createLock", + description: `Create a new veAERO lock on Aerodrome (Base Mainnet) by depositing AERO tokens for a specified duration. Requires: aeroAmount (e.g., '100.5'), lockDurationSeconds (min 604800 for 1 week, max 126144000 for 4 years). The contract rounds duration down to the nearest week. Creates a veAERO NFT for governance.`, + schema: CreateLockSchema, + }) + async createLock( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const ownerAddress = wallet.getAddress(); + if (!ownerAddress || ownerAddress === ZERO_ADDRESS) { + return "Error: Wallet address is not available."; + } + console.log(`[Aerodrome Provider] Executing createLock for ${ownerAddress} with args:`, args); + + try { + const lockDurationSeconds = BigInt(args.lockDurationSeconds); + + if (lockDurationSeconds < MIN_LOCK_DURATION) { + return `Error: Lock duration (${lockDurationSeconds}s) must be at least 1 week (${MIN_LOCK_DURATION}s)`; + } + if (lockDurationSeconds > MAX_LOCK_DURATION) { + return `Error: Lock duration (${lockDurationSeconds}s) cannot exceed 4 years (${MAX_LOCK_DURATION}s)`; + } + + const { decimals, symbol } = await getTokenInfo(wallet, AERO_ADDRESS as Hex); + + const atomicAmount = parseUnits(args.aeroAmount, decimals); + if (atomicAmount <= 0n) { + return "Error: AERO amount must be greater than 0"; + } + + const balance = await wallet.readContract({ + address: AERO_ADDRESS as Hex, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [ownerAddress as Hex], + }); + + if (BigInt(balance) < atomicAmount) { + return `Error: Insufficient AERO balance. You have ${formatUnits(BigInt(balance), decimals)} ${symbol}, but attempted to lock ${args.aeroAmount} ${symbol}.`; + } + + console.log(`[Aerodrome Provider] Approving ${atomicAmount} AERO wei for VotingEscrow...`); + const approvalResult = await approve( + wallet, + AERO_ADDRESS, + VOTING_ESCROW_ADDRESS, + atomicAmount, + ); + + if (approvalResult.startsWith("Error")) { + console.error("[Aerodrome Provider] Approval Error:", approvalResult); + return `Error approving VotingEscrow contract: ${approvalResult}`; + } + + const data = encodeFunctionData({ + abi: VOTING_ESCROW_ABI, + functionName: "create_lock", + args: [atomicAmount, lockDurationSeconds], + }); + + const txHash = await wallet.sendTransaction({ + to: VOTING_ESCROW_ADDRESS as Hex, + data, + }); + const receipt = await wallet.waitForTransactionReceipt(txHash); + + const durationText = formatDuration(Number(lockDurationSeconds)); + + return formatTransactionResult( + "created veAERO lock", + `Locked ${args.aeroAmount} ${symbol} for ${durationText}.`, + txHash, + receipt, + ); + } catch (error: unknown) { + return handleTransactionError("creating veAERO lock", error); + } + } + + /** + * Casts votes on Aerodrome Finance (Base Mainnet) for liquidity pool emissions + * using the specified veAERO NFT. + * + * @param wallet - The EVM wallet provider to use for the transaction + * @param args - The voting parameters + * @returns A formatted string with the transaction result or an error message + */ + @CreateAction({ + name: "vote", + description: `Cast votes on Aerodrome (Base Mainnet) for liquidity pool emissions using a specific veAERO NFT. Requires: veAeroTokenId, poolAddresses (array), and weights (array of positive integers corresponding to pools). Allocates voting power proportionally. Can only vote once per weekly epoch.`, + schema: VoteSchema, + }) + async vote(wallet: EvmWalletProvider, args: z.infer): Promise { + const ownerAddress = wallet.getAddress(); + if (!ownerAddress || ownerAddress === ZERO_ADDRESS) { + return "Error: Wallet address is not available."; + } + console.log(`[Aerodrome Provider] Executing vote with args:`, args); + + try { + const tokenId = BigInt(args.veAeroTokenId); + const poolAddresses = args.poolAddresses.map(addr => getAddress(addr) as Hex); + const weights = args.weights.map(w => BigInt(w)); + + const ownerOf = await wallet + .readContract({ + address: VOTING_ESCROW_ADDRESS as Hex, + abi: VOTING_ESCROW_ABI, + functionName: "ownerOf", + args: [tokenId], + }) + .catch(() => ZERO_ADDRESS); + + if (ownerOf !== ownerAddress) { + return `Error: Wallet ${ownerAddress} does not own veAERO token ID ${args.veAeroTokenId}.`; + } + + const currentEpochStart = getCurrentEpochStart(SECONDS_IN_WEEK); + const lastVotedTs = await wallet.readContract({ + address: VOTER_ADDRESS as Hex, + abi: VOTER_ABI, + functionName: "lastVoted", + args: [tokenId], + }); + + if (lastVotedTs >= currentEpochStart) { + const nextEpochTime = new Date( + Number((currentEpochStart + SECONDS_IN_WEEK) * BigInt(1000)), + ).toISOString(); + return `Error: Already voted with token ID ${tokenId} in the current epoch (since ${new Date( + Number(currentEpochStart * 1000n), + ).toISOString()}). You can vote again after ${nextEpochTime}.`; + } + + const votingPower = await wallet.readContract({ + address: VOTING_ESCROW_ADDRESS as Hex, + abi: VOTING_ESCROW_ABI, + functionName: "balanceOfNFT", + args: [tokenId], + }); + + console.log(`[Aerodrome Provider] veAERO #${tokenId} has voting power: ${votingPower}`); + + console.log("[Aerodrome Provider] Verifying gauges for provided pools..."); + for (let i = 0; i < poolAddresses.length; i++) { + const gauge = await wallet.readContract({ + address: VOTER_ADDRESS as Hex, + abi: VOTER_ABI, + functionName: "gauges", + args: [poolAddresses[i]], + }); + + if (gauge === ZERO_ADDRESS) { + return `Error: Pool ${poolAddresses[i]} does not have a registered gauge. Only pools with gauges can receive votes.`; + } + } + + const data = encodeFunctionData({ + abi: VOTER_ABI, + functionName: "vote", + args: [tokenId, poolAddresses, weights], + }); + + const txHash = await wallet.sendTransaction({ + to: VOTER_ADDRESS as Hex, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + let voteAllocation = ""; + const totalWeight = weights.reduce((sum, w) => sum + w, BigInt(0)); + for (let i = 0; i < poolAddresses.length; i++) { + const percentage = + totalWeight > 0 + ? (Number((weights[i] * BigInt(10000)) / totalWeight) / 100).toFixed(2) + : "N/A"; + voteAllocation += `\n - Pool ${poolAddresses[i]}: ${weights[i]} weight (~${percentage}%)`; + } + + return formatTransactionResult( + "cast votes", + `Voted with veAERO NFT #${args.veAeroTokenId} (voting power: ${votingPower}).${voteAllocation}`, + txHash, + receipt, + ); + } catch (error: unknown) { + return handleTransactionError("casting votes", error); + } + } + + /** + * Swaps an exact amount of one token for a minimum amount of another token + * through the Aerodrome Router on Base Mainnet. + * + * @param wallet - The EVM wallet provider to use for the transaction + * @param args - The swap parameters + * @returns A formatted string with the transaction result or an error message + */ + @CreateAction({ + name: "swapExactTokensForTokens", + description: `Swap an exact amount of an input token for a minimum amount of an output token on Aerodrome (Base Mainnet). Requires: tokenInAddress, tokenOutAddress, amountIn (e.g., '1.5'), amountOutMin (atomic units/wei), to (recipient address), deadline (Unix timestamp), and optionally useStablePool (boolean, default false for volatile). Note: Assumes a direct route exists.`, + schema: SwapExactTokensSchema, + }) + async swapExactTokens( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const ownerAddress = wallet.getAddress(); + if (!ownerAddress || ownerAddress === ZERO_ADDRESS) { + return "Error: Wallet address is not available."; + } + console.log(`[Aerodrome Provider] Executing swapExactTokens with args:`, args); + + try { + const tokenIn = getAddress(args.tokenInAddress); + const tokenOut = getAddress(args.tokenOutAddress); + const recipient = getAddress(args.to); + const deadline = BigInt(args.deadline); + const useStable = args.useStablePool ?? false; + + const currentTimestamp = BigInt(Math.floor(Date.now() / 1000)); + if (deadline <= currentTimestamp) { + return `Error: Deadline (${args.deadline}) has already passed (Current time: ${currentTimestamp}). Please provide a future timestamp.`; + } + + const [tokenInInfo, tokenOutInfo] = await Promise.all([ + getTokenInfo(wallet, tokenIn as Hex), + getTokenInfo(wallet, tokenOut as Hex), + ]); + + const atomicAmountIn = parseUnits(args.amountIn, tokenInInfo.decimals); + if (atomicAmountIn <= 0n) { + return "Error: Swap amount must be greater than 0"; + } + + const balance = await wallet.readContract({ + address: tokenIn as Hex, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [ownerAddress as Hex], + }); + + if (BigInt(balance) < atomicAmountIn) { + return `Error: Insufficient ${tokenInInfo.symbol} balance. You have ${formatUnits(BigInt(balance), tokenInInfo.decimals)} ${tokenInInfo.symbol}, but attempted to swap ${args.amountIn} ${tokenInInfo.symbol}.`; + } + + const route = [ + { + from: tokenIn as Hex, + to: tokenOut as Hex, + stable: useStable, + factory: ZERO_ADDRESS as Hex, + }, + ]; + + let estimatedOutput; + try { + const amountsOut = await wallet.readContract({ + address: ROUTER_ADDRESS as Hex, + abi: ROUTER_ABI, + functionName: "getAmountsOut", + args: [atomicAmountIn, route], + }); + + estimatedOutput = amountsOut[1]; + + const amountOutMin = BigInt(args.amountOutMin); + if (estimatedOutput > 0n && amountOutMin < (estimatedOutput * 95n) / 100n) { + console.log( + `[Aerodrome Provider] Warning: amountOutMin (${amountOutMin}) is more than 5% lower than estimated output (${estimatedOutput}). This allows for high slippage.`, + ); + } + + if (amountOutMin > estimatedOutput) { + console.log( + `[Aerodrome Provider] Warning: amountOutMin (${amountOutMin}) is higher than estimated output (${estimatedOutput}). Transaction is likely to fail.`, + ); + } + } catch (error) { + console.error("[Aerodrome Provider] Error getting price quote:", error); + console.log("[Aerodrome Provider] Continuing with swap attempt without quote..."); + } + + console.log( + `[Aerodrome Provider] Approving ${atomicAmountIn} ${tokenInInfo.symbol} for Router...`, + ); + const approvalResult = await approve(wallet, tokenIn, ROUTER_ADDRESS, atomicAmountIn); + if (approvalResult.startsWith("Error")) { + return `Error approving Router contract: ${approvalResult}`; + } + + const data = encodeFunctionData({ + abi: ROUTER_ABI, + functionName: "swapExactTokensForTokens", + args: [atomicAmountIn, BigInt(args.amountOutMin), route, recipient as Hex, deadline], + }); + + const txHash = await wallet.sendTransaction({ + to: ROUTER_ADDRESS as Hex, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + let outputDetails = `${args.amountIn} ${tokenInInfo.symbol} for at least ${args.amountOutMin} wei of ${tokenOutInfo.symbol}`; + if (estimatedOutput) { + outputDetails += ` (estimated output: ${estimatedOutput} wei)`; + } + + return formatTransactionResult( + "completed swap", + `Swapped ${outputDetails}. Recipient: ${recipient}`, + txHash, + receipt, + ); + } catch (error: unknown) { + return handleTransactionError("swapping tokens", error); + } + } + + /** + * Checks if the action provider supports the given network. + * + * @param network - The network to check. + * @returns True if the action provider supports the network, false otherwise. + */ + supportsNetwork(network: Network): boolean { + return ( + network.protocolFamily === "evm" && + !!network.networkId && + SUPPORTED_NETWORKS.includes(network.networkId) + ); + } + + /** + * Private method to get the start timestamp for the current voting epoch + * Used for testing. + * + * @returns The timestamp (seconds since epoch) of the current epoch start + */ + private _getCurrentEpochStart(): bigint { + return getCurrentEpochStart(SECONDS_IN_WEEK); + } +} + +export const aerodromeActionProvider = (): AerodromeActionProvider => new AerodromeActionProvider(); diff --git a/typescript/agentkit/src/action-providers/aerodrome/constants.ts b/typescript/agentkit/src/action-providers/aerodrome/constants.ts new file mode 100644 index 000000000..9389e1b63 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/constants.ts @@ -0,0 +1,331 @@ +import { Abi } from "viem"; + +export const AERO_ADDRESS = "0x940181a94A35A4569E4529A3CDfB74e38FD98631"; +export const VOTING_ESCROW_ADDRESS = "0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4"; +export const VOTER_ADDRESS = "0x16613524e02ad97eDfeF371bC883F2F5d6C480A5"; +export const ROUTER_ADDRESS = "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43"; + +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +// Constants for veAERO lock durations +export const WEEK_SECONDS = 604800; +export const MIN_LOCK_DURATION = BigInt(WEEK_SECONDS); // 1 week +export const MAX_LOCK_DURATION = BigInt(126144000); // 4 years + +// --- ABIs --- + +export const AERO_ABI = [ + { + constant: true, + inputs: [], + name: "name", + outputs: [{ name: "", type: "string" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: false, + inputs: [ + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + ], + name: "approve", + outputs: [{ name: "", type: "bool" }], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, + { + constant: true, + inputs: [], + name: "totalSupply", + outputs: [{ name: "", type: "uint256" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [], + name: "decimals", + outputs: [{ name: "", type: "uint8" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [{ name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "balance", type: "uint256" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + payable: false, + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; + +export const VOTING_ESCROW_ABI = [ + { + inputs: [ + { internalType: "uint256", name: "_value", type: "uint256" }, + { internalType: "uint256", name: "_lockDuration", type: "uint256" }, + ], + name: "create_lock", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + { internalType: "uint256", name: "_value", type: "uint256" }, + ], + name: "increase_amount", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + { internalType: "uint256", name: "_lockDuration", type: "uint256" }, + ], + name: "increase_unlock_time", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_tokenId", type: "uint256" }], + name: "withdraw", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + + { + inputs: [{ internalType: "uint256", name: "_tokenId", type: "uint256" }], + name: "locked", + outputs: [ + { + components: [ + { internalType: "int128", name: "amount", type: "int128" }, + { internalType: "uint256", name: "end", type: "uint256" }, + { internalType: "bool", name: "isPermanent", type: "bool" }, + ], + internalType: "struct IVotingEscrow.LockedBalance", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_tokenId", type: "uint256" }], + name: "balanceOfNFT", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "_owner", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_tokenId", type: "uint256" }], + name: "ownerOf", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; + +export const VOTER_ABI = [ + { + inputs: [ + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + { internalType: "address[]", name: "_poolVote", type: "address[]" }, + { internalType: "uint256[]", name: "_weights", type: "uint256[]" }, + ], + name: "vote", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_tokenId", type: "uint256" }], + name: "lastVoted", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "_pool", type: "address" }], + name: "gauges", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "pools", + outputs: [{ internalType: "address[]", name: "", type: "address[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "arg0", type: "uint256" }], + name: "poolVote", + outputs: [{ internalType: "address[]", name: "", type: "address[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "arg0", type: "uint256" }, + { internalType: "address", name: "arg1", type: "address" }, + ], + name: "votes", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; + +export const ROUTER_ABI = [ + { + inputs: [ + { internalType: "uint256", name: "amountIn", type: "uint256" }, + { internalType: "uint256", name: "amountOutMin", type: "uint256" }, + { + components: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "address", name: "factory", type: "address" }, + ], + internalType: "struct IRouter.Route[]", + name: "routes", + type: "tuple[]", + }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + ], + name: "swapExactTokensForTokens", + outputs: [{ internalType: "uint256[]", name: "amounts", type: "uint256[]" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "amountIn", type: "uint256" }, + { + components: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "address", name: "factory", type: "address" }, + ], + internalType: "struct IRouter.Route[]", + name: "routes", + type: "tuple[]", + }, + ], + name: "getAmountsOut", + outputs: [{ internalType: "uint256[]", name: "amounts", type: "uint256[]" }], + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; + +export const ERC20_ABI = [ + { + constant: true, + inputs: [], + name: "name", + outputs: [{ name: "", type: "string" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [], + name: "symbol", + outputs: [{ name: "", type: "string" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [], + name: "decimals", + outputs: [{ name: "", type: "uint8" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [{ name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ name: "balance", type: "uint256" }], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: false, + inputs: [ + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + ], + name: "transfer", + outputs: [{ name: "", type: "bool" }], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, + { + constant: false, + inputs: [ + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + ], + name: "approve", + outputs: [{ name: "", type: "bool" }], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, + { + constant: true, + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + payable: false, + stateMutability: "view", + type: "function", + }, +] as const satisfies Abi; diff --git a/typescript/agentkit/src/action-providers/aerodrome/index.ts b/typescript/agentkit/src/action-providers/aerodrome/index.ts new file mode 100644 index 000000000..5d148a45a --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/index.ts @@ -0,0 +1,15 @@ +/** + * Aerodrome Action Provider Module Index + * + * Exports the Aerodrome Action Provider for use with Coinbase AgentKit + * Features include: + * - veAERO management (create locks) + * - Voting for gauges + * - V2 swapping using stable and volatile pools + */ + +export { AerodromeActionProvider, aerodromeActionProvider } from "./aerodromeActionProvider"; + +export * from "./schemas"; + +export * from "./constants"; diff --git a/typescript/agentkit/src/action-providers/aerodrome/schemas.ts b/typescript/agentkit/src/action-providers/aerodrome/schemas.ts new file mode 100644 index 000000000..2d34760ed --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/schemas.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +const addressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format"); +const positiveDecimalStringSchema = z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid positive decimal value") + .refine(val => parseFloat(val) > 0, { message: "Amount must be greater than zero" }); +const positiveIntegerStringSchema = z + .string() + .regex(/^\d+$/, "Must be a valid positive integer") + .refine(val => BigInt(val) > 0, { message: "Value must be greater than zero" }); + +export const CreateLockSchema = z + .object({ + aeroAmount: positiveDecimalStringSchema.describe( + "Amount of AERO tokens to lock (e.g., '100.5').", + ), + lockDurationSeconds: z + .string() + .regex(/^\d+$/, "Must be a valid positive integer") + .describe( + "Lock duration in seconds. Minimum 604800 (1 week), maximum 126144000 (4 years). The contract rounds duration down to the nearest week boundary.", + ), + }) + .describe("Input schema for creating a new veAERO lock on Aerodrome (Base Mainnet)."); + +export const VoteSchema = z + .object({ + veAeroTokenId: positiveIntegerStringSchema.describe( + "The token ID of the veAERO NFT to use for voting.", + ), + poolAddresses: z + .array(addressSchema) + .min(1, "Must provide at least one pool address") + .describe("An array of Aerodrome pool addresses to vote for (must have associated gauges)."), + weights: z + .array(positiveIntegerStringSchema) + .min(1, "Must provide at least one weight") + .describe( + "An array of positive integer voting weights corresponding to the poolAddresses array. Your veAERO's voting power will be distributed proportionally based on these weights (e.g., [100, 50] means the first pool gets 2/3rds of the vote, the second gets 1/3rd).", + ), + }) + .refine(data => data.poolAddresses.length === data.weights.length, { + message: "Pool addresses and weights arrays must have the same number of elements.", + path: ["poolAddresses", "weights"], + }) + .describe("Input schema for casting votes with a veAERO NFT on Aerodrome (Base Mainnet)."); + +export const SwapExactTokensSchema = z + .object({ + amountIn: positiveDecimalStringSchema.describe( + "The exact amount of the input token to swap (e.g., '1.5').", + ), + amountOutMin: z + .string() + .regex(/^\d+$/, "Must be a valid non-negative integer") + .describe( + "The minimum amount of output token expected (in atomic units, e.g., wei) to prevent excessive slippage.", + ), + tokenInAddress: addressSchema.describe("Address of the token being swapped FROM."), + tokenOutAddress: addressSchema.describe("Address of the token being swapped TO."), + to: addressSchema.describe("Address to receive the output tokens."), + deadline: positiveIntegerStringSchema.describe( + "Unix timestamp deadline (seconds since epoch) for the transaction to succeed. Must be in the future.", + ), + useStablePool: z + .boolean() + .optional() + .default(false) + .describe( + "Set to true to use the stable pool for the swap, false (default) to use the volatile pool. Ensure the chosen pool type exists for the token pair.", + ), + }) + .describe( + "Input schema for swapping an exact amount of input tokens for a minimum amount of output tokens on Aerodrome (Base Mainnet). Assumes a direct route between the two tokens.", + ); diff --git a/typescript/agentkit/src/action-providers/aerodrome/utils.ts b/typescript/agentkit/src/action-providers/aerodrome/utils.ts new file mode 100644 index 000000000..a4ac4dc02 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/utils.ts @@ -0,0 +1,102 @@ +import { Hex } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { ERC20_ABI } from "./constants"; + +/** + * Get token information (decimals and symbol) + * + * @param wallet - The EVM wallet provider + * @param tokenAddress - The token address + * @returns Object containing token decimals and symbol + */ +export async function getTokenInfo( + wallet: EvmWalletProvider, + tokenAddress: Hex, +): Promise<{ decimals: number; symbol: string }> { + const [decimals, symbol] = await Promise.all([ + wallet.readContract({ address: tokenAddress, abi: ERC20_ABI, functionName: "decimals" }), + wallet.readContract({ address: tokenAddress, abi: ERC20_ABI, functionName: "symbol" }), + ]); + return { decimals, symbol }; +} + +/** + * Format transaction results consistently + * + * @param action - The action performed (e.g., "created veAERO lock") + * @param details - Details of the action + * @param txHash - Transaction hash + * @param receipt - Transaction receipt + * @param receipt.gasUsed - Amount of gas used for the transaction + * @returns Formatted transaction result string + */ +export function formatTransactionResult( + action: string, + details: string, + txHash: Hex, + receipt: { gasUsed?: bigint | string }, +): string { + return `Successfully ${action}. ${details}\nTransaction: ${txHash}. Gas used: ${receipt.gasUsed}`; +} + +/** + * Handle common transaction errors + * + * @param action - The action that failed (e.g., "creating veAERO lock") + * @param error - The error that occurred + * @returns User-friendly error message + */ +export function handleTransactionError(action: string, error: unknown): string { + console.error(`[Aerodrome Provider] Error ${action}:`, error); + + // Check for common error patterns + if (error instanceof Error) { + const errorMsg = error.message; + if (errorMsg.includes("NotApprovedOrOwner")) { + return `Error ${action}: You don't own or are not approved for the specified token.`; + } + if (errorMsg.includes("INSUFFICIENT_OUTPUT_AMOUNT")) { + return `Error ${action}: Insufficient output amount. The slippage tolerance may be too strict for current market conditions.`; + } + if (errorMsg.includes("INSUFFICIENT_LIQUIDITY")) { + return `Error ${action}: Insufficient liquidity for this trade.`; + } + if (errorMsg.includes("Expired") || errorMsg.includes("deadline")) { + return `Error ${action}: Transaction deadline passed during execution.`; + } + // Add more specific error patterns as needed + return `Error ${action}: ${errorMsg}`; + } + + return `Error ${action}: ${String(error)}`; +} + +/** + * Format durations in a human-readable way + * + * @param seconds - Duration in seconds + * @returns Human-readable duration string + */ +export function formatDuration(seconds: number): string { + const weeks = Math.floor(seconds / 604800); + const days = Math.floor((seconds % 604800) / 86400); + + if (weeks === 0) { + return `${days} days`; + } else if (days === 0) { + return `${weeks} week${weeks !== 1 ? "s" : ""}`; + } else { + return `${weeks} week${weeks !== 1 ? "s" : ""} and ${days} day${days !== 1 ? "s" : ""}`; + } +} + +/** + * Get the start timestamp for the current voting epoch + * + * @param secondsInWeek - Duration of a week in seconds + * @returns The timestamp (seconds since epoch) of the current epoch start + */ +export function getCurrentEpochStart(secondsInWeek: bigint): bigint { + const now = BigInt(Math.floor(Date.now() / 1000)); + return now - (now % secondsInWeek); +} diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 7f2b0233a..a9bfea1a8 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -40,3 +40,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; +export * from "./aerodrome";