Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/smart-geckos-quote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a read-only AssetFare action provider for capped multichain route capabilities and quotes.
13 changes: 13 additions & 0 deletions typescript/agentkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ const agent = createAgent({
</table>
</details>
<details>
<summary><strong>AssetFare</strong></summary>
<table width="100%">
<tr>
<td width="200"><code>get_capabilities</code></td>
<td width="768">Reads the current capped public multichain route scope and non-custodial safety boundary.</td>
</tr>
<tr>
<td width="200"><code>quote_route</code></td>
<td width="768">Requests a read-only, fee-inclusive route quote across Solana, Base, Arbitrum, or Robinhood Chain.</td>
</tr>
</table>
</details>
<details>
<summary><strong>Base Account</strong></summary>
<table width="100%">
<tr>
Expand Down
32 changes: 32 additions & 0 deletions typescript/agentkit/src/action-providers/assetfare/README.md
Original file line number Diff line number Diff line change
@@ -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:

- <https://api.assetfare.dev/v2/capabilities>
- <https://api.assetfare.dev/v2/openapi.json>
- <https://assetfare.dev/llms-full.txt>
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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<typeof EmptySchema>): Promise<string> {
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<typeof AssetFareQuoteSchema>): Promise<string> {
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<JsonRecord> {
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);
2 changes: 2 additions & 0 deletions typescript/agentkit/src/action-providers/assetfare/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./assetfareActionProvider";
export * from "./schemas";
18 changes: 18 additions & 0 deletions typescript/agentkit/src/action-providers/assetfare/schemas.ts
Original file line number Diff line number Diff line change
@@ -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",
});
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading