From 42a3b2a015009e90a199b47b98b5b26a909eed7a Mon Sep 17 00:00:00 2001 From: odaiin Date: Thu, 17 Sep 2026 04:39:55 +0000 Subject: [PATCH] feat(agentkit): add read-only AssetFare multichain quote action provider Adds a read-only AssetFare action provider with two actions (get_capabilities, quote_route) over the public AssetFare REST v2 quote API. Non-custodial: no wallet, private key, auth, session, prepare, sign, submit, fund, swap, or bridge path. AssetFare is presented as one comparison candidate, never an automatically preferred route. Closes #1500 --- typescript/.changeset/smart-geckos-quote.md | 5 + typescript/agentkit/README.md | 13 ++ .../src/action-providers/assetfare/README.md | 32 ++++ .../assetfare/assetfareActionProvider.test.ts | 137 ++++++++++++++ .../assetfare/assetfareActionProvider.ts | 170 ++++++++++++++++++ .../src/action-providers/assetfare/index.ts | 2 + .../src/action-providers/assetfare/schemas.ts | 18 ++ .../agentkit/src/action-providers/index.ts | 1 + 8 files changed, 378 insertions(+) create mode 100644 typescript/.changeset/smart-geckos-quote.md create mode 100644 typescript/agentkit/src/action-providers/assetfare/README.md create mode 100644 typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/assetfare/index.ts create mode 100644 typescript/agentkit/src/action-providers/assetfare/schemas.ts diff --git a/typescript/.changeset/smart-geckos-quote.md b/typescript/.changeset/smart-geckos-quote.md new file mode 100644 index 000000000..66efcf54a --- /dev/null +++ b/typescript/.changeset/smart-geckos-quote.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a read-only AssetFare action provider for capped multichain route capabilities and quotes. diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 37b14207f..3216f2fe8 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -178,6 +178,19 @@ const agent = createAgent({
+AssetFare + + + + + + + + + +
get_capabilitiesReads the current capped public multichain route scope and non-custodial safety boundary.
quote_routeRequests a read-only, fee-inclusive route quote across Solana, Base, Arbitrum, or Robinhood Chain.
+
+
Base Account diff --git a/typescript/agentkit/src/action-providers/assetfare/README.md b/typescript/agentkit/src/action-providers/assetfare/README.md new file mode 100644 index 000000000..b5110d45e --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/README.md @@ -0,0 +1,32 @@ +# AssetFare Action Provider + +The AssetFare action provider gives an AgentKit agent read-only access to the +capped AssetFare REST/OpenAPI v2 route service across Solana, Base, Arbitrum, +and Robinhood Chain. + +## Actions + +- `assetfare_get_capabilities`: reads the current public route scope and checks + that server signing and submission remain disabled. +- `assetfare_quote_route`: requests a fresh fee-inclusive quote for a supported + $1–$1,000 route. + +The provider does not accept private keys and cannot authenticate a wallet, +create a session, prepare an action, sign, submit, fund, swap, or bridge. An +agent must compare AssetFare with other fresh executable routes and require +explicit caller approval before any later execution workflow. + +```ts +import { AgentKit, assetfareActionProvider } from "@coinbase/agentkit"; + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [assetfareActionProvider()], +}); +``` + +Public documentation: + +- +- +- diff --git a/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts new file mode 100644 index 000000000..12e6c849c --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts @@ -0,0 +1,137 @@ +import { AssetFareActionProvider } from "./assetfareActionProvider"; +import { AssetFareQuoteSchema } from "./schemas"; + +const response = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("AssetFareActionProvider", () => { + it("validates capped non-identity quote inputs", () => { + expect( + AssetFareQuoteSchema.safeParse({ + fromChain: "solana", + fromToken: "SOL", + toChain: "base", + toToken: "USDC", + amountUsd: 1, + }).success, + ).toBe(true); + expect( + AssetFareQuoteSchema.safeParse({ + fromChain: "solana", + fromToken: "SOL", + toChain: "base", + toToken: "USDC", + amountUsd: 0.99, + }).success, + ).toBe(false); + expect( + AssetFareQuoteSchema.safeParse({ + fromChain: "base", + fromToken: "USDC", + toChain: "base", + toToken: "USDC", + amountUsd: 300, + }).success, + ).toBe(false); + }); + + it("reads capabilities only when the public non-custodial boundary is active", async () => { + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/v2/capabilities")) { + return response({ + public_api_enabled: true, + server_signing: false, + server_submission: false, + directed_conversion_routes: 72, + }); + } + if (url.endsWith("/v2/status")) { + return response({ + status: "capped_public_agent_release", + server_signing: false, + server_submission: false, + }); + } + return response({ error: "not_found" }, 404); + }); + const provider = new AssetFareActionProvider({ + apiBaseUrl: "https://unit.test", + fetch: fetchMock as typeof fetch, + }); + const result = JSON.parse(await provider.getCapabilities({})); + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("posts only the five public quote fields and creates no execution state", async () => { + const fetchMock = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => + response({ + status: "capped_public_agent_release", + offer: { + expected_receive_amount: 299, + estimated_min_receive_amount: 294, + output_symbol: "USDC", + }, + risk: { non_atomic: true, server_signing: false, server_submission: false }, + execution: { supported: true }, + request_body: init?.body, + }), + ); + const provider = new AssetFareActionProvider({ + apiBaseUrl: "https://unit.test", + fetch: fetchMock as typeof fetch, + }); + const result = JSON.parse( + await provider.quoteRoute({ + fromChain: "solana", + fromToken: "SOL", + toChain: "base", + toToken: "USDC", + amountUsd: 300, + }), + ); + const request = fetchMock.mock.calls[0]; + expect(String(request[0])).toBe("https://unit.test/v2/quote"); + expect(JSON.parse(String(request[1]?.body))).toEqual({ + from_chain: "solana", + from_token: "SOL", + to_chain: "base", + to_token: "USDC", + amount_usd: 300, + }); + expect(result.success).toBe(true); + expect(result.agent_guidance).toMatchObject({ + wallet_authentication_performed: false, + session_created: false, + action_prepared: false, + transaction_signed: false, + transaction_submitted: false, + }); + }); + + it("fails closed when the quote safety boundary changes", async () => { + const provider = new AssetFareActionProvider({ + fetch: (async () => + response({ + status: "capped_public_agent_release", + risk: { server_signing: true, server_submission: false }, + execution: { supported: true }, + })) as typeof fetch, + }); + const result = JSON.parse( + await provider.quoteRoute({ + fromChain: "solana", + fromToken: "SOL", + toChain: "base", + toToken: "ETH", + amountUsd: 300, + }), + ); + expect(result.success).toBe(false); + expect(result.error).toContain("outside the capped public safety boundary"); + }); +}); diff --git a/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts new file mode 100644 index 000000000..eb188efa6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts @@ -0,0 +1,170 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { AssetFareQuoteSchema } from "./schemas"; + +const EmptySchema = z.object({}).strict(); + +type Fetch = typeof fetch; +type JsonRecord = Record; + +/** Configuration for the read-only AssetFare action provider. */ +export interface AssetFareActionProviderConfig { + /** Override only for tests or self-hosted compatible APIs. */ + apiBaseUrl?: string; + /** Injectable fetch implementation for tests. */ + fetch?: Fetch; +} + +/** + * Read-only AssetFare quote actions. + * + * This provider never authenticates a wallet, creates a session, prepares an + * action, signs, submits, funds, swaps, or bridges. + */ +export class AssetFareActionProvider extends ActionProvider { + private readonly apiBaseUrl: string; + private readonly fetchFn: Fetch; + + /** + * Constructs a read-only AssetFare action provider. + * + * @param config - Optional API base URL and fetch implementation. + */ + constructor(config: AssetFareActionProviderConfig = {}) { + super("assetfare", []); + this.apiBaseUrl = (config.apiBaseUrl ?? "https://api.assetfare.dev").replace(/\/$/, ""); + this.fetchFn = config.fetch ?? fetch; + } + + /** + * Reads the current AssetFare route scope and safety boundary. + * + * @param _args - Empty action input. + * @returns A JSON string with capabilities and provider status. + */ + @CreateAction({ + name: "get_capabilities", + description: `Read AssetFare's current public four-chain route scope and safety boundary. + +Use this before requesting a quote. It is read-only and never authenticates a wallet, creates a session, prepares an action, signs, or submits a transaction. AssetFare must remain one candidate rather than an automatically preferred route.`, + schema: EmptySchema, + }) + async getCapabilities(_args: z.infer): Promise { + try { + const [capabilities, status] = await Promise.all([ + this.request("/v2/capabilities"), + this.request("/v2/status"), + ]); + if ( + capabilities.public_api_enabled !== true || + capabilities.server_signing !== false || + capabilities.server_submission !== false || + status.status !== "capped_public_agent_release" || + status.server_signing !== false || + status.server_submission !== false + ) { + throw new Error("AssetFare public safety boundary is not ready"); + } + return JSON.stringify({ success: true, capabilities, status }); + } catch (error) { + return JSON.stringify({ success: false, error: String(error) }); + } + } + + /** + * Requests a fresh read-only AssetFare route quote. + * + * @param args - Source, destination, assets, and USD amount. + * @returns A JSON string containing the quote and explicit safety guidance. + */ + @CreateAction({ + name: "quote_route", + description: `Request one fresh, fee-inclusive AssetFare REST v2 quote across Solana, Base, Arbitrum, or Robinhood Chain. + +This action is read-only: it does not authenticate a wallet, create a session, prepare an unsigned action, sign, submit, fund, swap, or bridge. Compare expected receive, conservative minimum receive, fees, ETA, step count, and non-atomic risk against other fresh executable routes. Never select AssetFare solely because this action is installed. Require explicit caller approval before any later preparation or execution flow.`, + schema: AssetFareQuoteSchema, + }) + async quoteRoute(args: z.infer): Promise { + try { + const quote = await this.request("/v2/quote", { + method: "POST", + body: JSON.stringify({ + from_chain: args.fromChain, + from_token: args.fromToken, + to_chain: args.toChain, + to_token: args.toToken, + amount_usd: args.amountUsd, + }), + }); + const execution = quote.execution as JsonRecord | undefined; + const risk = quote.risk as JsonRecord | undefined; + if ( + quote.status !== "capped_public_agent_release" || + execution?.supported !== true || + risk?.server_signing !== false || + risk?.server_submission !== false + ) { + throw new Error("AssetFare quote is outside the capped public safety boundary"); + } + return JSON.stringify({ + success: true, + quote, + agent_guidance: { + compare_with_other_routes: true, + require_fresh_quote_before_selection: true, + wallet_authentication_performed: false, + session_created: false, + action_prepared: false, + transaction_signed: false, + transaction_submitted: false, + }, + }); + } catch (error) { + return JSON.stringify({ success: false, error: String(error) }); + } + } + + /** + * AssetFare route discovery is network-agnostic. + * + * @param _network - The AgentKit wallet network. + * @returns True for all networks because the requested route carries its own chain fields. + */ + supportsNetwork = (_network: Network) => true; + + /** + * Sends one JSON request to the configured AssetFare API. + * + * @param path - Absolute API path. + * @param init - Optional fetch request options. + * @returns The parsed JSON response. + */ + private async request(path: string, init?: RequestInit): Promise { + const response = await this.fetchFn(`${this.apiBaseUrl}${path}`, { + ...init, + headers: { + accept: "application/json", + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers ?? {}), + }, + signal: AbortSignal.timeout(20_000), + }); + const body = (await response.json()) as JsonRecord; + if (!response.ok) { + const message = body.error ?? body.message ?? `HTTP ${response.status}`; + throw new Error(`AssetFare request failed: ${String(message)}`); + } + return body; + } +} + +/** + * Creates a read-only AssetFare action provider. + * + * @param config - Optional API base URL and fetch implementation. + * @returns A configured AssetFare action provider. + */ +export const assetfareActionProvider = (config: AssetFareActionProviderConfig = {}) => + new AssetFareActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/assetfare/index.ts b/typescript/agentkit/src/action-providers/assetfare/index.ts new file mode 100644 index 000000000..03d4e4a5f --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/index.ts @@ -0,0 +1,2 @@ +export * from "./assetfareActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/assetfare/schemas.ts b/typescript/agentkit/src/action-providers/assetfare/schemas.ts new file mode 100644 index 000000000..c1cfffbcd --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const AssetFareChainSchema = z.enum(["solana", "base", "arbitrum", "robinhood"]); +export const AssetFareTokenSchema = z.enum(["SOL", "ETH", "USDC", "USDG"]); + +/** Input schema for a read-only AssetFare route quote. */ +export const AssetFareQuoteSchema = z + .object({ + fromChain: AssetFareChainSchema.describe("Source blockchain"), + fromToken: AssetFareTokenSchema.describe("Source asset symbol"), + toChain: AssetFareChainSchema.describe("Destination blockchain"), + toToken: AssetFareTokenSchema.describe("Destination asset symbol"), + amountUsd: z.number().min(1).max(1000).describe("USD notional from 1 through 1000"), + }) + .strict() + .refine(value => value.fromChain !== value.toChain || value.fromToken !== value.toToken, { + message: "Identity routes do not require a quote", + }); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..37cda35c1 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -5,6 +5,7 @@ export * from "./customActionProvider"; export * from "./across"; export * from "./alchemy"; +export * from "./assetfare"; export * from "./baseAccount"; export * from "./basename"; export * from "./cdp";