Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
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 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:
Expand Down
1 change: 1 addition & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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.
- **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 <token>` authorizes every manage call.
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
11 changes: 7 additions & 4 deletions packages/api/src/payments/accept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof decodePaymentHeader>;
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 } };
}
Expand Down Expand Up @@ -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();

Expand Down
18 changes: 10 additions & 8 deletions packages/api/src/payments/escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) },
Expand All @@ -173,9 +173,11 @@ export async function fundEscrowTask(db: DBAdapter, env: Bindings, target: FundT
};
}

let payload: ReturnType<typeof decodePaymentHeader>;
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 } };
}
Expand Down Expand Up @@ -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();

Expand Down
16 changes: 14 additions & 2 deletions packages/api/src/payments/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) } });
Expand Down
61 changes: 60 additions & 1 deletion packages/api/src/payments/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
PaymentMalformed,
buildRequirements,
buildPaymentRequired,
payloadForFacilitator,
decodeForFacilitator,
encodeB64Json,
localPrechecks,
usdcToAtomic,
Expand Down Expand Up @@ -250,7 +252,7 @@ describe('decodePaymentHeader', () => {
(p as Record<string, unknown>).junk = { x: 1 };
const out = decodePaymentHeader(toB64(p));
expect((out.accepted.extra as Record<string, unknown>).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<string, unknown>).junk).toBeUndefined();
});
Expand Down Expand Up @@ -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<string, unknown>) => PaymentPayloadV2.parse(o);
Expand Down
44 changes: 42 additions & 2 deletions packages/api/src/payments/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<PaymentRequired, 'resource' | 'extensions'>,
): 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<PaymentRequired, 'resource' | 'extensions'>,
): { 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 =
Expand Down
Loading
Loading