From 8b47fc618fb0e45957ff6492d5ecace3c4241ad1 Mon Sep 17 00:00:00 2001 From: Pranav Vinodan Date: Sun, 13 Apr 2025 05:38:37 +0530 Subject: [PATCH 1/3] feat: Aerodome Action Provider --- .../src/action-providers/aerodome/README.md | 136 +++++ .../aerodome/aerodomeActionProvider.test.ts | 495 ++++++++++++++++++ .../aerodome/aerodomeActionProvider.ts | 346 ++++++++++++ .../action-providers/aerodome/constants.ts | 324 ++++++++++++ .../src/action-providers/aerodome/index.ts | 7 + .../src/action-providers/aerodome/schemas.ts | 76 +++ .../agentkit/src/action-providers/index.ts | 1 + 7 files changed, 1385 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/aerodome/README.md create mode 100644 typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/aerodome/constants.ts create mode 100644 typescript/agentkit/src/action-providers/aerodome/index.ts create mode 100644 typescript/agentkit/src/action-providers/aerodome/schemas.ts diff --git a/typescript/agentkit/src/action-providers/aerodome/README.md b/typescript/agentkit/src/action-providers/aerodome/README.md new file mode 100644 index 000000000..ea23a83ee --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodome/README.md @@ -0,0 +1,136 @@ +# Aerodrome Action Provider + +This directory contains the **AerodromeActionProvider** implementation, which enables AI agents to interact with [Aerodrome Finance](https://aerodrome.finance/) on Base Mainnet. + +## Overview + +The AerodromeActionProvider extends the ActionProvider class and integrates with EvmWalletProvider for blockchain interactions. It enables programmatic access to core Aerodrome DeFi operations including: + +- Creating veAERO governance locks +- Voting for liquidity pool emissions with veAERO NFTs +- Swapping tokens using Aerodrome's pools + +## Directory Structure + +``` +aerodome/ +├── aerodomeActionProvider.ts # Main provider implementation +├── aerodomeActionProvider.test.ts # Provider test suite +├── constants.ts # Contract addresses and ABIs +├── schemas.ts # Action schemas and type validation +├── index.ts # Package exports +└── README.md # Documentation (this file) +``` + +## Network Support + +This provider **only** supports Base Mainnet (`base-mainnet`). All contract interactions are configured for this specific network. + +## Contract Addresses + +The provider interacts with the following Aerodrome contract addresses on Base Mainnet: + +- AERO Token: `0x940181a94A35A4569E4529A3CDfB74e38FD98631` +- Voting Escrow (veAERO): `0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4` +- Voter: `0x16613524e02ad97eDfeF371bC883F2F5d6C480A5` +- Router: `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43` + +## Actions + +### Create Lock +- `createLock`: Creates a new veAERO lock by depositing AERO tokens for a specified duration + - **Purpose**: Generate a veAERO NFT that provides governance and voting power + - **Input**: + - `aeroAmount` (string): Amount of AERO tokens to lock (e.g., '100.5') + - `lockDurationSeconds` (string): Lock duration in seconds (min: 604800 for 1 week, max: 126144000 for 4 years) + - **Output**: String describing the transaction result or error + - **Example**: + ```typescript + const result = await provider.createLock(walletProvider, { + aeroAmount: "100.5", + lockDurationSeconds: "2592000" // 30 days + }); + ``` + - **Notes**: The contract automatically rounds lock durations down to the nearest week boundary + +### Vote +- `vote`: Casts votes for liquidity pool emissions using a veAERO NFT + - **Purpose**: Allocate veAERO voting power to direct AERO emissions to specific pools + - **Input**: + - `veAeroTokenId` (string): The ID of the veAERO NFT to vote with + - `poolAddresses` (string[]): Array of Aerodrome pool addresses to vote for + - `weights` (string[]): Array of positive integer voting weights corresponding to the pools + - **Output**: String describing the transaction result or error + - **Example**: + ```typescript + const result = await provider.vote(walletProvider, { + veAeroTokenId: "1", + poolAddresses: [ + "0xaaaa567890123456789012345678901234567890", + "0xbbbb567890123456789012345678901234567890" + ], + weights: ["70", "30"] // 70% to first pool, 30% to second pool + }); + ``` + - **Notes**: Voting is restricted to once per weekly epoch per veAERO token + +### Swap Exact Tokens +- `swapExactTokens`: Swaps an exact amount of input tokens for a minimum amount of output tokens + - **Purpose**: Execute token swaps through Aerodrome's liquidity pools + - **Input**: + - `amountIn` (string): The exact amount of input token to swap (e.g., '1.5') + - `amountOutMin` (string): Minimum amount of output tokens expected (in atomic units) + - `tokenInAddress` (string): Address of the token being swapped from + - `tokenOutAddress` (string): Address of the token being swapped to + - `to` (string): Address to receive the output tokens + - `deadline` (string): Unix timestamp deadline for the transaction + - `useStablePool` (boolean, optional): Whether to use stable pool (default: false) + - **Output**: String describing the transaction result or error + - **Example**: + ```typescript + const result = await provider.swapExactTokens(walletProvider, { + amountIn: "10", + amountOutMin: "9500000000", + tokenInAddress: "0xcccc567890123456789012345678901234567890", + tokenOutAddress: "0xdddd567890123456789012345678901234567890", + to: "0x1234567890123456789012345678901234567890", + deadline: "1714675200", // April 2, 2024 + useStablePool: false + }); + ``` + - **Notes**: Supports both volatile and stable pools depending on the token pair + +## Implementation Details + +### Error Handling +All actions include comprehensive error handling for common failure scenarios: +- Token approval failures +- Insufficient balance/allowance +- Pool liquidity constraints +- Network validation + +## Adding New Actions + +To add new Aerodrome actions: + +1. Define your action schema in `schemas.ts` +2. Implement the action in `aerodomeActionProvider.ts` +3. Add tests in `aerodomeActionProvider.test.ts` + +## Usage Example + +```typescript +import { AerodromeActionProvider } from "./action-providers/aerodome"; +import { ViemWalletProvider } from "./wallet-providers"; + +// Initialize providers +const aerodrome = new AerodromeActionProvider(); +const wallet = new ViemWalletProvider(/* wallet config */); + +// Create a veAERO lock +const lockResult = await aerodrome.createLock(wallet, { + aeroAmount: "100", + lockDurationSeconds: "604800" // 1 week +}); + +console.log(lockResult); diff --git a/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.test.ts b/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.test.ts new file mode 100644 index 000000000..ac545df7d --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.test.ts @@ -0,0 +1,495 @@ +/** + * AerodromeActionProvider Tests + */ + +import { encodeFunctionData, parseUnits, ReadContractParameters, Abi } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { approve } from "../../utils"; +import { AerodromeActionProvider } from "./aerodomeActionProvider"; +import { Network } from "../../network"; +import { + ERC20_ABI, + VOTING_ESCROW_ABI, + VOTER_ABI, + ROUTER_ABI, + AERO_ADDRESS, + VOTING_ESCROW_ADDRESS, + VOTER_ADDRESS, + ROUTER_ADDRESS, +} from "./constants"; + +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 }; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +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 => { + if (params.functionName === "decimals") return MOCK_DECIMALS; + if (params.functionName === "symbol") { + if (params.address.toLowerCase() === MOCK_TOKEN_IN.toLowerCase()) return "TOKEN_IN"; + if (params.address.toLowerCase() === MOCK_TOKEN_OUT.toLowerCase()) return "TOKEN_OUT"; + return "AERO"; + } + if (params.functionName === "lastVoted") return 0n; + if (params.functionName === "gauges") return MOCK_POOL_ADDRESS_1; + return 0; + }), + } as unknown as jest.Mocked; + + mockApprove.mockResolvedValue("Approval successful"); + + 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(mockWallet.readContract).toHaveBeenCalledWith({ + address: AERO_ADDRESS as `0x${string}`, + abi: ERC20_ABI, + functionName: "decimals", + }); + + 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 with ${args.aeroAmount} AERO`); + 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: Transaction failed"); + }); + }); + + 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 voted with veAERO NFT #${args.veAeroTokenId}`); + expect(response.toLowerCase()).toContain(MOCK_POOL_ADDRESS_1.toLowerCase()); + expect(response.toLowerCase()).toContain(MOCK_POOL_ADDRESS_2.toLowerCase()); + expect(response).toContain("66.66%"); + expect(response).toContain("33.33%"); + }); + + 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 => { + if (params.functionName === "lastVoted") return 1681315200n; + if (params.functionName === "gauges") return MOCK_POOL_ADDRESS_1; + return 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 => { + if (params.functionName === "gauges") return ZERO_ADDRESS; + return 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: Transaction failed"); + }); + + it("should correctly handle NotApprovedOrOwner errors", async () => { + const args = { + veAeroTokenId: "1", + poolAddresses: [MOCK_POOL_ADDRESS_1], + weights: ["100"], + }; + + const notApprovedError = new Error("execution reverted: Not approved or owner"); + notApprovedError.message = "execution reverted: NotApprovedOrOwner"; + mockWallet.sendTransaction.mockRejectedValue(notApprovedError); + + const response = await provider.vote(mockWallet, args); + expect(response).toContain("Error casting votes: Wallet"); + 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); + + const decimalsCall = mockWallet.readContract.mock.calls.find( + call => + call[0]?.functionName === "decimals" && + call[0]?.address?.toLowerCase() === MOCK_TOKEN_IN.toLowerCase(), + ); + expect(decimalsCall).toBeTruthy(); + + 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 initiated swap of ${args.amountIn} TOKEN_IN`); + expect(response).toContain(`for at least ${args.amountOutMin} wei of TOKEN_OUT`); + }); + + 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.mockRejectedValue(new Error("Transaction failed")); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error swapping tokens: Transaction failed"); + }); + + 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, + }; + + 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: Insufficient output amount"); + 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, + }; + + 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: Insufficient liquidity for this trade pair and amount", + ); + }); + + 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, + }; + + const expiredError = new Error("execution reverted: Expired"); + mockWallet.sendTransaction.mockRejectedValue(expiredError); + + const response = await provider.swapExactTokens(mockWallet, args); + expect(response).toContain("Error swapping tokens: Transaction deadline"); + expect(response).toContain("likely passed during execution"); + }); + }); + + describe("_getCurrentEpochStart", () => { + it("should correctly calculate epoch start time", () => { + const epochStart = provider["_getCurrentEpochStart"](); + expect(epochStart).toBe(1680739200n); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts b/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts new file mode 100644 index 000000000..37bd3dc52 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts @@ -0,0 +1,346 @@ +import { z } from "zod"; +import { encodeFunctionData, Hex, parseUnits, getAddress } 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, +} from "./constants"; +import { CreateLockSchema, VoteSchema, SwapExactTokensSchema } from "./schemas"; +import { Network } from "../../network"; + +export const SUPPORTED_NETWORKS = ["base-mainnet"]; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const SECONDS_IN_WEEK = BigInt(604800); +const MIN_LOCK_DURATION = SECONDS_IN_WEEK; // 1 week +const MAX_LOCK_DURATION = BigInt(126144000); // 4 years + +/** + * 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 = await wallet.readContract({ + address: AERO_ADDRESS as Hex, + abi: ERC20_ABI, + functionName: "decimals", + }); + + const atomicAmount = parseUnits(args.aeroAmount, decimals); + if (atomicAmount <= 0n) { + return "Error: AERO amount must be greater than 0"; + } + + console.log(` + [Aerodrome Provider] Approving ${atomicAmount} AERO wei for VotingEscrow (${VOTING_ESCROW_ADDRESS})... + `); + 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}`; + } + console.log("[Aerodrome Provider] Approval successful or already sufficient."); + + console.log(`[Aerodrome Provider] Encoding create_lock transaction...`); + const data = encodeFunctionData({ + abi: VOTING_ESCROW_ABI, + functionName: "create_lock", + args: [atomicAmount, lockDurationSeconds], + }); + + console.log(` + [Aerodrome Provider] Sending create_lock transaction to ${VOTING_ESCROW_ADDRESS}... + `); + const txHash = await wallet.sendTransaction({ + to: VOTING_ESCROW_ADDRESS as Hex, + data, + }); + console.log(`[Aerodrome Provider] Transaction sent: ${txHash}. Waiting for receipt...`); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + console.log(`[Aerodrome Provider] Transaction confirmed. Gas used: ${receipt.gasUsed}`); + + return `Successfully created veAERO lock with ${args.aeroAmount} AERO for ${args.lockDurationSeconds} seconds. Transaction: ${txHash}. Gas used: ${receipt.gasUsed}`; + } catch (error: unknown) { + console.error("[Aerodrome Provider] Error creating veAERO lock:", error); + return `Error creating veAERO lock: ${error instanceof Error ? error.message : String(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 for ${ownerAddress} 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 currentEpochStart = this._getCurrentEpochStart(); + console.log(`[Aerodrome Provider] Current epoch start timestamp: ${currentEpochStart}`); + const lastVotedTs = await wallet.readContract({ + address: VOTER_ADDRESS as Hex, + abi: VOTER_ABI, + functionName: "lastVoted", + args: [tokenId], + }); + console.log(`[Aerodrome Provider] Last voted timestamp for token ${tokenId}: ${lastVotedTs}`); + + 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}.`; + } + + console.log("[Aerodrome Provider] Verifying gauges for provided pools..."); + for (const poolAddress of poolAddresses) { + const gauge = await wallet.readContract({ + address: VOTER_ADDRESS as Hex, + abi: VOTER_ABI, + functionName: "gauges", + args: [poolAddress], + }); + if (gauge === ZERO_ADDRESS) { + return `Error: Pool ${poolAddress} does not have a registered gauge. Only pools with gauges can receive votes.`; + } + } + console.log("[Aerodrome Provider] All specified pools have valid gauges."); + + console.log(`[Aerodrome Provider] Encoding vote transaction...`); + const data = encodeFunctionData({ + abi: VOTER_ABI, + functionName: "vote", + args: [tokenId, poolAddresses, weights], + }); + + console.log(`[Aerodrome Provider] Sending vote transaction to ${VOTER_ADDRESS}...`); + const txHash = await wallet.sendTransaction({ + to: VOTER_ADDRESS as Hex, + data, + }); + console.log(`[Aerodrome Provider] Transaction sent: ${txHash}. Waiting for receipt...`); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + console.log(`[Aerodrome Provider] Transaction confirmed. Gas used: ${receipt.gasUsed}`); + + 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}%)`; + } + const responseMessage = `Successfully voted with veAERO NFT #${args.veAeroTokenId}. Vote allocation: ${voteAllocation}\nTransaction: ${txHash}. Gas used: ${receipt.gasUsed}`; + return responseMessage; + } catch (error: unknown) { + console.error("[Aerodrome Provider] Error casting votes:", error); + if (error instanceof Error && error.message?.includes("NotApprovedOrOwner")) { + return `Error casting votes: Wallet ${ownerAddress} does not own or is not approved for veAERO token ID ${args.veAeroTokenId}.`; + } + return `Error casting votes: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Swaps an exact amount of input tokens for a minimum amount of output tokens on Aerodrome (Base Mainnet). + * + * @param wallet - The EVM wallet provider used to execute the transaction + * @param args - Parameters for the swap as defined in SwapExactTokensSchema + * @returns A promise resolving to the transaction result as a string + */ + @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 for ${ownerAddress} with args: + ${JSON.stringify(args, null, 2)} + `); + + 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 [decimals, tokenInSymbol, tokenOutSymbol] = await Promise.all([ + wallet.readContract({ address: tokenIn, abi: ERC20_ABI, functionName: "decimals" }), + wallet.readContract({ address: tokenIn, abi: ERC20_ABI, functionName: "symbol" }), + wallet.readContract({ address: tokenOut, abi: ERC20_ABI, functionName: "symbol" }), + ]); + + const atomicAmountIn = parseUnits(args.amountIn, decimals); + if (atomicAmountIn <= 0n) { + return "Error: Swap amount must be greater than 0"; + } + const amountOutMin = BigInt(args.amountOutMin); + + console.log(` + [Aerodrome Provider] Approving ${atomicAmountIn} ${tokenInSymbol} wei for Router (${ROUTER_ADDRESS})... + `); + const approvalResult = await approve(wallet, tokenIn, ROUTER_ADDRESS, atomicAmountIn); + + if (approvalResult.startsWith("Error")) { + console.error("[Aerodrome Provider] Approval Error:", approvalResult); + return `Error approving Router contract: ${approvalResult}`; + } + console.log("[Aerodrome Provider] Approval successful or already sufficient."); + + const route = [ + { + from: tokenIn, + to: tokenOut, + stable: useStable, + factory: ZERO_ADDRESS as Hex, + }, + ]; + console.log(` + [Aerodrome Provider] Using route: ${tokenInSymbol} -> ${tokenOutSymbol} (Stable: ${useStable}) + `); + + console.log(`[Aerodrome Provider] Encoding swapExactTokensForTokens transaction...`); + const data = encodeFunctionData({ + abi: ROUTER_ABI, + functionName: "swapExactTokensForTokens", + args: [atomicAmountIn, amountOutMin, route, recipient, deadline], + }); + + console.log(`[Aerodrome Provider] Sending swap transaction to ${ROUTER_ADDRESS}...`); + const txHash = await wallet.sendTransaction({ + to: ROUTER_ADDRESS as Hex, + data, + }); + console.log(`[Aerodrome Provider] Transaction sent: ${txHash}. Waiting for receipt...`); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + console.log(`[Aerodrome Provider] Transaction confirmed. Gas used: ${receipt.gasUsed}`); + + return `Successfully initiated swap of ${args.amountIn} ${tokenInSymbol} for at least ${args.amountOutMin} wei of ${tokenOutSymbol}. Recipient: ${recipient}\nTransaction: ${txHash}. Gas used: ${receipt.gasUsed}`; + } catch (error: unknown) { + console.error("[Aerodrome Provider] Error swapping tokens:", error); + if (error instanceof Error && error.message?.includes("INSUFFICIENT_OUTPUT_AMOUNT")) { + return "Error swapping tokens: Insufficient output amount. Slippage may be too high or amountOutMin too strict for current market conditions."; + } + if (error instanceof Error && error.message?.includes("INSUFFICIENT_LIQUIDITY")) { + return "Error swapping tokens: Insufficient liquidity for this trade pair and amount."; + } + if (error instanceof Error && error.message?.includes("Expired")) { + return `Error swapping tokens: Transaction deadline (${args.deadline}) likely passed during execution.`; + } + return `Error swapping tokens: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Checks if the Aerodrome action provider supports the given network. + * Currently supports Base Mainnet ONLY. + * + * @param network - The network to check for support + * @returns True if the network is supported, false otherwise + */ + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && SUPPORTED_NETWORKS.includes(network.networkId!); + + /** + * Helper to get the start of the current epoch. + * + * @returns The timestamp (in seconds) of the start of the current week's epoch as a bigint + */ + private _getCurrentEpochStart(): bigint { + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + // Unix epoch (Jan 1 1970) was a Thursday, so simple division works. + const epochStart = (nowSeconds / SECONDS_IN_WEEK) * SECONDS_IN_WEEK; + return epochStart; + } +} + +export const aerodromeActionProvider = () => new AerodromeActionProvider(); diff --git a/typescript/agentkit/src/action-providers/aerodome/constants.ts b/typescript/agentkit/src/action-providers/aerodome/constants.ts new file mode 100644 index 000000000..4942e91f9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodome/constants.ts @@ -0,0 +1,324 @@ +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"; + +// --- 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/aerodome/index.ts b/typescript/agentkit/src/action-providers/aerodome/index.ts new file mode 100644 index 000000000..fd6b70ca4 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodome/index.ts @@ -0,0 +1,7 @@ +/** + * Aerodrome Action Provider Module Index + */ + +export { AerodromeActionProvider } from "./aerodomeActionProvider"; +export * from "./schemas"; +export * from "./constants"; diff --git a/typescript/agentkit/src/action-providers/aerodome/schemas.ts b/typescript/agentkit/src/action-providers/aerodome/schemas.ts new file mode 100644 index 000000000..2d34760ed --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodome/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/index.ts b/typescript/agentkit/src/action-providers/index.ts index 7f2b0233a..0bf0a8dca 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 "./aerodome"; From 1c87504e638d1952a59ae2c04a433265a64a9512 Mon Sep 17 00:00:00 2001 From: Pranav Vinodan Date: Tue, 15 Apr 2025 04:58:51 +0530 Subject: [PATCH 2/3] cleanup --- .../src/action-providers/aerodome/README.md | 136 ------------------ .../src/action-providers/aerodome/index.ts | 7 - .../src/action-providers/aerodrome/README.md | 33 +++++ .../aerodromeActionProvider.test.ts} | 2 +- .../aerodromeActionProvider.ts} | 2 +- .../{aerodome => aerodrome}/constants.ts | 2 + .../src/action-providers/aerodrome/index.ts | 17 +++ .../{aerodome => aerodrome}/schemas.ts | 0 .../agentkit/src/action-providers/index.ts | 2 +- 9 files changed, 55 insertions(+), 146 deletions(-) delete mode 100644 typescript/agentkit/src/action-providers/aerodome/README.md delete mode 100644 typescript/agentkit/src/action-providers/aerodome/index.ts create mode 100644 typescript/agentkit/src/action-providers/aerodrome/README.md rename typescript/agentkit/src/action-providers/{aerodome/aerodomeActionProvider.test.ts => aerodrome/aerodromeActionProvider.test.ts} (99%) rename typescript/agentkit/src/action-providers/{aerodome/aerodomeActionProvider.ts => aerodrome/aerodromeActionProvider.ts} (99%) rename typescript/agentkit/src/action-providers/{aerodome => aerodrome}/constants.ts (99%) create mode 100644 typescript/agentkit/src/action-providers/aerodrome/index.ts rename typescript/agentkit/src/action-providers/{aerodome => aerodrome}/schemas.ts (100%) diff --git a/typescript/agentkit/src/action-providers/aerodome/README.md b/typescript/agentkit/src/action-providers/aerodome/README.md deleted file mode 100644 index ea23a83ee..000000000 --- a/typescript/agentkit/src/action-providers/aerodome/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# Aerodrome Action Provider - -This directory contains the **AerodromeActionProvider** implementation, which enables AI agents to interact with [Aerodrome Finance](https://aerodrome.finance/) on Base Mainnet. - -## Overview - -The AerodromeActionProvider extends the ActionProvider class and integrates with EvmWalletProvider for blockchain interactions. It enables programmatic access to core Aerodrome DeFi operations including: - -- Creating veAERO governance locks -- Voting for liquidity pool emissions with veAERO NFTs -- Swapping tokens using Aerodrome's pools - -## Directory Structure - -``` -aerodome/ -├── aerodomeActionProvider.ts # Main provider implementation -├── aerodomeActionProvider.test.ts # Provider test suite -├── constants.ts # Contract addresses and ABIs -├── schemas.ts # Action schemas and type validation -├── index.ts # Package exports -└── README.md # Documentation (this file) -``` - -## Network Support - -This provider **only** supports Base Mainnet (`base-mainnet`). All contract interactions are configured for this specific network. - -## Contract Addresses - -The provider interacts with the following Aerodrome contract addresses on Base Mainnet: - -- AERO Token: `0x940181a94A35A4569E4529A3CDfB74e38FD98631` -- Voting Escrow (veAERO): `0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4` -- Voter: `0x16613524e02ad97eDfeF371bC883F2F5d6C480A5` -- Router: `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43` - -## Actions - -### Create Lock -- `createLock`: Creates a new veAERO lock by depositing AERO tokens for a specified duration - - **Purpose**: Generate a veAERO NFT that provides governance and voting power - - **Input**: - - `aeroAmount` (string): Amount of AERO tokens to lock (e.g., '100.5') - - `lockDurationSeconds` (string): Lock duration in seconds (min: 604800 for 1 week, max: 126144000 for 4 years) - - **Output**: String describing the transaction result or error - - **Example**: - ```typescript - const result = await provider.createLock(walletProvider, { - aeroAmount: "100.5", - lockDurationSeconds: "2592000" // 30 days - }); - ``` - - **Notes**: The contract automatically rounds lock durations down to the nearest week boundary - -### Vote -- `vote`: Casts votes for liquidity pool emissions using a veAERO NFT - - **Purpose**: Allocate veAERO voting power to direct AERO emissions to specific pools - - **Input**: - - `veAeroTokenId` (string): The ID of the veAERO NFT to vote with - - `poolAddresses` (string[]): Array of Aerodrome pool addresses to vote for - - `weights` (string[]): Array of positive integer voting weights corresponding to the pools - - **Output**: String describing the transaction result or error - - **Example**: - ```typescript - const result = await provider.vote(walletProvider, { - veAeroTokenId: "1", - poolAddresses: [ - "0xaaaa567890123456789012345678901234567890", - "0xbbbb567890123456789012345678901234567890" - ], - weights: ["70", "30"] // 70% to first pool, 30% to second pool - }); - ``` - - **Notes**: Voting is restricted to once per weekly epoch per veAERO token - -### Swap Exact Tokens -- `swapExactTokens`: Swaps an exact amount of input tokens for a minimum amount of output tokens - - **Purpose**: Execute token swaps through Aerodrome's liquidity pools - - **Input**: - - `amountIn` (string): The exact amount of input token to swap (e.g., '1.5') - - `amountOutMin` (string): Minimum amount of output tokens expected (in atomic units) - - `tokenInAddress` (string): Address of the token being swapped from - - `tokenOutAddress` (string): Address of the token being swapped to - - `to` (string): Address to receive the output tokens - - `deadline` (string): Unix timestamp deadline for the transaction - - `useStablePool` (boolean, optional): Whether to use stable pool (default: false) - - **Output**: String describing the transaction result or error - - **Example**: - ```typescript - const result = await provider.swapExactTokens(walletProvider, { - amountIn: "10", - amountOutMin: "9500000000", - tokenInAddress: "0xcccc567890123456789012345678901234567890", - tokenOutAddress: "0xdddd567890123456789012345678901234567890", - to: "0x1234567890123456789012345678901234567890", - deadline: "1714675200", // April 2, 2024 - useStablePool: false - }); - ``` - - **Notes**: Supports both volatile and stable pools depending on the token pair - -## Implementation Details - -### Error Handling -All actions include comprehensive error handling for common failure scenarios: -- Token approval failures -- Insufficient balance/allowance -- Pool liquidity constraints -- Network validation - -## Adding New Actions - -To add new Aerodrome actions: - -1. Define your action schema in `schemas.ts` -2. Implement the action in `aerodomeActionProvider.ts` -3. Add tests in `aerodomeActionProvider.test.ts` - -## Usage Example - -```typescript -import { AerodromeActionProvider } from "./action-providers/aerodome"; -import { ViemWalletProvider } from "./wallet-providers"; - -// Initialize providers -const aerodrome = new AerodromeActionProvider(); -const wallet = new ViemWalletProvider(/* wallet config */); - -// Create a veAERO lock -const lockResult = await aerodrome.createLock(wallet, { - aeroAmount: "100", - lockDurationSeconds: "604800" // 1 week -}); - -console.log(lockResult); diff --git a/typescript/agentkit/src/action-providers/aerodome/index.ts b/typescript/agentkit/src/action-providers/aerodome/index.ts deleted file mode 100644 index fd6b70ca4..000000000 --- a/typescript/agentkit/src/action-providers/aerodome/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Aerodrome Action Provider Module Index - */ - -export { AerodromeActionProvider } from "./aerodomeActionProvider"; -export * from "./schemas"; -export * from "./constants"; 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/aerodome/aerodomeActionProvider.test.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts similarity index 99% rename from typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.test.ts rename to typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts index ac545df7d..b9d6af6e6 100644 --- a/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts @@ -5,7 +5,7 @@ import { encodeFunctionData, parseUnits, ReadContractParameters, Abi } from "viem"; import { EvmWalletProvider } from "../../wallet-providers"; import { approve } from "../../utils"; -import { AerodromeActionProvider } from "./aerodomeActionProvider"; +import { AerodromeActionProvider } from "./aerodromeActionProvider"; import { Network } from "../../network"; import { ERC20_ABI, diff --git a/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts similarity index 99% rename from typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts rename to typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts index 37bd3dc52..0a51d92ef 100644 --- a/typescript/agentkit/src/action-providers/aerodome/aerodomeActionProvider.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts @@ -13,12 +13,12 @@ import { VOTING_ESCROW_ADDRESS, VOTER_ADDRESS, ROUTER_ADDRESS, + ZERO_ADDRESS, } from "./constants"; import { CreateLockSchema, VoteSchema, SwapExactTokensSchema } from "./schemas"; import { Network } from "../../network"; export const SUPPORTED_NETWORKS = ["base-mainnet"]; -const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const SECONDS_IN_WEEK = BigInt(604800); const MIN_LOCK_DURATION = SECONDS_IN_WEEK; // 1 week const MAX_LOCK_DURATION = BigInt(126144000); // 4 years diff --git a/typescript/agentkit/src/action-providers/aerodome/constants.ts b/typescript/agentkit/src/action-providers/aerodrome/constants.ts similarity index 99% rename from typescript/agentkit/src/action-providers/aerodome/constants.ts rename to typescript/agentkit/src/action-providers/aerodrome/constants.ts index 4942e91f9..c202caccb 100644 --- a/typescript/agentkit/src/action-providers/aerodome/constants.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/constants.ts @@ -5,6 +5,8 @@ export const VOTING_ESCROW_ADDRESS = "0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4 export const VOTER_ADDRESS = "0x16613524e02ad97eDfeF371bC883F2F5d6C480A5"; export const ROUTER_ADDRESS = "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43"; +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + // --- ABIs --- export const AERO_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..5987b7947 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/index.ts @@ -0,0 +1,17 @@ +/** + * 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"; + +// Re-export schemas for consumers who need them +export * from "./schemas"; + +// Re-export constants for any external code that might need them +export * from "./constants"; diff --git a/typescript/agentkit/src/action-providers/aerodome/schemas.ts b/typescript/agentkit/src/action-providers/aerodrome/schemas.ts similarity index 100% rename from typescript/agentkit/src/action-providers/aerodome/schemas.ts rename to typescript/agentkit/src/action-providers/aerodrome/schemas.ts diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 0bf0a8dca..a9bfea1a8 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -40,4 +40,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; -export * from "./aerodome"; +export * from "./aerodrome"; From 9d2702bddc619d1751df8ea296a291d85cabcef6 Mon Sep 17 00:00:00 2001 From: Pranav Vinodan Date: Tue, 15 Apr 2025 06:10:24 +0530 Subject: [PATCH 3/3] feat: minor enhancements --- .../aerodrome/aerodromeActionProvider.test.ts | 177 ++++++++---- .../aerodrome/aerodromeActionProvider.ts | 253 +++++++++++------- .../action-providers/aerodrome/constants.ts | 5 + .../src/action-providers/aerodrome/index.ts | 2 - .../src/action-providers/aerodrome/utils.ts | 102 +++++++ 5 files changed, 382 insertions(+), 157 deletions(-) create mode 100644 typescript/agentkit/src/action-providers/aerodrome/utils.ts diff --git a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts index b9d6af6e6..593b64606 100644 --- a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts @@ -2,13 +2,12 @@ * AerodromeActionProvider Tests */ -import { encodeFunctionData, parseUnits, ReadContractParameters, Abi } from "viem"; +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 { - ERC20_ABI, VOTING_ESCROW_ABI, VOTER_ABI, ROUTER_ABI, @@ -16,7 +15,9 @@ import { 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"; @@ -26,9 +27,9 @@ const MOCK_TOKEN_OUT = "0xdddd567890123456789012345678901234567890"; const MOCK_TX_HASH = "0xabcdef1234567890"; const MOCK_DECIMALS = 18; const MOCK_RECEIPT = { gasUsed: 100000n }; -const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; jest.mock("../../utils"); +jest.mock("./utils"); const mockApprove = approve as jest.MockedFunction; describe("AerodromeActionProvider", () => { @@ -41,21 +42,87 @@ describe("AerodromeActionProvider", () => { 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 => { - if (params.functionName === "decimals") return MOCK_DECIMALS; + readContract: jest.fn().mockImplementation((params: ReadContractParameters) => { + if (params.functionName === "decimals") return Promise.resolve(MOCK_DECIMALS); if (params.functionName === "symbol") { - if (params.address.toLowerCase() === MOCK_TOKEN_IN.toLowerCase()) return "TOKEN_IN"; - if (params.address.toLowerCase() === MOCK_TOKEN_OUT.toLowerCase()) return "TOKEN_OUT"; - return "AERO"; + 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 0n; - if (params.functionName === "gauges") return MOCK_POOL_ADDRESS_1; - return 0; + 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); }); @@ -110,12 +177,6 @@ describe("AerodromeActionProvider", () => { const response = await provider.createLock(mockWallet, args); - expect(mockWallet.readContract).toHaveBeenCalledWith({ - address: AERO_ADDRESS as `0x${string}`, - abi: ERC20_ABI, - functionName: "decimals", - }); - expect(mockApprove).toHaveBeenCalledWith( mockWallet, AERO_ADDRESS, @@ -133,7 +194,7 @@ describe("AerodromeActionProvider", () => { }); expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); - expect(response).toContain(`Successfully created veAERO lock with ${args.aeroAmount} AERO`); + expect(response).toContain(`Successfully created veAERO lock`); expect(response).toContain(MOCK_TX_HASH); }); @@ -190,7 +251,7 @@ describe("AerodromeActionProvider", () => { mockWallet.sendTransaction.mockRejectedValue(new Error("Transaction failed")); const response = await provider.createLock(mockWallet, args); - expect(response).toContain("Error creating veAERO lock: Transaction failed"); + expect(response).toContain("Error creating veAERO lock"); }); }); @@ -233,11 +294,7 @@ describe("AerodromeActionProvider", () => { }); expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); - expect(response).toContain(`Successfully voted with veAERO NFT #${args.veAeroTokenId}`); - expect(response.toLowerCase()).toContain(MOCK_POOL_ADDRESS_1.toLowerCase()); - expect(response.toLowerCase()).toContain(MOCK_POOL_ADDRESS_2.toLowerCase()); - expect(response).toContain("66.66%"); - expect(response).toContain("33.33%"); + expect(response).toContain(`Successfully cast votes`); }); it("should return error if already voted in current epoch", async () => { @@ -247,11 +304,14 @@ describe("AerodromeActionProvider", () => { weights: ["100"], }; - mockWallet.readContract = jest.fn().mockImplementation(params => { - if (params.functionName === "lastVoted") return 1681315200n; - if (params.functionName === "gauges") return MOCK_POOL_ADDRESS_1; - return MOCK_DECIMALS; - }); + 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"); @@ -265,10 +325,13 @@ describe("AerodromeActionProvider", () => { weights: ["100"], }; - mockWallet.readContract = jest.fn().mockImplementation(params => { - if (params.functionName === "gauges") return ZERO_ADDRESS; - return 0; - }); + 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"); @@ -285,7 +348,7 @@ describe("AerodromeActionProvider", () => { mockWallet.sendTransaction.mockRejectedValue(new Error("Transaction failed")); const response = await provider.vote(mockWallet, args); - expect(response).toContain("Error casting votes: Transaction failed"); + expect(response).toContain("Error casting votes"); }); it("should correctly handle NotApprovedOrOwner errors", async () => { @@ -295,12 +358,20 @@ describe("AerodromeActionProvider", () => { weights: ["100"], }; - const notApprovedError = new Error("execution reverted: Not approved or owner"); - notApprovedError.message = "execution reverted: NotApprovedOrOwner"; + 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: Wallet"); + expect(response).toContain("Error casting votes"); expect(response).toContain("does not own or is not approved for veAERO token ID"); }); }); @@ -321,13 +392,6 @@ describe("AerodromeActionProvider", () => { const response = await provider.swapExactTokens(mockWallet, args); - const decimalsCall = mockWallet.readContract.mock.calls.find( - call => - call[0]?.functionName === "decimals" && - call[0]?.address?.toLowerCase() === MOCK_TOKEN_IN.toLowerCase(), - ); - expect(decimalsCall).toBeTruthy(); - expect(mockApprove).toHaveBeenCalledWith( mockWallet, expect.stringMatching(new RegExp(MOCK_TOKEN_IN, "i")), @@ -358,8 +422,7 @@ describe("AerodromeActionProvider", () => { }); expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); - expect(response).toContain(`Successfully initiated swap of ${args.amountIn} TOKEN_IN`); - expect(response).toContain(`for at least ${args.amountOutMin} wei of TOKEN_OUT`); + expect(response).toContain(`Successfully completed swap`); }); it("should return error if deadline has already passed", async () => { @@ -421,10 +484,11 @@ describe("AerodromeActionProvider", () => { 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: Transaction failed"); + expect(response).toContain("Error swapping tokens"); }); it("should handle INSUFFICIENT_OUTPUT_AMOUNT errors", async () => { @@ -438,11 +502,12 @@ describe("AerodromeActionProvider", () => { 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: Insufficient output amount"); + expect(response).toContain("Error swapping tokens"); expect(response).toContain("Slippage may be too high"); }); @@ -457,13 +522,13 @@ describe("AerodromeActionProvider", () => { 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: Insufficient liquidity for this trade pair and amount", - ); + expect(response).toContain("Error swapping tokens"); + expect(response).toContain("Insufficient liquidity"); }); it("should handle Expired errors", async () => { @@ -477,19 +542,19 @@ describe("AerodromeActionProvider", () => { 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: Transaction deadline"); - expect(response).toContain("likely passed during execution"); + expect(response).toContain("Error swapping tokens"); + expect(response).toContain("deadline"); }); }); - describe("_getCurrentEpochStart", () => { + describe("getCurrentEpochStart function", () => { it("should correctly calculate epoch start time", () => { - const epochStart = provider["_getCurrentEpochStart"](); - expect(epochStart).toBe(1680739200n); + 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 index 0a51d92ef..d1717da7a 100644 --- a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { encodeFunctionData, Hex, parseUnits, getAddress } from "viem"; +import { encodeFunctionData, Hex, parseUnits, getAddress, formatUnits } from "viem"; import { ActionProvider } from "../actionProvider"; import { EvmWalletProvider } from "../../wallet-providers"; import { CreateAction } from "../actionDecorator"; @@ -14,14 +14,22 @@ import { 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(604800); -const MIN_LOCK_DURATION = SECONDS_IN_WEEK; // 1 week -const MAX_LOCK_DURATION = BigInt(126144000); // 4 years +const SECONDS_IN_WEEK = BigInt(WEEK_SECONDS); /** * AerodromeActionProvider enables AI agents to interact with Aerodrome Finance on Base Mainnet. @@ -67,20 +75,25 @@ export class AerodromeActionProvider extends ActionProvider { return `Error: Lock duration (${lockDurationSeconds}s) cannot exceed 4 years (${MAX_LOCK_DURATION}s)`; } - const decimals = await wallet.readContract({ - address: AERO_ADDRESS as Hex, - abi: ERC20_ABI, - functionName: "decimals", - }); + 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"; } - console.log(` - [Aerodrome Provider] Approving ${atomicAmount} AERO wei for VotingEscrow (${VOTING_ESCROW_ADDRESS})... - `); + 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, @@ -92,31 +105,29 @@ export class AerodromeActionProvider extends ActionProvider { console.error("[Aerodrome Provider] Approval Error:", approvalResult); return `Error approving VotingEscrow contract: ${approvalResult}`; } - console.log("[Aerodrome Provider] Approval successful or already sufficient."); - console.log(`[Aerodrome Provider] Encoding create_lock transaction...`); const data = encodeFunctionData({ abi: VOTING_ESCROW_ABI, functionName: "create_lock", args: [atomicAmount, lockDurationSeconds], }); - console.log(` - [Aerodrome Provider] Sending create_lock transaction to ${VOTING_ESCROW_ADDRESS}... - `); const txHash = await wallet.sendTransaction({ to: VOTING_ESCROW_ADDRESS as Hex, data, }); - console.log(`[Aerodrome Provider] Transaction sent: ${txHash}. Waiting for receipt...`); - const receipt = await wallet.waitForTransactionReceipt(txHash); - console.log(`[Aerodrome Provider] Transaction confirmed. Gas used: ${receipt.gasUsed}`); - return `Successfully created veAERO lock with ${args.aeroAmount} AERO for ${args.lockDurationSeconds} seconds. Transaction: ${txHash}. Gas used: ${receipt.gasUsed}`; + const durationText = formatDuration(Number(lockDurationSeconds)); + + return formatTransactionResult( + "created veAERO lock", + `Locked ${args.aeroAmount} ${symbol} for ${durationText}.`, + txHash, + receipt, + ); } catch (error: unknown) { - console.error("[Aerodrome Provider] Error creating veAERO lock:", error); - return `Error creating veAERO lock: ${error instanceof Error ? error.message : String(error)}`; + return handleTransactionError("creating veAERO lock", error); } } @@ -138,22 +149,33 @@ export class AerodromeActionProvider extends ActionProvider { if (!ownerAddress || ownerAddress === ZERO_ADDRESS) { return "Error: Wallet address is not available."; } - console.log(`[Aerodrome Provider] Executing vote for ${ownerAddress} with args:`, args); + 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 currentEpochStart = this._getCurrentEpochStart(); - console.log(`[Aerodrome Provider] Current epoch start timestamp: ${currentEpochStart}`); + 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], }); - console.log(`[Aerodrome Provider] Last voted timestamp for token ${tokenId}: ${lastVotedTs}`); if (lastVotedTs >= currentEpochStart) { const nextEpochTime = new Date( @@ -164,36 +186,41 @@ export class AerodromeActionProvider extends ActionProvider { ).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 (const poolAddress of poolAddresses) { + for (let i = 0; i < poolAddresses.length; i++) { const gauge = await wallet.readContract({ address: VOTER_ADDRESS as Hex, abi: VOTER_ABI, functionName: "gauges", - args: [poolAddress], + args: [poolAddresses[i]], }); + if (gauge === ZERO_ADDRESS) { - return `Error: Pool ${poolAddress} does not have a registered gauge. Only pools with gauges can receive votes.`; + return `Error: Pool ${poolAddresses[i]} does not have a registered gauge. Only pools with gauges can receive votes.`; } } - console.log("[Aerodrome Provider] All specified pools have valid gauges."); - console.log(`[Aerodrome Provider] Encoding vote transaction...`); const data = encodeFunctionData({ abi: VOTER_ABI, functionName: "vote", args: [tokenId, poolAddresses, weights], }); - console.log(`[Aerodrome Provider] Sending vote transaction to ${VOTER_ADDRESS}...`); const txHash = await wallet.sendTransaction({ to: VOTER_ADDRESS as Hex, data, }); - console.log(`[Aerodrome Provider] Transaction sent: ${txHash}. Waiting for receipt...`); const receipt = await wallet.waitForTransactionReceipt(txHash); - console.log(`[Aerodrome Provider] Transaction confirmed. Gas used: ${receipt.gasUsed}`); let voteAllocation = ""; const totalWeight = weights.reduce((sum, w) => sum + w, BigInt(0)); @@ -204,23 +231,25 @@ export class AerodromeActionProvider extends ActionProvider { : "N/A"; voteAllocation += `\n - Pool ${poolAddresses[i]}: ${weights[i]} weight (~${percentage}%)`; } - const responseMessage = `Successfully voted with veAERO NFT #${args.veAeroTokenId}. Vote allocation: ${voteAllocation}\nTransaction: ${txHash}. Gas used: ${receipt.gasUsed}`; - return responseMessage; + + return formatTransactionResult( + "cast votes", + `Voted with veAERO NFT #${args.veAeroTokenId} (voting power: ${votingPower}).${voteAllocation}`, + txHash, + receipt, + ); } catch (error: unknown) { - console.error("[Aerodrome Provider] Error casting votes:", error); - if (error instanceof Error && error.message?.includes("NotApprovedOrOwner")) { - return `Error casting votes: Wallet ${ownerAddress} does not own or is not approved for veAERO token ID ${args.veAeroTokenId}.`; - } - return `Error casting votes: ${error instanceof Error ? error.message : String(error)}`; + return handleTransactionError("casting votes", error); } } /** - * Swaps an exact amount of input tokens for a minimum amount of output tokens on Aerodrome (Base Mainnet). + * 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 used to execute the transaction - * @param args - Parameters for the swap as defined in SwapExactTokensSchema - * @returns A promise resolving to the transaction result as a string + * @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", @@ -235,10 +264,7 @@ export class AerodromeActionProvider extends ActionProvider { if (!ownerAddress || ownerAddress === ZERO_ADDRESS) { return "Error: Wallet address is not available."; } - console.log(` - [Aerodrome Provider] Executing swapExactTokens for ${ownerAddress} with args: - ${JSON.stringify(args, null, 2)} - `); + console.log(`[Aerodrome Provider] Executing swapExactTokens with args:`, args); try { const tokenIn = getAddress(args.tokenInAddress); @@ -252,95 +278,124 @@ export class AerodromeActionProvider extends ActionProvider { return `Error: Deadline (${args.deadline}) has already passed (Current time: ${currentTimestamp}). Please provide a future timestamp.`; } - const [decimals, tokenInSymbol, tokenOutSymbol] = await Promise.all([ - wallet.readContract({ address: tokenIn, abi: ERC20_ABI, functionName: "decimals" }), - wallet.readContract({ address: tokenIn, abi: ERC20_ABI, functionName: "symbol" }), - wallet.readContract({ address: tokenOut, abi: ERC20_ABI, functionName: "symbol" }), + const [tokenInInfo, tokenOutInfo] = await Promise.all([ + getTokenInfo(wallet, tokenIn as Hex), + getTokenInfo(wallet, tokenOut as Hex), ]); - const atomicAmountIn = parseUnits(args.amountIn, decimals); + const atomicAmountIn = parseUnits(args.amountIn, tokenInInfo.decimals); if (atomicAmountIn <= 0n) { return "Error: Swap amount must be greater than 0"; } - const amountOutMin = BigInt(args.amountOutMin); - console.log(` - [Aerodrome Provider] Approving ${atomicAmountIn} ${tokenInSymbol} wei for Router (${ROUTER_ADDRESS})... - `); - const approvalResult = await approve(wallet, tokenIn, ROUTER_ADDRESS, atomicAmountIn); + const balance = await wallet.readContract({ + address: tokenIn as Hex, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [ownerAddress as Hex], + }); - if (approvalResult.startsWith("Error")) { - console.error("[Aerodrome Provider] Approval Error:", approvalResult); - return `Error approving Router contract: ${approvalResult}`; + 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}.`; } - console.log("[Aerodrome Provider] Approval successful or already sufficient."); const route = [ { - from: tokenIn, - to: tokenOut, + from: tokenIn as Hex, + to: tokenOut as Hex, stable: useStable, factory: ZERO_ADDRESS as Hex, }, ]; - console.log(` - [Aerodrome Provider] Using route: ${tokenInSymbol} -> ${tokenOutSymbol} (Stable: ${useStable}) - `); - console.log(`[Aerodrome Provider] Encoding swapExactTokensForTokens transaction...`); + 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, amountOutMin, route, recipient, deadline], + args: [atomicAmountIn, BigInt(args.amountOutMin), route, recipient as Hex, deadline], }); - console.log(`[Aerodrome Provider] Sending swap transaction to ${ROUTER_ADDRESS}...`); const txHash = await wallet.sendTransaction({ to: ROUTER_ADDRESS as Hex, data, }); - console.log(`[Aerodrome Provider] Transaction sent: ${txHash}. Waiting for receipt...`); const receipt = await wallet.waitForTransactionReceipt(txHash); - console.log(`[Aerodrome Provider] Transaction confirmed. Gas used: ${receipt.gasUsed}`); - return `Successfully initiated swap of ${args.amountIn} ${tokenInSymbol} for at least ${args.amountOutMin} wei of ${tokenOutSymbol}. Recipient: ${recipient}\nTransaction: ${txHash}. Gas used: ${receipt.gasUsed}`; - } catch (error: unknown) { - console.error("[Aerodrome Provider] Error swapping tokens:", error); - if (error instanceof Error && error.message?.includes("INSUFFICIENT_OUTPUT_AMOUNT")) { - return "Error swapping tokens: Insufficient output amount. Slippage may be too high or amountOutMin too strict for current market conditions."; + let outputDetails = `${args.amountIn} ${tokenInInfo.symbol} for at least ${args.amountOutMin} wei of ${tokenOutInfo.symbol}`; + if (estimatedOutput) { + outputDetails += ` (estimated output: ${estimatedOutput} wei)`; } - if (error instanceof Error && error.message?.includes("INSUFFICIENT_LIQUIDITY")) { - return "Error swapping tokens: Insufficient liquidity for this trade pair and amount."; - } - if (error instanceof Error && error.message?.includes("Expired")) { - return `Error swapping tokens: Transaction deadline (${args.deadline}) likely passed during execution.`; - } - return `Error swapping tokens: ${error instanceof Error ? error.message : String(error)}`; + + return formatTransactionResult( + "completed swap", + `Swapped ${outputDetails}. Recipient: ${recipient}`, + txHash, + receipt, + ); + } catch (error: unknown) { + return handleTransactionError("swapping tokens", error); } } /** - * Checks if the Aerodrome action provider supports the given network. - * Currently supports Base Mainnet ONLY. + * Checks if the action provider supports the given network. * - * @param network - The network to check for support - * @returns True if the network is supported, false otherwise + * @param network - The network to check. + * @returns True if the action provider supports the network, false otherwise. */ - supportsNetwork = (network: Network) => - network.protocolFamily === "evm" && SUPPORTED_NETWORKS.includes(network.networkId!); + supportsNetwork(network: Network): boolean { + return ( + network.protocolFamily === "evm" && + !!network.networkId && + SUPPORTED_NETWORKS.includes(network.networkId) + ); + } /** - * Helper to get the start of the current epoch. + * Private method to get the start timestamp for the current voting epoch + * Used for testing. * - * @returns The timestamp (in seconds) of the start of the current week's epoch as a bigint + * @returns The timestamp (seconds since epoch) of the current epoch start */ private _getCurrentEpochStart(): bigint { - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - // Unix epoch (Jan 1 1970) was a Thursday, so simple division works. - const epochStart = (nowSeconds / SECONDS_IN_WEEK) * SECONDS_IN_WEEK; - return epochStart; + return getCurrentEpochStart(SECONDS_IN_WEEK); } } -export const aerodromeActionProvider = () => new AerodromeActionProvider(); +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 index c202caccb..9389e1b63 100644 --- a/typescript/agentkit/src/action-providers/aerodrome/constants.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/constants.ts @@ -7,6 +7,11 @@ 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 = [ diff --git a/typescript/agentkit/src/action-providers/aerodrome/index.ts b/typescript/agentkit/src/action-providers/aerodrome/index.ts index 5987b7947..5d148a45a 100644 --- a/typescript/agentkit/src/action-providers/aerodrome/index.ts +++ b/typescript/agentkit/src/action-providers/aerodrome/index.ts @@ -10,8 +10,6 @@ export { AerodromeActionProvider, aerodromeActionProvider } from "./aerodromeActionProvider"; -// Re-export schemas for consumers who need them export * from "./schemas"; -// Re-export constants for any external code that might need them export * from "./constants"; 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); +}