Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 23 additions & 3 deletions adapters/x402-middleware/src/challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,27 @@
// only, no server nonce/expiresAt). One `accepts` entry: the buyer signs an EIP-3009 authorization
// for `maxAmountRequired` USDC to `payTo`, using the `extra` domain hints.
import type { OfferingSlug } from '@grey/schemas/responses';
import { buildEvaluationKit } from '@grey/schemas/evaluationKit';
import type { X402Config, PaymentRequirements } from './types.js';
import { buildEvaluationArtifact } from '@grey/schemas/evaluationKit';
import type { EvaluationKitEntry } from '@grey/schemas/evaluationKit';
import type { X402Config, PaymentRequirements, CdpBazaarExtension } from './types.js';
import { priceAtomicFor } from './prices.js';

/** Reshape Grey's EvaluationKitEntry into CDP's `extensions.bazaar` wire shape (Task 3). Shared
* by challenge.ts and trustRung.ts's buildTrustRungPaymentRequirements — one mapping, not two. */
export function buildCdpBazaarExtension(kit: EvaluationKitEntry): CdpBazaarExtension {
return {
bazaar: {
info: {
// Every x402 route buildPaymentRequirements is called for is a paid POST/JSON route
// (the 2 free GETs never go through x402 at all) — method is not derived per-slug.
input: { type: 'http', method: 'POST', bodyType: 'json' },
output: kit.sample ? { example: kit.sample.response } : undefined,
},
schema: kit.inputSchema,
},
};
}

export function buildPaymentRequirements(
cfg: X402Config,
slug: string,
Expand All @@ -14,7 +31,9 @@ export function buildPaymentRequirements(
): PaymentRequirements {
// E1-B: every x402 route carries its own Bazaar discovery metadata in the 402 body — the
// single EvaluationKit source (Invariant #33), not a hand-authored per-route literal.
const kit = buildEvaluationKit(slug as OfferingSlug);
// buildEvaluationArtifact (not the leaner buildEvaluationKit) so extensions.bazaar.info.output
// can carry a real sample — Round 2's evaluation artifacts, reused rather than re-authored.
const kit = buildEvaluationArtifact(slug as OfferingSlug);
const body: PaymentRequirements = {
x402Version: 1,
accepts: [
Expand Down Expand Up @@ -43,6 +62,7 @@ export function buildPaymentRequirements(
},
},
],
extensions: buildCdpBazaarExtension(kit),
};
if (error) body.error = error;
return body;
Expand Down
6 changes: 4 additions & 2 deletions adapters/x402-middleware/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @grey/x402-middleware — sell-side x402 `exact`-scheme payment gate (Fastify).
// DIRECT settlement (FDQ-26) via a gas-only relayer (FDQ-31(a)); single price source (invariant #20).
export { loadX402Config } from './config.js';
export { makeX402PreHandler, slugFromUrl } from './preHandler.js';
export { makeX402PreHandler, makeX402PaymentPresenceCheck, slugFromUrl } from './preHandler.js';
export type { X402PreHandlerDeps } from './preHandler.js';
export { makeRelayerClients } from './clients.js';
export type { PublicClientLike, WalletClientLike, RelayerClients } from './clients.js';
Expand All @@ -14,14 +14,15 @@ export {
isPaidSlug,
} from './prices.js';
export type { PaidSlug } from './prices.js';
export { buildPaymentRequirements } from './challenge.js';
export { buildPaymentRequirements, buildCdpBazaarExtension } from './challenge.js';
export {
TRUST_RUNG_SLUG,
trustRungEnabled,
trustRungPriceAtomic,
trustRungPriceUsd,
buildTrustRungPaymentRequirements,
makeTrustRungPreHandler,
makeTrustRungPaymentPresenceCheck,
} from './trustRung.js';
export { decodePaymentHeader, verifyPayment } from './verify.js';
export type { VerifyResult } from './verify.js';
Expand All @@ -35,4 +36,5 @@ export type {
PaymentRequirements,
PaymentPayload,
TransferAuthorization,
CdpBazaarExtension,
} from './types.js';
48 changes: 44 additions & 4 deletions adapters/x402-middleware/src/preHandler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
// The Fastify preHandler (FDQ-29) grey-core installs on the 7 paid routes — drop-in for the old
// The Fastify hooks (FDQ-29) grey-core installs on the 7 paid routes — drop-in for the old
// no-op x402Placeholder. Orchestrates: challenge (402) → verify → settle → gate the handler.
//
// CDP/Bazaar alignment Phase 1 revision: this is now TWO hooks, not one, because Fastify's
// request lifecycle runs preValidation -> [body-schema validation] -> preHandler -> handler.
// `makeX402PaymentPresenceCheck` is body-independent (checks only that X-PAYMENT is present) and
// belongs on `preValidation`, BEFORE schema validation — that's what lets a probe with no known
// body shape still get a 402-with-Bazaar-metadata instead of a bare schema 400. The real
// decode/verify/settle logic stays in `makeX402PreHandler`, wired to `preHandler`, AFTER schema
// validation — so a buyer with a valid payment but a malformed body still 400s before being
// charged, same protection as before this whole change.
//
// Failure semantics (spec exit-criterion 3):
// - no/invalid X-PAYMENT or any verify failure → 402 + PaymentRequirements, NO settlement.
// - no X-PAYMENT → 402 + PaymentRequirements, from the preValidation hook, NO settlement.
// - invalid/unverifiable X-PAYMENT → 402 + PaymentRequirements, from the preHandler hook (only
// reached once the body has already passed schema validation), NO settlement.
// - settle throws (submit error OR reverted receipt) → 502, NO handler, payment not consumed.
// - settle succeeds → X-PAYMENT-RESPONSE header set (persists even if the handler later throws),
// handler runs; a post-settlement handler error still leaves the payment standing.
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import type {
FastifyReply,
FastifyRequest,
preHandlerHookHandler,
preValidationHookHandler,
} from 'fastify';
import type { X402Config } from './types.js';
import type { PublicClientLike, WalletClientLike } from './clients.js';
import { isPaidSlug, priceAtomicFor } from './prices.js';
Expand Down Expand Up @@ -39,7 +55,31 @@ function encodePaymentResponse(txHash: string, network: string): string {
).toString('base64');
}

