From 4eedd4767348897be644b160ddcd7d40c2759d1e Mon Sep 17 00:00:00 2001 From: myezverseteam Date: Fri, 15 May 2026 18:49:39 +0100 Subject: [PATCH] feat: add EzPath action provider --- .../src/action-providers/ezpath/README.md | 50 ++++ .../src/action-providers/ezpath/constants.ts | 9 + .../ezpath/ezpathActionProvider.test.ts | 227 ++++++++++++++++++ .../ezpath/ezpathActionProvider.ts | 189 +++++++++++++++ .../src/action-providers/ezpath/index.ts | 3 + .../src/action-providers/ezpath/schemas.ts | 39 +++ .../agentkit/src/action-providers/index.ts | 1 + 7 files changed, 518 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/ezpath/README.md create mode 100644 typescript/agentkit/src/action-providers/ezpath/constants.ts create mode 100644 typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/ezpath/index.ts create mode 100644 typescript/agentkit/src/action-providers/ezpath/schemas.ts diff --git a/typescript/agentkit/src/action-providers/ezpath/README.md b/typescript/agentkit/src/action-providers/ezpath/README.md new file mode 100644 index 000000000..a5463e648 --- /dev/null +++ b/typescript/agentkit/src/action-providers/ezpath/README.md @@ -0,0 +1,50 @@ +# EzPath Action Provider + +AgentKit action provider for [EZ-Path](https://ezpath.myezverse.xyz) — a pay-per-request DEX meta-router on Base mainnet that races 0x, ParaSwap, Aerodrome, and Uniswap V3 to return the best swap quote. + +## How it works + +Payment is handled automatically via the [X402 protocol](https://x402.org). On every request the action: + +1. Probes the EZ-Path endpoint — receives an HTTP 402 with the live toll address and tier pricing +2. Signs an EIP-3009 `TransferWithAuthorization` using the agent's wallet (no pre-approval or allowance required) +3. Retries with the signed payment in the `X-Payment` header +4. Returns the normalized quote with routing metadata + +The agent's USDC balance on Base is debited per request. No subscription, no API key. + +## Execution tiers + +| Tier | Cost | Routing logic | +|---|---|---| +| `basic` | $0.03 | Direct 0x execution | +| `resilient` | $0.10 | Concurrent race: 0x/ParaSwap vs Aerodrome — highest `buyAmount` wins | +| `institutional` | $0.50 | Race + Uniswap V3 triple-fee-tier safety net if both lanes fail | + +## Usage + +```typescript +import { AgentKit } from "@coinbase/agentkit"; +import { ezpathActionProvider } from "./ezpath"; + +const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [ezpathActionProvider()], +}); +``` + +The `get_swap_quote` action activates on natural-language swap intent: + +> *"What's the best rate for 1 USDC → WETH on Base?"* +> *"Get me an institutional-tier quote: sell 1000000 USDC atoms, buy WETH"* + +## Prerequisites + +- Agent wallet must hold **USDC on Base mainnet** (contract: `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`) +- The EZ-Path relayer settles the on-chain transfer; no ETH is required by the plugin + +## Links + +- Live endpoint: https://ezpath.myezverse.xyz +- OpenAPI schema: https://ezpath.myezverse.xyz/openapi.json +- Agent manifest: https://ezpath.myezverse.xyz/.well-known/agent.json diff --git a/typescript/agentkit/src/action-providers/ezpath/constants.ts b/typescript/agentkit/src/action-providers/ezpath/constants.ts new file mode 100644 index 000000000..664c17567 --- /dev/null +++ b/typescript/agentkit/src/action-providers/ezpath/constants.ts @@ -0,0 +1,9 @@ +export const EZPATH_API = "https://ezpath.myezverse.xyz/api/v1/quote"; +export const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +export const TOLL_DEFAULT = "0x13dDE704389b1118B20d2BCc6D3Ace749600e2ad" as const; + +export const TIER_ATOMIC = { + basic: 30_000n, + resilient: 100_000n, + institutional: 500_000n, +} as const; diff --git a/typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.test.ts b/typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.test.ts new file mode 100644 index 000000000..2f3e72a49 --- /dev/null +++ b/typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.test.ts @@ -0,0 +1,227 @@ +import { EzPathActionProvider } from "./ezpathActionProvider"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const MOCK_ADDRESS = "0x48Ccd1fF2903483B12298760eA9b5D6106E999E9"; +const MOCK_SIG = "0x" + "ab".repeat(65); +const TOLL_ADDRESS = "0x13dDE704389b1118B20d2BCc6D3Ace749600e2ad"; +const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const WETH = "0x4200000000000000000000000000000000000006"; + +const PROBE_BODY = { + tiers: { + basic: { min_atomic: "30000", min_usdc: 0.03 }, + resilient: { min_atomic: "100000", min_usdc: 0.10 }, + institutional: { min_atomic: "500000", min_usdc: 0.50 }, + }, +}; + +const QUOTE_BODY = { + request_id: "a1b2c3d4-0000-0000-0000-000000000001", + sellToken: USDC, + buyToken: WETH, + sellAmount: "1000000", + buyAmount: "449123456789012", + price: "0.000449", + sources: [{ name: "Native_V2", proportion: "1" }], + routingEngine: "0x", + tier: "basic", + routing_metadata: { execution_mode: "direct", winner: "0x" }, +}; + +function mockWalletProvider() { + return { + getAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + signTypedData: jest.fn().mockResolvedValue(MOCK_SIG), + }; +} + +function mockFetch(...responses: Array<{ status: number; body: unknown; headers?: Record }>) { + let call = 0; + return jest.fn().mockImplementation(() => { + const r = responses[call++] ?? responses[responses.length - 1]; + return Promise.resolve({ + status: r.status, + ok: r.status >= 200 && r.status < 300, + json: () => Promise.resolve(r.body), + headers: { get: (k: string) => (r.headers ?? {})[k] ?? null }, + }); + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("EzPathActionProvider", () => { + let provider: EzPathActionProvider; + let wallet: ReturnType; + + beforeEach(() => { + provider = new EzPathActionProvider(); + wallet = mockWalletProvider(); + }); + + afterEach(() => jest.restoreAllMocks()); + + // ── supportsNetwork ──────────────────────────────────────────────────────── + + describe("supportsNetwork", () => { + it("returns true for Base mainnet (chainId 8453)", () => { + expect(provider.supportsNetwork({ chainId: "8453", protocolFamily: "evm" } as never)).toBe(true); + }); + + it("returns false for other chains", () => { + expect(provider.supportsNetwork({ chainId: "1", protocolFamily: "evm" } as never)).toBe(false); + expect(provider.supportsNetwork({ chainId: "137", protocolFamily: "evm" } as never)).toBe(false); + }); + }); + + // ── getSwapQuote — happy path ────────────────────────────────────────────── + + describe("getSwapQuote", () => { + it("returns formatted quote on success (basic tier)", async () => { + global.fetch = mockFetch( + { status: 402, body: PROBE_BODY, headers: { "X-402-Address": TOLL_ADDRESS } }, + { status: 200, body: QUOTE_BODY, headers: { "X-Settlement-Tx": "0xabc123", "X-Routing-Engine": "0x" } }, + ); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, + buyToken: WETH, + sellAmount: "1000000", + tier: "basic", + }); + + expect(result).toContain("EZ-Path quote received."); + expect(result).toContain("tier=basic"); + expect(result).toContain("winner=0x"); + expect(result).toContain("mode=direct"); + expect(result).toContain("price=0.000449"); + expect(result).toContain("settlement_tx=0xabc123"); + }); + + it("includes race_comparison for resilient tier", async () => { + const resilientBody = { + ...QUOTE_BODY, + tier: "resilient", + routing_metadata: { + execution_mode: "concurrent_race", + winner: "0x", + race_comparison: { + lane_1_aggregator_out: "449123456789012", + lane_2_aerodrome_out: "0", + }, + }, + }; + + global.fetch = mockFetch( + { status: 402, body: PROBE_BODY, headers: { "X-402-Address": TOLL_ADDRESS } }, + { status: 200, body: resilientBody, headers: {} }, + ); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, + buyToken: WETH, + sellAmount: "1000000", + tier: "resilient", + }); + + expect(result).toContain("mode=concurrent_race"); + expect(result).toContain("lane_1=449123456789012"); + }); + + it("passes slippagePercentage as query param when provided", async () => { + global.fetch = mockFetch( + { status: 402, body: PROBE_BODY, headers: { "X-402-Address": TOLL_ADDRESS } }, + { status: 200, body: QUOTE_BODY, headers: {} }, + ); + + await provider.getSwapQuote(wallet as never, { + sellToken: USDC, + buyToken: WETH, + sellAmount: "1000000", + tier: "basic", + slippagePercentage: 0.01, + }); + + const firstCall = (global.fetch as jest.Mock).mock.calls[0][0] as string; + expect(firstCall).toContain("slippagePercentage=0.01"); + }); + }); + + // ── getSwapQuote — error paths ───────────────────────────────────────────── + + describe("getSwapQuote error handling", () => { + it("returns rate-limit message on 429 during probe", async () => { + global.fetch = mockFetch({ + status: 429, body: {}, headers: { "Retry-After": "30" }, + }); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, buyToken: WETH, sellAmount: "1000000", tier: "basic", + }); + + expect(result).toContain("Rate limited"); + expect(result).toContain("30"); + }); + + it("returns error if 402 is missing X-402-Address header", async () => { + global.fetch = mockFetch({ status: 402, body: PROBE_BODY, headers: {} }); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, buyToken: WETH, sellAmount: "1000000", tier: "basic", + }); + + expect(result).toContain("missing X-402-Address"); + }); + + it("returns signing error if signTypedData throws", async () => { + global.fetch = mockFetch( + { status: 402, body: PROBE_BODY, headers: { "X-402-Address": TOLL_ADDRESS } }, + ); + wallet.signTypedData.mockRejectedValue(new Error("user rejected")); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, buyToken: WETH, sellAmount: "1000000", tier: "basic", + }); + + expect(result).toContain("EIP-3009 signing failed"); + expect(result).toContain("user rejected"); + }); + + it("returns rejection message on 401 from paid request", async () => { + global.fetch = mockFetch( + { status: 402, body: PROBE_BODY, headers: { "X-402-Address": TOLL_ADDRESS } }, + { status: 401, body: { reason: "invalid_signature" }, headers: {} }, + ); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, buyToken: WETH, sellAmount: "1000000", tier: "basic", + }); + + expect(result).toContain("rejected payment signature"); + expect(result).toContain("invalid_signature"); + }); + + it("returns error message on unexpected probe status", async () => { + global.fetch = mockFetch({ status: 500, body: {}, headers: {} }); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, buyToken: WETH, sellAmount: "1000000", tier: "basic", + }); + + expect(result).toContain("Unexpected response"); + expect(result).toContain("500"); + }); + + it("returns network error message if fetch throws", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("network failure")); + + const result = await provider.getSwapQuote(wallet as never, { + sellToken: USDC, buyToken: WETH, sellAmount: "1000000", tier: "basic", + }); + + expect(result).toContain("EZ-Path unavailable"); + expect(result).toContain("network failure"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.ts b/typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.ts new file mode 100644 index 000000000..6275d2a4c --- /dev/null +++ b/typescript/agentkit/src/action-providers/ezpath/ezpathActionProvider.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; +import { ActionProvider, CreateAction, EvmWalletProvider, Network } from "@coinbase/agentkit"; +import { toHex } from "viem"; +import { EZPATH_API, USDC_BASE, TIER_ATOMIC } from "./constants.js"; +import { GetSwapQuoteSchema } from "./schemas.js"; + +const EIP3009_TYPES = { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], +} as const; + +/** + * EzPathActionProvider gives AgentKit agents access to EZ-Path — a pay-per-request + * DEX meta-router on Base mainnet that races 0x, ParaSwap, Aerodrome, and Uniswap V3 + * to return the highest buyAmount for any ERC-20 swap. + * + * Payment is handled automatically via X402 / EIP-3009 USDC authorization. + * The agent's wallet signs a TransferWithAuthorization; no pre-approval or + * allowance is required. + */ +export class EzPathActionProvider extends ActionProvider { + constructor() { + super("ezpath", []); + } + + @CreateAction({ + name: "get_swap_quote", + description: + "Fetch the best available DEX swap quote on Base mainnet via EZ-Path. " + + "Races 0x, ParaSwap, Aerodrome, and Uniswap V3 and returns the highest buyAmount. " + + "Payment is settled automatically — the agent's USDC balance is debited per request " + + "($0.03 basic / $0.10 resilient / $0.50 institutional). " + + "Use this before executing a swap to guarantee optimal execution.", + schema: GetSwapQuoteSchema, + }) + async getSwapQuote( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const url = new URL(EZPATH_API); + url.searchParams.set("sellToken", args.sellToken); + url.searchParams.set("buyToken", args.buyToken); + url.searchParams.set("sellAmount", args.sellAmount); + if (args.slippagePercentage !== undefined) { + url.searchParams.set("slippagePercentage", String(args.slippagePercentage)); + } + + // ── Step 1: probe — discover live toll address and confirm tier pricing + let probe: Response; + try { + probe = await fetch(url.toString()); + } catch (err) { + return `EZ-Path unavailable: ${err instanceof Error ? err.message : String(err)}`; + } + + if (probe.status === 429) { + return `Rate limited by EZ-Path. Retry after ${probe.headers.get("Retry-After") ?? "60"} seconds.`; + } + if (probe.status !== 402) { + return `Unexpected response from EZ-Path during negotiation: HTTP ${probe.status}`; + } + + const probeBody = await probe.json() as { tiers?: Record }; + const tollAddress = probe.headers.get("X-402-Address"); + if (!tollAddress) return "EZ-Path 402 response missing X-402-Address header."; + + const tierConfig = probeBody.tiers?.[args.tier]; + const valueAtomic = tierConfig ? BigInt(tierConfig.min_atomic) : TIER_ATOMIC[args.tier]; + + // ── Step 2: sign EIP-3009 TransferWithAuthorization using the agent's wallet + const from = await walletProvider.getAddress(); + const validAfter = 0n; + const validBefore = BigInt(Math.floor(Date.now() / 1000) + 300); + const nonce = toHex(crypto.getRandomValues(new Uint8Array(32))); + + let signature: string; + try { + signature = await walletProvider.signTypedData({ + domain: { + name: "USD Coin", + version: "2", + chainId: 8453, + verifyingContract: USDC_BASE, + }, + types: EIP3009_TYPES, + primaryType: "TransferWithAuthorization", + message: { + from, + to: tollAddress, + value: valueAtomic, + validAfter, + validBefore, + nonce: nonce as `0x${string}`, + }, + }); + } catch (err) { + return `EIP-3009 signing failed: ${err instanceof Error ? err.message : String(err)}`; + } + + const paymentPayload = btoa( + JSON.stringify({ + x402Version: 1, + scheme: "exact", + network: "base", + payload: { + signature, + authorization: { + from, + to: tollAddress, + value: valueAtomic.toString(), + validAfter: validAfter.toString(), + validBefore: validBefore.toString(), + nonce, + }, + }, + }), + ); + + // ── Step 3: retry with payment header + let res: Response; + try { + res = await fetch(url.toString(), { headers: { "X-Payment": paymentPayload } }); + } catch (err) { + return `EZ-Path request failed: ${err instanceof Error ? err.message : String(err)}`; + } + + if (res.status === 429) { + return `Rate limited by EZ-Path. Retry after ${res.headers.get("Retry-After") ?? "60"} seconds.`; + } + if (res.status === 402) { + return "Payment rejected — authorization value may be below the selected tier minimum."; + } + if (res.status === 401) { + const body = await res.json() as { reason?: string }; + return `EZ-Path rejected payment signature: ${body.reason ?? "unknown reason"}`; + } + if (!res.ok) { + const body = await res.json() as { detail?: string }; + return `EZ-Path error ${res.status}: ${body.detail ?? "unknown error"}`; + } + + const data = await res.json() as { + buyAmount: string; + price: string; + sources: Array<{ name: string; proportion: string }>; + tier: string; + routing_metadata: { + execution_mode: string; + winner: string; + race_comparison?: { + lane_1_aggregator_out: string; + lane_2_aerodrome_out: string; + }; + }; + }; + + const settlementTx = res.headers.get("X-Settlement-Tx"); + const meta = data.routing_metadata; + const sources = data.sources + .map(s => `${s.name} (${(parseFloat(s.proportion) * 100).toFixed(0)}%)`) + .join(", "); + const raceInfo = meta.race_comparison + ? ` | lane_1=${meta.race_comparison.lane_1_aggregator_out} lane_2=${meta.race_comparison.lane_2_aerodrome_out}` + : ""; + + return [ + "EZ-Path quote received.", + `tier=${data.tier} | winner=${meta.winner} | mode=${meta.execution_mode}${raceInfo}`, + `price=${data.price} buyToken per sellToken`, + `buyAmount=${data.buyAmount}`, + `sources=${sources}`, + settlementTx ? `settlement_tx=${settlementTx}` : null, + ] + .filter(Boolean) + .join("\n"); + } + + supportsNetwork(network: Network): boolean { + return network.chainId === "8453"; + } +} + +export const ezpathActionProvider = () => new EzPathActionProvider(); diff --git a/typescript/agentkit/src/action-providers/ezpath/index.ts b/typescript/agentkit/src/action-providers/ezpath/index.ts new file mode 100644 index 000000000..cdeecebfe --- /dev/null +++ b/typescript/agentkit/src/action-providers/ezpath/index.ts @@ -0,0 +1,3 @@ +export { EzPathActionProvider, ezpathActionProvider } from "./ezpathActionProvider.js"; +export { GetSwapQuoteSchema } from "./schemas.js"; +export { EZPATH_API, USDC_BASE, TIER_ATOMIC } from "./constants.js"; diff --git a/typescript/agentkit/src/action-providers/ezpath/schemas.ts b/typescript/agentkit/src/action-providers/ezpath/schemas.ts new file mode 100644 index 000000000..036b66765 --- /dev/null +++ b/typescript/agentkit/src/action-providers/ezpath/schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export const GetSwapQuoteSchema = z + .object({ + sellToken: z + .string() + .describe( + "ERC-20 contract address of the token to sell on Base mainnet. " + + "Example USDC: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ), + buyToken: z + .string() + .describe( + "ERC-20 contract address of the token to buy on Base mainnet. " + + "Example WETH: 0x4200000000000000000000000000000000000006", + ), + sellAmount: z + .string() + .describe( + "Amount to sell in the token's smallest unit (base decimals). " + + "Example: '1000000' = 1 USDC (6 decimals), '1000000000000000000' = 1 WETH (18 decimals).", + ), + tier: z + .enum(["basic", "resilient", "institutional"]) + .default("basic") + .describe( + "Execution tier. " + + "basic ($0.03): direct 0x route. " + + "resilient ($0.10): dual-lane concurrent race — 0x/ParaSwap vs Aerodrome. " + + "institutional ($0.50): race + Uniswap V3 safety net.", + ), + slippagePercentage: z + .number() + .min(0) + .max(1) + .optional() + .describe("Maximum acceptable slippage as a decimal fraction. Example: 0.01 = 1%."), + }) + .strip(); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..51b862061 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -41,3 +41,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; +export * from "./ezpath";