Skip to content
Open
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/great-lions-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": minor
---

Added AgentToll action provider: pay-per-call Base data over x402 (new-token scout with safety verdicts, token safety checks, wallet portfolios, onchain token prices, Basename resolution, market brief), paid in USDC from the agent's wallet with no API keys
48 changes: 48 additions & 0 deletions typescript/agentkit/src/action-providers/agenttoll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# AgentToll Action Provider

Base-native onchain data for agents, pay-per-call over [x402](https://x402.org):
each action costs $0.001–$0.008 in USDC, paid automatically from the agent's
wallet. No API keys, no accounts — and a failed request is never charged, since
settlement only happens when data is returned.

Backed by [agenttoll.app](https://agenttoll.app) (open source, MIT), settled
through the Coinbase CDP facilitator on Base mainnet.

## Actions

| Action | What it answers | Price |
|---|---|---|
| `scout_new_base_tokens` | What launched on Base today, and is any of it safe to touch? New pools with a safety verdict attached | $0.008 |
| `check_base_token_safety` | Is this token a honeypot? Simulated buy & sell, taxes, owner powers, holder concentration, deployer history | $0.003 |
| `get_base_wallet_portfolio` | What does this address hold, in USD? ETH + ERC-20s, largest first, spam floor | $0.003 |
| `get_base_token_price` | What is this token worth right now? Priced from onchain DEX liquidity | $0.001 |
| `resolve_basename` | Who is `jesse.base.eth`? Name → address + records, or address → primary name | $0.001 |
| `get_market_brief` | One-call snapshot: prices, Base gas, Fear & Greed | $0.005 |

Safety verdicts are deliberately conservative: a check that could not run is
never a "pass", and a token too new to judge is reported `insufficient-data`,
never `clear`.

## Setup

```typescript
import { AgentKit, agenttollActionProvider } from "@coinbase/agentkit";

const agentKit = await AgentKit.from({
walletProvider,
actionProviders: [agenttollActionProvider()],
});
```

The wallet needs a little USDC on Base mainnet — a dollar covers hundreds of
calls. Supported network: `base-mainnet`.

## Notes

- Every endpoint also answers unauthenticated with an x402 v2 quote that
carries its own request/response schema, so agents can discover the API
without this provider: `curl -i https://agenttoll.app/api/base/scout`.
- Machine-readable catalog: https://agenttoll.app/api/catalog ·
discovery: https://agenttoll.app/.well-known/x402
- Usage stats are read from USDC transfers onchain, not self-reported:
https://agenttoll.app/api/stats
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { agenttollActionProvider } from "./agenttollActionProvider";
import { EvmWalletProvider } from "../../wallet-providers";

// The x402 payment wrapper is exercised by its own package tests; here it is
// mocked so these tests cover the provider's request building and error paths.
jest.mock("@x402/fetch", () => ({
x402Client: jest.fn().mockImplementation(() => ({})),
wrapFetchWithPayment: jest.fn((fetchFn: typeof fetch) => fetchFn),
}));
jest.mock("@x402/evm/exact/client", () => ({
registerExactEvmScheme: jest.fn(),
}));

describe("AgenttollActionProvider", () => {
const fetchMock = jest.fn();
global.fetch = fetchMock;

const mockWallet = {
toSigner: jest.fn().mockReturnValue({ address: "0x1234" }),
readContract: jest.fn(),
} as unknown as EvmWalletProvider;
Object.setPrototypeOf(mockWallet, EvmWalletProvider.prototype);

const provider = agenttollActionProvider();

beforeEach(() => {
jest.clearAllMocks();
});

describe("scoutNewTokens", () => {
it("returns the response body on success", async () => {
const body = JSON.stringify({ pools: [], summary: { found: 0 } });
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(body) });

const result = await provider.scoutNewTokens(mockWallet, { minLiquidity: 25000, pools: 2 });

expect(result).toBe(body);
expect(fetchMock).toHaveBeenCalledWith(
"https://agenttoll.app/api/base/scout?minLiquidity=25000&pools=2",
{ method: "GET" },
);
});

it("omits unset query parameters", async () => {
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") });

await provider.scoutNewTokens(mockWallet, {});

expect(fetchMock).toHaveBeenCalledWith("https://agenttoll.app/api/base/scout", {
method: "GET",
});
});

it("reports a non-ok response without throwing", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 502,
text: jest.fn().mockResolvedValue('{"error":"upstream"}'),
});

const result = JSON.parse(await provider.scoutNewTokens(mockWallet, {}));

expect(result.error).toBe(true);
expect(result.status).toBe(502);
});

it("reports network errors without throwing", async () => {
fetchMock.mockRejectedValue(new Error("boom"));

const result = JSON.parse(await provider.scoutNewTokens(mockWallet, {}));

expect(result.error).toBe(true);
expect(result.message).toContain("boom");
});
});

describe("checkTokenSafety", () => {
it("builds the path from the address", async () => {
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") });
const address = "0x940181a94A35A4569E4529A3CDfB74e38FD98631";

await provider.checkTokenSafety(mockWallet, { address });

expect(fetchMock).toHaveBeenCalledWith(`https://agenttoll.app/api/base/safety/${address}`, {
method: "GET",
});
});
});

describe("getWalletPortfolio", () => {
it("passes the optional floor and limit", async () => {
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") });
const address = "0xe55359021A6A22D8385b827405991c56075F56f8";

await provider.getWalletPortfolio(mockWallet, { address, minValue: 100, limit: 5 });

expect(fetchMock).toHaveBeenCalledWith(
`https://agenttoll.app/api/base/portfolio/${address}?minValue=100&limit=5`,
{ method: "GET" },
);
});
});

describe("resolveBasename", () => {
it("URL-encodes the query", async () => {
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") });

await provider.resolveBasename(mockWallet, { query: "jesse.base.eth" });

expect(fetchMock).toHaveBeenCalledWith("https://agenttoll.app/api/base/name/jesse.base.eth", {
method: "GET",
});
});
});

describe("getMarketBrief", () => {
it("joins symbols into one parameter", async () => {
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") });

await provider.getMarketBrief(mockWallet, { symbols: ["eth", "degen"] });

expect(fetchMock).toHaveBeenCalledWith(
"https://agenttoll.app/api/brief?symbols=eth%2Cdegen",
{
method: "GET",
},
);
});
});

describe("supportsNetwork", () => {
it("supports base-mainnet only", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe(
true,
);
expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-sepolia" })).toBe(
false,
);
expect(
provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" }),
).toBe(false);
});
});

describe("config", () => {
it("honors a base URL override", async () => {
fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") });
const custom = agenttollActionProvider({ baseUrl: "https://example.test/" });

await custom.getMarketBrief(mockWallet, {});

expect(fetchMock).toHaveBeenCalledWith("https://example.test/api/brief", { method: "GET" });
});
});
});
Loading
Loading