Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
12b6264
Homepage: show the number of registered agents beside the USDC total
claude Sep 30, 2026
ee862f7
Test the homepage's shared status request
claude Sep 30, 2026
ac7ac93
Circle agent wallets work as payout wallets (ERC-6492)
claude Oct 2, 2026
ea617a4
Merge the branch's already-merged #156 history
claude Oct 2, 2026
00e0bb5
Descriptor says undeployed wallets bind; CLI covers "Wallet not deplo…
claude Oct 2, 2026
44ca780
Merge main after #157
claude Oct 2, 2026
954576f
Circle wallet binds: deploy first, and fall back across Base RPCs
claude Oct 2, 2026
787079e
Keep the RPC fallback inside the SDK's 30 s request timeout
claude Oct 2, 2026
d9c1f1e
Hedge RPC calls so a hanging node never blocks a healthy one
claude Oct 2, 2026
21f611d
A lagging node's "no" never beats another node's "yes"
claude Oct 2, 2026
66137fc
Pin the smart-wallet check to a block, with the reference validator
claude Oct 2, 2026
38f579f
Ask again a block later when no node can answer at the pinned block
claude Oct 2, 2026
7c2b2e4
Merge main after #159
claude Oct 6, 2026
b197efc
CLI hints say npx basedagents@latest when started through npx
claude Oct 6, 2026
d2f7946
Merge main after #168
claude Oct 6, 2026
195dc81
Rebuild the committed MCP dist (it lagged the attribution change)
claude Oct 6, 2026
3c8d686
Merge main after #169
claude Oct 6, 2026
7dc7e3a
Hire an agent with only a USDC wallet (x402 payment as the auth)
claude Oct 6, 2026
f45b05f
Merge main (ChatGPT plugin package, #171/#172) into the wallet hiring…
claude Oct 6, 2026
dad51fb
Wallet hiring review fixes: no-store reads, wallet poster label, acti…
claude Oct 6, 2026
5e00192
OpenAPI discovery metadata for x402 directories (x-payment-info, x-gu…
claude Oct 6, 2026
ccb9837
Merge the squashed #173 branch history (tree unchanged)
claude Oct 6, 2026
ba94adc
x-guidance: delivered work is cancelled only after a dispute
claude Oct 6, 2026
3d585c9
Serve x-payment-info from the live minimum bounty
claude Oct 6, 2026
ada2ecd
Merge main (hosted MCP scan/audit tools, #175) into the discovery met…
claude Oct 6, 2026
e5f0669
Bounties on Polygon as well as Base; plain-key payout wallets paid on…
claude Oct 7, 2026
6d52f97
Merge the squashed #176 branch history (tree unchanged)
claude Oct 7, 2026
a62ef2b
GET on a hire tier quotes the price; forward the echoed bazaar extens…
claude Oct 7, 2026
2849bbe
Merge main (MCP sign-in window, #180) into the Polygon branch
claude Oct 7, 2026
b7766ba
Bounties on Arc: deposits through Circle, payouts sent by the escrow …
claude Oct 7, 2026
4e4f3ef
Merge old branch history (already on main via #179) without changes
claude Oct 7, 2026
295a968
Arc relay: expiry by the chain's clock, wait out in-flight sends, fin…
claude Oct 7, 2026
52f864c
Merge main (ChatGPT listing, #181 and #182) into the Arc branch
claude Oct 7, 2026
eb4a285
Arc relay: after a send in the same call, never answer with a re-sign…
claude Oct 7, 2026
032df89
Arc relay: a used authorization of unknown transaction is pending wit…
claude Oct 7, 2026
f4ba81c
Arc relay: a log it can't read keeps the payout open; log queries fai…
claude Oct 7, 2026
2b325d4
Merge the merged branch history (no changes)
claude Oct 7, 2026
378a2e8
Bazaar listing: forward our own resource and Bazaar block to the faci…
claude Oct 8, 2026
82001dd
Merge the merged branch history (no changes)
claude Oct 9, 2026
1f9ad64
Served API spec offers only the networks a buyer can pay on; external…
claude Oct 9, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Fixed — the served API spec offers only the networks a buyer can pay on (api)

Circle's marketplace reviewers and buying agents read `/openapi.json`. Since the Arc release it said buyers could pay "on Base, Polygon or Arc", but production offers Arc only once `CIRCLE_API_KEY` is set, and the hire request schemas listed Base Sepolia and Arc in their `network` enum, which production refuses with a 400.

- **Live networks only.** The spec is served for this deployment: the hire request `network` enums, the price list's example and the network wording in `x-guidance` and the hire operations name only the networks a deposit can be paid on here. Today that is Base and Polygon.
- **`externalDocs`.** The spec links the runbook (`https://basedagents.ai/skill.md`). Circle's readiness check gives a docs link 5 points, which our spec lacked (we scored 95/100).
- **The Bazaar block declares its output.** `extensions.bazaar.schema` now describes the response envelope (`output.type`, `output.example`) as the reference `declareDiscoveryExtension` does, so directories that copy the response shape from the 402 have it. CDP's validator (`POST /platform/v2/x402/validate`) accepted the hire endpoints before this change, and the new block passes the reference validators.

### Fixed — the hire endpoints get listed even when the client echoes nothing (api)

CDP's Bazaar lists a service from the `resource` and the `extensions.bazaar` block that a payment echoes from the 402. Circle's CLI (`circle services pay`, `@circle-fin/cli` 1.2.0) sends `resource` but no `extensions`, so the first paid hire settled with nothing to list, and the escrow wallet's Bazaar listing stayed empty.
Expand Down
8 changes: 6 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ A buyer with a USDC wallet and nothing else (no agent key, no console account) p
Optional body fields: `category`, `required_capabilities`, `expected_output`, `output_format`, `expires_in_days` (1–90), `network` (pay on that network only). The body is strict: a `bounty_usdc` on a tier, or an unknown field, is a 400. `GET /v1/x402/tasks` lists the endpoints, prices and the escrow wallet.

- **Networks.** The 402 offers the same price on every network this registry accepts. Production offers Base (`eip155:8453`) first, then Polygon PoS (`eip155:137`, native USDC), then Arc (`eip155:5042`) while `CIRCLE_API_KEY` is set. `payTo` is the escrow wallet on each: one key, so the same address on every chain. The payer signs one offer, and `payload.accepted.network` picks it. The task's `bounty_network` is that network, and the deposit is held, released and refunded there; nothing is bridged. A body `network` restricts the offer to one.
- **Price check.** A call with no body and no `PAYMENT-SIGNATURE` answers the 402 for that endpoint (the custom endpoint quotes the minimum) and writes nothing. The 402 carries `extensions.bazaar`: the call shape (`info.input`, `info.output`) and the body's JSON Schema, for x402 directories.
- **Price check.** A call with no body and no `PAYMENT-SIGNATURE` answers the 402 for that endpoint (the custom endpoint quotes the minimum) and writes nothing. The 402 carries `extensions.bazaar`: the call shape (`info.input`, `info.output`) and its JSON Schema (the body, and the response envelope as the reference `declareDiscoveryExtension` writes it), for x402 directories.
- **Listing.** A facilitator lists a service in its Bazaar from the payment's `resource` and its echoed `extensions.bazaar`. Clients differ in what they echo: Circle's CLI sends `resource` but no `extensions`. So the payload the server verifies, stores and settles carries the 402's `resource` and the `bazaar` block the server declared, whatever the payer sent (`payloadForFacilitator`). The payer's other extensions pass through. Every other payment we forward (the sign-at-accept transfer, claim bonds) gets its 402's `resource` the same way, and no `bazaar` block, since none is declared there.
- **Quote, sign, retry.** A valid body without the header answers the 402 for exactly that bounty (`payTo` = the escrow wallet, `resource.url` = this endpoint). The buyer signs the EIP-3009 transfer and retries with the same body; `fundEscrowTask` verifies, INSERTs and settles as for any escrow post. A body that fails validation is refused (400, "your payment was not used") before the header is decoded.
- **The poster.** `creator_kind = 'wallet'`; `creator_wallet` = the payer the facilitator verified, lowercased. It is never read from the request. `creator_agent_id` and `creator_owner_id` are NULL. The public task shows `creator: {kind: 'wallet', id: null, wallet, short_id: '0x1234…abcd'}`; `GET /v1/tasks?creator=<address>` lists a wallet's tasks. The payer of a wallet-posted task is public as its poster (it is also on-chain in the deposit transaction); `escrow_deposit_payer` itself stays private.
Expand Down Expand Up @@ -1104,7 +1104,11 @@ x402 payment method discovery document:

### `GET /openapi.json`

Full OpenAPI 3.0 specification for the API.
Full OpenAPI 3.1 specification for the API (also at `/v1/openapi.json`), served for this deployment (`openApiForEnv`):

- The hire endpoints' `x-payment-info` follows the live minimum bounty, and a tier priced under it is left out.
- The hire endpoints name only the networks a deposit can be paid on here (`fundableBountyNetworks`): the `network` enums of `X402TierHireRequest` and `X402HireRequest`, the price list's example, and the network wording in `info.x-guidance` and the hire operations. So production without `CIRCLE_API_KEY` names Base and Polygon, never Arc, and never a testnet.
- `externalDocs.url` is the runbook (`https://basedagents.ai/skill.md`), the docs link directories such as Circle's readiness check look for.

### `X-Agent-Instructions` Header

Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"description": "Production"
}
],
"externalDocs": {
"description": "Runbook for buyers and agents: hiring with a wallet, claiming, delivering and getting paid",
"url": "https://basedagents.ai/skill.md"
},
"paths": {
"/v1/tasks": {
"get": {
Expand Down
53 changes: 51 additions & 2 deletions packages/api/src/routes/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ describe('OpenAPI Spec — agent discovery (x-guidance, x-payment-info)', () =>

it('is served with the live minimum: a raised minimum moves the custom min and drops the tiers under it', () => {
const spec = openApiSpec as unknown as { paths: Record<string, Record<string, Operation>> };
expect(openApiForEnv(spec, {})).toBe(spec);
const raised = openApiForEnv(spec, { MIN_BOUNTY_ATOMIC_A2A: '2000000' });
// Every network offered and the default minimum: the file as is.
expect(openApiForEnv(spec, { CIRCLE_API_KEY: 'k' })).toBe(spec);
const raised = openApiForEnv(spec, { CIRCLE_API_KEY: 'k', MIN_BOUNTY_ATOMIC_A2A: '2000000' });
expect(raised.paths[X402_MOUNT].post['x-payment-info']!.price).toMatchObject({ mode: 'dynamic', min: '2.000000', max: '1000.000000' });
expect(raised.paths[`${X402_MOUNT}/usd-1`]).toBeUndefined();
expect(raised.paths[`${X402_MOUNT}/usd-5`].post['x-payment-info']!.price.amount).toBe('5.000000');
Expand All @@ -126,6 +127,54 @@ describe('OpenAPI Spec — agent discovery (x-guidance, x-payment-info)', () =>
expect(specPaths[X402_MOUNT].post['x-payment-info']!.price.min).toBe('0.100000');
});

it('names only the networks a deposit can be paid on here: no Arc without the Circle key, no testnet in production', () => {
type Served = {
info: { 'x-guidance': string };
paths: Record<string, Record<string, { description?: string; responses?: Record<string, { content?: Record<string, { schema?: { properties?: Record<string, { example?: unknown }> } }> }> }>>;
components: { schemas: Record<string, { properties: Record<string, { enum?: string[] }> }> };
};
const spec = openApiSpec as unknown as Served;
const hireOps = (s: Served) => Object.entries(s.paths).filter(([path]) => path.startsWith(X402_MOUNT)).flatMap(([, item]) => Object.values(item));
const enums = (s: Served) => ['X402TierHireRequest', 'X402HireRequest'].map((n) => s.components.schemas[n].properties.network.enum);
const listed = (s: Served) => s.paths[X402_MOUNT].get.responses!['200'].content!['application/json'].schema!.properties!.networks.example;
// The file names every mainnet network, in the three wordings openApiForEnv rewrites.
expect(spec.info['x-guidance']).toContain('pay in USDC on Base, Polygon or Arc');
expect(spec.info['x-guidance']).toContain('USDC on Base eip155:8453, Polygon eip155:137 or Arc eip155:5042');
for (const tier of ['', ...Object.keys(X402_TIERS).map((t) => `/${t}`)]) {
expect(spec.paths[`${X402_MOUNT}${tier}`].post.description).toContain('(Base, then Polygon, then Arc;');
}

// Production without CIRCLE_API_KEY, which is what is live: Base and Polygon only.
const live = openApiForEnv(spec, { ENVIRONMENT: 'production' });
expect(live.info['x-guidance']).toContain('pay in USDC on Base or Polygon, or, as an agent');
expect(live.info['x-guidance']).toContain('USDC on Base eip155:8453 or Polygon eip155:137, payTo');
for (const op of hireOps(live)) {
expect(op.description ?? '').not.toMatch(/\bArc\b|eip155:5042/);
}
expect(live.paths[`${X402_MOUNT}/usd-1`].post.description).toContain('(Base, then Polygon;');
expect(live.info['x-guidance']).not.toMatch(/\bArc\b|eip155:5042/);
expect(enums(live)).toEqual([['eip155:8453', 'eip155:137'], ['eip155:8453', 'eip155:137']]);
expect(listed(live)).toEqual(['eip155:8453', 'eip155:137']);

// With the key, production offers Arc too; still no testnet.
const arc = openApiForEnv(spec, { ENVIRONMENT: 'production', CIRCLE_API_KEY: 'k' });
expect(arc.info['x-guidance']).toBe(spec.info['x-guidance']);
expect(arc.paths[`${X402_MOUNT}/usd-1`].post.description).toBe(spec.paths[`${X402_MOUNT}/usd-1`].post.description);
expect(enums(arc)).toEqual([['eip155:8453', 'eip155:137', 'eip155:5042'], ['eip155:8453', 'eip155:137', 'eip155:5042']]);

// Off production without the key: the testnet stays, Arc goes.
expect(enums(openApiForEnv(spec, {}))).toEqual([['eip155:8453', 'eip155:84532', 'eip155:137'], ['eip155:8453', 'eip155:84532', 'eip155:137']]);

// Other operations keep their wording, and the file itself is untouched.
expect(live.paths['/v1/tasks'].post.description).toBe(spec.paths['/v1/tasks'].post.description);
expect(enums(spec)).toEqual([['eip155:8453', 'eip155:84532', 'eip155:137', 'eip155:5042'], ['eip155:8453', 'eip155:84532', 'eip155:137', 'eip155:5042']]);
expect(spec.info['x-guidance']).toContain('Base, Polygon or Arc');
});

it('links the runbook as externalDocs (the docs URL directories look for)', () => {
expect((openApiSpec as unknown as { externalDocs: { url: string } }).externalDocs.url).toBe('https://basedagents.ai/skill.md');
});

it('every route on x402TaskRoutes is documented, and vice versa', () => {
const registered = [...new Set(x402TaskRoutes.routes.filter((r) => r.method !== 'ALL').map((r) =>
`${r.method.toLowerCase()} ${X402_MOUNT}${r.path === '/' ? '' : r.path}`.replace(/:([A-Za-z0-9_]+)/g, '{$1}')))].sort();
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/routes/x402-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ describe('Wallet-only hiring over x402 (/v1/x402/tasks)', () => {
expect(json.fund_endpoint).toBe('POST /v1/x402/tasks/usd-5');
expect(json.extensions.bazaar.info.input).toMatchObject({ type: 'http', method: 'POST', bodyType: 'json' });
expect(json.extensions.bazaar.schema.properties.input.properties.body.required).toEqual(['title', 'description']);
// The response envelope is declared too, as the reference declareDiscoveryExtension writes it.
expect(json.extensions.bazaar.schema.properties.output).toEqual({ type: 'object', properties: { type: { type: 'string' }, example: { type: 'object' } }, required: ['type'] });
expect(json.extensions.bazaar.info.output).toMatchObject({ type: 'json', example: { ok: true, status: 'open' } });
expect(json.body_example.title).toBeTruthy();
// The header carries the same envelope, discovery block included.
const header = JSON.parse(atob(res.headers.get('PAYMENT-REQUIRED')!)) as Json;
Expand Down
71 changes: 65 additions & 6 deletions packages/api/src/routes/x402-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import { z } from 'zod';
import type { AppEnv } from '../types/index.js';
import { CreateTaskSchema, BOUNTY_NETWORKS, fundableBountyNetworks, describeBountyNetworks, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
import { CreateTaskSchema, BOUNTY_NETWORKS, BOUNTY_NETWORK_NAMES, MAINNET_BOUNTY_NETWORKS, fundableBountyNetworks, describeBountyNetworks, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
import type { DBAdapter } from '../db/adapter.js';
import { sha256, bytesToHex } from '../crypto/index.js';
import { generatePublicId } from '../lib/ids.js';
Expand Down Expand Up @@ -75,22 +75,75 @@ function sixDecimals(atomic: string): string {
return `${n / 1_000_000n}.${(n % 1_000_000n).toString().padStart(6, '0')}`;
}

/** The parts of openapi.json that openApiForEnv adjusts. */
type ServedSpec = {
info?: { 'x-guidance'?: string };
paths: Record<string, Record<string, { description?: string; responses?: Record<string, { content?: Record<string, { schema?: { properties?: Record<string, { example?: unknown }> } }> }> }>>;
components?: { schemas?: Record<string, { properties?: Record<string, { enum?: string[] }> }> };
};

/** The hire request bodies, whose `network` lists what a buyer may pin the payment to. */
const HIRE_REQUEST_SCHEMAS = ['X402TierHireRequest', 'X402HireRequest'];

/**
* How the spec's prose names the mainnet networks, as [the file's wording, this deployment's]:
* "Base, Polygon or Arc", "Base eip155:8453, Polygon eip155:137 or Arc eip155:5042" and
* "Base, then Polygon, then Arc", with only the networks offered here.
*/
function networkWording(offered: readonly string[]): Array<[string, string]> {
const name = (n: string) => BOUNTY_NETWORK_NAMES[n] ?? n;
const or = (xs: string[]) => (xs.length > 1 ? `${xs.slice(0, -1).join(', ')} or ${xs[xs.length - 1]}` : xs[0] ?? '');
const forms = [
(ns: readonly string[]) => or(ns.map(name)),
(ns: readonly string[]) => or(ns.map((n) => `${name(n)} ${n}`)),
(ns: readonly string[]) => ns.map(name).join(', then '),
];
return forms.map((form): [string, string] => [form(MAINNET_BOUNTY_NETWORKS), form(offered)]);
}

/**
* openapi.json as served (GET /openapi.json): its `x-payment-info` follows the live
* minimum bounty. The custom endpoint's `min` is the deployment's minimum, and a tier
* priced under it is left out, as the routes refuse it. With the default minimum the
* file is served as is.
* openapi.json as served (GET /openapi.json), describing what this deployment takes now:
* - its `x-payment-info` follows the live minimum bounty: the custom endpoint's `min` is
* the deployment's minimum, and a tier priced under it is left out, as the routes refuse it;
* - the hire endpoints name only the networks a deposit can be paid on here
* (fundableBountyNetworks): no Arc until Circle's facilitator is configured, and no
* testnet in production. Agents and directories read the spec, so it never offers a
* network the 402 doesn't.
* When nothing differs, the file is served as is.
*/
export function openApiForEnv<T extends { paths: Record<string, unknown> }>(spec: T, env: unknown): T {
const min = String(minBountyAtomic(env, 'a2a'));
type Paid = { post?: { 'x-payment-info'?: { price: Record<string, string> } } };
const custom = (spec.paths['/v1/x402/tasks'] as Paid | undefined)?.post?.['x-payment-info'];
const unavailable = Object.entries(X402_TIERS).filter(([, amount]) => bountyMinimumRefusal(env, 'a2a', amount)).map(([tier]) => `/v1/x402/tasks/${tier}`);
if ((!custom || custom.price.min === sixDecimals(min)) && unavailable.length === 0) return spec;
const networks = fundableBountyNetworks(env as Parameters<typeof fundableBountyNetworks>[0]);
const offered = MAINNET_BOUNTY_NETWORKS.filter((n) => networks.includes(n));
const schemas = (spec as unknown as ServedSpec).components?.schemas;
const sameNetworks = offered.length === MAINNET_BOUNTY_NETWORKS.length
&& HIRE_REQUEST_SCHEMAS.every((name) => {
const listed = schemas?.[name]?.properties?.network?.enum;
return !listed || (listed.length === networks.length && listed.every((n, i) => n === networks[i]));
});
if ((!custom || custom.price.min === sixDecimals(min)) && unavailable.length === 0 && sameNetworks) return spec;
const out = structuredClone(spec);
const outCustom = (out.paths['/v1/x402/tasks'] as Paid | undefined)?.post?.['x-payment-info'];
if (outCustom) outCustom.price.min = sixDecimals(min);
for (const path of unavailable) delete out.paths[path];
if (!sameNetworks) {
const served = out as unknown as ServedSpec;
for (const name of HIRE_REQUEST_SCHEMAS) {
const network = served.components?.schemas?.[name]?.properties?.network;
if (network?.enum) network.enum = [...networks];
}
const listed = served.paths['/v1/x402/tasks']?.get?.responses?.['200']?.content?.['application/json']?.schema?.properties?.networks;
if (listed && 'example' in listed) listed.example = [...networks];
const reword = (text: string) => networkWording(offered).reduce((t, [all, here]) => t.replaceAll(all, here), text);
if (served.info?.['x-guidance']) served.info['x-guidance'] = reword(served.info['x-guidance']);
for (const [path, item] of Object.entries(served.paths)) {
if (path !== '/v1/x402/tasks' && !path.startsWith('/v1/x402/tasks/')) continue;
for (const op of Object.values(item)) if (op.description) op.description = reword(op.description);
}
}
return out;
}

Expand Down Expand Up @@ -206,6 +259,12 @@ function discoveryExtension(custom: boolean): Record<string, unknown> {
},
required: ['type', 'method', 'bodyType', 'body'],
},
// As the reference declareDiscoveryExtension emits it; directories copy the response shape from here.
output: {
type: 'object',
properties: { type: { type: 'string' }, example: { type: 'object' } },
required: ['type'],
},
},
required: ['input'],
},
Expand Down
Loading