export function makeX402PreHandler(cfg: X402Config, deps: X402PreHandlerDeps): preHandlerHookHandler {
/** `preValidation` half of the split gate — see this file's header comment. Body-independent: only
* checks that X-PAYMENT is present, before Fastify's schema validation runs. */
export function makeX402PaymentPresenceCheck(cfg: X402Config): preValidationHookHandler {
return async function x402PaymentPresenceCheck(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const slug = slugFromUrl(req.url);
if (!slug) return; // installed only on paid routes; defensive no-op otherwise.

const header = req.headers['x-payment'];
if (typeof header !== 'string' || header.length === 0) {
reply.code(402).send(buildPaymentRequirements(cfg, slug, req.url, 'payment required'));
}
};
}

/** `preHandler` half of the split gate — decode/verify/settle, unchanged from before the split.
* Runs after Fastify's schema validation (see header comment); its own header-presence check
* below is now redundant with makeX402PaymentPresenceCheck when both hooks are wired together,
* but keeps this function correct and self-contained for direct unit-test/call-site use. */
export function makeX402PreHandler(
cfg: X402Config,
deps: X402PreHandlerDeps,
): preHandlerHookHandler {
return async function x402PreHandler(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const slug = slugFromUrl(req.url);
if (!slug) return; // installed only on paid routes; defensive no-op otherwise.
Expand Down
31 changes: 28 additions & 3 deletions adapters/x402-middleware/src/trustRung.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@
// var. Deliberately kept OUT of prices.ts's PAID_SLUG_ORDER/PAID_SLUGS/PRICE_TABLE — those stay
// byte-identical to the 7 normal paid slugs; this file is fully isolated so the block can never be
// lifted by an accidental edit to the well-tested normal pricing path.
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import type {
FastifyReply,
FastifyRequest,
preHandlerHookHandler,
preValidationHookHandler,
} from 'fastify';
import type { OfferingSlug } from '@grey/schemas/responses';
import { resolvePriceUsd } from '@grey/schemas/pricing';
import { buildEvaluationKit } from '@grey/schemas/evaluationKit';
import { buildEvaluationArtifact } from '@grey/schemas/evaluationKit';
import type { X402Config, PaymentRequirements } from './types.js';
import type { X402PreHandlerDeps } from './preHandler.js';
import { decodePaymentHeader, verifyPayment } from './verify.js';
import { settle } from './settle.js';
import { buildCdpBazaarExtension } from './challenge.js';

export const TRUST_RUNG_SLUG: OfferingSlug = 'legitimacy_scan_trust_rung';

Expand Down Expand Up @@ -43,7 +49,7 @@ export function buildTrustRungPaymentRequirements(
resource: string,
error?: string,
): PaymentRequirements {
const kit = buildEvaluationKit(TRUST_RUNG_SLUG);
const kit = buildEvaluationArtifact(TRUST_RUNG_SLUG);
const body: PaymentRequirements = {
x402Version: 1,
accepts: [
Expand Down Expand Up @@ -72,6 +78,7 @@ export function buildTrustRungPaymentRequirements(
},
},
],
extensions: buildCdpBazaarExtension(kit),
};
if (error) body.error = error;
return body;
Expand All @@ -84,13 +91,31 @@ function encodePaymentResponse(txHash: string, network: string): string {
).toString('base64');
}

/** `preValidation` half of the trust-rung gate — CDP/Bazaar Phase 1 revision, mirrors
* preHandler.ts's makeX402PaymentPresenceCheck. Body-independent: only checks that X-PAYMENT is
* present, before Fastify's schema validation runs. Only ever installed on the trust-rung route
* (mounted only when `trustRungEnabled()` is true). */
export function makeTrustRungPaymentPresenceCheck(cfg: X402Config): preValidationHookHandler {
return async function trustRungPaymentPresenceCheck(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const header = req.headers['x-payment'];
if (typeof header !== 'string' || header.length === 0) {
reply.code(402).send(buildTrustRungPaymentRequirements(cfg, req.url, 'payment required'));
}
};
}

/**
* A SEPARATE preHandler, not a variant dispatched by the normal `makeX402PreHandler`
* (preHandler.ts's `slugFromUrl` deliberately does not recognize this slug, since it checks
* against `isPaidSlug` / the normal 7-slug PRICE_TABLE). Only ever installed on the trust-rung
* route, which itself is only mounted when `trustRungEnabled()` is true — so this function being
* unreachable is a property of grey-core's route wiring, not of this function checking the flag
* itself. Otherwise byte-identical verify→settle logic to the normal gate (same reused functions).
* `preHandler` half of the split gate (see makeTrustRungPaymentPresenceCheck above) — decode/
* verify/settle, unchanged from before the split; runs after Fastify's schema validation.
*/
export function makeTrustRungPreHandler(
cfg: X402Config,
Expand Down
33 changes: 32 additions & 1 deletion adapters/x402-middleware/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ export interface PaymentPayload {
};
}

/**
* CDP's canonical Bazaar discovery extension shape (CDP/Bazaar alignment Phase 1, Task 3).
*
* UNCERTAINTY, documented rather than hidden: CDP's public docs (docs.cdp.coinbase.com/x402/bazaar,
* checked 2026-08-02) describe `bazaar.info.{input,output}` + `bazaar.schema` but do not publish a
* complete worked JSON example of the surrounding `extensions` envelope — specifically, whether
* `extensions` sits at the top of the PaymentRequirements body (sibling to `x402Version`/`accepts`)
* or inside each `accepts[]` entry is not shown in a full example anywhere found (docs, the x402
* gitbook, or the coinbase/x402 GitHub repo's visible structure). Placed at the TOP LEVEL here —
* the more spec-consistent reading, since discovery metadata describes the RESOURCE, not a specific
* payment option, and matches the docs' own description of it as "top-level". This is Grey's best
* good-faith mapping from EvaluationKitEntry, not independently verified against a live
* CDP-indexed endpoint — Grey has no CDP API keys yet (Phase 2). Re-verify against the real
* facilitator once keys exist, before treating this shape as load-bearing.
*/
export interface CdpBazaarExtension {
bazaar: {
info: {
input: { type: 'http'; method: 'GET' | 'POST'; bodyType?: 'json' };
output?: { example?: unknown };
};
/** The request body's JSON Schema, verbatim from EvaluationKitEntry.inputSchema. */
schema: object | null;
};
}

/** 402 body — strict-canonical x402 `PaymentRequirements` (Forces ruling: maxTimeoutSeconds only,
* no server nonce/expiresAt; the buyer chooses the EIP-3009 nonce). */
export interface PaymentRequirements {
Expand All @@ -70,7 +96,9 @@ export interface PaymentRequirements {
maxTimeoutSeconds: number;
asset: Address;
/** EIP-712 domain hints the buyer needs to sign the authorization, plus (E1-B) the Bazaar
* discovery metadata projected from @grey/schemas/evaluationKit — "on every x402 route". */
* discovery metadata projected from @grey/schemas/evaluationKit — "on every x402 route".
* Grey's own shape — kept alongside `extensions.bazaar` below (Task 3), not replaced by it;
* other consumers may already read this field. Flag before removing, don't drop unilaterally. */
extra: {
name: string;
version: string;
Expand All @@ -86,5 +114,8 @@ export interface PaymentRequirements {
>;
};
}>;
/** CDP's canonical wire shape (Task 3) — top-level, see CdpBazaarExtension's own doc comment
* for the placement uncertainty this represents Grey's best-effort resolution of. */
extensions?: CdpBazaarExtension;
error?: string;
}
26 changes: 25 additions & 1 deletion adapters/x402-middleware/test/challenge.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildPaymentRequirements } from '../src/challenge.js';
import { buildPaymentRequirements, buildCdpBazaarExtension } from '../src/challenge.js';
import { TEST_CFG } from './_sign.js';

describe('buildPaymentRequirements — strict-canonical x402', () => {
Expand Down Expand Up @@ -61,4 +61,28 @@ describe('buildPaymentRequirements — strict-canonical x402', () => {
expect(bazaar.outputSchema).toBeTruthy();
expect(bazaar.iconUrl).toBe('https://whitepapergrey.com/icons/legitimacy_scan.svg');
});

it('CDP/Bazaar alignment Phase 1: also carries the top-level extensions.bazaar shape, alongside (not replacing) extra.bazaar', () => {
const body = buildPaymentRequirements(
TEST_CFG,
'legitimacy_scan',
'/v1/offerings/legitimacy_scan',
);
// extra.bazaar is untouched — Task 3 says keep it, other consumers may read it.
expect(body.accepts[0].extra.bazaar).toBeTruthy();
// extensions sits at the top of the body, sibling to accepts, not inside accepts[0].
expect(body.extensions).toBeTruthy();
expect(body).not.toHaveProperty('accepts[0].extensions');
const ext = body.extensions!.bazaar;
expect(ext.info.input).toEqual({ type: 'http', method: 'POST', bodyType: 'json' });
expect(ext.info.output?.example).toBeTruthy(); // legitimacy_scan has a sample response (Round 2)
expect(ext.schema).toEqual(body.accepts[0].extra.bazaar.inputSchema); // same JSON Schema, reshaped location
});

it('buildCdpBazaarExtension is a pure reshape — same output for the same EvaluationKitEntry input', () => {
const kitA = { inputSchema: { type: 'object' }, sample: undefined } as never;
const extA = buildCdpBazaarExtension(kitA);
expect(extA.bazaar.schema).toEqual({ type: 'object' });
expect(extA.bazaar.info.output).toBeUndefined(); // no sample -> no output example, not a fabricated one
});
});
Loading