diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 37b14207f..240ba9134 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -178,6 +178,51 @@ const agent = createAgent({
+Aerodrome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
aerodrome_get_quoteGets a swap quote from Aerodrome Finance on Base.
aerodrome_swapSwaps tokens on Aerodrome with slippage protection.
aerodrome_add_liquidityAdds liquidity to an Aerodrome pool and receives LP tokens.
aerodrome_remove_liquidityRemoves liquidity from an Aerodrome pool with slippage protection.
aerodrome_create_lockLocks AERO tokens to create a veAERO NFT for governance voting.
aerodrome_voteVotes with a veAERO NFT to direct AERO emissions to pools.
aerodrome_increase_amountAdds more AERO to an existing veAERO lock.
aerodrome_increase_unlock_timeExtends the lock duration of a veAERO position.
aerodrome_withdrawWithdraws AERO from an expired veAERO lock.
aerodrome_claim_rewardsClaims trading fees and bribes earned from veAERO voting.
+
+
Base Account 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..17f8d4c4b --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/README.md @@ -0,0 +1,57 @@ +# Aerodrome Action Provider + +This action provider integrates [Aerodrome Finance](https://aerodrome.finance/) with AgentKit, enabling AI agents to interact with the leading DEX on Base. Supports token swaps, liquidity management, veAERO governance locking, voting, reward claiming, and lock management. + +## Directory Structure + +``` +aerodrome/ +├── aerodromeActionProvider.ts # Main provider with all actions +├── aerodromeActionProvider.test.ts # Jest test suite (62 tests) +├── constants.ts # Contract addresses & ABIs +├── schemas.ts # Zod validation schemas +├── index.ts # Public exports +└── README.md # This file +``` + +## Actions + +### Trading +- `aerodrome_get_quote`: Get a swap quote for a token pair (read-only) +- `aerodrome_swap`: Swap tokens with slippage protection (auto-calculates min output from quote) + +### Liquidity +- `aerodrome_add_liquidity`: Add liquidity to a pool and receive LP tokens +- `aerodrome_remove_liquidity`: Remove liquidity with slippage protection via quoteRemoveLiquidity + +### Governance (veAERO) +- `aerodrome_create_lock`: Lock AERO tokens to create a veAERO NFT for governance voting +- `aerodrome_vote`: Vote with veAERO NFT to direct AERO emissions to pools +- `aerodrome_increase_amount`: Add more AERO to an existing veAERO lock +- `aerodrome_increase_unlock_time`: Extend the lock duration of a veAERO position +- `aerodrome_withdraw`: Withdraw AERO from an expired veAERO lock + +### Rewards +- `aerodrome_claim_rewards`: Claim trading fees and bribes earned from veAERO voting + +## Network Support + +- Base Mainnet only + +## Design Decisions + +- **Slippage-based swap**: Uses `slippageBps` (default 1%, max 10%) instead of raw `amountOutMin`. The action internally fetches a quote and computes the minimum output. +- **Safe LP removal**: Uses `quoteRemoveLiquidity` to estimate expected output, then applies slippage protection. Never uses zero minimums. +- **Token details via multicall**: Reuses `getTokenDetails` from the ERC20 action provider for efficient token info + balance fetching. +- **veAERO token ID extraction**: Parses the `Deposit` event log (filtered by contract address) from the `createLock` transaction to return the veAERO NFT token ID. +- **Pre-flight checks**: Vote verifies NFT ownership, voting power, and epoch status. Withdraw checks lock expiry and permanent lock status. increaseUnlockTime validates the 4-year ceiling. +- **Default deadline**: 10 minutes from execution time. +- **Gauge validation**: Vote and claimRewards validate gauge existence before submitting transactions. + +## Notes + +- Aerodrome uses a `Route` struct for swaps (not simple address paths). Each route specifies `from`, `to`, `stable`, and `factory`. +- Pools can be **stable** (correlated assets, Curve's x^3*y + y^3*x invariant) or **volatile** (standard x*y=k). +- veAERO voting directs AERO emissions to pools. Voters earn 100% of trading fees and bribes. +- Voting occurs per epoch (weekly, Thursday to Thursday UTC). +- Lock durations are rounded down to the nearest Thursday epoch boundary on-chain. diff --git a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts new file mode 100644 index 000000000..a8ba536e1 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.test.ts @@ -0,0 +1,773 @@ +import { parseUnits, Hex, keccak256, toHex, pad, toBytes } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { approve } from "../../utils"; +import { getTokenDetails } from "../erc20/utils"; +import { AerodromeActionProvider } from "./aerodromeActionProvider"; +import { + AERODROME_ROUTER_ADDRESS, + AERODROME_VOTER_ADDRESS, + AERODROME_VOTING_ESCROW_ADDRESS, + AERODROME_POOL_FACTORY_ADDRESS, + AERO_TOKEN_ADDRESS, + AERODROME_VOTING_ESCROW_ABI, + EPOCH_DURATION, +} from "./constants"; + +const MOCK_TOKEN_A = "0x4200000000000000000000000000000000000006"; +const MOCK_TOKEN_B = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const MOCK_POOL_ADDRESS = "0x1111111111111111111111111111111111111111"; +const MOCK_POOL_ADDRESS_2 = "0x5555555555555555555555555555555555555555"; +const MOCK_GAUGE_ADDRESS = "0x2222222222222222222222222222222222222222"; +const MOCK_GAUGE_ADDRESS_2 = "0x6666666666666666666666666666666666666666"; +const MOCK_FEE_ADDRESS = "0x3333333333333333333333333333333333333333"; +const MOCK_BRIBE_ADDRESS = "0x4444444444444444444444444444444444444444"; +const MOCK_WALLET_ADDRESS = "0x9876543210987654321098765432109876543210"; +const MOCK_TX_HASH = "0xabcdef1234567890" as `0x${string}`; +const MOCK_RECEIPT = { status: 1, blockNumber: 1234567, logs: [] }; + +jest.mock("../../utils"); +jest.mock("../erc20/utils"); + +const mockApprove = approve as jest.MockedFunction; +const mockGetTokenDetails = getTokenDetails as jest.MockedFunction; + +describe("Aerodrome Action Provider", () => { + const actionProvider = new AerodromeActionProvider(); + let mockWallet: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_WALLET_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet" }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: jest.fn().mockResolvedValue(MOCK_RECEIPT), + readContract: jest.fn(), + getPublicClient: jest.fn().mockReturnValue({ multicall: jest.fn() }), + } as unknown as jest.Mocked; + + mockApprove.mockResolvedValue("Approval successful"); + + mockGetTokenDetails.mockImplementation(async (_wallet, address) => { + if (address === MOCK_TOKEN_A) { + return { name: "WETH", decimals: 18, balance: parseUnits("10", 18), formattedBalance: "10.0" }; + } + if (address === MOCK_TOKEN_B) { + return { name: "USDC", decimals: 6, balance: parseUnits("5000", 6), formattedBalance: "5000.0" }; + } + if (address === AERO_TOKEN_ADDRESS) { + return { name: "AERO", decimals: 18, balance: parseUnits("1000", 18), formattedBalance: "1000.0" }; + } + if (address === MOCK_POOL_ADDRESS) { + return { name: "vAMM-WETH/USDC", decimals: 18, balance: parseUnits("5", 18), formattedBalance: "5.0" }; + } + return null; + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // getQuote + // ═══════════════════════════════════════════════════════════════ + describe("getQuote", () => { + it("should successfully get a swap quote", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1", 18), parseUnits("3000", 6)]); + const response = await actionProvider.getQuote(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", stable: false, + }); + expect(response).toContain("Quote:"); + expect(response).toContain("WETH"); + expect(response).toContain("USDC"); + expect(response).toContain("volatile"); + }); + + it("should show stable pool type in quote", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1000", 6), parseUnits("999", 6)]); + const response = await actionProvider.getQuote(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1000", stable: true, + }); + expect(response).toContain("stable"); + }); + + it("should return error for zero output (non-existent pool)", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1", 18), 0n]); + const response = await actionProvider.getQuote(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", stable: false, + }); + expect(response).toContain("zero output"); + }); + + it("should handle invalid token addresses", async () => { + mockGetTokenDetails.mockResolvedValue(null); + const response = await actionProvider.getQuote(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", stable: false, + }); + expect(response).toContain("Could not fetch token details"); + }); + + it("should handle readContract error (catch block)", async () => { + mockWallet.readContract.mockRejectedValue(new Error("RPC error")); + const response = await actionProvider.getQuote(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", stable: false, + }); + expect(response).toContain("Error getting quote"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // swap + // ═══════════════════════════════════════════════════════════════ + describe("swap", () => { + it("should successfully swap tokens", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1", 18), parseUnits("3000", 6)]); + const response = await actionProvider.swap(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", slippageBps: 100, stable: false, + }); + expect(mockApprove).toHaveBeenCalledWith(mockWallet, MOCK_TOKEN_A, AERODROME_ROUTER_ADDRESS, parseUnits("1", 18)); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Swapped 1 WETH"); + expect(response).toContain("slippage: 1%"); + expect(response).toContain(MOCK_TX_HASH); + }); + + it("should return error when token details are null", async () => { + mockGetTokenDetails.mockResolvedValue(null); + const response = await actionProvider.swap(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", slippageBps: 100, stable: false, + }); + expect(response).toContain("Could not fetch token details"); + }); + + it("should fail with insufficient balance", async () => { + mockGetTokenDetails.mockImplementation(async (_w, addr) => { + if (addr === MOCK_TOKEN_A) return { name: "WETH", decimals: 18, balance: parseUnits("0.1", 18), formattedBalance: "0.1" }; + return { name: "USDC", decimals: 6, balance: parseUnits("5000", 6), formattedBalance: "5000.0" }; + }); + const response = await actionProvider.swap(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", slippageBps: 100, stable: false, + }); + expect(response).toContain("Insufficient balance"); + }); + + it("should return error for zero quote output inside swap", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1", 18), 0n]); + const response = await actionProvider.swap(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", slippageBps: 100, stable: false, + }); + expect(response).toContain("zero output"); + expect(mockApprove).not.toHaveBeenCalled(); + }); + + it("should fail when approval fails", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1", 18), parseUnits("3000", 6)]); + mockApprove.mockResolvedValue("Error: insufficient allowance"); + const response = await actionProvider.swap(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", slippageBps: 100, stable: false, + }); + expect(response).toContain("Error approving tokens"); + }); + + it("should handle sendTransaction error", async () => { + mockWallet.readContract.mockResolvedValue([parseUnits("1", 18), parseUnits("3000", 6)]); + mockWallet.sendTransaction.mockRejectedValue(new Error("INSUFFICIENT_OUTPUT_AMOUNT")); + const response = await actionProvider.swap(mockWallet, { + tokenIn: MOCK_TOKEN_A, tokenOut: MOCK_TOKEN_B, amountIn: "1", slippageBps: 100, stable: false, + }); + expect(response).toContain("Error swapping tokens"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // addLiquidity + // ═══════════════════════════════════════════════════════════════ + describe("addLiquidity", () => { + it("should successfully add liquidity", async () => { + mockWallet.readContract.mockResolvedValueOnce([parseUnits("1", 18), parseUnits("3000", 6), parseUnits("1", 18)]); + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(mockApprove).toHaveBeenCalledTimes(2); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Added liquidity"); + }); + + it("should return error when token details are null", async () => { + mockGetTokenDetails.mockResolvedValue(null); + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(response).toContain("Could not fetch token details"); + }); + + it("should fail with insufficient token A balance", async () => { + mockGetTokenDetails.mockImplementation(async (_w, addr) => { + if (addr === MOCK_TOKEN_A) return { name: "WETH", decimals: 18, balance: parseUnits("0.1", 18), formattedBalance: "0.1" }; + return { name: "USDC", decimals: 6, balance: parseUnits("5000", 6), formattedBalance: "5000.0" }; + }); + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(response).toContain("Insufficient WETH balance"); + }); + + it("should fail with insufficient token B balance", async () => { + mockGetTokenDetails.mockImplementation(async (_w, addr) => { + if (addr === MOCK_TOKEN_A) return { name: "WETH", decimals: 18, balance: parseUnits("10", 18), formattedBalance: "10.0" }; + return { name: "USDC", decimals: 6, balance: parseUnits("100", 6), formattedBalance: "100.0" }; + }); + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(response).toContain("Insufficient USDC balance"); + }); + + it("should fail when token A approval fails", async () => { + mockWallet.readContract.mockResolvedValueOnce([parseUnits("1", 18), parseUnits("3000", 6), parseUnits("1", 18)]); + mockApprove.mockResolvedValue("Error: approval reverted"); + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(response).toContain("Error approving WETH"); + }); + + it("should fail when token B approval fails but token A succeeds", async () => { + mockWallet.readContract.mockResolvedValueOnce([parseUnits("1", 18), parseUnits("3000", 6), parseUnits("1", 18)]); + mockApprove + .mockResolvedValueOnce("Approval successful") // tokenA succeeds + .mockResolvedValueOnce("Error: approval reverted"); // tokenB fails + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(response).toContain("Error approving USDC"); + }); + + it("should handle sendTransaction error", async () => { + mockWallet.readContract.mockResolvedValueOnce([parseUnits("1", 18), parseUnits("3000", 6), parseUnits("1", 18)]); + mockWallet.sendTransaction.mockRejectedValue(new Error("revert")); + const response = await actionProvider.addLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, amountA: "1", amountB: "3000", stable: false, slippageBps: 100, + }); + expect(response).toContain("Error adding liquidity"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // removeLiquidity + // ═══════════════════════════════════════════════════════════════ + describe("removeLiquidity", () => { + it("should successfully remove liquidity with slippage protection", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_POOL_ADDRESS) // poolFor + .mockResolvedValueOnce([parseUnits("0.5", 18), parseUnits("1500", 6)]); // quoteRemoveLiquidity + const response = await actionProvider.removeLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, liquidity: "1", stable: false, slippageBps: 100, + }); + expect(mockApprove).toHaveBeenCalled(); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Removed 1 LP tokens"); + expect(response).toContain("slippage: 1%"); + }); + + it("should return error when LP token details are null (pool does not exist)", async () => { + mockWallet.readContract.mockResolvedValueOnce("0x7777777777777777777777777777777777777777"); // poolFor returns unknown addr + // getTokenDetails returns null for unknown pool + const response = await actionProvider.removeLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, liquidity: "1", stable: false, slippageBps: 100, + }); + expect(response).toContain("Could not fetch LP token details"); + }); + + it("should fail with insufficient LP balance", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_POOL_ADDRESS) + .mockResolvedValueOnce([parseUnits("0.5", 18), parseUnits("1500", 6)]); + mockGetTokenDetails.mockImplementation(async (_w, addr) => { + if (addr === MOCK_POOL_ADDRESS) return { name: "vAMM-WETH/USDC", decimals: 18, balance: parseUnits("0.5", 18), formattedBalance: "0.5" }; + return null; + }); + const response = await actionProvider.removeLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, liquidity: "1", stable: false, slippageBps: 100, + }); + expect(response).toContain("Insufficient LP balance"); + }); + + it("should fail when approval fails", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_POOL_ADDRESS) + .mockResolvedValueOnce([parseUnits("0.5", 18), parseUnits("1500", 6)]); + mockApprove.mockResolvedValue("Error: approval failed"); + const response = await actionProvider.removeLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, liquidity: "1", stable: false, slippageBps: 100, + }); + expect(response).toContain("Error approving LP tokens"); + }); + + it("should handle sendTransaction error", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_POOL_ADDRESS) + .mockResolvedValueOnce([parseUnits("0.5", 18), parseUnits("1500", 6)]); + mockWallet.sendTransaction.mockRejectedValue(new Error("revert")); + const response = await actionProvider.removeLiquidity(mockWallet, { + tokenA: MOCK_TOKEN_A, tokenB: MOCK_TOKEN_B, liquidity: "1", stable: false, slippageBps: 100, + }); + expect(response).toContain("Error removing liquidity"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // createLock + // ═══════════════════════════════════════════════════════════════ + describe("createLock", () => { + it("should successfully create a veAERO lock and extract tokenId from logs", async () => { + // Manually construct Deposit event log topics and data + // Deposit(address indexed provider, uint256 indexed tokenId, uint8 indexed depositType, uint256 value, uint256 locktime, uint256 ts) + const eventSig = keccak256(toBytes("Deposit(address,uint256,uint8,uint256,uint256,uint256)")); + const providerTopic = pad(MOCK_WALLET_ADDRESS as Hex, { size: 32 }); + const tokenIdTopic = pad(toHex(42n), { size: 32 }); + const depositTypeTopic = pad(toHex(1), { size: 32 }); + // Non-indexed data: value, locktime, ts (each 32 bytes) + const data = (pad(toHex(parseUnits("100", 18)), { size: 32 }) + + pad(toHex(BigInt(Math.floor(Date.now() / 1000) + 86400 * 365)), { size: 32 }).slice(2) + + pad(toHex(BigInt(Math.floor(Date.now() / 1000))), { size: 32 }).slice(2)) as Hex; + + const receiptWithLogs = { + status: 1, + blockNumber: 1234567, + logs: [{ + address: AERODROME_VOTING_ESCROW_ADDRESS, + data, + topics: [eventSig, providerTopic, tokenIdTopic, depositTypeTopic], + }], + }; + mockWallet.waitForTransactionReceipt.mockResolvedValue(receiptWithLogs); + + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + + expect(mockApprove).toHaveBeenCalledWith(mockWallet, AERO_TOKEN_ADDRESS, AERODROME_VOTING_ESCROW_ADDRESS, parseUnits("100", 18)); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Locked 100 AERO"); + expect(response).toContain("365 days"); + expect(response).toContain("token ID: 42"); + }); + + it("should return unknown tokenId when no Deposit logs are present", async () => { + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + expect(response).toContain("token ID: unknown"); + }); + + it("should filter logs by VotingEscrow address (ignore other contracts)", async () => { + const eventSig2 = keccak256(toBytes("Deposit(address,uint256,uint8,uint256,uint256,uint256)")); + const receiptWithWrongAddress = { + status: 1, + blockNumber: 1234567, + logs: [{ + address: "0x0000000000000000000000000000000000000001", // wrong contract + data: pad(toHex(1n), { size: 32 }) as Hex, + topics: [eventSig2, pad(MOCK_WALLET_ADDRESS as Hex, { size: 32 }), pad(toHex(99n), { size: 32 }), pad(toHex(1), { size: 32 })], + }], + }; + mockWallet.waitForTransactionReceipt.mockResolvedValue(receiptWithWrongAddress); + + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + expect(response).toContain("token ID: unknown"); + }); + + it("should fail when AERO token details are null", async () => { + mockGetTokenDetails.mockResolvedValue(null); + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + expect(response).toContain("Could not fetch AERO token details"); + }); + + it("should fail with insufficient AERO balance", async () => { + mockGetTokenDetails.mockImplementation(async () => ({ + name: "AERO", decimals: 18, balance: parseUnits("10", 18), formattedBalance: "10.0", + })); + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + expect(response).toContain("Insufficient AERO balance"); + }); + + it("should fail when approval fails", async () => { + mockApprove.mockResolvedValue("Error: approval reverted"); + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + expect(response).toContain("Error approving AERO tokens"); + }); + + it("should handle sendTransaction error", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("revert")); + const response = await actionProvider.createLock(mockWallet, { + amount: "100", + lockDurationDays: 365, + }); + expect(response).toContain("Error creating veAERO lock"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // vote + // ═══════════════════════════════════════════════════════════════ + describe("vote", () => { + it("should successfully vote with veAERO", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) // ownerOf + .mockResolvedValueOnce(parseUnits("100", 18)) // balanceOfNFT + .mockResolvedValueOnce(0n) // lastVoted + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS); // gauges + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], weights: [100], + }); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Voted with veAERO #1"); + expect(response).toContain("100.0%"); + }); + + it("should vote for multiple pools with weight distribution", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) // ownerOf + .mockResolvedValueOnce(parseUnits("100", 18)) // balanceOfNFT + .mockResolvedValueOnce(0n) // lastVoted + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS) // gauges pool 1 + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS_2); // gauges pool 2 + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS, MOCK_POOL_ADDRESS_2], weights: [70, 30], + }); + expect(response).toContain("70.0%"); + expect(response).toContain("30.0%"); + }); + + it("should fail when wallet does not own the NFT", async () => { + mockWallet.readContract.mockResolvedValueOnce("0x0000000000000000000000000000000000000001"); + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], weights: [100], + }); + expect(response).toContain("does not own veAERO"); + }); + + it("should fail when voting power is zero", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(0n); + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], weights: [100], + }); + expect(response).toContain("zero voting power"); + }); + + it("should fail when already voted this epoch", async () => { + const now = BigInt(Math.floor(Date.now() / 1000)); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(parseUnits("100", 18)) + .mockResolvedValueOnce(now); // lastVoted = now (inside current epoch) + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], weights: [100], + }); + expect(response).toContain("already voted this epoch"); + }); + + it("should allow voting when last vote was in previous epoch", async () => { + const currentEpochStart = BigInt(Math.floor(Math.floor(Date.now() / 1000) / EPOCH_DURATION) * EPOCH_DURATION); + const lastEpochVote = currentEpochStart - 1n; // 1 second before current epoch + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(parseUnits("100", 18)) + .mockResolvedValueOnce(lastEpochVote) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS); + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], weights: [100], + }); + expect(response).toContain("Voted with veAERO #1"); + }); + + it("should fail when second pool has no gauge", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(parseUnits("100", 18)) + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS) // pool 1 has gauge + .mockResolvedValueOnce("0x0000000000000000000000000000000000000000"); // pool 2 has no gauge + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS, MOCK_POOL_ADDRESS_2], weights: [50, 50], + }); + expect(response).toContain("No gauge found"); + expect(response).toContain(MOCK_POOL_ADDRESS_2); + }); + + it("should handle sendTransaction error", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(parseUnits("100", 18)) + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS); + mockWallet.sendTransaction.mockRejectedValue(new Error("revert")); + const response = await actionProvider.vote(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], weights: [100], + }); + expect(response).toContain("Error voting"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // increaseAmount + // ═══════════════════════════════════════════════════════════════ + describe("increaseAmount", () => { + it("should successfully increase lock amount", async () => { + mockWallet.readContract.mockResolvedValueOnce(MOCK_WALLET_ADDRESS); + const response = await actionProvider.increaseAmount(mockWallet, { tokenId: "1", amount: "50" }); + expect(mockApprove).toHaveBeenCalled(); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Added 50 AERO to veAERO #1"); + }); + + it("should fail when wallet does not own the NFT", async () => { + mockWallet.readContract.mockResolvedValueOnce("0x0000000000000000000000000000000000000001"); + const response = await actionProvider.increaseAmount(mockWallet, { tokenId: "1", amount: "50" }); + expect(response).toContain("does not own veAERO"); + }); + + it("should fail with insufficient balance", async () => { + mockWallet.readContract.mockResolvedValueOnce(MOCK_WALLET_ADDRESS); + mockGetTokenDetails.mockImplementation(async () => ({ + name: "AERO", decimals: 18, balance: parseUnits("10", 18), formattedBalance: "10.0", + })); + const response = await actionProvider.increaseAmount(mockWallet, { tokenId: "1", amount: "100" }); + expect(response).toContain("Insufficient AERO balance"); + }); + + it("should fail when approval fails", async () => { + mockWallet.readContract.mockResolvedValueOnce(MOCK_WALLET_ADDRESS); + mockApprove.mockResolvedValue("Error: reverted"); + const response = await actionProvider.increaseAmount(mockWallet, { tokenId: "1", amount: "50" }); + expect(response).toContain("Error approving AERO tokens"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // increaseUnlockTime + // ═══════════════════════════════════════════════════════════════ + describe("increaseUnlockTime", () => { + it("should successfully extend lock duration", async () => { + const futureEnd = BigInt(Math.floor(Date.now() / 1000) + 86400 * 180); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), futureEnd, false]); + const response = await actionProvider.increaseUnlockTime(mockWallet, { tokenId: "1", additionalDays: 90 }); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Extended veAERO #1 lock by 90 days"); + }); + + it("should fail when wallet does not own the NFT", async () => { + mockWallet.readContract.mockResolvedValueOnce("0x0000000000000000000000000000000000000001"); + const response = await actionProvider.increaseUnlockTime(mockWallet, { tokenId: "1", additionalDays: 90 }); + expect(response).toContain("does not own veAERO"); + }); + + it("should fail when lock is permanent", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), 0n, true]); // isPermanent + const response = await actionProvider.increaseUnlockTime(mockWallet, { tokenId: "1", additionalDays: 90 }); + expect(response).toContain("permanently locked"); + }); + + it("should fail when lock has expired", async () => { + const pastEnd = BigInt(Math.floor(Date.now() / 1000) - 86400); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), pastEnd, false]); + const response = await actionProvider.increaseUnlockTime(mockWallet, { tokenId: "1", additionalDays: 90 }); + expect(response).toContain("lock has already expired"); + }); + + it("should fail when extension would exceed 4-year max", async () => { + const futureEnd = BigInt(Math.floor(Date.now() / 1000) + 86400 * 1400); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), futureEnd, false]); + const response = await actionProvider.increaseUnlockTime(mockWallet, { tokenId: "1", additionalDays: 365 }); + expect(response).toContain("exceed the 4-year maximum"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // withdraw + // ═══════════════════════════════════════════════════════════════ + describe("withdraw", () => { + it("should successfully withdraw from expired lock", async () => { + const pastTimestamp = BigInt(Math.floor(Date.now() / 1000) - 86400); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), pastTimestamp, false]); + const response = await actionProvider.withdraw(mockWallet, { tokenId: "1" }); + expect(mockWallet.sendTransaction).toHaveBeenCalled(); + expect(response).toContain("Withdrawn AERO from expired veAERO #1"); + }); + + it("should fail when wallet does not own the NFT", async () => { + mockWallet.readContract.mockResolvedValueOnce("0x0000000000000000000000000000000000000001"); + const response = await actionProvider.withdraw(mockWallet, { tokenId: "1" }); + expect(response).toContain("does not own veAERO"); + }); + + it("should fail when lock has not expired", async () => { + const futureTimestamp = BigInt(Math.floor(Date.now() / 1000) + 86400 * 30); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), futureTimestamp, false]); + const response = await actionProvider.withdraw(mockWallet, { tokenId: "1" }); + expect(response).toContain("lock has not expired"); + expect(response).toContain("days remaining"); + }); + + it("should fail when lock is permanent", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), 0n, true]); + const response = await actionProvider.withdraw(mockWallet, { tokenId: "1" }); + expect(response).toContain("permanently locked"); + }); + + it("should handle sendTransaction error", async () => { + const pastTimestamp = BigInt(Math.floor(Date.now() / 1000) - 86400); + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce([BigInt(100e18), pastTimestamp, false]); + mockWallet.sendTransaction.mockRejectedValue(new Error("revert")); + const response = await actionProvider.withdraw(mockWallet, { tokenId: "1" }); + expect(response).toContain("Error withdrawing"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // claimRewards + // ═══════════════════════════════════════════════════════════════ + describe("claimRewards", () => { + it("should successfully claim fees and bribes", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS) + .mockResolvedValueOnce(MOCK_FEE_ADDRESS) + .mockResolvedValueOnce(MOCK_BRIBE_ADDRESS); + const response = await actionProvider.claimRewards(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], + feeTokens: [[MOCK_TOKEN_A, MOCK_TOKEN_B]], bribeTokens: [[MOCK_TOKEN_A]], + }); + expect(response).toContain("Claimed rewards for veAERO #1"); + expect(response).toContain("Claimed trading fees"); + expect(response).toContain("Claimed bribes"); + }); + + it("should warn when fees succeed but bribes fail", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS) + .mockResolvedValueOnce(MOCK_FEE_ADDRESS) + .mockResolvedValueOnce(MOCK_BRIBE_ADDRESS); + // First sendTransaction (fees) succeeds, second (bribes) fails + mockWallet.sendTransaction + .mockResolvedValueOnce(MOCK_TX_HASH) + .mockRejectedValueOnce(new Error("bribe revert")); + const response = await actionProvider.claimRewards(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], + feeTokens: [[MOCK_TOKEN_A, MOCK_TOKEN_B]], bribeTokens: [[MOCK_TOKEN_A]], + }); + expect(response).toContain("Claimed rewards for veAERO #1"); // hasSuccess = true + expect(response).toContain("Claimed trading fees"); + expect(response).toContain("Bribe claim skipped or failed"); + }); + + it("should warn when fees fail but bribes succeed", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS) + .mockResolvedValueOnce(MOCK_FEE_ADDRESS) + .mockResolvedValueOnce(MOCK_BRIBE_ADDRESS); + mockWallet.sendTransaction + .mockRejectedValueOnce(new Error("fee revert")) // fees fail + .mockResolvedValueOnce(MOCK_TX_HASH); // bribes succeed + const response = await actionProvider.claimRewards(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], + feeTokens: [[MOCK_TOKEN_A, MOCK_TOKEN_B]], bribeTokens: [[MOCK_TOKEN_A]], + }); + expect(response).toContain("Claimed rewards for veAERO #1"); // hasSuccess = true (bribes succeeded) + expect(response).toContain("Fee claim skipped or failed"); + expect(response).toContain("Claimed bribes"); + }); + + it("should show warning when both fees and bribes fail", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce(MOCK_GAUGE_ADDRESS) + .mockResolvedValueOnce(MOCK_FEE_ADDRESS) + .mockResolvedValueOnce(MOCK_BRIBE_ADDRESS); + mockWallet.sendTransaction + .mockRejectedValueOnce(new Error("fee revert")) + .mockRejectedValueOnce(new Error("bribe revert")); + const response = await actionProvider.claimRewards(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], + feeTokens: [[MOCK_TOKEN_A, MOCK_TOKEN_B]], bribeTokens: [[MOCK_TOKEN_A]], + }); + expect(response).toContain("Warning: No rewards were successfully claimed"); + expect(response).toContain("Fee claim skipped or failed"); + expect(response).toContain("Bribe claim skipped or failed"); + }); + + it("should fail when wallet does not own the NFT", async () => { + mockWallet.readContract.mockResolvedValueOnce("0x0000000000000000000000000000000000000001"); + const response = await actionProvider.claimRewards(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], + feeTokens: [[MOCK_TOKEN_A]], bribeTokens: [[MOCK_TOKEN_A]], + }); + expect(response).toContain("does not own veAERO"); + }); + + it("should fail when gauge does not exist", async () => { + mockWallet.readContract + .mockResolvedValueOnce(MOCK_WALLET_ADDRESS) + .mockResolvedValueOnce("0x0000000000000000000000000000000000000000"); + const response = await actionProvider.claimRewards(mockWallet, { + tokenId: "1", pools: [MOCK_POOL_ADDRESS], + feeTokens: [[MOCK_TOKEN_A]], bribeTokens: [[MOCK_TOKEN_A]], + }); + expect(response).toContain("No gauge found"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // supportsNetwork + // ═══════════════════════════════════════════════════════════════ + describe("supportsNetwork", () => { + it("should return true for Base Mainnet", () => { + expect(actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe(true); + }); + + it("should return false for Base Sepolia (testnet)", () => { + expect(actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "base-sepolia" })).toBe(false); + }); + + it("should return false for Ethereum mainnet", () => { + expect(actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" })).toBe(false); + }); + + it("should return false for non-EVM networks", () => { + expect(actionProvider.supportsNetwork({ protocolFamily: "solana", networkId: "base-mainnet" })).toBe(false); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts new file mode 100644 index 000000000..4a565bfe5 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/aerodromeActionProvider.ts @@ -0,0 +1,943 @@ +import { z } from "zod"; +import { encodeFunctionData, parseUnits, formatUnits, Hex, decodeEventLog } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { approve } from "../../utils"; +import { getTokenDetails } from "../erc20/utils"; +import { Network } from "../../network"; +import { + AERODROME_ROUTER_ADDRESS, + AERODROME_VOTER_ADDRESS, + AERODROME_VOTING_ESCROW_ADDRESS, + AERODROME_POOL_FACTORY_ADDRESS, + AERO_TOKEN_ADDRESS, + AERODROME_ROUTER_ABI, + AERODROME_VOTING_ESCROW_ABI, + AERODROME_VOTER_ABI, + DEFAULT_DEADLINE_SECONDS, + DEFAULT_SLIPPAGE_BPS, + MAX_LOCK_DURATION, + MIN_LOCK_DURATION, + EPOCH_DURATION, +} from "./constants"; +import { + AerodromeGetQuoteSchema, + AerodromeSwapSchema, + AerodromeAddLiquiditySchema, + AerodromeRemoveLiquiditySchema, + AerodromeCreateLockSchema, + AerodromeVoteSchema, + AerodromeIncreaseAmountSchema, + AerodromeIncreaseUnlockTimeSchema, + AerodromeWithdrawSchema, + AerodromeClaimRewardsSchema, +} from "./schemas"; + +const SUPPORTED_NETWORKS = ["base-mainnet"]; + +/** + * AerodromeActionProvider provides actions for interacting with Aerodrome Finance, + * the leading DEX on Base. Supports token swaps, liquidity provision, veAERO + * governance locking, voting, reward claiming, and lock management. + */ +export class AerodromeActionProvider extends ActionProvider { + constructor() { + super("aerodrome", []); + } + + /** + * Gets a swap quote from Aerodrome Router. + */ + @CreateAction({ + name: "aerodrome_get_quote", + description: `Get a swap quote from Aerodrome Finance on Base. Returns the expected output amount for a given input. + +It takes: +- tokenIn: The address of the input token +- tokenOut: The address of the output token (must differ from tokenIn) +- amountIn: The amount of input tokens in whole units (e.g., '1.5' for 1.5 WETH) +- stable: Whether to use the stable pool (for correlated assets like stablecoins) or volatile pool (default: false)`, + schema: AerodromeGetQuoteSchema, + }) + async getQuote( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const tokenInDetails = await getTokenDetails(wallet, args.tokenIn); + const tokenOutDetails = await getTokenDetails(wallet, args.tokenOut); + + if (!tokenInDetails || !tokenOutDetails) { + return "Error: Could not fetch token details. Please verify the token addresses are valid ERC20 tokens on Base."; + } + + const atomicAmountIn = parseUnits(args.amountIn, tokenInDetails.decimals); + + const route = { + from: args.tokenIn as Hex, + to: args.tokenOut as Hex, + stable: args.stable, + factory: AERODROME_POOL_FACTORY_ADDRESS, + }; + + const amounts = (await wallet.readContract({ + address: AERODROME_ROUTER_ADDRESS, + abi: AERODROME_ROUTER_ABI, + functionName: "getAmountsOut", + args: [atomicAmountIn, [route]], + })) as readonly bigint[]; + + const amountOut = amounts[amounts.length - 1]; + + // H-2 fix: guard against zero output from non-existent pool + if (amountOut === 0n) { + return "Error: Quote returned zero output. The pool may not exist or have insufficient liquidity for this pair."; + } + + const formattedOut = formatUnits(amountOut, tokenOutDetails.decimals); + + return `Quote: ${args.amountIn} ${tokenInDetails.name} → ${formattedOut} ${tokenOutDetails.name} (${args.stable ? "stable" : "volatile"} pool)`; + } catch (error) { + return `Error getting quote: ${error}`; + } + } + + /** + * Swaps tokens on Aerodrome using the Router. + */ + @CreateAction({ + name: "aerodrome_swap", + description: `Swap tokens on Aerodrome Finance (Base). Uses a slippage tolerance to compute the minimum output amount automatically. + +It takes: +- tokenIn: The address of the input token +- tokenOut: The address of the output token (must differ from tokenIn) +- amountIn: The amount of input tokens in whole units (e.g., '1.5' for 1.5 WETH) +- slippageBps: Maximum slippage in basis points (100 = 1%, default: 1%, max: 10%) +- stable: Whether to use the stable pool or volatile pool (default: false) + +The action will first get a quote, apply slippage tolerance, handle token approval, then execute the swap. Base uses a private sequencer which reduces but does not eliminate MEV risk for large swaps.`, + schema: AerodromeSwapSchema, + }) + async swap(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + const tokenInDetails = await getTokenDetails(wallet, args.tokenIn); + const tokenOutDetails = await getTokenDetails(wallet, args.tokenOut); + + if (!tokenInDetails || !tokenOutDetails) { + return "Error: Could not fetch token details. Please verify the token addresses are valid ERC20 tokens on Base."; + } + + const atomicAmountIn = parseUnits(args.amountIn, tokenInDetails.decimals); + + if (atomicAmountIn > tokenInDetails.balance) { + return `Error: Insufficient balance. You have ${tokenInDetails.formattedBalance} ${tokenInDetails.name} but tried to swap ${args.amountIn}.`; + } + + const route = { + from: args.tokenIn as Hex, + to: args.tokenOut as Hex, + stable: args.stable, + factory: AERODROME_POOL_FACTORY_ADDRESS, + }; + + const amounts = (await wallet.readContract({ + address: AERODROME_ROUTER_ADDRESS, + abi: AERODROME_ROUTER_ABI, + functionName: "getAmountsOut", + args: [atomicAmountIn, [route]], + })) as readonly bigint[]; + + const expectedOut = amounts[amounts.length - 1]; + + // H-2 fix: guard against zero output + if (expectedOut === 0n) { + return "Error: Quote returned zero output. The pool may not exist or have insufficient liquidity for this pair."; + } + + const slippage = args.slippageBps ?? DEFAULT_SLIPPAGE_BPS; + const amountOutMin = (expectedOut * BigInt(10000 - slippage)) / BigInt(10000); + + const approvalResult = await approve( + wallet, + args.tokenIn, + AERODROME_ROUTER_ADDRESS, + atomicAmountIn, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving tokens: ${approvalResult}`; + } + + const deadline = BigInt(Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS); + const walletAddress = wallet.getAddress(); + + const data = encodeFunctionData({ + abi: AERODROME_ROUTER_ABI, + functionName: "swapExactTokensForTokens", + args: [atomicAmountIn, amountOutMin, [route], walletAddress as Hex, deadline], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_ROUTER_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + const formattedMinOut = formatUnits(amountOutMin, tokenOutDetails.decimals); + const formattedExpected = formatUnits(expectedOut, tokenOutDetails.decimals); + + return `Swapped ${args.amountIn} ${tokenInDetails.name} for ~${formattedExpected} ${tokenOutDetails.name} (min: ${formattedMinOut}, slippage: ${slippage / 100}%) on Aerodrome.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error swapping tokens: ${error}`; + } + } + + /** + * Adds liquidity to an Aerodrome pool. + */ + @CreateAction({ + name: "aerodrome_add_liquidity", + description: `Add liquidity to an Aerodrome pool on Base. Deposits two tokens into a pool and receives LP tokens. + +It takes: +- tokenA: The address of the first token +- tokenB: The address of the second token (must differ from tokenA) +- amountA: The amount of the first token in whole units (e.g., '1.5') +- amountB: The amount of the second token in whole units (e.g., '1.5') +- stable: Whether this is a stable pool (for correlated assets) or volatile pool (default: false) +- slippageBps: Maximum slippage in basis points (100 = 1%, default: 1%, max: 10%) + +Warning: Providing liquidity to volatile pools carries impermanent loss risk. If token prices diverge significantly, you may receive less value back than deposited. Stable pools minimize this risk for correlated assets.`, + schema: AerodromeAddLiquiditySchema, + }) + async addLiquidity( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const tokenADetails = await getTokenDetails(wallet, args.tokenA); + const tokenBDetails = await getTokenDetails(wallet, args.tokenB); + + if (!tokenADetails || !tokenBDetails) { + return "Error: Could not fetch token details. Please verify the token addresses are valid ERC20 tokens on Base."; + } + + const atomicAmountA = parseUnits(args.amountA, tokenADetails.decimals); + const atomicAmountB = parseUnits(args.amountB, tokenBDetails.decimals); + + if (atomicAmountA > tokenADetails.balance) { + return `Error: Insufficient ${tokenADetails.name} balance. Have ${tokenADetails.formattedBalance}, need ${args.amountA}.`; + } + if (atomicAmountB > tokenBDetails.balance) { + return `Error: Insufficient ${tokenBDetails.name} balance. Have ${tokenBDetails.formattedBalance}, need ${args.amountB}.`; + } + + // BUG-2 fix: Use quoteAddLiquidity to get pool-ratio-adjusted amounts, then apply slippage + const quoteResult = (await wallet.readContract({ + address: AERODROME_ROUTER_ADDRESS, + abi: AERODROME_ROUTER_ABI, + functionName: "quoteAddLiquidity", + args: [ + args.tokenA as Hex, + args.tokenB as Hex, + args.stable, + AERODROME_POOL_FACTORY_ADDRESS, + atomicAmountA, + atomicAmountB, + ], + })) as readonly [bigint, bigint, bigint]; + + const quotedAmountA = quoteResult[0]; + const quotedAmountB = quoteResult[1]; + + const approvalA = await approve(wallet, args.tokenA, AERODROME_ROUTER_ADDRESS, atomicAmountA); + if (approvalA.startsWith("Error")) { + return `Error approving ${tokenADetails.name}: ${approvalA}`; + } + + const approvalB = await approve(wallet, args.tokenB, AERODROME_ROUTER_ADDRESS, atomicAmountB); + if (approvalB.startsWith("Error")) { + return `Error approving ${tokenBDetails.name}: ${approvalB}`; + } + + const slippage = args.slippageBps ?? DEFAULT_SLIPPAGE_BPS; + const amountAMin = (quotedAmountA * BigInt(10000 - slippage)) / BigInt(10000); + const amountBMin = (quotedAmountB * BigInt(10000 - slippage)) / BigInt(10000); + const deadline = BigInt(Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS); + const walletAddress = wallet.getAddress(); + + const data = encodeFunctionData({ + abi: AERODROME_ROUTER_ABI, + functionName: "addLiquidity", + args: [ + args.tokenA as Hex, + args.tokenB as Hex, + args.stable, + atomicAmountA, + atomicAmountB, + amountAMin, + amountBMin, + walletAddress as Hex, + deadline, + ], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_ROUTER_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Added liquidity: ${args.amountA} ${tokenADetails.name} + ${args.amountB} ${tokenBDetails.name} to Aerodrome ${args.stable ? "stable" : "volatile"} pool.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error adding liquidity: ${error}`; + } + } + + /** + * Removes liquidity from an Aerodrome pool. + * C-1 fix: Uses quoteRemoveLiquidity for proper slippage protection. + */ + @CreateAction({ + name: "aerodrome_remove_liquidity", + description: `Remove liquidity from an Aerodrome pool on Base. Burns LP tokens and receives the underlying tokens back with slippage protection. + +It takes: +- tokenA: The address of the first token +- tokenB: The address of the second token (must differ from tokenA) +- liquidity: The amount of LP tokens to burn in whole units +- stable: Whether this is a stable pool or volatile pool (default: false) +- slippageBps: Maximum slippage in basis points (100 = 1%, default: 1%, max: 10%)`, + schema: AerodromeRemoveLiquiditySchema, + }) + async removeLiquidity( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const poolAddress = (await wallet.readContract({ + address: AERODROME_ROUTER_ADDRESS, + abi: AERODROME_ROUTER_ABI, + functionName: "poolFor", + args: [args.tokenA as Hex, args.tokenB as Hex, args.stable, AERODROME_POOL_FACTORY_ADDRESS], + })) as Hex; + + const lpDetails = await getTokenDetails(wallet, poolAddress); + if (!lpDetails) { + return "Error: Could not fetch LP token details. The pool may not exist for this token pair."; + } + + const atomicLiquidity = parseUnits(args.liquidity, lpDetails.decimals); + + if (atomicLiquidity > lpDetails.balance) { + return `Error: Insufficient LP balance. Have ${lpDetails.formattedBalance}, tried to remove ${args.liquidity}.`; + } + + // C-1 fix: Quote expected output amounts and apply slippage + const quoteResult = (await wallet.readContract({ + address: AERODROME_ROUTER_ADDRESS, + abi: AERODROME_ROUTER_ABI, + functionName: "quoteRemoveLiquidity", + args: [ + args.tokenA as Hex, + args.tokenB as Hex, + args.stable, + AERODROME_POOL_FACTORY_ADDRESS, + atomicLiquidity, + ], + })) as readonly [bigint, bigint]; + + const expectedAmountA = quoteResult[0]; + const expectedAmountB = quoteResult[1]; + + const slippage = args.slippageBps ?? DEFAULT_SLIPPAGE_BPS; + const amountAMin = (expectedAmountA * BigInt(10000 - slippage)) / BigInt(10000); + const amountBMin = (expectedAmountB * BigInt(10000 - slippage)) / BigInt(10000); + + const approvalResult = await approve( + wallet, + poolAddress, + AERODROME_ROUTER_ADDRESS, + atomicLiquidity, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving LP tokens: ${approvalResult}`; + } + + const deadline = BigInt(Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS); + const walletAddress = wallet.getAddress(); + + const data = encodeFunctionData({ + abi: AERODROME_ROUTER_ABI, + functionName: "removeLiquidity", + args: [ + args.tokenA as Hex, + args.tokenB as Hex, + args.stable, + atomicLiquidity, + amountAMin, + amountBMin, + walletAddress as Hex, + deadline, + ], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_ROUTER_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Removed ${args.liquidity} LP tokens from Aerodrome ${args.stable ? "stable" : "volatile"} pool (slippage: ${slippage / 100}%).\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error removing liquidity: ${error}`; + } + } + + /** + * Creates a veAERO lock by depositing AERO tokens. + */ + @CreateAction({ + name: "aerodrome_create_lock", + description: `Create a veAERO lock by depositing AERO tokens on Aerodrome. Locks AERO to receive a veAERO NFT that grants voting power for directing pool emissions. + +It takes: +- amount: The amount of AERO tokens to lock in whole units (e.g., '100') +- lockDurationDays: Lock duration in days (min 7, max 1460 / 4 years). Longer locks give more voting power + +Important notes: +- Voting power decays linearly toward zero at unlock time. Longer locks = more voting power. +- Lock duration is rounded down to the nearest Thursday epoch boundary on-chain. For example, locking for 8 days starting on a Friday effectively gives ~7 days of lock. +- Returns the veAERO NFT token ID which can be used for voting on pool emissions.`, + schema: AerodromeCreateLockSchema, + }) + async createLock( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const aeroDetails = await getTokenDetails(wallet, AERO_TOKEN_ADDRESS); + if (!aeroDetails) { + return "Error: Could not fetch AERO token details."; + } + + const atomicAmount = parseUnits(args.amount, aeroDetails.decimals); + + if (atomicAmount > aeroDetails.balance) { + return `Error: Insufficient AERO balance. Have ${aeroDetails.formattedBalance}, tried to lock ${args.amount}.`; + } + + const lockDurationSeconds = args.lockDurationDays * 24 * 60 * 60; + + if (lockDurationSeconds < MIN_LOCK_DURATION) { + return "Error: Lock duration must be at least 7 days."; + } + if (lockDurationSeconds > MAX_LOCK_DURATION) { + return "Error: Lock duration cannot exceed 4 years (1460 days)."; + } + + const approvalResult = await approve( + wallet, + AERO_TOKEN_ADDRESS, + AERODROME_VOTING_ESCROW_ADDRESS, + atomicAmount, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving AERO tokens: ${approvalResult}`; + } + + const data = encodeFunctionData({ + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "createLock", + args: [atomicAmount, BigInt(lockDurationSeconds)], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_VOTING_ESCROW_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + // Extract veAERO tokenId from the Deposit event logs + // H-4 fix: filter by emitting contract address + let tokenId = "unknown"; + try { + for (const log of receipt.logs || []) { + if (log.address?.toLowerCase() !== AERODROME_VOTING_ESCROW_ADDRESS.toLowerCase()) { + continue; + } + try { + const decoded = decodeEventLog({ + abi: AERODROME_VOTING_ESCROW_ABI, + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === "Deposit") { + tokenId = String((decoded.args as { tokenId: bigint }).tokenId); + break; + } + } catch { + // Skip logs that don't match the Deposit event + } + } + } catch { + // If log parsing fails entirely, tokenId stays "unknown" + } + + return `Locked ${args.amount} AERO for ${args.lockDurationDays} days on Aerodrome. veAERO NFT token ID: ${tokenId}.\nUse this token ID to vote on pool emissions.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error creating veAERO lock: ${error}`; + } + } + + /** + * Votes with a veAERO NFT for pool emissions on Aerodrome. + * H-3 fix: Verifies NFT ownership and voting power before voting. + * M-4 fix: Checks if already voted this epoch. + */ + @CreateAction({ + name: "aerodrome_vote", + description: `Vote with a veAERO NFT to direct AERO emissions to specific liquidity pools on Aerodrome. + +It takes: +- tokenId: The veAERO NFT token ID to vote with (must be owned by your wallet) +- pools: Array of pool addresses to vote for +- weights: Array of vote weights (relative, normalized by the contract). Must match pools array length + +Important: Votes are cast per epoch (weekly, Thursday to Thursday UTC). Voting directs AERO emissions proportionally to the pools you vote for. veAERO voters earn trading fees and bribes from voted pools.`, + schema: AerodromeVoteSchema, + }) + async vote(wallet: EvmWalletProvider, args: z.infer): Promise { + try { + // H-3 fix: Verify wallet owns the veAERO NFT + const owner = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "ownerOf", + args: [BigInt(args.tokenId)], + })) as Hex; + + const walletAddress = wallet.getAddress(); + if (owner.toLowerCase() !== walletAddress.toLowerCase()) { + return `Error: Your wallet (${walletAddress}) does not own veAERO #${args.tokenId}. Owner is ${owner}.`; + } + + // Check voting power + const votingPower = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "balanceOfNFT", + args: [BigInt(args.tokenId)], + })) as bigint; + + if (votingPower === 0n) { + return `Error: veAERO #${args.tokenId} has zero voting power. The lock may have expired.`; + } + + // M-4 fix: Check if already voted this epoch + const lastVotedTimestamp = (await wallet.readContract({ + address: AERODROME_VOTER_ADDRESS, + abi: AERODROME_VOTER_ABI, + functionName: "lastVoted", + args: [BigInt(args.tokenId)], + })) as bigint; + + const currentEpochStart = BigInt( + Math.floor(Math.floor(Date.now() / 1000) / EPOCH_DURATION) * EPOCH_DURATION, + ); + if (lastVotedTimestamp >= currentEpochStart) { + return `Error: veAERO #${args.tokenId} has already voted this epoch (Thursday to Thursday). You can vote again after the next epoch starts.`; + } + + // Validate gauges exist for all pools + for (const pool of args.pools) { + const gauge = (await wallet.readContract({ + address: AERODROME_VOTER_ADDRESS, + abi: AERODROME_VOTER_ABI, + functionName: "gauges", + args: [pool as Hex], + })) as Hex; + + if (!gauge || gauge === "0x0000000000000000000000000000000000000000") { + return `Error: No gauge found for pool ${pool}. This pool may not have an active gauge.`; + } + } + + const data = encodeFunctionData({ + abi: AERODROME_VOTER_ABI, + functionName: "vote", + args: [BigInt(args.tokenId), args.pools as Hex[], args.weights.map(w => BigInt(w))], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_VOTER_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + const totalWeight = args.weights.reduce((a, b) => a + b, 0); + const voteDetails = args.pools + .map((pool, i) => ` ${pool}: ${((args.weights[i] / totalWeight) * 100).toFixed(1)}%`) + .join("\n"); + + return `Voted with veAERO #${args.tokenId} on Aerodrome:\n${voteDetails}\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error voting: ${error}`; + } + } + + /** + * Increases the locked AERO amount in an existing veAERO position. + */ + @CreateAction({ + name: "aerodrome_increase_amount", + description: `Add more AERO tokens to an existing veAERO lock on Aerodrome. Increases your voting power without creating a new lock. + +It takes: +- tokenId: The veAERO NFT token ID to add more AERO to +- amount: The additional amount of AERO tokens to lock in whole units`, + schema: AerodromeIncreaseAmountSchema, + }) + async increaseAmount( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // NEW-1 fix: verify ownership + const owner = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "ownerOf", + args: [BigInt(args.tokenId)], + })) as Hex; + + const walletAddress = wallet.getAddress(); + if (owner.toLowerCase() !== walletAddress.toLowerCase()) { + return `Error: Your wallet (${walletAddress}) does not own veAERO #${args.tokenId}. Owner is ${owner}.`; + } + + const aeroDetails = await getTokenDetails(wallet, AERO_TOKEN_ADDRESS); + if (!aeroDetails) { + return "Error: Could not fetch AERO token details."; + } + + const atomicAmount = parseUnits(args.amount, aeroDetails.decimals); + + if (atomicAmount > aeroDetails.balance) { + return `Error: Insufficient AERO balance. Have ${aeroDetails.formattedBalance}, tried to add ${args.amount}.`; + } + + const approvalResult = await approve( + wallet, + AERO_TOKEN_ADDRESS, + AERODROME_VOTING_ESCROW_ADDRESS, + atomicAmount, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving AERO tokens: ${approvalResult}`; + } + + const data = encodeFunctionData({ + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "increaseAmount", + args: [BigInt(args.tokenId), atomicAmount], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_VOTING_ESCROW_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Added ${args.amount} AERO to veAERO #${args.tokenId} on Aerodrome.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error increasing lock amount: ${error}`; + } + } + + /** + * Extends the lock duration of an existing veAERO position. + */ + @CreateAction({ + name: "aerodrome_increase_unlock_time", + description: `Extend the lock duration of an existing veAERO position on Aerodrome. Increases your voting power by locking for longer. + +It takes: +- tokenId: The veAERO NFT token ID to extend the lock for +- additionalDays: Additional days to extend the lock (total lock cannot exceed 4 years from now) + +Note: The new unlock time is rounded down to the nearest Thursday epoch boundary on-chain.`, + schema: AerodromeIncreaseUnlockTimeSchema, + }) + async increaseUnlockTime( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // NEW-1 fix: verify ownership + const owner = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "ownerOf", + args: [BigInt(args.tokenId)], + })) as Hex; + + const walletAddress = wallet.getAddress(); + if (owner.toLowerCase() !== walletAddress.toLowerCase()) { + return `Error: Your wallet (${walletAddress}) does not own veAERO #${args.tokenId}. Owner is ${owner}.`; + } + + // D-1/D-2 fix: check lock is not expired and not permanent + const lockInfo = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "locked", + args: [BigInt(args.tokenId)], + })) as readonly [bigint, bigint, boolean]; + + const currentEnd = lockInfo[1]; + const isPermanent = lockInfo[2]; + const now = BigInt(Math.floor(Date.now() / 1000)); + + if (isPermanent) { + return `Error: veAERO #${args.tokenId} is permanently locked. Cannot extend unlock time on a permanent lock.`; + } + + if (currentEnd <= now) { + return `Error: veAERO #${args.tokenId} lock has already expired. Cannot extend an expired lock — create a new lock instead.`; + } + const additionalSeconds = BigInt(args.additionalDays * 24 * 60 * 60); + + // BUG-1 fix: The contract computes new end as (block.timestamp + _lockDuration). + // So _lockDuration must be the TOTAL desired duration from NOW, not a delta. + // To "extend by X days", we compute: (currentEnd - now) + additionalSeconds + // This gives the contract: now + (currentEnd - now) + additionalSeconds = currentEnd + additionalSeconds + const remainingSeconds = currentEnd > now ? currentEnd - now : 0n; + const totalDurationFromNow = remainingSeconds + additionalSeconds; + + // Ceiling check: new end = now + totalDurationFromNow. Must not exceed now + MAX_LOCK_DURATION. + if (totalDurationFromNow > BigInt(MAX_LOCK_DURATION)) { + const maxAdditionalDays = Number((BigInt(MAX_LOCK_DURATION) - remainingSeconds) / BigInt(86400)); + return `Error: Extending by ${args.additionalDays} days would exceed the 4-year maximum lock. Maximum additional days allowed: ~${maxAdditionalDays}.`; + } + + const data = encodeFunctionData({ + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "increaseUnlockTime", + args: [BigInt(args.tokenId), totalDurationFromNow], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_VOTING_ESCROW_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Extended veAERO #${args.tokenId} lock by ${args.additionalDays} days on Aerodrome.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error extending lock duration: ${error}`; + } + } + + /** + * Withdraws unlocked AERO from an expired veAERO position. + */ + @CreateAction({ + name: "aerodrome_withdraw", + description: `Withdraw unlocked AERO tokens from an expired veAERO position on Aerodrome. The lock must have expired before withdrawal is possible. + +It takes: +- tokenId: The veAERO NFT token ID to withdraw from (lock must be expired)`, + schema: AerodromeWithdrawSchema, + }) + async withdraw( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // NEW-1 fix: verify ownership + const owner = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "ownerOf", + args: [BigInt(args.tokenId)], + })) as Hex; + + const walletAddress = wallet.getAddress(); + if (owner.toLowerCase() !== walletAddress.toLowerCase()) { + return `Error: Your wallet (${walletAddress}) does not own veAERO #${args.tokenId}. Owner is ${owner}.`; + } + + // Check lock status + const lockInfo = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "locked", + args: [BigInt(args.tokenId)], + })) as readonly [bigint, bigint, boolean]; + + const lockEnd = lockInfo[1]; + const isPermanent = lockInfo[2]; + const now = BigInt(Math.floor(Date.now() / 1000)); + + if (isPermanent) { + return `Error: veAERO #${args.tokenId} is permanently locked. Call unlockPermanent first.`; + } + + if (lockEnd > now) { + const daysRemaining = Number((lockEnd - now) / BigInt(86400)); + return `Error: veAERO #${args.tokenId} lock has not expired yet. ${daysRemaining} days remaining until unlock.`; + } + + const data = encodeFunctionData({ + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "withdraw", + args: [BigInt(args.tokenId)], + }); + + const txHash = await wallet.sendTransaction({ + to: AERODROME_VOTING_ESCROW_ADDRESS, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Withdrawn AERO from expired veAERO #${args.tokenId} on Aerodrome.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error withdrawing: ${error}`; + } + } + + /** + * Claims trading fees and bribes from voted pools. + */ + @CreateAction({ + name: "aerodrome_claim_rewards", + description: `Claim trading fees and bribes earned from veAERO voting on Aerodrome. + +It takes: +- tokenId: The veAERO NFT token ID to claim rewards for +- pools: Array of pool addresses to claim fees and bribes from +- feeTokens: Array of arrays of fee token addresses per pool (the pool's underlying tokens, e.g., [WETH, USDC]) +- bribeTokens: Array of arrays of bribe reward token addresses per pool (incentive tokens deposited by protocols) + +veAERO voters earn 100% of trading fees and bribes from the pools they voted for. Fee tokens and bribe tokens are different — fees are the pool's underlying tokens, bribes are incentives from protocols.`, + schema: AerodromeClaimRewardsSchema, + }) + async claimRewards( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // D-3 fix: verify ownership (consistent with all other veAERO actions) + const owner = (await wallet.readContract({ + address: AERODROME_VOTING_ESCROW_ADDRESS, + abi: AERODROME_VOTING_ESCROW_ABI, + functionName: "ownerOf", + args: [BigInt(args.tokenId)], + })) as Hex; + + const walletAddress = wallet.getAddress(); + if (owner.toLowerCase() !== walletAddress.toLowerCase()) { + return `Error: Your wallet (${walletAddress}) does not own veAERO #${args.tokenId}. Owner is ${owner}.`; + } + + // Get fee and bribe contract addresses for each pool's gauge + const feeAddresses: Hex[] = []; + const bribeAddresses: Hex[] = []; + + for (const pool of args.pools) { + const gauge = (await wallet.readContract({ + address: AERODROME_VOTER_ADDRESS, + abi: AERODROME_VOTER_ABI, + functionName: "gauges", + args: [pool as Hex], + })) as Hex; + + if (!gauge || gauge === "0x0000000000000000000000000000000000000000") { + return `Error: No gauge found for pool ${pool}.`; + } + + const feeAddress = (await wallet.readContract({ + address: AERODROME_VOTER_ADDRESS, + abi: AERODROME_VOTER_ABI, + functionName: "gaugeToFees", + args: [gauge], + })) as Hex; + + const bribeAddress = (await wallet.readContract({ + address: AERODROME_VOTER_ADDRESS, + abi: AERODROME_VOTER_ABI, + functionName: "gaugeToBribe", + args: [gauge], + })) as Hex; + + feeAddresses.push(feeAddress); + bribeAddresses.push(bribeAddress); + } + + const results: string[] = []; + + // Claim fees + try { + const feeData = encodeFunctionData({ + abi: AERODROME_VOTER_ABI, + functionName: "claimFees", + args: [feeAddresses, args.feeTokens as Hex[][], BigInt(args.tokenId)], + }); + + const feeTxHash = await wallet.sendTransaction({ + to: AERODROME_VOTER_ADDRESS, + data: feeData, + }); + await wallet.waitForTransactionReceipt(feeTxHash); + results.push(`Claimed trading fees. Tx: ${feeTxHash}`); + } catch (error) { + results.push(`Fee claim skipped or failed: ${error}`); + } + + // Claim bribes + try { + const bribeData = encodeFunctionData({ + abi: AERODROME_VOTER_ABI, + functionName: "claimBribes", + args: [bribeAddresses, args.bribeTokens as Hex[][], BigInt(args.tokenId)], + }); + + const bribeTxHash = await wallet.sendTransaction({ + to: AERODROME_VOTER_ADDRESS, + data: bribeData, + }); + await wallet.waitForTransactionReceipt(bribeTxHash); + results.push(`Claimed bribes. Tx: ${bribeTxHash}`); + } catch (error) { + results.push(`Bribe claim skipped or failed: ${error}`); + } + + // NEW-5 fix: reflect actual outcome in message + const hasSuccess = results.some(r => r.startsWith("Claimed")); + const prefix = hasSuccess + ? `Claimed rewards for veAERO #${args.tokenId} on Aerodrome:` + : `Warning: No rewards were successfully claimed for veAERO #${args.tokenId}:`; + return `${prefix}\n${results.join("\n")}`; + } catch (error) { + return `Error claiming rewards: ${error}`; + } + } + + /** + * Checks if the Aerodrome action provider supports the given network. + */ + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && SUPPORTED_NETWORKS.includes(network.networkId!); +} + +export const aerodromeActionProvider = () => new AerodromeActionProvider(); diff --git a/typescript/agentkit/src/action-providers/aerodrome/constants.ts b/typescript/agentkit/src/action-providers/aerodrome/constants.ts new file mode 100644 index 000000000..82b74817f --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/constants.ts @@ -0,0 +1,314 @@ +import { Address } from "viem"; + +// Aerodrome contract addresses on Base Mainnet +export const AERODROME_ROUTER_ADDRESS: Address = "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43"; +export const AERODROME_VOTER_ADDRESS: Address = "0x16613524e02ad97eDfeF371bC883F2F5d6C480A5"; +export const AERODROME_VOTING_ESCROW_ADDRESS: Address = + "0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4"; +export const AERODROME_POOL_FACTORY_ADDRESS: Address = + "0x420DD381b31aEf6683db6B902084cB0FFECe40Da"; +export const AERO_TOKEN_ADDRESS: Address = "0x940181a94A35A4569E4529A3CDfB74e38FD98631"; + +// Default swap deadline: 10 minutes from now +export const DEFAULT_DEADLINE_SECONDS = 600; + +// Default slippage: 1% +export const DEFAULT_SLIPPAGE_BPS = 100; + +// Max lock duration: 4 years in seconds (matches VotingEscrow MAXTIME) +export const MAX_LOCK_DURATION = 4 * 365 * 24 * 60 * 60; + +// Min lock duration: 1 week in seconds +export const MIN_LOCK_DURATION = 7 * 24 * 60 * 60; + +// Epoch duration: 1 week in seconds (Thursday to Thursday) +export const EPOCH_DURATION = 7 * 24 * 60 * 60; + +export const AERODROME_ROUTER_ABI = [ + { + 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", + }, + { + 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: "address", name: "tokenA", type: "address" }, + { internalType: "address", name: "tokenB", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "uint256", name: "amountADesired", type: "uint256" }, + { internalType: "uint256", name: "amountBDesired", type: "uint256" }, + { internalType: "uint256", name: "amountAMin", type: "uint256" }, + { internalType: "uint256", name: "amountBMin", type: "uint256" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + ], + name: "addLiquidity", + outputs: [ + { internalType: "uint256", name: "amountA", type: "uint256" }, + { internalType: "uint256", name: "amountB", type: "uint256" }, + { internalType: "uint256", name: "liquidity", type: "uint256" }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "tokenA", type: "address" }, + { internalType: "address", name: "tokenB", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "uint256", name: "liquidity", type: "uint256" }, + { internalType: "uint256", name: "amountAMin", type: "uint256" }, + { internalType: "uint256", name: "amountBMin", type: "uint256" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + ], + name: "removeLiquidity", + outputs: [ + { internalType: "uint256", name: "amountA", type: "uint256" }, + { internalType: "uint256", name: "amountB", type: "uint256" }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "tokenA", type: "address" }, + { internalType: "address", name: "tokenB", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "address", name: "_factory", type: "address" }, + ], + name: "poolFor", + outputs: [{ internalType: "address", name: "pool", type: "address" }], + stateMutability: "view", + type: "function", + }, + // BUG-2 fix: quoteAddLiquidity for proper slippage calculation + { + inputs: [ + { internalType: "address", name: "tokenA", type: "address" }, + { internalType: "address", name: "tokenB", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "address", name: "_factory", type: "address" }, + { internalType: "uint256", name: "amountADesired", type: "uint256" }, + { internalType: "uint256", name: "amountBDesired", type: "uint256" }, + ], + name: "quoteAddLiquidity", + outputs: [ + { internalType: "uint256", name: "amountA", type: "uint256" }, + { internalType: "uint256", name: "amountB", type: "uint256" }, + { internalType: "uint256", name: "liquidity", type: "uint256" }, + ], + stateMutability: "view", + type: "function", + }, + // C-3 fix: quoteRemoveLiquidity for safe LP removal with slippage + { + inputs: [ + { internalType: "address", name: "tokenA", type: "address" }, + { internalType: "address", name: "tokenB", type: "address" }, + { internalType: "bool", name: "stable", type: "bool" }, + { internalType: "address", name: "_factory", type: "address" }, + { internalType: "uint256", name: "liquidity", type: "uint256" }, + ], + name: "quoteRemoveLiquidity", + outputs: [ + { internalType: "uint256", name: "amountA", type: "uint256" }, + { internalType: "uint256", name: "amountB", type: "uint256" }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +export const AERODROME_VOTING_ESCROW_ABI = [ + { + inputs: [ + { internalType: "uint256", name: "_value", type: "uint256" }, + { internalType: "uint256", name: "_lockDuration", type: "uint256" }, + ], + name: "createLock", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + { internalType: "uint256", name: "_value", type: "uint256" }, + ], + name: "increaseAmount", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + { internalType: "uint256", name: "_lockDuration", type: "uint256" }, + ], + name: "increaseUnlockTime", + 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: "balanceOfNFT", + 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", + }, + { + inputs: [{ internalType: "uint256", name: "_tokenId", type: "uint256" }], + name: "locked", + outputs: [ + { internalType: "int128", name: "amount", type: "int128" }, + { internalType: "uint256", name: "end", type: "uint256" }, + { internalType: "bool", name: "isPermanent", type: "bool" }, + ], + stateMutability: "view", + type: "function", + }, + // C-2 fix: Correct Deposit event ABI — depositType is indexed and at position 3 + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "provider", type: "address" }, + { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, + { + indexed: true, + internalType: "enum IVotingEscrow.DepositType", + name: "depositType", + type: "uint8", + }, + { indexed: false, internalType: "uint256", name: "value", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "locktime", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "ts", type: "uint256" }, + ], + name: "Deposit", + type: "event", + }, +] as const; + +export const AERODROME_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: "address", name: "pool", type: "address" }], + name: "gauges", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + 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: "_bribes", type: "address[]" }, + { internalType: "address[][]", name: "_tokens", type: "address[][]" }, + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + ], + name: "claimBribes", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address[]", name: "_fees", type: "address[]" }, + { internalType: "address[][]", name: "_tokens", type: "address[][]" }, + { internalType: "uint256", name: "_tokenId", type: "uint256" }, + ], + name: "claimFees", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address[]", name: "_gauges", type: "address[]" }], + name: "claimRewards", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "gauge", type: "address" }], + name: "gaugeToBribe", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "gauge", type: "address" }], + name: "gaugeToFees", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +] as const; 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..a0647dc46 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export * from "./aerodromeActionProvider"; diff --git a/typescript/agentkit/src/action-providers/aerodrome/schemas.ts b/typescript/agentkit/src/action-providers/aerodrome/schemas.ts new file mode 100644 index 000000000..ccd6e6390 --- /dev/null +++ b/typescript/agentkit/src/action-providers/aerodrome/schemas.ts @@ -0,0 +1,272 @@ +import { z } from "zod"; + +const addressRegex = /^0x[a-fA-F0-9]{40}$/; +const amountRegex = /^\d+(\.\d+)?$/; +const tokenIdRegex = /^\d+$/; + +/** + * Input schema for getting a swap quote on Aerodrome. + */ +export const AerodromeGetQuoteSchema = z + .object({ + tokenIn: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the input token"), + tokenOut: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the output token"), + amountIn: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The amount of input tokens in whole units (e.g., '1.5' for 1.5 tokens)"), + stable: z + .boolean() + .default(false) + .describe( + "Whether to use the stable pool (for correlated assets like USDC/USDbC) or volatile pool (default)", + ), + }) + .refine(data => data.tokenIn.toLowerCase() !== data.tokenOut.toLowerCase(), { + message: "tokenIn and tokenOut must be different tokens", + }) + .describe("Input schema for getting a swap quote on Aerodrome"); + +/** + * Input schema for swapping tokens on Aerodrome. + */ +export const AerodromeSwapSchema = z + .object({ + tokenIn: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the input token"), + tokenOut: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the output token"), + amountIn: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The amount of input tokens in whole units (e.g., '1.5' for 1.5 tokens)"), + slippageBps: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe("Maximum slippage in basis points (100 = 1%, max 1000 = 10%). Default is 1%"), + stable: z + .boolean() + .default(false) + .describe( + "Whether to use the stable pool (for correlated assets like USDC/USDbC) or volatile pool (default)", + ), + }) + .refine(data => data.tokenIn.toLowerCase() !== data.tokenOut.toLowerCase(), { + message: "tokenIn and tokenOut must be different tokens", + }) + .describe("Input schema for swapping tokens on Aerodrome"); + +/** + * Input schema for adding liquidity on Aerodrome. + */ +export const AerodromeAddLiquiditySchema = z + .object({ + tokenA: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the first token"), + tokenB: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the second token"), + amountA: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The amount of the first token in whole units (e.g., '1.5')"), + amountB: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The amount of the second token in whole units (e.g., '1.5')"), + stable: z + .boolean() + .default(false) + .describe("Whether this is a stable pool (for correlated assets) or volatile pool (default)"), + slippageBps: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe("Maximum slippage in basis points (100 = 1%, max 1000 = 10%). Default is 1%"), + }) + .refine(data => data.tokenA.toLowerCase() !== data.tokenB.toLowerCase(), { + message: "tokenA and tokenB must be different tokens", + }) + .describe("Input schema for adding liquidity on Aerodrome"); + +/** + * Input schema for removing liquidity on Aerodrome. + */ +export const AerodromeRemoveLiquiditySchema = z + .object({ + tokenA: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the first token"), + tokenB: z + .string() + .regex(addressRegex, "Invalid Ethereum address format") + .describe("The address of the second token"), + liquidity: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The amount of LP tokens to remove in whole units"), + stable: z + .boolean() + .default(false) + .describe("Whether this is a stable pool or volatile pool"), + slippageBps: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe("Maximum slippage in basis points (100 = 1%, max 1000 = 10%). Default is 1%"), + }) + .refine(data => data.tokenA.toLowerCase() !== data.tokenB.toLowerCase(), { + message: "tokenA and tokenB must be different tokens", + }) + .describe("Input schema for removing liquidity on Aerodrome"); + +/** + * Input schema for creating a veAERO lock on Aerodrome. + */ +export const AerodromeCreateLockSchema = z + .object({ + amount: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The amount of AERO tokens to lock in whole units (e.g., '100')"), + lockDurationDays: z + .number() + .int() + .min(7) + .max(1460) + .describe( + "Lock duration in days (minimum 7 days, maximum 1460 days / 4 years). Longer locks give more voting power. Duration is rounded down to the nearest Thursday epoch boundary on-chain", + ), + }) + .describe("Input schema for creating a veAERO lock on Aerodrome"); + +/** + * Input schema for voting with veAERO on Aerodrome. + */ +export const AerodromeVoteSchema = z + .object({ + tokenId: z + .string() + .regex(tokenIdRegex, "Must be a valid token ID") + .refine(s => BigInt(s) > 0n, "tokenId must be a positive number") + .describe("The veAERO NFT token ID to vote with"), + pools: z + .array(z.string().regex(addressRegex, "Invalid Ethereum address format")) + .min(1) + .describe("Array of pool addresses to vote for"), + weights: z + .array(z.number().int().min(1)) + .min(1) + .describe( + "Array of vote weights for each pool (relative, normalized by the contract). Must match pools array length", + ), + }) + .refine(data => data.pools.length === data.weights.length, { + message: "pools and weights arrays must have the same length", + }) + .describe("Input schema for voting with veAERO on Aerodrome"); + +/** + * Input schema for increasing the locked AERO amount in an existing veAERO position. + */ +export const AerodromeIncreaseAmountSchema = z + .object({ + tokenId: z + .string() + .regex(tokenIdRegex, "Must be a valid token ID") + .refine(s => BigInt(s) > 0n, "tokenId must be a positive number") + .describe("The veAERO NFT token ID to add more AERO to"), + amount: z + .string() + .regex(amountRegex, "Must be a valid integer or decimal value") + .describe("The additional amount of AERO tokens to lock in whole units"), + }) + .describe("Input schema for increasing locked AERO amount on Aerodrome"); + +/** + * Input schema for extending the lock duration of an existing veAERO position. + */ +export const AerodromeIncreaseUnlockTimeSchema = z + .object({ + tokenId: z + .string() + .regex(tokenIdRegex, "Must be a valid token ID") + .refine(s => BigInt(s) > 0n, "tokenId must be a positive number") + .describe("The veAERO NFT token ID to extend the lock for"), + additionalDays: z + .number() + .int() + .min(7) + .max(1460) + .describe("Additional days to extend the lock (total lock cannot exceed 4 years)"), + }) + .describe("Input schema for extending veAERO lock duration on Aerodrome"); + +/** + * Input schema for withdrawing unlocked AERO from an expired veAERO position. + */ +export const AerodromeWithdrawSchema = z + .object({ + tokenId: z + .string() + .regex(tokenIdRegex, "Must be a valid token ID") + .refine(s => BigInt(s) > 0n, "tokenId must be a positive number") + .describe("The veAERO NFT token ID to withdraw from (lock must be expired)"), + }) + .describe("Input schema for withdrawing unlocked AERO from Aerodrome"); + +/** + * Input schema for claiming trading fees and bribes from voted pools. + */ +export const AerodromeClaimRewardsSchema = z + .object({ + tokenId: z + .string() + .regex(tokenIdRegex, "Must be a valid token ID") + .refine(s => BigInt(s) > 0n, "tokenId must be a positive number") + .describe("The veAERO NFT token ID to claim rewards for"), + pools: z + .array(z.string().regex(addressRegex, "Invalid Ethereum address format")) + .min(1) + .describe("Array of pool addresses to claim fees and bribes from"), + feeTokens: z + .array(z.array(z.string().regex(addressRegex, "Invalid Ethereum address format"))) + .min(1) + .describe( + "Array of arrays of fee token addresses per pool. Fee tokens are the pool's underlying tokens (e.g., [WETH, USDC] for the WETH/USDC pool)", + ), + bribeTokens: z + .array(z.array(z.string().regex(addressRegex, "Invalid Ethereum address format"))) + .min(1) + .describe( + "Array of arrays of bribe reward token addresses per pool. Bribe tokens are incentive tokens deposited by protocols (may differ from the pool's underlying tokens)", + ), + }) + .refine( + data => + data.pools.length === data.feeTokens.length && + data.pools.length === data.bribeTokens.length, + { message: "pools, feeTokens, and bribeTokens arrays must have the same length" }, + ) + .describe("Input schema for claiming Aerodrome fees and bribes"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..a167ffd40 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -4,6 +4,7 @@ export * from "./actionProvider"; export * from "./customActionProvider"; export * from "./across"; +export * from "./aerodrome"; export * from "./alchemy"; export * from "./baseAccount"; export * from "./basename";