diff --git a/typescript/.changeset/whole-times-fetch.md b/typescript/.changeset/whole-times-fetch.md new file mode 100644 index 000000000..63bb04da8 --- /dev/null +++ b/typescript/.changeset/whole-times-fetch.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added ZeroX Action Provider to enable token swaps using the 0x Protocol API diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 457109e79..1dd2f92a1 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -524,6 +524,15 @@ it will return payment details that can be used on retry. make_http_request_with_x402 Combines make_http_request and retry_http_request_with_x402 into a single step. +ZeroX + + + + + + + +
get_swap_price_quote_from_0xFetches a price quote for swapping between two tokens using the 0x API.
execute_swap_on_0xExecutes a token swap between two tokens using the 0x API.
diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 35ff1f726..7ac55703e 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -33,4 +33,5 @@ export * from "./vaultsfyi"; export * from "./x402"; export * from "./zerion"; export * from "./zerodev"; +export * from "./zeroX"; export * from "./zora"; diff --git a/typescript/agentkit/src/action-providers/zeroX/README.md b/typescript/agentkit/src/action-providers/zeroX/README.md new file mode 100644 index 000000000..7abee511c --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/README.md @@ -0,0 +1,66 @@ +# 0x Action Provider + +This directory contains the **ZeroXActionProvider** implementation, which provides actions to interact with the **0x Protocol** for token swaps on EVM-compatible networks. + +## Directory Structure + +``` +zeroX/ +├── zeroXActionProvider.ts # Main provider with 0x Protocol functionality +├── zeroXActionProvider.test.ts # Tests +├── schemas.ts # Swap schemas +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +- `get_swap_price_quote_from_0x`: Get a price quote for swapping tokens + - Returns detailed price information including exchange rate, fees, and minimum buy amount + - Does not execute any transactions + - Supports optional affiliate fee collection + +- `execute_swap_on_0x`: Execute a token swap between two tokens + - Handles token approvals automatically if needed + - Executes the swap transaction + - Returns the transaction hash and swap details upon success + - Supports optional affiliate fee collection + +## Adding New Actions + +To add new 0x Protocol actions: + +1. Define your action schema in `schemas.ts` +2. Implement the action in `zeroXActionProvider.ts` +3. Add tests in `zeroXActionProvider.test.ts` + +## Network Support + +The 0x provider supports all EVM-compatible networks where the 0x API is available. + +## Configuration + +The provider requires the following configuration: + +```typescript +const provider = zeroXActionProvider({ + apiKey: "your-0x-api-key" // Required for 0x API access +}); +``` + +You can also set the API key via environment variable: + +``` +ZEROX_API_KEY=your-0x-api-key +``` + +## Notes + +- The contract address for native ETH is `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee` +- All token amounts should be specified in whole units (e.g., "1.5" ETH, not wei) +- Slippage setting is optional and defaults to 1% (100 basis points) if not specified +- Always check price quotes before executing swaps to ensure favorable rates +- For affiliate fees: provide swapFeeRecipient to enable fees (swapFeeBps defaults to 1%) +- Affiliate fees are automatically collected from the sellToken and sent to the specified recipient address + +For more information on the **0x Protocol**, visit [0x Protocol Documentation](https://docs.0x.org/). \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/zeroX/index.ts b/typescript/agentkit/src/action-providers/zeroX/index.ts new file mode 100644 index 000000000..dae43042d --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/index.ts @@ -0,0 +1 @@ +export * from "./zeroXActionProvider"; diff --git a/typescript/agentkit/src/action-providers/zeroX/schemas.ts b/typescript/agentkit/src/action-providers/zeroX/schemas.ts new file mode 100644 index 000000000..a55013e4e --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +/** + * Input schema for getting a swap price. + */ +export const GetSwapPriceSchema = z + .object({ + sellToken: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The token contract address to sell"), + buyToken: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The token contract address to buy"), + sellAmount: z + .string() + .describe("The amount of sellToken to sell in whole units (e.g., 1.5 WETH, 10 USDC)"), + slippageBps: z + .number() + .int() + .min(0) + .max(10000) + .optional() + .default(100) + .describe("The maximum acceptable slippage in basis points (0-10000, default: 100)"), + swapFeeRecipient: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .optional() + .describe("The wallet address to receive an affiliate fee on the trade"), + swapFeeBps: z + .number() + .int() + .min(0) + .max(1000) + .default(100) + .describe( + "The amount in basis points (0-1000) of the sellToken to charge as trading fee (defaults to 100 = 1%), only used if swapFeeRecipient is provided", + ), + }) + .strip() + .describe("Get a price quote for swapping one token for another"); + +/** + * Input schema for executing a swap. + */ +export const ExecuteSwapSchema = z + .object({ + sellToken: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The token contract address to sell"), + buyToken: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The token contract address to buy"), + sellAmount: z + .string() + .describe("The amount of sellToken to sell in whole units (e.g., 1.5 WETH, 10 USDC)"), + slippageBps: z + .number() + .int() + .min(0) + .max(10000) + .optional() + .default(100) + .describe("The maximum acceptable slippage in basis points (0-10000, default: 100)"), + swapFeeRecipient: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .optional() + .describe("The wallet address to receive an affiliate fee on the trade"), + swapFeeBps: z + .number() + .int() + .min(0) + .max(1000) + .default(100) + .describe( + "The amount in basis points (0-1000) of the sellToken to charge as trading fee (defaults to 100 = 1%), only used if swapFeeRecipient is provided", + ), + }) + .strip() + .describe("Execute a swap between two tokens"); diff --git a/typescript/agentkit/src/action-providers/zeroX/utils.ts b/typescript/agentkit/src/action-providers/zeroX/utils.ts new file mode 100644 index 000000000..4d9eee7d5 --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/utils.ts @@ -0,0 +1,130 @@ +import { Hex, erc20Abi } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; + +// Permit2 contract address is the same across all networks +export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + +/** + * Checks if a token is native ETH. + * + * @param token - The token address to check. + * @returns True if the token is native ETH, false otherwise. + */ +export function isNativeEth(token: string): boolean { + return token.toLowerCase() === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +} + +/** + * Gets the details (decimals and name) for both fromToken and toToken + * + * @param walletProvider - The EVM wallet provider to read contracts + * @param fromToken - The contract address of the from token + * @param toToken - The contract address of the to token + * @returns Promise<{fromTokenDecimals: number, toTokenDecimals: number, fromTokenName: string, toTokenName: string}> + */ +export async function getTokenDetails( + walletProvider: EvmWalletProvider, + fromToken: string, + toToken: string, +): Promise<{ + fromTokenDecimals: number; + toTokenDecimals: number; + fromTokenName: string; + toTokenName: string; +}> { + // Initialize default values for native ETH + let fromTokenDecimals = 18; + let fromTokenName = "ETH"; + let toTokenDecimals = 18; + let toTokenName = "ETH"; + + // Prepare multicall contracts array + const contracts: { + address: Hex; + abi: typeof erc20Abi; + functionName: "decimals" | "name"; + }[] = []; + const contractIndexMap = { + fromDecimals: -1, + fromName: -1, + toDecimals: -1, + toName: -1, + }; + + // Add from token contracts if not native ETH + if (!isNativeEth(fromToken)) { + contractIndexMap.fromDecimals = contracts.length; + contracts.push({ + address: fromToken as Hex, + abi: erc20Abi, + functionName: "decimals", + }); + + contractIndexMap.fromName = contracts.length; + contracts.push({ + address: fromToken as Hex, + abi: erc20Abi, + functionName: "name", + }); + } + + // Add to token contracts if not native ETH + if (!isNativeEth(toToken)) { + contractIndexMap.toDecimals = contracts.length; + contracts.push({ + address: toToken as Hex, + abi: erc20Abi, + functionName: "decimals", + }); + + contractIndexMap.toName = contracts.length; + contracts.push({ + address: toToken as Hex, + abi: erc20Abi, + functionName: "name", + }); + } + + // Execute multicall if there are contracts to call + if (contracts.length > 0) { + try { + const results = await walletProvider.getPublicClient().multicall({ + contracts, + }); + + // Extract from token details + if (contractIndexMap.fromDecimals !== -1) { + const decimalsResult = results[contractIndexMap.fromDecimals]; + const nameResult = results[contractIndexMap.fromName]; + + if (decimalsResult.status === "success" && nameResult.status === "success") { + fromTokenDecimals = decimalsResult.result as number; + fromTokenName = nameResult.result as string; + } else { + throw new Error( + `Failed to read details for fromToken ${fromToken}. This address may not be a valid ERC20 contract.`, + ); + } + } + + // Extract to token details + if (contractIndexMap.toDecimals !== -1) { + const decimalsResult = results[contractIndexMap.toDecimals]; + const nameResult = results[contractIndexMap.toName]; + + if (decimalsResult.status === "success" && nameResult.status === "success") { + toTokenDecimals = decimalsResult.result as number; + toTokenName = nameResult.result as string; + } else { + throw new Error( + `Failed to read details for toToken ${toToken}. This address may not be a valid ERC20 contract.`, + ); + } + } + } catch (error) { + throw new Error(`Failed to read token details via multicall. Error: ${error}`); + } + } + + return { fromTokenDecimals, toTokenDecimals, fromTokenName, toTokenName }; +} diff --git a/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.test.ts b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.test.ts new file mode 100644 index 000000000..4b4e231cf --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.test.ts @@ -0,0 +1,542 @@ +import { zeroXActionProvider } from "./zeroXActionProvider"; +import { GetSwapPriceSchema, ExecuteSwapSchema } from "./schemas"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { Hex } from "viem"; + +// Mock the fetch function +global.fetch = jest.fn(); + +describe("ZeroX Schema Validation", () => { + it("should validate GetSwapPrice schema with valid input", () => { + const validInput = { + sellToken: "0x1234567890123456789012345678901234567890", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + slippageBps: 50, + }; + + const result = GetSwapPriceSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); + + it("should fail validation with invalid address format", () => { + const invalidInput = { + sellToken: "invalid-address", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + slippageBps: 50, + }; + + const result = GetSwapPriceSchema.safeParse(invalidInput); + expect(result.success).toBe(false); + }); + + it("should use default slippageBps when not provided", () => { + const inputWithoutSlippage = { + sellToken: "0x1234567890123456789012345678901234567890", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + }; + + const result = GetSwapPriceSchema.safeParse(inputWithoutSlippage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.slippageBps).toBe(100); // Default value from schema + } + }); + + it("should validate swap fee parameters when both provided", () => { + const inputWithSwapFees = { + sellToken: "0x1234567890123456789012345678901234567890", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + swapFeeBps: 50, + }; + + const result = GetSwapPriceSchema.safeParse(inputWithSwapFees); + expect(result.success).toBe(true); + }); + + it("should validate when only swapFeeRecipient provided (swapFeeBps defaults to 100)", () => { + const inputWithOnlyRecipient = { + sellToken: "0x1234567890123456789012345678901234567890", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + swapFeeBps: 100, + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + }; + + const result = GetSwapPriceSchema.safeParse(inputWithOnlyRecipient); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.swapFeeBps).toBe(100); // Default value + } + }); + + it("should fail validation when swapFeeBps exceeds maximum", () => { + const inputWithInvalidSwapFeeBps = { + sellToken: "0x1234567890123456789012345678901234567890", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + swapFeeBps: 1500, // Exceeds maximum of 1000 + }; + + const result = GetSwapPriceSchema.safeParse(inputWithInvalidSwapFeeBps); + expect(result.success).toBe(false); + }); + + it("should validate ExecuteSwap schema with valid input", () => { + const validInput = { + sellToken: "0x1234567890123456789012345678901234567890", + buyToken: "0x0987654321098765432109876543210987654321", + sellAmount: "1.5", + slippageBps: 50, + }; + + const result = ExecuteSwapSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); +}); + +describe("ZeroX Action Provider", () => { + let provider: ReturnType; + let mockWalletProvider: jest.Mocked; + + const MOCK_SELL_TOKEN = "0x1234567890123456789012345678901234567890"; + const MOCK_BUY_TOKEN = "0x0987654321098765432109876543210987654321"; + const MOCK_SELL_AMOUNT = "1.5"; + const MOCK_CHAIN_ID = 1; + const MOCK_ADDRESS = "0xabcdef1234567890abcdef1234567890abcdef12"; + + beforeEach(() => { + provider = zeroXActionProvider({ apiKey: "test-api-key" }); + + mockWalletProvider = { + getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ + chainId: MOCK_CHAIN_ID, + protocolFamily: "evm", + networkId: "ethereum-mainnet", + }), + readContract: jest.fn(), + getPublicClient: jest.fn().mockReturnValue({ + multicall: jest.fn(), + }), + sendTransaction: jest.fn(), + waitForTransactionReceipt: jest.fn(), + signTypedData: jest.fn(), + } as unknown as jest.Mocked; + + // Reset mocks + (global.fetch as jest.Mock).mockReset(); + }); + + describe("getSwapPrice", () => { + beforeEach(() => { + // Mock multicall for token details (decimals and names) + const mockMulticallResults = [ + { status: "success", result: 18 }, // sellToken decimals + { status: "success", result: "TEST" }, // sellToken name + { status: "success", result: 6 }, // buyToken decimals + { status: "success", result: "USDC" }, // buyToken name + ]; + + (mockWalletProvider.getPublicClient().multicall as jest.Mock).mockResolvedValue( + mockMulticallResults, + ); + + // Mock fetch for price API + const mockPriceResponse = { + buyAmount: "1000000", // 1 USDC with 6 decimals + minBuyAmount: "990000", // 0.99 USDC with 6 decimals + totalNetworkFee: "100000000000000", // 0.0001 ETH + issues: { balance: null }, + liquidityAvailable: true, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockPriceResponse), + }); + }); + + it("should get swap price successfully", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + const response = await provider.getSwapPrice(mockWalletProvider, args); + const parsedResponse = JSON.parse(response); + + // Verify fetch was called with correct URL params + expect(global.fetch).toHaveBeenCalledTimes(1); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain( + "api.0x.org/swap/permit2/price", + ); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain(`chainId=${MOCK_CHAIN_ID}`); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain( + `sellToken=${MOCK_SELL_TOKEN}`, + ); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain(`buyToken=${MOCK_BUY_TOKEN}`); + + // Verify response formatting + expect(parsedResponse.success).toBe(true); + expect(parsedResponse.sellToken).toBe(MOCK_SELL_TOKEN); + expect(parsedResponse.sellTokenName).toBe("TEST"); + expect(parsedResponse.buyToken).toBe(MOCK_BUY_TOKEN); + expect(parsedResponse.buyTokenName).toBe("USDC"); + expect(parsedResponse.liquidityAvailable).toBe(true); + expect(parsedResponse.balanceEnough).toBe(true); + expect(parsedResponse.slippageBps).toBe(50); + expect(parsedResponse.buyAmount).toBeDefined(); + expect(parsedResponse.minBuyAmount).toBeDefined(); + }); + + it("should handle API errors", async () => { + (global.fetch as jest.Mock).mockReset(); + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + text: jest.fn().mockResolvedValueOnce("Invalid request parameters"), + }); + + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + const response = await provider.getSwapPrice(mockWalletProvider, args); + const parsedResponse = JSON.parse(response); + + expect(parsedResponse.success).toBe(false); + expect(parsedResponse.error).toContain("Error fetching swap price"); + }); + + it("should handle fetch errors", async () => { + (global.fetch as jest.Mock).mockReset(); + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error("Network error")); + + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + const response = await provider.getSwapPrice(mockWalletProvider, args); + const parsedResponse = JSON.parse(response); + + expect(parsedResponse.success).toBe(false); + expect(parsedResponse.error).toContain("Error fetching swap price"); + }); + + it("should include swap fee parameters in API call when provided", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + swapFeeBps: 100, + }; + + await provider.getSwapPrice(mockWalletProvider, args); + + // Verify fetch was called with swap fee parameters + expect(global.fetch).toHaveBeenCalledTimes(1); + const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(fetchUrl).toContain("swapFeeRecipient=0xabcdef1234567890abcdef1234567890abcdef12"); + expect(fetchUrl).toContain("swapFeeBps=100"); + expect(fetchUrl).toContain(`swapFeeToken=${MOCK_SELL_TOKEN}`); + }); + + it("should not include swap fee parameters when not provided", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + await provider.getSwapPrice(mockWalletProvider, args); + + // Verify fetch was called without swap fee parameters + expect(global.fetch).toHaveBeenCalledTimes(1); + const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(fetchUrl).not.toContain("swapFeeRecipient"); + expect(fetchUrl).not.toContain("swapFeeBps"); + expect(fetchUrl).not.toContain("swapFeeToken"); + }); + + it("should include swap fee parameters with default swapFeeBps when only recipient provided", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + }; + + await provider.getSwapPrice(mockWalletProvider, args); + + // Verify fetch was called with swap fee parameters including default swapFeeBps + expect(global.fetch).toHaveBeenCalledTimes(1); + const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(fetchUrl).toContain("swapFeeRecipient=0xabcdef1234567890abcdef1234567890abcdef12"); + expect(fetchUrl).toContain("swapFeeBps=100"); // Default value + expect(fetchUrl).toContain(`swapFeeToken=${MOCK_SELL_TOKEN}`); + }); + }); + + describe("executeSwap", () => { + const MOCK_TX_HASH = "0xtxhash123456"; + + beforeEach(() => { + // Mock multicall for token details (decimals and names) + const mockMulticallResults = [ + { status: "success", result: 18 }, // sellToken decimals + { status: "success", result: "TEST" }, // sellToken name + { status: "success", result: 6 }, // buyToken decimals + { status: "success", result: "USDC" }, // buyToken name + ]; + + (mockWalletProvider.getPublicClient().multicall as jest.Mock).mockResolvedValue( + mockMulticallResults, + ); + + // Mock API responses + const mockPriceResponse = { + buyAmount: "1000000", // 1 USDC with 6 decimals + minBuyAmount: "990000", // 0.99 USDC with 6 decimals + totalNetworkFee: "100000000000000", // 0.0001 ETH + issues: null, + liquidityAvailable: true, + }; + + const mockQuoteResponse = { + buyAmount: "1000000", + minBuyAmount: "990000", + totalNetworkFee: "100000000000000", + transaction: { + to: "0x0000000000000000000000000000000000000001", + data: "0x12345678", + value: "1500000000000000000", // 1.5 ETH + gas: "300000", + gasPrice: "20000000000", + }, + }; + + // First fetch for price + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockPriceResponse), + }); + + // Second fetch for quote + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockQuoteResponse), + }); + + // Mock transaction functions + mockWalletProvider.sendTransaction.mockResolvedValueOnce(MOCK_TX_HASH as Hex); + mockWalletProvider.waitForTransactionReceipt.mockResolvedValueOnce({ + transactionHash: MOCK_TX_HASH, + status: "success", + }); + }); + + it("should execute swap successfully", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + const response = await provider.executeSwap(mockWalletProvider, args); + const parsedResponse = JSON.parse(response); + + // Verify API calls + expect(global.fetch).toHaveBeenCalledTimes(2); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toContain( + "api.0x.org/swap/permit2/price", + ); + expect((global.fetch as jest.Mock).mock.calls[1][0]).toContain( + "api.0x.org/swap/permit2/quote", + ); + + // Verify transaction was sent + expect(mockWalletProvider.sendTransaction).toHaveBeenCalledTimes(1); + expect(mockWalletProvider.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); + + // Verify response formatting + expect(parsedResponse.success).toBe(true); + expect(parsedResponse.sellToken).toBe(MOCK_SELL_TOKEN); + expect(parsedResponse.sellTokenName).toBe("TEST"); + expect(parsedResponse.buyToken).toBe(MOCK_BUY_TOKEN); + expect(parsedResponse.buyTokenName).toBe("USDC"); + expect(parsedResponse.transactionHash).toBe(MOCK_TX_HASH); + expect(parsedResponse.slippageBps).toBe(50); + expect(parsedResponse.network).toBe("ethereum-mainnet"); + }); + + it("should handle price API errors", async () => { + (global.fetch as jest.Mock).mockReset(); + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + text: jest.fn().mockResolvedValueOnce("Invalid request parameters"), + }); + + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + const response = await provider.executeSwap(mockWalletProvider, args); + const parsedResponse = JSON.parse(response); + + expect(parsedResponse.success).toBe(false); + expect(parsedResponse.error).toContain("Error fetching swap price"); + }); + + it("should handle no liquidity available", async () => { + (global.fetch as jest.Mock).mockReset(); + + const mockPriceResponse = { + liquidityAvailable: false, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockPriceResponse), + }); + + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + const response = await provider.executeSwap(mockWalletProvider, args); + const parsedResponse = JSON.parse(response); + + expect(parsedResponse.success).toBe(false); + expect(parsedResponse.error).toContain("No liquidity available"); + }); + + it("should include swap fee parameters in both API calls when provided", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + swapFeeBps: 100, + }; + + await provider.executeSwap(mockWalletProvider, args); + + // Verify both API calls include swap fee parameters + expect(global.fetch).toHaveBeenCalledTimes(2); + + // Check price API call + const priceUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(priceUrl).toContain("swapFeeRecipient=0xabcdef1234567890abcdef1234567890abcdef12"); + expect(priceUrl).toContain("swapFeeBps=100"); + expect(priceUrl).toContain(`swapFeeToken=${MOCK_SELL_TOKEN}`); + + // Check quote API call + const quoteUrl = (global.fetch as jest.Mock).mock.calls[1][0]; + expect(quoteUrl).toContain("swapFeeRecipient=0xabcdef1234567890abcdef1234567890abcdef12"); + expect(quoteUrl).toContain("swapFeeBps=100"); + expect(quoteUrl).toContain(`swapFeeToken=${MOCK_SELL_TOKEN}`); + }); + + it("should not include swap fee parameters when not provided", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + }; + + await provider.executeSwap(mockWalletProvider, args); + + // Verify both API calls exclude swap fee parameters + expect(global.fetch).toHaveBeenCalledTimes(2); + + // Check price API call + const priceUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(priceUrl).not.toContain("swapFeeRecipient"); + expect(priceUrl).not.toContain("swapFeeBps"); + expect(priceUrl).not.toContain("swapFeeToken"); + + // Check quote API call + const quoteUrl = (global.fetch as jest.Mock).mock.calls[1][0]; + expect(quoteUrl).not.toContain("swapFeeRecipient"); + expect(quoteUrl).not.toContain("swapFeeBps"); + expect(quoteUrl).not.toContain("swapFeeToken"); + }); + + it("should include swap fee parameters with default swapFeeBps when only recipient provided", async () => { + const args = { + sellToken: MOCK_SELL_TOKEN, + buyToken: MOCK_BUY_TOKEN, + sellAmount: MOCK_SELL_AMOUNT, + slippageBps: 50, + swapFeeBps: 100, + swapFeeRecipient: "0xabcdef1234567890abcdef1234567890abcdef12", + }; + + await provider.executeSwap(mockWalletProvider, args); + + // Verify both API calls include swap fee parameters with default swapFeeBps + expect(global.fetch).toHaveBeenCalledTimes(2); + + // Check price API call + const priceUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(priceUrl).toContain("swapFeeRecipient=0xabcdef1234567890abcdef1234567890abcdef12"); + expect(priceUrl).toContain("swapFeeBps=100"); // Default value + expect(priceUrl).toContain(`swapFeeToken=${MOCK_SELL_TOKEN}`); + + // Check quote API call + const quoteUrl = (global.fetch as jest.Mock).mock.calls[1][0]; + expect(quoteUrl).toContain("swapFeeRecipient=0xabcdef1234567890abcdef1234567890abcdef12"); + expect(quoteUrl).toContain("swapFeeBps=100"); // Default value + expect(quoteUrl).toContain(`swapFeeToken=${MOCK_SELL_TOKEN}`); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for evm networks", () => { + expect(provider.supportsNetwork({ protocolFamily: "evm" })).toBe(true); + }); + + it("should return false for non-evm networks", () => { + expect(provider.supportsNetwork({ protocolFamily: "solana" })).toBe(false); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts new file mode 100644 index 000000000..7079d6f76 --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts @@ -0,0 +1,447 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { GetSwapPriceSchema, ExecuteSwapSchema } from "./schemas"; +import { EvmWalletProvider, LegacyCdpWalletProvider } from "../../wallet-providers"; +import { + erc20Abi, + formatUnits, + parseUnits, + maxUint256, + encodeFunctionData, + size, + concat, + Hex, + numberToHex, +} from "viem"; +import { getTokenDetails, PERMIT2_ADDRESS } from "./utils"; +/** + * Configuration for the ZeroXActionProvider. + */ +export interface ZeroXActionProviderConfig { + /** + * The API key to use for 0x API requests. + */ + apiKey?: string; +} + +/** + * 0x API Action Provider for token swaps. + * Requires a 0x API key. + */ +export class ZeroXActionProvider extends ActionProvider { + #apiKey: string; + + /** + * Constructor for the ZeroXActionProvider. + * + * @param config - Configuration for the provider. + */ + constructor(config: ZeroXActionProviderConfig) { + super("zerox", []); + const apiKey = config.apiKey || process.env.ZEROX_API_KEY; + if (!apiKey) { + throw new Error("0x API key not provided."); + } + this.#apiKey = apiKey; + } + + /** + * Gets a price quote for swapping one token for another. + * + * @param walletProvider - The wallet provider to get information from. + * @param args - The input arguments for the action. + * @returns A message containing the price quote. + */ + @CreateAction({ + name: "get_swap_price_quote_from_0x", + description: ` +This tool fetches a price quote for swapping between two tokens using the 0x API. + +It takes the following inputs: +- sellToken: The contract address of the token to sell +- buyToken: The contract address of the token to buy +- sellAmount: The amount of sellToken to swap in whole units (e.g. 1 ETH or 10 USDC) +- slippageBps: (Optional) Maximum allowed slippage in basis points (100 = 1%) +- swapFeeRecipient: (Optional) The wallet address to receive affiliate trading fees +- swapFeeBps: The amount in basis points (0-1000) to charge as affiliate fees (defaults to 100 = 1%), only used if swapFeeRecipient is provided + +Important notes: +- The contract address for native ETH is "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +- This only fetches a price quote and does not execute a swap +- Supported on all EVM networks compatible with 0x API +- Use sellToken units exactly as provided, do not convert to wei or any other units +`, + schema: GetSwapPriceSchema, + }) + async getSwapPrice( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const network = walletProvider.getNetwork(); + const chainId = network.chainId; + if (!chainId) throw new Error("Chain ID not available from wallet provider"); + + try { + // Get token details + const { + fromTokenDecimals: sellTokenDecimals, + toTokenDecimals: buyTokenDecimals, + fromTokenName: sellTokenName, + toTokenName: buyTokenName, + } = await getTokenDetails(walletProvider, args.sellToken, args.buyToken); + + // Convert sell amount to base units + const sellAmount = parseUnits(args.sellAmount, sellTokenDecimals).toString(); + + // Create URL for the price API request + const url = new URL("https://api.0x.org/swap/permit2/price"); + url.searchParams.append("chainId", chainId.toString()); + url.searchParams.append("sellToken", args.sellToken); + url.searchParams.append("buyToken", args.buyToken); + url.searchParams.append("sellAmount", sellAmount); + url.searchParams.append("taker", walletProvider.getAddress()); + url.searchParams.append("slippageBps", args.slippageBps.toString()); + if (args.swapFeeRecipient) { + url.searchParams.append("swapFeeRecipient", args.swapFeeRecipient); + url.searchParams.append("swapFeeBps", args.swapFeeBps.toString()); + url.searchParams.append("swapFeeToken", args.sellToken); + } + + // Make the request + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + "0x-api-key": this.#apiKey, + "0x-version": "v2", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return JSON.stringify({ + success: false, + error: `Error fetching swap price: ${response.status} ${response.statusText} - ${errorText}`, + }); + } + + const data = await response.json(); + + // Format the response + const formattedResponse = { + success: true, + sellAmount: formatUnits(BigInt(sellAmount), sellTokenDecimals), + sellTokenName: sellTokenName, + sellToken: args.sellToken, + buyAmount: formatUnits(data.buyAmount, buyTokenDecimals), + minBuyAmount: data.minBuyAmount ? formatUnits(data.minBuyAmount, buyTokenDecimals) : null, + buyTokenName: buyTokenName, + buyToken: args.buyToken, + slippageBps: args.slippageBps, + liquidityAvailable: data.liquidityAvailable, + balanceEnough: data.issues?.balance === null, + priceOfBuyTokenInSellToken: ( + Number(formatUnits(BigInt(sellAmount), sellTokenDecimals)) / + Number(formatUnits(data.buyAmount, buyTokenDecimals)) + ).toString(), + priceOfSellTokenInBuyToken: ( + Number(formatUnits(data.buyAmount, buyTokenDecimals)) / + Number(formatUnits(BigInt(sellAmount), sellTokenDecimals)) + ).toString(), + }; + + return JSON.stringify(formattedResponse); + } catch (error) { + return JSON.stringify({ + success: false, + error: `Error fetching swap price: ${error}`, + }); + } + } + + /** + * Executes a token swap using the 0x API. + * + * @param walletProvider - The wallet provider to use for the swap. + * @param args - The input arguments for the action. + * @returns A message containing the result of the swap. + */ + @CreateAction({ + name: "execute_swap_on_0x", + description: ` +This tool executes a token swap between two tokens using the 0x API. + +It takes the following inputs: +- sellToken: The contract address of the token to sell +- buyToken: The contract address of the token to buy +- sellAmount: The amount of sellToken to swap in whole units (e.g. 1 ETH or 10 USDC) +- slippageBps: (Optional) Maximum allowed slippage in basis points (100 = 1%) +- swapFeeRecipient: (Optional) The wallet address to receive affiliate trading fees +- swapFeeBps: The amount in basis points (0-1000) to charge as affiliate fees (defaults to 100 = 1%), only used if swapFeeRecipient is provided + +Important notes: +- The contract address for native ETH is "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +- This will execute an actual swap transaction that sends tokens from your wallet +- If needed, it will automatically approve the permit2 contract to spend the sell token +- The approval transaction is only needed once per token +- Ensure you have sufficient balance of the sell token before executing +- The trade size might influence the excecution price depending on available liquidity +- First fetch a price quote and only execute swap if you are happy with the indicated price +- Supported on all EVM networks compatible with 0x API +- Use sellToken units exactly as provided, do not convert to wei or any other units +`, + schema: ExecuteSwapSchema, + }) + async executeSwap( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + // Sanity checks + const network = walletProvider.getNetwork(); + const chainId = network.chainId; + if (!chainId) throw new Error("Chain ID not available from wallet provider"); + + try { + // Get token details + const { + fromTokenDecimals: sellTokenDecimals, + toTokenDecimals: buyTokenDecimals, + fromTokenName: sellTokenName, + toTokenName: buyTokenName, + } = await getTokenDetails(walletProvider, args.sellToken, args.buyToken); + + // Convert sell amount to base units + const sellAmount = parseUnits(args.sellAmount, sellTokenDecimals).toString(); + + // Get the wallet address + const walletAddress = walletProvider.getAddress(); + + // Fetch price quote first + const priceUrl = new URL("https://api.0x.org/swap/permit2/price"); + priceUrl.searchParams.append("chainId", chainId.toString()); + priceUrl.searchParams.append("sellToken", args.sellToken); + priceUrl.searchParams.append("buyToken", args.buyToken); + priceUrl.searchParams.append("sellAmount", sellAmount); + priceUrl.searchParams.append("taker", walletAddress); + priceUrl.searchParams.append("slippageBps", args.slippageBps.toString()); + if (args.swapFeeRecipient) { + priceUrl.searchParams.append("swapFeeRecipient", args.swapFeeRecipient); + priceUrl.searchParams.append("swapFeeBps", args.swapFeeBps.toString()); + priceUrl.searchParams.append("swapFeeToken", args.sellToken); + } + + const priceResponse = await fetch(priceUrl.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + "0x-api-key": this.#apiKey, + "0x-version": "v2", + }, + }); + + if (!priceResponse.ok) { + const errorText = await priceResponse.text(); + return JSON.stringify({ + success: false, + error: `Error fetching swap price: ${priceResponse.status} ${priceResponse.statusText} - ${errorText}`, + }); + } + + const priceData = await priceResponse.json(); + + // Check if liquidity is available + if (priceData.liquidityAvailable === false) { + return JSON.stringify({ + success: false, + error: "No liquidity available for this swap.", + }); + } + + // Check if balance of sell token is enough + if (priceData.balance != null) { + return JSON.stringify({ + success: false, + error: `Insufficient balance of sell token ${priceData.balance.token}. Requested to swap ${priceData.balance.expected}, but balance is only ${priceData.balance.actual}.`, + }); + } + + // Check if permit2 approval is needed for ERC20 tokens + // Only needed once per token per address + let approvalTxHash: Hex | null = null; + if (priceData.issues?.allowance) { + try { + approvalTxHash = await walletProvider.sendTransaction({ + to: args.sellToken as Hex, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [PERMIT2_ADDRESS, maxUint256], + }), + }); + + await walletProvider.waitForTransactionReceipt(approvalTxHash); + } catch (error) { + return JSON.stringify({ + success: false, + error: `Error approving token: ${error}`, + }); + } + } + + // Fetch the swap quote + const quoteUrl = new URL("https://api.0x.org/swap/permit2/quote"); + quoteUrl.searchParams.append("chainId", chainId.toString()); + quoteUrl.searchParams.append("sellToken", args.sellToken); + quoteUrl.searchParams.append("buyToken", args.buyToken); + quoteUrl.searchParams.append("sellAmount", sellAmount); + quoteUrl.searchParams.append("taker", walletAddress); + quoteUrl.searchParams.append("slippageBps", args.slippageBps.toString()); + if (args.swapFeeRecipient) { + quoteUrl.searchParams.append("swapFeeRecipient", args.swapFeeRecipient); + quoteUrl.searchParams.append("swapFeeBps", args.swapFeeBps.toString()); + quoteUrl.searchParams.append("swapFeeToken", args.sellToken); + } + + const quoteResponse = await fetch(quoteUrl.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + "0x-api-key": this.#apiKey, + "0x-version": "v2", + }, + }); + + if (!quoteResponse.ok) { + const errorText = await quoteResponse.text(); + return JSON.stringify({ + success: false, + error: `Error fetching swap quote: ${quoteResponse.status} ${quoteResponse.statusText} - ${errorText}`, + }); + } + + const quoteData = await quoteResponse.json(); + + // Sign Permit2.eip712 returned from quote + let signature: Hex | undefined; + if (quoteData.permit2?.eip712) { + try { + // For LegacyCdpWalletProvider, remove EIP712Domain to avoid ambiguous primary types + const types = + walletProvider instanceof LegacyCdpWalletProvider + ? (() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { EIP712Domain, ...rest } = quoteData.permit2.eip712.types; + return rest; + })() + : quoteData.permit2.eip712.types; + + const typedData = { + domain: quoteData.permit2.eip712.domain, + types, + primaryType: quoteData.permit2.eip712.primaryType, + message: quoteData.permit2.eip712.message, + } as const; + + signature = await walletProvider.signTypedData(typedData); + + // Append sig length and sig data to transaction.data + if (signature && quoteData.transaction?.data) { + const signatureLengthInHex = numberToHex(size(signature), { + signed: false, + size: 32, + }); + + const transactionData = quoteData.transaction.data as Hex; + const sigLengthHex = signatureLengthInHex as Hex; + const sig = signature as Hex; + + quoteData.transaction.data = concat([transactionData, sigLengthHex, sig]); + } + } catch (error) { + return JSON.stringify({ + success: false, + error: `Error signing permit2 message: ${error}`, + }); + } + } + + // Execute swap + try { + // Prepare transaction parameters + const txParams: { + to: Hex; + data: Hex; + gas?: bigint; + value?: bigint; + } = { + to: quoteData.transaction.to as Hex, + data: quoteData.transaction.data as Hex, + ...(quoteData?.transaction.gas ? { gas: BigInt(quoteData.transaction.gas) } : {}), + ...(quoteData.transaction.value ? { value: BigInt(quoteData.transaction.value) } : {}), + }; + + // Send transaction + const txHash = await walletProvider.sendTransaction(txParams); + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + if (receipt.status !== "complete" && receipt.status !== "success") { + return JSON.stringify({ + success: false, + ...(approvalTxHash ? { approvalTxHash } : {}), + transactionHash: receipt.transactionHash, + error: `Swap transaction failed`, + }); + } + + // Format the response + const formattedResponse = { + success: true, + ...(approvalTxHash ? { approvalTxHash } : {}), + transactionHash: receipt.transactionHash, + sellAmount: formatUnits(BigInt(sellAmount), sellTokenDecimals), + sellTokenName: sellTokenName, + sellToken: args.sellToken, + buyAmount: formatUnits(quoteData.buyAmount, buyTokenDecimals), + minBuyAmount: quoteData.minBuyAmount + ? formatUnits(quoteData.minBuyAmount, buyTokenDecimals) + : null, + buyTokenName: buyTokenName, + buyToken: args.buyToken, + slippageBps: args.slippageBps, + network: network.networkId, + }; + + return JSON.stringify(formattedResponse); + } catch (error) { + return JSON.stringify({ + success: false, + error: `Error sending swap transaction: ${error}`, + ...(approvalTxHash ? { approvalTxHash } : {}), + }); + } + } catch (error) { + return JSON.stringify({ + success: false, + error: `Error executing swap: ${error}`, + }); + } + } + + /** + * Checks if the ZeroX action provider supports the given network. + * + * @param network - The network to check. + * @returns True if the ZeroX action provider supports the network, false otherwise. + */ + supportsNetwork = (network: Network) => network.protocolFamily === "evm"; +} + +/** + * Creates a new ZeroXActionProvider with the provided configuration. + * + * @param config - Optional configuration for the provider. + * @returns A new ZeroXActionProvider. + */ +export const zeroXActionProvider = (config: ZeroXActionProviderConfig) => + new ZeroXActionProvider(config);