From 710af5b7c5ea67fb6036d498f7bda56909d58ff2 Mon Sep 17 00:00:00 2001 From: Thedongraphix Date: Sun, 6 Jul 2025 11:58:25 +0300 Subject: [PATCH] feat(agentkit): add uniswap v3 action provider for base --- .../agentkit/src/action-providers/index.ts | 1 + .../__tests__/uniswapActionProvider.test.ts | 23 ++ .../src/action-providers/uniswap/constants.ts | 116 ++++++++ .../src/action-providers/uniswap/index.ts | 3 + .../src/action-providers/uniswap/schemas.ts | 39 +++ .../uniswap/uniswapActionProvider.ts | 269 ++++++++++++++++++ 6 files changed, 451 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/uniswap/__tests__/uniswapActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/uniswap/constants.ts create mode 100644 typescript/agentkit/src/action-providers/uniswap/index.ts create mode 100644 typescript/agentkit/src/action-providers/uniswap/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/uniswap/uniswapActionProvider.ts diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 2ea47ab83..14c6cd7ad 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -30,3 +30,4 @@ export * from "./onramp"; export * from "./vaultsfyi"; export * from "./x402"; export * from "./zerodev"; +export * from "./uniswap"; diff --git a/typescript/agentkit/src/action-providers/uniswap/__tests__/uniswapActionProvider.test.ts b/typescript/agentkit/src/action-providers/uniswap/__tests__/uniswapActionProvider.test.ts new file mode 100644 index 000000000..27462bb5d --- /dev/null +++ b/typescript/agentkit/src/action-providers/uniswap/__tests__/uniswapActionProvider.test.ts @@ -0,0 +1,23 @@ +import { uniswapActionProvider } from "../uniswapActionProvider"; + +describe("UniswapActionProvider", () => { + const actionProvider = uniswapActionProvider(); + + describe("supportsNetwork", () => { + it("should return true for base-mainnet", () => { + expect(actionProvider.supportsNetwork({ networkId: "base-mainnet", protocolFamily: "evm" })).toBe(true); + }); + + it("should return true for base-sepolia", () => { + expect(actionProvider.supportsNetwork({ networkId: "base-sepolia", protocolFamily: "evm" })).toBe(true); + }); + + it("should return false for ethereum-mainnet", () => { + expect(actionProvider.supportsNetwork({ networkId: "ethereum-mainnet", protocolFamily: "evm" })).toBe(false); + }); + + it("should return false for solana", () => { + expect(actionProvider.supportsNetwork({ protocolFamily: "solana", networkId: "solana-mainnet" })).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/uniswap/constants.ts b/typescript/agentkit/src/action-providers/uniswap/constants.ts new file mode 100644 index 000000000..3ee9ff721 --- /dev/null +++ b/typescript/agentkit/src/action-providers/uniswap/constants.ts @@ -0,0 +1,116 @@ +import type { Abi } from "abitype"; + +/** + * Uniswap V3 contract addresses for Base networks + */ +export const UNISWAP_ADDRESSES: Record> = { + "base-sepolia": { + SwapRouter02: "0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4", + Quoter: "0xC5290058841028F1614F3A6F0F5816cAd0df5E27", + WETH: "0x4200000000000000000000000000000000000006", + }, + "base-mainnet": { + SwapRouter02: "0x2626664c2603336E57B271c5C0b26F421741e481", + Quoter: "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a", + WETH: "0x4200000000000000000000000000000000000006", + }, +}; + +/** + * Uniswap V3 Quoter contract ABI (subset for quoting) + */ +export const UNISWAP_QUOTER_ABI: Abi = [ + { + inputs: [ + { + components: [ + { internalType: "address", name: "tokenIn", type: "address" }, + { internalType: "address", name: "tokenOut", type: "address" }, + { internalType: "uint256", name: "amountIn", type: "uint256" }, + { internalType: "uint24", name: "fee", type: "uint24" }, + { internalType: "uint160", name: "sqrtPriceLimitX96", type: "uint160" }, + ], + internalType: "struct IQuoterV2.QuoteExactInputSingleParams", + name: "params", + type: "tuple", + }, + ], + name: "quoteExactInputSingle", + outputs: [ + { internalType: "uint256", name: "amountOut", type: "uint256" }, + { internalType: "uint160", name: "sqrtPriceX96After", type: "uint160" }, + { internalType: "uint32", name: "initializedTicksCrossed", type: "uint32" }, + { internalType: "uint256", name: "gasEstimate", type: "uint256" }, + ], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +/** + * Uniswap V3 SwapRouter02 contract ABI (subset for swapping) + */ +export const UNISWAP_ROUTER_ABI: Abi = [ + { + inputs: [ + { + components: [ + { internalType: "address", name: "tokenIn", type: "address" }, + { internalType: "address", name: "tokenOut", type: "address" }, + { internalType: "uint24", name: "fee", type: "uint24" }, + { internalType: "address", name: "recipient", type: "address" }, + { internalType: "uint256", name: "amountIn", type: "uint256" }, + { internalType: "uint256", name: "amountOutMinimum", type: "uint256" }, + { internalType: "uint160", name: "sqrtPriceLimitX96", type: "uint160" }, + ], + internalType: "struct IV3SwapRouter.ExactInputSingleParams", + name: "params", + type: "tuple", + }, + ], + name: "exactInputSingle", + outputs: [{ internalType: "uint256", name: "amountOut", type: "uint256" }], + stateMutability: "payable", + type: "function", + }, +] as const; + +/** + * Standard ERC20 token ABI (subset needed for approvals and metadata) + */ +export const ERC20_ABI: Abi = [ + { + inputs: [ + { internalType: "address", name: "spender", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + ], + name: "approve", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "address", name: "spender", type: "address" }, + ], + name: "allowance", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "decimals", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, +] as const; \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/uniswap/index.ts b/typescript/agentkit/src/action-providers/uniswap/index.ts new file mode 100644 index 000000000..c605ca441 --- /dev/null +++ b/typescript/agentkit/src/action-providers/uniswap/index.ts @@ -0,0 +1,3 @@ +export * from "./schemas"; +export * from "./constants"; +export * from "./uniswapActionProvider"; \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/uniswap/schemas.ts b/typescript/agentkit/src/action-providers/uniswap/schemas.ts new file mode 100644 index 000000000..fef499972 --- /dev/null +++ b/typescript/agentkit/src/action-providers/uniswap/schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +/** + * Input schema for Uniswap token swap action. + */ +export const SwapSchema = z + .object({ + tokenIn: z.string().describe("The contract address of the token to swap from"), + tokenOut: z.string().describe("The contract address of the token to swap to"), + amount: z.string().describe("The amount of tokenIn to swap (in token units, e.g. '1.5')"), + slippageTolerance: z + .number() + .min(0) + .max(100) + .default(0.5) + .describe("Maximum acceptable slippage percentage (0-100, default: 0.5%)"), + fee: z + .number() + .default(3000) + .describe("Pool fee tier in hundredths of a bip (default: 3000 = 0.3%)"), + }) + .strip() + .describe("Instructions for swapping tokens on Uniswap V3"); + +/** + * Input schema for getting a quote for a Uniswap swap. + */ +export const QuoteSchema = z + .object({ + tokenIn: z.string().describe("The contract address of the token to swap from"), + tokenOut: z.string().describe("The contract address of the token to swap to"), + amount: z.string().describe("The amount of tokenIn to quote (in token units, e.g. '1.5')"), + fee: z + .number() + .default(3000) + .describe("Pool fee tier in hundredths of a bip (default: 3000 = 0.3%)"), + }) + .strip() + .describe("Instructions for getting a quote for a Uniswap V3 swap"); \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/uniswap/uniswapActionProvider.ts b/typescript/agentkit/src/action-providers/uniswap/uniswapActionProvider.ts new file mode 100644 index 000000000..4125b607c --- /dev/null +++ b/typescript/agentkit/src/action-providers/uniswap/uniswapActionProvider.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; +import { encodeFunctionData, parseUnits, formatUnits, getAddress } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { SwapSchema, QuoteSchema } from "./schemas"; +import { + UNISWAP_ADDRESSES, + UNISWAP_QUOTER_ABI, + UNISWAP_ROUTER_ABI, + ERC20_ABI, +} from "./constants"; + +/** + * Uniswap V3 Action Provider for Base network + * Enables token swaps and quotes on Uniswap V3 DEX + */ +export class UniswapActionProvider extends ActionProvider { + /** + * Constructor for the UniswapActionProvider. + */ + constructor() { + super("uniswap", []); + } + + /** + * Get a quote for a token swap on Uniswap V3. + * + * @param walletProvider - The wallet provider to use for contract calls. + * @param args - The input arguments for getting a quote. + * @returns A message containing the quote details. + */ + @CreateAction({ + name: "get_quote", + description: ` + Get a quote for swapping tokens on Uniswap V3. This will show you how many tokens you would receive + for a given input amount without executing the swap. + + Supports all ERC20 tokens available on Uniswap V3 pools on Base network. + `, + schema: QuoteSchema, + }) + async getQuote( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const network = walletProvider.getNetwork(); + const addresses = UNISWAP_ADDRESSES[network.networkId!]; + + if (!addresses) { + return `Error: Uniswap not supported on network ${network.networkId}`; + } + + // Get token decimals for proper amount conversion + const [tokenInDecimals, tokenOutDecimals, tokenInSymbol, tokenOutSymbol] = await Promise.all([ + walletProvider.readContract({ + address: getAddress(args.tokenIn), + abi: ERC20_ABI, + functionName: "decimals", + args: [], + }), + walletProvider.readContract({ + address: getAddress(args.tokenOut), + abi: ERC20_ABI, + functionName: "decimals", + args: [], + }), + walletProvider.readContract({ + address: getAddress(args.tokenIn), + abi: ERC20_ABI, + functionName: "symbol", + args: [], + }), + walletProvider.readContract({ + address: getAddress(args.tokenOut), + abi: ERC20_ABI, + functionName: "symbol", + args: [], + }), + ]); + + // Convert amount to wei using token decimals + const amountIn = parseUnits(args.amount, tokenInDecimals as number); + + // Get quote from Uniswap + const quoteResult = await walletProvider.readContract({ + address: getAddress(addresses.Quoter), + abi: UNISWAP_QUOTER_ABI, + functionName: "quoteExactInputSingle", + args: [ + { + tokenIn: getAddress(args.tokenIn), + tokenOut: getAddress(args.tokenOut), + amountIn, + fee: args.fee, + sqrtPriceLimitX96: 0, + }, + ], + }); + + const amountOut = (quoteResult as any)[0] as bigint; + const formattedAmountOut = formatUnits(amountOut, tokenOutDecimals as number); + + return `Quote: ${args.amount} ${tokenInSymbol} → ${formattedAmountOut} ${tokenOutSymbol} (Fee: ${args.fee / 10000}%)`; + } catch (error) { + return `Error getting quote: ${error}`; + } + } + + /** + * Swap tokens on Uniswap V3. + * + * @param walletProvider - The wallet provider to execute the swap. + * @param args - The input arguments for the swap. + * @returns A message containing the swap transaction details. + */ + @CreateAction({ + name: "swap", + description: ` + Swap tokens on Uniswap V3. This will execute a token swap on Base network. + + Important notes: + - Ensure you have sufficient balance of the input token + - The swap will automatically handle token approvals if needed + - Uses exact input swap (you specify exact amount in, get variable amount out) + - Supports slippage protection + + For ETH swaps, use the WETH token address: 0x4200000000000000000000000000000000000006 + `, + schema: SwapSchema, + }) + async swap( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const network = walletProvider.getNetwork(); + const addresses = UNISWAP_ADDRESSES[network.networkId!]; + + if (!addresses) { + return `Error: Uniswap not supported on network ${network.networkId}`; + } + + const walletAddress = walletProvider.getAddress(); + + // Get token metadata + const [tokenInDecimals, tokenOutDecimals, tokenInSymbol, tokenOutSymbol] = await Promise.all([ + walletProvider.readContract({ + address: getAddress(args.tokenIn), + abi: ERC20_ABI, + functionName: "decimals", + args: [], + }), + walletProvider.readContract({ + address: getAddress(args.tokenOut), + abi: ERC20_ABI, + functionName: "decimals", + args: [], + }), + walletProvider.readContract({ + address: getAddress(args.tokenIn), + abi: ERC20_ABI, + functionName: "symbol", + args: [], + }), + walletProvider.readContract({ + address: getAddress(args.tokenOut), + abi: ERC20_ABI, + functionName: "symbol", + args: [], + }), + ]); + + const amountIn = parseUnits(args.amount, tokenInDecimals as number); + + // Check if approval is needed (skip for ETH/WETH) + const isWETH = getAddress(args.tokenIn).toLowerCase() === addresses.WETH.toLowerCase(); + + if (!isWETH) { + const allowance = await walletProvider.readContract({ + address: getAddress(args.tokenIn), + abi: ERC20_ABI, + functionName: "allowance", + args: [walletAddress, getAddress(addresses.SwapRouter02)], + }) as bigint; + + if (allowance < amountIn) { + // Approve router to spend tokens + const approveHash = await walletProvider.sendTransaction({ + to: getAddress(args.tokenIn), + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [getAddress(addresses.SwapRouter02), amountIn], + }), + }); + + await walletProvider.waitForTransactionReceipt(approveHash); + } + } + + // Get quote for minimum amount out calculation + const quoteResult = await walletProvider.readContract({ + address: getAddress(addresses.Quoter), + abi: UNISWAP_QUOTER_ABI, + functionName: "quoteExactInputSingle", + args: [ + { + tokenIn: getAddress(args.tokenIn), + tokenOut: getAddress(args.tokenOut), + amountIn, + fee: args.fee, + sqrtPriceLimitX96: 0, + }, + ], + }); + + const expectedAmountOut = (quoteResult as any)[0] as bigint; + const slippageBps = Math.floor(args.slippageTolerance * 100); // Convert % to basis points + const amountOutMinimum = (expectedAmountOut * BigInt(10000 - slippageBps)) / BigInt(10000); + + // Execute the swap + const swapHash = await walletProvider.sendTransaction({ + to: getAddress(addresses.SwapRouter02), + data: encodeFunctionData({ + abi: UNISWAP_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: getAddress(args.tokenIn), + tokenOut: getAddress(args.tokenOut), + fee: args.fee, + recipient: walletAddress, + amountIn, + amountOutMinimum, + sqrtPriceLimitX96: 0, + }, + ], + }), + value: isWETH ? amountIn : 0n, + }); + + await walletProvider.waitForTransactionReceipt(swapHash); + + const expectedAmountOutFormatted = formatUnits(expectedAmountOut, tokenOutDecimals as number); + const minAmountOutFormatted = formatUnits(amountOutMinimum, tokenOutDecimals as number); + + return `Successfully swapped ${args.amount} ${tokenInSymbol} for ~${expectedAmountOutFormatted} ${tokenOutSymbol}\n` + + `Minimum received: ${minAmountOutFormatted} ${tokenOutSymbol} (${args.slippageTolerance}% slippage)\n` + + `Transaction hash: ${swapHash}`; + + } catch (error) { + return `Error executing swap: ${error}`; + } + } + + /** + * Checks if the Uniswap action provider supports the given network. + * + * @param network - The network to check. + * @returns True if the network is Base mainnet or sepolia, false otherwise. + */ + supportsNetwork = (network: Network) => + network.networkId === "base-mainnet" || network.networkId === "base-sepolia"; +} + +export const uniswapActionProvider = () => new UniswapActionProvider(); \ No newline at end of file