diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca0a6..313def9 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 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. + +- **The server forwards its own declaration.** The payload that is verified, stored and settled carries the 402's `resource` and the `bazaar` block the endpoint declared, whatever the payer echoed. A payer can no longer keep a hire endpoint unlisted, or list it under another URL or description. The payer's other extensions pass through. +- **Every forwarded payment gets the same rule.** The sign-at-accept transfer and claim bonds get their 402's `resource`, and a `bazaar` block a payer adds there is dropped, since none is declared. +- **The size cap still holds.** A payment that our declaration would push past the 16 KB header cap is refused as `payment_malformed` before anything is stored, so settle can always decode what was stored. + ### Added — bounties on Arc (api, sdk 0.10.4, web) USDC bounties can be paid on Arc (`eip155:5042`), Circle's stablecoin chain, as well as Base and Polygon. Coinbase's CDP facilitator does not settle on Arc, so Arc runs on two paths: diff --git a/SPEC.md b/SPEC.md index 2919fe6..cca474c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -540,6 +540,7 @@ Optional body fields: `category`, `required_capabilities`, `expected_output`, `o - **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. +- **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. - **Manage token.** The paid POST returns `manage.token` (`bat_` + 32 random bytes, base64url) once. Only its sha256 is stored (`manage_token_hash`, never exposed). `Authorization: Bearer ` authorizes every manage call. diff --git a/packages/api/src/openapi.json b/packages/api/src/openapi.json index 5b3eba3..d43e28b 100644 --- a/packages/api/src/openapi.json +++ b/packages/api/src/openapi.json @@ -5468,7 +5468,7 @@ }, "extensions": { "type": "object", - "description": "x402 v2 extensions. The wallet-only hire endpoints carry `bazaar`: how to call them (`info.input`, `info.output`) and the JSON Schema of the body." + "description": "x402 v2 extensions. The wallet-only hire endpoints carry `bazaar`: how to call them (`info.input`, `info.output`) and the JSON Schema of the body. A payment may echo it or not: the server forwards its own `resource` and `bazaar` block to the facilitator either way." } } }, diff --git a/packages/api/src/payments/accept.ts b/packages/api/src/payments/accept.ts index 13267c2..801f41f 100644 --- a/packages/api/src/payments/accept.ts +++ b/packages/api/src/payments/accept.ts @@ -17,7 +17,8 @@ import { afterAccept, loadTask, logPaymentEvent, bountyView } from '../tasks/ser import { paymentProviderFor } from './index.js'; import { encryptPaymentSignature } from './crypto.js'; import { - decodePaymentHeader, buildRequirements, buildPaymentRequired, encodeB64Json, localPrechecks, PaymentMalformed, isNetwork, + decodeForFacilitator, buildRequirements, buildPaymentRequired, encodeB64Json, localPrechecks, PaymentMalformed, isNetwork, + type PaymentPayloadV2, } from './x402.js'; import { settleTask, reauthPermitted, wireSettleResponse, REAUTH_CLASSES } from './settle.js'; @@ -150,9 +151,11 @@ export async function acceptBountyTask( if (!payout.ok) return { status: 409, body: payoutRefusal(payout, task.bounty_network) }; const requirements = buildRequirements(task, payout.address, env); - let payload: ReturnType; + let payload: PaymentPayloadV2; + /** What is stored and settled: the payer's payload with our resource (no Bazaar block is declared here). */ + let header: string; try { - payload = decodePaymentHeader(rawHeader); + ({ payload, header } = decodeForFacilitator(rawHeader, buildPaymentRequired(task, requirements))); } catch (err) { return { status: 400, body: { error: 'payment_malformed', message: 'The payment header is not a valid x402 v2 payment payload.', detail: err instanceof PaymentMalformed ? err.detail : String(err), payment_requirements: requirements } }; } @@ -180,7 +183,7 @@ export async function acceptBountyTask( } const encKey = env?.PAYMENT_ENCRYPTION_KEY as string; // guaranteed by paymentProviderFor - const encrypted = await encryptPaymentSignature(rawHeader, encKey); + const encrypted = await encryptPaymentSignature(header, encKey); const auth = payload.payload.authorization; const expiresAt = new Date(Number(auth.validBefore) * 1000).toISOString(); diff --git a/packages/api/src/payments/escrow.ts b/packages/api/src/payments/escrow.ts index 9690f7b..2faa728 100644 --- a/packages/api/src/payments/escrow.ts +++ b/packages/api/src/payments/escrow.ts @@ -30,8 +30,8 @@ import { import { paymentProviderFor } from './index.js'; import { encryptPaymentSignature } from './crypto.js'; import { - decodePaymentHeader, buildRequirements, buildPaymentRequired, encodeB64Json, localPrechecks, PaymentMalformed, isNetwork, - TASK_RESOURCE_BASE, type PaymentRequirementsV2, + decodeForFacilitator, buildRequirements, buildPaymentRequired, encodeB64Json, localPrechecks, PaymentMalformed, isNetwork, + TASK_RESOURCE_BASE, type PaymentPayloadV2, type PaymentRequirementsV2, } from './x402.js'; import { settleTask, reauthPermitted, wireSettleResponse, REAUTH_CLASSES, type SettleTrigger, type SettleResult } from './settle.js'; import { houseWalletFor, escrowDisabledReason, sameAddress } from './house-wallet.js'; @@ -151,11 +151,11 @@ export async function fundEscrowTask(db: DBAdapter, env: Bindings, target: FundT const offers = offered.map((network) => buildRequirements({ task_id: taskId, bounty_amount: bounty.amount, bounty_network: network }, house.address, env)); let requirements = offers[0]; - const resource = fundResource(target); + // The 402, and the resource and declarations a paid request forwards to the facilitator. + const paymentRequired = buildPaymentRequired({ task_id: taskId }, offers, undefined, fundResource(target)); + if (target.kind === 'new' && target.resource?.extensions) paymentRequired.extensions = target.resource.extensions; if (!rawHeader) { - const paymentRequired = buildPaymentRequired({ task_id: taskId }, offers, undefined, resource); - if (target.kind === 'new' && target.resource?.extensions) paymentRequired.extensions = target.resource.extensions; return { status: 402, headers: { 'PAYMENT-REQUIRED': encodeB64Json(paymentRequired) }, @@ -173,9 +173,11 @@ export async function fundEscrowTask(db: DBAdapter, env: Bindings, target: FundT }; } - let payload: ReturnType; + let payload: PaymentPayloadV2; + /** What is stored and settled: the payer's payload with our resource and declarations. */ + let header: string; try { - payload = decodePaymentHeader(rawHeader); + ({ payload, header } = decodeForFacilitator(rawHeader, paymentRequired)); } catch (err) { return { status: 400, body: { error: 'payment_malformed', message: 'The payment header is not a valid x402 v2 payment payload.', detail: err instanceof PaymentMalformed ? err.detail : String(err), payment_requirements: requirements } }; } @@ -215,7 +217,7 @@ export async function fundEscrowTask(db: DBAdapter, env: Bindings, target: FundT } const encKey = env.PAYMENT_ENCRYPTION_KEY as string; // guaranteed by paymentProviderFor - const encrypted = await encryptPaymentSignature(rawHeader, encKey); + const encrypted = await encryptPaymentSignature(header, encKey); const payer = verify.payer ?? auth.from; const expiresAt = new Date(Number(auth.validBefore) * 1000).toISOString(); diff --git a/packages/api/src/payments/payments.test.ts b/packages/api/src/payments/payments.test.ts index fada950..6eba6b7 100644 --- a/packages/api/src/payments/payments.test.ts +++ b/packages/api/src/payments/payments.test.ts @@ -426,9 +426,11 @@ describe('x402 Payment Integration (sign-at-accept)', () => { expect(row.settled_at).not.toBeNull(); expect(row.auto_release_at).toBeNull(); - // The stored payload is the raw header, encrypted, and the requirements are the ones we served. + // The stored payload is the payer's, encrypted, with our resource; the requirements are the ones we served. const stored = await decryptPaymentSignature(row.payment_signature as string, TEST_ENC_KEY); - expect(JSON.parse(Buffer.from(stored, 'base64').toString('utf8')).x402Version).toBe(2); + const storedPayload = JSON.parse(Buffer.from(stored, 'base64').toString('utf8')); + expect(storedPayload.x402Version).toBe(2); + expect(storedPayload.resource.url).toBe(`https://api.basedagents.ai/v1/tasks/${taskId}/accept`); expect(JSON.parse(row.payment_requirements as string)).toEqual(requirements); expect(facilitator.verifyCalls.length).toBe(1); @@ -440,6 +442,16 @@ describe('x402 Payment Integration (sign-at-accept)', () => { expect(chain.map((c) => c.entry_type)).toEqual(['task_delivered', 'task_verified', 'task_payment_settled']); }); + it('a Bazaar block the payer sends at accept is not forwarded: nothing is listed under the deliverer', async () => { + const { taskId, requirements } = await deliveredPaidTask(); + const echoed = { ...paymentPayloadFor(requirements), extensions: { bazaar: { info: { input: { type: 'http', method: 'GET' } } } } }; + expect((await accept(taskId, encodeB64Json(echoed))).status).toBe(200); + for (const call of [facilitator.verifyCalls[0], facilitator.settleCalls[0]]) { + expect(call.payload).not.toHaveProperty('extensions'); + expect(call.payload.resource?.url).toBe(`https://api.basedagents.ai/v1/tasks/${taskId}/accept`); + } + }); + it('rejects a payload signed to the wrong recipient before calling the facilitator → 402', async () => { const { taskId, requirements } = await deliveredPaidTask(); const header = paymentHeaderFor(requirements, undefined, { authorization: { to: '0x' + '9'.repeat(40) } }); diff --git a/packages/api/src/payments/x402.test.ts b/packages/api/src/payments/x402.test.ts index 46a9e9a..1d3bb98 100644 --- a/packages/api/src/payments/x402.test.ts +++ b/packages/api/src/payments/x402.test.ts @@ -11,6 +11,8 @@ import { PaymentMalformed, buildRequirements, buildPaymentRequired, + payloadForFacilitator, + decodeForFacilitator, encodeB64Json, localPrechecks, usdcToAtomic, @@ -250,7 +252,7 @@ describe('decodePaymentHeader', () => { (p as Record).junk = { x: 1 }; const out = decodePaymentHeader(toB64(p)); expect((out.accepted.extra as Record).foo).toBe('bar'); - // The facilitator catalogs a resource from the echoed bazaar block, so it is forwarded. + // Decoding keeps the echo; payloadForFacilitator decides what is forwarded. expect(out.extensions).toEqual({ bazaar: { info: { input: { type: 'http' } } } }); expect((out as Record).junk).toBeUndefined(); }); @@ -314,6 +316,63 @@ describe('buildRequirements / buildPaymentRequired', () => { }); }); +describe('payloadForFacilitator / decodeForFacilitator', () => { + const BAZAAR = { + info: { input: { type: 'http', method: 'POST', bodyType: 'json', body: { title: 't', description: 'd' } } }, + schema: { type: 'object', properties: { input: { type: 'object' } }, required: ['input'] }, + }; + const req = buildRequirements(TASK, PAY_TO); + const declared = { ...buildPaymentRequired(TASK, req, undefined, { url: 'https://api.basedagents.ai/v1/x402/tasks/usd-5', description: 'Hire' }), extensions: { bazaar: BAZAAR } }; + + it("fills in our resource and Bazaar block when the payer echoes neither (Circle's CLI echoes no extensions)", () => { + const out = payloadForFacilitator(PaymentPayloadV2.parse(makePayload()), declared); + expect(out.resource).toEqual({ url: 'https://api.basedagents.ai/v1/x402/tasks/usd-5', description: 'Hire', mimeType: 'application/json' }); + expect(out.extensions).toEqual({ bazaar: BAZAAR }); + // The signed parts are untouched. + expect(out.accepted).toEqual(req); + expect(out.payload).toEqual(PaymentPayloadV2.parse(makePayload()).payload); + }); + + it('replaces a rewritten echo and another resource with ours; other extensions pass through', () => { + const p = makePayload({ resource: { url: 'https://elsewhere.example/listing', description: 'Free money' } }); + p.extensions = { bazaar: { info: { input: { type: 'http', method: 'GET' } } }, 'payment-identifier': { info: { required: false, id: 'pay_0123456789abcdef' } } }; + const out = payloadForFacilitator(PaymentPayloadV2.parse(p), declared); + expect(out.resource?.url).toBe('https://api.basedagents.ai/v1/x402/tasks/usd-5'); + expect(out.extensions).toEqual({ bazaar: BAZAAR, 'payment-identifier': { info: { required: false, id: 'pay_0123456789abcdef' } } }); + }); + + it('drops a Bazaar block we did not declare, and leaves no empty extensions', () => { + const plain = buildPaymentRequired(TASK, req); + const p = makePayload(); + p.extensions = { bazaar: BAZAAR }; + const out = payloadForFacilitator(PaymentPayloadV2.parse(p), plain); + expect(out.resource?.url).toBe('https://api.basedagents.ai/v1/tasks/task_abc/accept'); + expect('extensions' in out).toBe(false); + p.extensions = { bazaar: BAZAAR, other: { a: 1 } }; + expect(payloadForFacilitator(PaymentPayloadV2.parse(p), plain).extensions).toEqual({ other: { a: 1 } }); + }); + + it('returns the header that is stored, which decodes back to the forwarded payload', () => { + const { payload, header } = decodeForFacilitator(toB64(makePayload()), declared); + expect(decodePaymentHeader(header)).toEqual(payload); + expect(payload.extensions?.bazaar).toEqual(BAZAAR); + // Malformed input still throws PaymentMalformed. + expect(() => decodeForFacilitator('not base64 !', declared)).toThrow(PaymentMalformed); + }); + + it('refuses a payload that our declarations would push past HEADER_MAX_BYTES (settle could not decode it)', () => { + // Under the cap as sent (a large extension of the payer's passes through), over it once our block is added. + const p = makePayload(); + p.extensions = { other: 'x'.repeat(10_500) }; + const raw = toB64(p); + expect(utf8.encode(raw).byteLength).toBeLessThan(HEADER_MAX_BYTES); + expect(() => decodePaymentHeader(raw)).not.toThrow(); + const big = { ...declared, extensions: { bazaar: { ...BAZAAR, note: 'n'.repeat(2_000) } } }; + expect(() => decodeForFacilitator(raw, big)).toThrow(/exceeds 16384 bytes/); + expect(() => decodeForFacilitator(toB64(makePayload()), big)).not.toThrow(); + }); +}); + describe('localPrechecks', () => { const req = buildRequirements(TASK, PAY_TO); const parse = (o: Record) => PaymentPayloadV2.parse(o); diff --git a/packages/api/src/payments/x402.ts b/packages/api/src/payments/x402.ts index 8c5b4b7..4b07df5 100644 --- a/packages/api/src/payments/x402.ts +++ b/packages/api/src/payments/x402.ts @@ -150,8 +150,9 @@ export const PaymentPayloadV2 = z.object({ resource: ResourceInfo.optional(), accepted: PaymentRequirementsV2, /** - * Extensions the client echoes from the 402 (e.g. `bazaar`). Kept and forwarded to the - * facilitator: a facilitator catalogs a resource from the echoed `bazaar` block at settle. + * Extensions the client echoes from the 402 (e.g. `bazaar`). Forwarded to the facilitator, + * except that the `bazaar` block, which a facilitator lists the service from, is always the + * one we declared (payloadForFacilitator). */ extensions: z.record(z.string(), z.unknown()).optional(), payload: z.object({ @@ -371,6 +372,45 @@ export function buildPaymentRequired( return out; } +/** + * The payload as we hand it to the facilitator. A facilitator lists a service + * in its Bazaar from the payload's `resource` and its echoed `bazaar` block, + * and some clients echo no extensions at all (Circle's CLI sends only + * `resource` and `accepted`). So both come from the 402 we sent: the resource + * is always ours, and the `bazaar` block is the one we declared, or none. A + * payer can't keep a service unlisted or rewrite its listing. The payer's + * other extensions pass through. + */ +export function payloadForFacilitator( + payload: PaymentPayloadV2, + declared: Pick, +): PaymentPayloadV2 { + const { extensions: echoed, ...rest } = payload; + const passed = Object.fromEntries(Object.entries(echoed ?? {}).filter(([key]) => key !== 'bazaar')); + const extensions = { ...passed, ...declared.extensions }; + return Object.keys(extensions).length + ? { ...rest, resource: declared.resource, extensions } + : { ...rest, resource: declared.resource }; +} + +/** + * Decode a payer's header into the payload we forward (payloadForFacilitator) + * and the header form of it that is stored and decoded again at settle. Throws + * PaymentMalformed like decodePaymentHeader, including when our declarations + * would take the stored header past HEADER_MAX_BYTES. + */ +export function decodeForFacilitator( + raw: string, + declared: Pick, +): { payload: PaymentPayloadV2; header: string } { + const payload = payloadForFacilitator(decodePaymentHeader(raw), declared); + const header = encodeB64Json(payload); + if (utf8.encode(header).byteLength > HEADER_MAX_BYTES) { + throw new PaymentMalformed(`header exceeds ${HEADER_MAX_BYTES} bytes with this resource's declarations`); + } + return { payload, header }; +} + // ─── Local prechecks (Step 5, before spending a facilitator call) ─── export type PrecheckReason = diff --git a/packages/api/src/routes/claim-governance.test.ts b/packages/api/src/routes/claim-governance.test.ts index b06e2ec..a54a960 100644 --- a/packages/api/src/routes/claim-governance.test.ts +++ b/packages/api/src/routes/claim-governance.test.ts @@ -222,7 +222,7 @@ describe('claim governance', () => { it('deposits ride the x402 dance: 402 challenge, then verify+settle credits the bond exactly once per authorization', async () => { const env = { ESCROW_WALLET_PRIVATE_KEY: HOUSE_KEY } as unknown as Bindings; - enablePaymentsForTests({ + const facilitator = enablePaymentsForTests({ verify: [{ kind: 'valid' }, { kind: 'valid' }], settle: [{ kind: 'settled', transaction: TEST_TX }], }); @@ -239,10 +239,13 @@ describe('claim governance', () => { house.address, env as never, ); const payload = payer.signTransfer(requirements, Math.floor(Date.now() / 1000)); - const header = encodeB64Json(payload); + // A Bazaar block we never declared is not forwarded (no listing under the house wallet). + const header = encodeB64Json({ ...payload, extensions: { bazaar: { info: { input: { type: 'http', method: 'GET' } } } } }); const paid = await depositClaimBond(db, env, worker.agentId, 2, header); expect(paid.status).toBe(200); + expect(facilitator.settleCalls[0].payload.resource?.url).toBe('https://api.basedagents.ai/v1/agents/me/claim-bond'); + expect(facilitator.settleCalls[0].payload).not.toHaveProperty('extensions'); expect((paid.body as { credited_atomic: string }).credited_atomic).toBe('2000000'); expect((await claimBudget(db, env, worker.agentId)).bond_slots).toBe(2); diff --git a/packages/api/src/routes/x402-tasks.test.ts b/packages/api/src/routes/x402-tasks.test.ts index 9ecabac..013c48b 100644 --- a/packages/api/src/routes/x402-tasks.test.ts +++ b/packages/api/src/routes/x402-tasks.test.ts @@ -178,10 +178,47 @@ describe('Wallet-only hiring over x402 (/v1/x402/tasks)', () => { const res = await post('/v1/x402/tasks/usd-1', BRIEF, { 'PAYMENT-SIGNATURE': encodeB64Json(payload) }); expect(res.status).toBe(200); const sent = facilitator.verifyCalls[0].payload as Json; - expect(sent.extensions.bazaar.info.input).toMatchObject({ type: 'http', method: 'POST' }); - expect(sent.resource.url).toBe('https://api.basedagents.ai/v1/x402/tasks/usd-1'); - // Settled from the stored header, the echo is still there. - expect((facilitator.settleCalls[0].payload as Json).extensions.bazaar).toBeTruthy(); + expect(sent.extensions.bazaar).toEqual(first.extensions.bazaar); + expect(sent.resource).toEqual(first.resource); + // Settled from the stored header, the block is still there. + expect((facilitator.settleCalls[0].payload as Json).extensions.bazaar).toEqual(first.extensions.bazaar); + }); + + it("a payment that echoes no extensions (Circle's CLI) still lists the service: verify and settle get our resource and Bazaar block", async () => { + const first = await (await post('/v1/x402/tasks/usd-1', BRIEF)).json() as Json; + // What `circle services pay` sends: the signed transfer, `accepted` and `resource`, nothing else. + const circleCli = { ...paymentPayloadFor(first.accepts[0], undefined, { authorization: { from: BUYER } }), resource: first.resource }; + expect(circleCli).not.toHaveProperty('extensions'); + const res = await post('/v1/x402/tasks/usd-1', BRIEF, { 'PAYMENT-SIGNATURE': encodeB64Json(circleCli) }); + expect(res.status).toBe(200); + for (const call of [facilitator.verifyCalls[0], facilitator.settleCalls[0]]) { + expect((call.payload as Json).resource).toEqual(first.resource); + expect((call.payload as Json).extensions).toEqual({ bazaar: first.extensions.bazaar }); + } + // A client that sends no resource either gets ours, on the custom endpoint too. + const custom = await (await post('/v1/x402/tasks', { ...BRIEF, bounty_usdc: '2.50' })).json() as Json; + const bare = paymentPayloadFor(custom.accepts[0], undefined, { authorization: { from: BUYER } }); + expect((await post('/v1/x402/tasks', { ...BRIEF, bounty_usdc: '2.50' }, { 'PAYMENT-SIGNATURE': encodeB64Json(bare) })).status).toBe(200); + const settled = facilitator.settleCalls[1].payload as Json; + expect(settled.resource).toEqual({ url: 'https://api.basedagents.ai/v1/x402/tasks', description: custom.resource.description, mimeType: 'application/json' }); + expect(settled.extensions.bazaar).toEqual(custom.extensions.bazaar); + }); + + it("a payer can't rewrite the listing: another resource and an edited Bazaar block are replaced by ours", async () => { + const first = await (await post('/v1/x402/tasks/usd-1', BRIEF)).json() as Json; + const edited = structuredClone(first.extensions) as Json; + edited.bazaar.info.input.body.title = 'Free USDC, claim here'; + const payload = { + ...paymentPayloadFor(first.accepts[0], undefined, { authorization: { from: BUYER } }), + resource: { url: 'https://elsewhere.example/free', description: 'Free USDC', mimeType: 'text/html' }, + extensions: { ...edited, 'payment-identifier': { info: { required: false, id: 'pay_0123456789abcdef' } } }, + }; + expect((await post('/v1/x402/tasks/usd-1', BRIEF, { 'PAYMENT-SIGNATURE': encodeB64Json(payload) })).status).toBe(200); + const sent = facilitator.settleCalls[0].payload as Json; + expect(sent.resource).toEqual(first.resource); + expect(sent.extensions.bazaar).toEqual(first.extensions.bazaar); + // The payer's other extensions are theirs to send. + expect(sent.extensions['payment-identifier']).toEqual({ info: { required: false, id: 'pay_0123456789abcdef' } }); }); it('the custom endpoint quotes the minimum when empty, and the chosen bounty once described', async () => { diff --git a/packages/api/src/tasks/bonds.ts b/packages/api/src/tasks/bonds.ts index 4536cc6..0a56802 100644 --- a/packages/api/src/tasks/bonds.ts +++ b/packages/api/src/tasks/bonds.ts @@ -15,7 +15,7 @@ import type { DBAdapter } from '../db/adapter.js'; import type { Bindings } from '../types/index.js'; import { - buildRequirements, buildPaymentRequired, decodePaymentHeader, encodeB64Json, localPrechecks, + buildRequirements, buildPaymentRequired, decodeForFacilitator, encodeB64Json, localPrechecks, PaymentMalformed, isNetwork, atomicToDisplay, } from '../payments/x402.js'; import { paymentProviderFor } from '../payments/index.js'; @@ -75,8 +75,10 @@ export async function depositClaimBond( description: `Refundable claim bond: ${slots} additional claim-budget slot${slots === 1 ? '' : 's'}.`, }; + // The 402, and the resource a paid request forwards to the facilitator. + const paymentRequired = buildPaymentRequired(bondRef, requirements, undefined, resource); + if (!rawHeader) { - const paymentRequired = buildPaymentRequired(bondRef, requirements, undefined, resource); return { status: 402, headers: { 'PAYMENT-REQUIRED': encodeB64Json(paymentRequired) }, @@ -92,7 +94,7 @@ export async function depositClaimBond( let payload; try { - payload = decodePaymentHeader(rawHeader); + payload = decodeForFacilitator(rawHeader, paymentRequired).payload; } catch (err) { return { status: 400, body: { error: 'payment_malformed', message: 'The payment header is not a valid x402 v2 payment payload.', detail: err instanceof PaymentMalformed ? err.detail : String(err) } }; }