diff --git a/CHANGELOG.md b/CHANGELOG.md
index 313def9..a18f23a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/SPEC.md b/SPEC.md
index cca474c..a913076 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -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=
` 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.
@@ -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
diff --git a/packages/api/src/openapi.json b/packages/api/src/openapi.json
index d43e28b..0ff189a 100644
--- a/packages/api/src/openapi.json
+++ b/packages/api/src/openapi.json
@@ -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": {
diff --git a/packages/api/src/routes/openapi.test.ts b/packages/api/src/routes/openapi.test.ts
index 096ecb3..3211331 100644
--- a/packages/api/src/routes/openapi.test.ts
+++ b/packages/api/src/routes/openapi.test.ts
@@ -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> };
- 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');
@@ -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 } }> }> }>>;
+ components: { schemas: Record }> };
+ };
+ 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();
diff --git a/packages/api/src/routes/x402-tasks.test.ts b/packages/api/src/routes/x402-tasks.test.ts
index 013c48b..3aa1b3e 100644
--- a/packages/api/src/routes/x402-tasks.test.ts
+++ b/packages/api/src/routes/x402-tasks.test.ts
@@ -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;
diff --git a/packages/api/src/routes/x402-tasks.ts b/packages/api/src/routes/x402-tasks.ts
index be92942..0b33e85 100644
--- a/packages/api/src/routes/x402-tasks.ts
+++ b/packages/api/src/routes/x402-tasks.ts
@@ -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';
@@ -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 } }> }> }>>;
+ components?: { schemas?: Record }> };
+};
+
+/** 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 }>(spec: T, env: unknown): T {
const min = String(minBountyAtomic(env, 'a2a'));
type Paid = { post?: { 'x-payment-info'?: { price: Record } } };
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[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;
}
@@ -206,6 +259,12 @@ function discoveryExtension(custom: boolean): Record {
},
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'],
},