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) } };
}