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'], },