From 4007135728c77932dfb8ae148704e95dd9623ecc Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Sun, 2 Aug 2026 17:51:52 -0400 Subject: [PATCH 1/2] fix(cdp-bazaar): validation-order bug + CDP extensions.bazaar wire shape (Phase 1) Per CDP-BAZAAR-COMPATIBILITY-AUDIT-REPORT-KOV.md, this addresses blockers #2 (wire-format mismatch) and the newly-found #3 (validation-order bug), NOT #1 (CDP Facilitator routing -- Phase 2, blocked on Forces obtaining CDP API keys; verify.ts/settle.ts/ clients.ts untouched here). Task 2 -- validation-order fix: offerings.ts and trustRung.ts wired the x402 gate as `preHandler`, which Fastify runs AFTER body-schema validation. A probe without a schema-valid body (no X-PAYMENT header, empty/malformed body -- exactly what CDP's own validator sends, and what any discovery crawler that doesn't already know the input shape would send) got a 400 before ever reaching the point where a 402-with-Bazaar-metadata would be returned. Fix: wire the gate as `preValidation` instead, which runs BEFORE schema validation -- identical function, one hook-name change, no business logic touched. A request that carries valid payment but an invalid body now settles first, then 400s on schema -- consistent with this codebase's already- established "settlement stands even if something after it fails" posture (preHandler.ts's own header comment), not a new risk category. New tests: all 7 priced offerings + the trust rung, empty/malformed body + no payment -> asserts 402 with Bazaar metadata attached, not 400 -- exactly the gap x402-routes.test.ts had, per the directive. Task 3 -- CDP's canonical wire shape: added a top-level `extensions.bazaar` object (`{bazaar: {info: {input, output}, schema}}`) alongside (not replacing) the existing `accepts[0].extra.bazaar` -- other consumers may still read the latter. Reshaped from EvaluationKitEntry via a new shared `buildCdpBazaarExtension()`, used by both challenge.ts and trustRung.ts. Documented uncertainty in code: CDP's public docs describe `bazaar.info`/`bazaar.schema` but don't publish a complete example of the surrounding `extensions` envelope's exact position -- placed at the top of the body (sibling to accepts), the more spec-consistent reading, not independently verified against a live CDP-indexed endpoint (no API keys yet). Re-verify against the real facilitator in Phase 2. EXPANSION-CDP-BAZAAR-ALIGNMENT-PHASE1-KOV-directive.md Tasks 2-3. --- adapters/x402-middleware/src/challenge.ts | 26 ++++++++++++-- adapters/x402-middleware/src/index.ts | 3 +- adapters/x402-middleware/src/trustRung.ts | 6 ++-- adapters/x402-middleware/src/types.ts | 33 +++++++++++++++++- .../x402-middleware/test/challenge.test.ts | 26 +++++++++++++- .../x402-middleware/test/trustRung.test.ts | 11 ++++++ .../grey-core/src/server/routes/offerings.ts | 19 +++++++++-- .../grey-core/src/server/routes/trustRung.ts | 4 ++- packages/grey-core/test/trustRung.test.ts | 15 ++++++++ packages/grey-core/test/x402-routes.test.ts | 34 +++++++++++++++++++ 10 files changed, 165 insertions(+), 12 deletions(-) diff --git a/adapters/x402-middleware/src/challenge.ts b/adapters/x402-middleware/src/challenge.ts index ba699da..a12b113 100644 --- a/adapters/x402-middleware/src/challenge.ts +++ b/adapters/x402-middleware/src/challenge.ts @@ -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, @@ -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: [ @@ -43,6 +62,7 @@ export function buildPaymentRequirements( }, }, ], + extensions: buildCdpBazaarExtension(kit), }; if (error) body.error = error; return body; diff --git a/adapters/x402-middleware/src/index.ts b/adapters/x402-middleware/src/index.ts index 2314ea6..80e312b 100644 --- a/adapters/x402-middleware/src/index.ts +++ b/adapters/x402-middleware/src/index.ts @@ -14,7 +14,7 @@ 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, @@ -35,4 +35,5 @@ export type { PaymentRequirements, PaymentPayload, TransferAuthorization, + CdpBazaarExtension, } from './types.js'; diff --git a/adapters/x402-middleware/src/trustRung.ts b/adapters/x402-middleware/src/trustRung.ts index d6bea70..f80b466 100644 --- a/adapters/x402-middleware/src/trustRung.ts +++ b/adapters/x402-middleware/src/trustRung.ts @@ -9,11 +9,12 @@ import type { FastifyReply, FastifyRequest, preHandlerHookHandler } 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'; @@ -43,7 +44,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: [ @@ -72,6 +73,7 @@ export function buildTrustRungPaymentRequirements( }, }, ], + extensions: buildCdpBazaarExtension(kit), }; if (error) body.error = error; return body; diff --git a/adapters/x402-middleware/src/types.ts b/adapters/x402-middleware/src/types.ts index 71371c7..dd644da 100644 --- a/adapters/x402-middleware/src/types.ts +++ b/adapters/x402-middleware/src/types.ts @@ -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 { @@ -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; @@ -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; } diff --git a/adapters/x402-middleware/test/challenge.test.ts b/adapters/x402-middleware/test/challenge.test.ts index 4fdf914..3879fe7 100644 --- a/adapters/x402-middleware/test/challenge.test.ts +++ b/adapters/x402-middleware/test/challenge.test.ts @@ -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', () => { @@ -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 + }); }); diff --git a/adapters/x402-middleware/test/trustRung.test.ts b/adapters/x402-middleware/test/trustRung.test.ts index 9d96a51..a886ad7 100644 --- a/adapters/x402-middleware/test/trustRung.test.ts +++ b/adapters/x402-middleware/test/trustRung.test.ts @@ -47,4 +47,15 @@ describe('trustRung — E1-C disable flag (Forces ruling B-1, Invariant #34)', ( expect(body.accepts[0].description).toContain(TRUST_RUNG_SLUG); expect(body.accepts[0].extra.bazaar.serviceName).toBe('Legitimacy Trust Rung'); }); + + it('CDP/Bazaar alignment Phase 1: also carries top-level extensions.bazaar, same shape as the normal 7 routes', () => { + const body = buildTrustRungPaymentRequirements(TEST_CFG, `/v1/offerings/${TRUST_RUNG_SLUG}`); + expect(body.extensions).toBeTruthy(); + expect(body.extensions!.bazaar.info.input).toEqual({ + type: 'http', + method: 'POST', + bodyType: 'json', + }); + expect(body.extensions!.bazaar.schema).toEqual(body.accepts[0].extra.bazaar.inputSchema); + }); }); diff --git a/packages/grey-core/src/server/routes/offerings.ts b/packages/grey-core/src/server/routes/offerings.ts index 0e4ec0c..2c8c9d7 100644 --- a/packages/grey-core/src/server/routes/offerings.ts +++ b/packages/grey-core/src/server/routes/offerings.ts @@ -1,6 +1,19 @@ // Paid offering routes: POST /v1/offerings/ × 7. Each validates its request body via the -// $grey marker (→ offeringRequestValidators), runs behind the x402 no-op preHandler, calls the -// cache-read handler, and wraps the payload in a GreyResponseEnvelope. +// $grey marker (→ offeringRequestValidators), runs behind the x402 gate, calls the cache-read +// handler, and wraps the payload in a GreyResponseEnvelope. +// +// CDP/Bazaar alignment Phase 1, Task 2: the x402 gate is wired as `preValidation`, NOT +// `preHandler`. Fastify's request lifecycle is onRequest -> preParsing -> preValidation -> +// [body/query/params SCHEMA VALIDATION] -> preHandler -> handler — so a `preHandler`-wired gate +// runs AFTER schema validation, meaning a request without a schema-valid body 400s before ever +// reaching the point where a 402-with-Bazaar-metadata would be returned. That defeats any +// discovery crawler that doesn't already know the input shape (the exact scenario CDP's own +// validator hits — see CDP-BAZAAR-COMPATIBILITY-AUDIT-REPORT-KOV.md). `preValidation` runs +// BEFORE schema validation, so: no X-PAYMENT header -> 402 immediately, body content irrelevant. +// A request that DOES carry payment but an invalid body now settles first, then 400s on schema — +// consistent with this codebase's already-established "settlement stands even if something after +// it fails" posture (see preHandler.ts's own header comment: "a post-settlement handler error +// still leaves the payment standing"); this is the same posture, one step earlier. import { randomUUID } from 'node:crypto'; import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; import type { PaidOfferingSlug } from '@grey/schemas/responses'; @@ -31,7 +44,7 @@ export function registerOfferingRoutes( `/v1/offerings/${slug}`, { schema: { body: { $grey: { kind: 'request', offering: slug } } }, - preHandler: x402PreHandler, + preValidation: x402PreHandler, }, async (req, reply) => { const start = deps.clock().getTime(); diff --git a/packages/grey-core/src/server/routes/trustRung.ts b/packages/grey-core/src/server/routes/trustRung.ts index b1abefc..b042b25 100644 --- a/packages/grey-core/src/server/routes/trustRung.ts +++ b/packages/grey-core/src/server/routes/trustRung.ts @@ -23,7 +23,9 @@ export function registerTrustRungRoute( `/v1/offerings/${TRUST_RUNG_SLUG}`, { schema: { body: { $grey: { kind: 'request', offering: TRUST_RUNG_SLUG } } }, - preHandler: trustRungPreHandler, + // CDP/Bazaar alignment Phase 1, Task 2: `preValidation`, not `preHandler` — see + // offerings.ts's header comment for why (runs before Fastify's body-schema validation). + preValidation: trustRungPreHandler, }, async (req, reply) => { const start = deps.clock().getTime(); diff --git a/packages/grey-core/test/trustRung.test.ts b/packages/grey-core/test/trustRung.test.ts index 710cb94..7115261 100644 --- a/packages/grey-core/test/trustRung.test.ts +++ b/packages/grey-core/test/trustRung.test.ts @@ -66,6 +66,21 @@ describe('trust rung — correctly reachable when explicitly enabled (proves the expect(body.accepts[0].maxAmountRequired).toBe('100000'); }); + it('CDP/Bazaar alignment Phase 1: an empty body with no payment still gets a 402, not a 400 (same fix as the normal 7 routes)', async () => { + const app = makeApp({}, passThroughX402, { + trustRungEnabled: true, + trustRungPreHandler: trustRungGate, + }); + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: {}, + }); + expect(res.statusCode, JSON.stringify(res.json())).toBe(402); + const body = res.json(); + expect(body.accepts[0].maxAmountRequired).toBe('100000'); + }); + it('is listed in discovery once enabled', async () => { const app = makeApp({}, passThroughX402, { trustRungEnabled: true, diff --git a/packages/grey-core/test/x402-routes.test.ts b/packages/grey-core/test/x402-routes.test.ts index c2db222..4006e9e 100644 --- a/packages/grey-core/test/x402-routes.test.ts +++ b/packages/grey-core/test/x402-routes.test.ts @@ -70,6 +70,40 @@ describe('x402 gate on the 7 paid routes', () => { }, ); + // CDP/Bazaar alignment Phase 1, Task 2: the gap the audit report found — a probe that doesn't + // already know the required body shape (no payment, empty/schema-invalid body) must still get a + // 402 carrying the Bazaar metadata, not a 400 that never reaches the payment gate at all. Every + // offering here has SOME way to violate its schema even with no required fields + // (daily_tech_brief has none, so its "malformed" case is an additionalProperties:false trip). + const MALFORMED: Record = { + legitimacy_scan: {}, // missing required token_address + verify_whitepaper: {}, // missing required token_address + verify_full_tech: {}, // missing required token_address + claim_extraction: {}, // missing required whitepaperUrl + claim_history: {}, // missing required projectIdentifier + quick_protocol_facts: {}, // missing required projectQuery + daily_tech_brief: { bogus_field: true }, // no required fields; additionalProperties:false trips instead + }; + + it.each(Object.keys(PRICE))( + 'POST /v1/offerings/%s with an empty/malformed body and no payment → still 402 with Bazaar metadata, never 400', + async (slug) => { + const app = makeApp({}, gate); + const res = await app.inject({ + method: 'POST', + url: `/v1/offerings/${slug}`, + payload: MALFORMED[slug], + }); + expect(res.statusCode, JSON.stringify(res.json())).toBe(402); + const body = res.json(); + expect(body.x402Version).toBe(1); + expect(body.accepts[0].maxAmountRequired).toBe(PRICE[slug]); + expect(body.accepts[0].extra.bazaar).toBeTruthy(); + expect(body.accepts[0].extra.bazaar.discoverable).toBe(true); + await app.close(); + }, + ); + it('malformed X-PAYMENT → clean 402, never 500', async () => { const app = makeApp({}, gate); const res = await app.inject({ From b9b95f3b99e1b57d54c3e30a73631d86c2e23a50 Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Sun, 2 Aug 2026 19:39:52 -0400 Subject: [PATCH 2/2] fix(cdp-bazaar): split the x402 gate into preValidation + preHandler (Phase 1 revision) Moving the whole gate to preValidation fixed the discovery-crawler bug but over-corrected: it also moved settlement ahead of schema validation, so a buyer with a valid payment but a malformed body now paid before failing. Split into two hooks: a new lightweight preValidation check (header presence only, body-independent) stays ahead of schema validation; the existing verify+settle logic stays on preHandler, after schema validation, restoring the pre-existing "malformed body never gets charged" protection. Applies to both the normal 7-offering gate and the trust-rung gate. --- adapters/x402-middleware/src/index.ts | 3 +- adapters/x402-middleware/src/preHandler.ts | 48 +++++++- adapters/x402-middleware/src/trustRung.ts | 25 +++- .../x402-middleware/test/preHandler.test.ts | 109 +++++++++++++++++- .../grey-core/src/channels/x402Adapter.ts | 22 ++-- packages/grey-core/src/server/index.ts | 24 ++-- .../grey-core/src/server/routes/offerings.ts | 52 ++++++--- .../grey-core/src/server/routes/trustRung.ts | 17 +-- packages/grey-core/src/start.ts | 37 +++--- packages/grey-core/test/_helpers.ts | 25 +++- .../test/channels/x402Adapter.test.ts | 29 +++-- packages/grey-core/test/probes.test.ts | 8 +- packages/grey-core/test/revenueLedger.test.ts | 14 ++- packages/grey-core/test/trustRung.test.ts | 47 +++++--- packages/grey-core/test/x402-routes.test.ts | 29 +++-- 15 files changed, 370 insertions(+), 119 deletions(-) diff --git a/adapters/x402-middleware/src/index.ts b/adapters/x402-middleware/src/index.ts index 80e312b..1531c0b 100644 --- a/adapters/x402-middleware/src/index.ts +++ b/adapters/x402-middleware/src/index.ts @@ -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'; @@ -22,6 +22,7 @@ export { trustRungPriceUsd, buildTrustRungPaymentRequirements, makeTrustRungPreHandler, + makeTrustRungPaymentPresenceCheck, } from './trustRung.js'; export { decodePaymentHeader, verifyPayment } from './verify.js'; export type { VerifyResult } from './verify.js'; diff --git a/adapters/x402-middleware/src/preHandler.ts b/adapters/x402-middleware/src/preHandler.ts index dad7e52..2884961 100644 --- a/adapters/x402-middleware/src/preHandler.ts +++ b/adapters/x402-middleware/src/preHandler.ts @@ -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'; @@ -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 { + 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 { const slug = slugFromUrl(req.url); if (!slug) return; // installed only on paid routes; defensive no-op otherwise. diff --git a/adapters/x402-middleware/src/trustRung.ts b/adapters/x402-middleware/src/trustRung.ts index f80b466..33f65e2 100644 --- a/adapters/x402-middleware/src/trustRung.ts +++ b/adapters/x402-middleware/src/trustRung.ts @@ -6,7 +6,12 @@ // 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 { buildEvaluationArtifact } from '@grey/schemas/evaluationKit'; @@ -86,6 +91,22 @@ 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 { + 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 @@ -93,6 +114,8 @@ function encodePaymentResponse(txHash: string, network: string): string { * 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, diff --git a/adapters/x402-middleware/test/preHandler.test.ts b/adapters/x402-middleware/test/preHandler.test.ts index 8b1a136..4a9e894 100644 --- a/adapters/x402-middleware/test/preHandler.test.ts +++ b/adapters/x402-middleware/test/preHandler.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; +import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { makeX402PreHandler, slugFromUrl } from '../src/preHandler.js'; +import { + makeX402PreHandler, + makeX402PaymentPresenceCheck, + slugFromUrl, +} from '../src/preHandler.js'; import { TEST_CFG, signedPayment, mockPublicClient, mockWallet } from './_sign.js'; // now() returns ms; 1e12 ms → 1e9 s, inside the default [0, 9_999_999_999) window. @@ -139,3 +144,105 @@ describe('makeX402PreHandler — orchestration', () => { expect(m.headers['X-PAYMENT-RESPONSE']).toBeUndefined(); }); }); + +// CDP/Bazaar alignment Phase 1 revision: proves the split hook pair's actual contract through a +// real Fastify instance (schema validation included) — a unit test calling makeX402PreHandler +// directly, like the tests above, can't exercise "does schema validation run first" at all, since +// there's no schema validation happening outside of Fastify's own request lifecycle. +describe('preValidation + preHandler split (CDP/Bazaar Phase 1 revision)', () => { + const BODY_SCHEMA = { + type: 'object', + required: ['token_address'], + properties: { token_address: { type: 'string' } }, + }; + + it('valid X-PAYMENT + malformed body → 400, not 402/200, and zero broadcast (settlement never runs)', async () => { + const { header } = await signedPayment(TEST_CFG); + const wallet = mockWallet(); + const app = Fastify(); + app.post( + '/v1/offerings/legitimacy_scan', + { + schema: { body: BODY_SCHEMA }, + preValidation: makeX402PaymentPresenceCheck(TEST_CFG), + preHandler: makeX402PreHandler(TEST_CFG, { + wallet, + publicClient: mockPublicClient({ used: false, status: 'success' }), + now, + }), + }, + async () => ({ ok: true }), + ); + + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + headers: { 'x-payment': header }, // a genuinely valid, verifiable payment + payload: {}, // missing required token_address → fails schema validation + }); + + expect(res.statusCode).toBe(400); + expect(wallet.calls).toHaveLength(0); // settle() never ran — schema validation blocked it first + await app.close(); + }); + + it('no X-PAYMENT + malformed body → 402 with requirements, before schema validation ever runs', async () => { + const app = Fastify(); + app.post( + '/v1/offerings/legitimacy_scan', + { + schema: { body: BODY_SCHEMA }, + preValidation: makeX402PaymentPresenceCheck(TEST_CFG), + preHandler: makeX402PreHandler(TEST_CFG, { + wallet: mockWallet(), + publicClient: mockPublicClient(), + now, + }), + }, + async () => ({ ok: true }), + ); + + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + payload: {}, // would also fail schema, but preValidation's 402 must win the race + }); + + expect(res.statusCode).toBe(402); + expect( + (res.json() as { accepts: { maxAmountRequired: string }[] }).accepts[0].maxAmountRequired, + ).toBe('250000'); + await app.close(); + }); + + it('valid X-PAYMENT + valid body → settles and reaches the handler, same as before the split', async () => { + const { header } = await signedPayment(TEST_CFG); + const wallet = mockWallet('0x' + 'ee'.repeat(32)); + const app = Fastify(); + app.post( + '/v1/offerings/legitimacy_scan', + { + schema: { body: BODY_SCHEMA }, + preValidation: makeX402PaymentPresenceCheck(TEST_CFG), + preHandler: makeX402PreHandler(TEST_CFG, { + wallet, + publicClient: mockPublicClient({ used: false, status: 'success' }), + now, + }), + }, + async () => ({ ok: true }), + ); + + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + headers: { 'x-payment': header }, + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + + expect(res.statusCode).toBe(200); + expect(wallet.calls).toHaveLength(1); + expect(res.headers['x-payment-response']).toBeDefined(); + await app.close(); + }); +}); diff --git a/packages/grey-core/src/channels/x402Adapter.ts b/packages/grey-core/src/channels/x402Adapter.ts index b4ca101..921d861 100644 --- a/packages/grey-core/src/channels/x402Adapter.ts +++ b/packages/grey-core/src/channels/x402Adapter.ts @@ -3,17 +3,19 @@ // path is imported and used untouched (invariant #19: the relayer key never enters grey-core). Its // only job is to run the existing `buildServer(deps, gate)` + `listen` through the ChannelIngress // lifecycle so x402 genuinely runs *through* the seam, and to surface the channel's identity/catalog. -import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; +import type { FastifyInstance } from 'fastify'; import type { HandlerDeps } from '../deps'; import { buildServer } from '../server'; +import type { X402Gate } from '../server/routes/offerings'; import type { McpRouteDeps } from '../server/routes/mcp'; import type { ChannelIdentity, ChannelIngress, OfferingRegistration } from './ingress'; export interface X402AdapterOptions { /** The channel-agnostic core deps (same object start.ts builds via createHandlerDeps). */ deps: HandlerDeps; - /** The x402 payment gate (built in start.ts from @grey/x402-middleware; used here untouched). */ - gate: preHandlerHookHandler; + /** The x402 payment gate — both hooks (built in start.ts from @grey/x402-middleware; used here + * untouched). */ + gate: X402Gate; /** Listen port (start.ts passes GREY_CORE_PORT ?? 3002). */ port: number; /** Listen host. Defaults to 0.0.0.0 (the production bind). */ @@ -23,9 +25,9 @@ export interface X402AdapterOptions { /** E1-C, Invariant #34: default OFF. start.ts is the one boot boundary that reads * @grey/x402-middleware's trustRungEnabled() and passes the result through here. */ trustRungEnabled?: boolean; - /** Required when trustRungEnabled is true — @grey/x402-middleware's makeTrustRungPreHandler(...) - * output. NOT the same as `gate`: different slug/price, so a different verify/settle path. */ - trustRungPreHandler?: preHandlerHookHandler; + /** Required when trustRungEnabled is true — both hooks from @grey/x402-middleware's trust-rung + * factories. NOT the same as `gate`: different slug/price, so a different verify/settle path. */ + trustRungGate?: X402Gate; /** E1-D: mounts POST /v1/mcp when present. Unconditional (unlike the trust rung) — MCP exposes * the same 7+2 normal offerings, not a blocked one. */ mcp?: McpRouteDeps; @@ -39,12 +41,12 @@ export interface X402AdapterOptions { */ export class X402Adapter implements ChannelIngress { private readonly deps: HandlerDeps; - private readonly gate: preHandlerHookHandler; + private readonly gate: X402Gate; private readonly port: number; private readonly host: string; private readonly relayerAddress?: string; private readonly trustRungEnabled: boolean; - private readonly trustRungPreHandler?: preHandlerHookHandler; + private readonly trustRungGate?: X402Gate; private readonly mcp?: McpRouteDeps; private readonly offerings: OfferingRegistration[] = []; private app: FastifyInstance | null = null; @@ -57,7 +59,7 @@ export class X402Adapter implements ChannelIngress { this.host = opts.host ?? '0.0.0.0'; this.relayerAddress = opts.relayerAddress; this.trustRungEnabled = opts.trustRungEnabled ?? false; - this.trustRungPreHandler = opts.trustRungPreHandler; + this.trustRungGate = opts.trustRungGate; this.mcp = opts.mcp; } @@ -66,7 +68,7 @@ export class X402Adapter implements ChannelIngress { // The SAME call start.ts made inline — the seam adds no per-request code. const app = buildServer(this.deps, this.gate, { trustRungEnabled: this.trustRungEnabled, - trustRungPreHandler: this.trustRungPreHandler, + trustRungGate: this.trustRungGate, mcp: this.mcp, }); this.app = app; diff --git a/packages/grey-core/src/server/index.ts b/packages/grey-core/src/server/index.ts index 5b9e2c2..08eaab1 100644 --- a/packages/grey-core/src/server/index.ts +++ b/packages/grey-core/src/server/index.ts @@ -1,13 +1,13 @@ -// @grey/core server factory. buildServer(deps, x402PreHandler) returns a configured FastifyInstance +// @grey/core server factory. buildServer(deps, x402Gate) returns a configured FastifyInstance // with the delegating validator compiler + probes + the 7 paid offering routes (gated by the x402 -// preHandler) + the 2 free resource routes. The gate is a REQUIRED param — Fastify-specific, so it +// gate) + the 2 free resource routes. The gate is a REQUIRED param — Fastify-specific, so it // stays out of the ingress-agnostic HandlerDeps; start.ts injects the real @grey/x402-middleware gate, // tests inject a pass-through. Tests drive via app.inject() — no port binding, no live DB, no Anthropic. -import Fastify, { type FastifyInstance, type preHandlerHookHandler } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import type { HandlerDeps } from '../deps'; import { installValidatorCompiler } from './validators'; import { registerProbes } from './routes/probes'; -import { registerOfferingRoutes } from './routes/offerings'; +import { registerOfferingRoutes, type X402Gate } from './routes/offerings'; import { registerResourceRoutes } from './routes/resources'; import { registerDiscoveryRoutes } from './routes/discovery'; import { registerTrustRungRoute } from './routes/trustRung'; @@ -17,9 +17,9 @@ export interface BuildServerOptions { /** E1-C, Invariant #34: default OFF. Only start.ts (reading @grey/x402-middleware's * trustRungEnabled()) and trust-rung-specific tests should ever pass `true`. */ trustRungEnabled?: boolean; - /** Required when trustRungEnabled is true — @grey/x402-middleware's - * makeTrustRungPreHandler(...) output. NOT the general x402PreHandler (different slug/price). */ - trustRungPreHandler?: preHandlerHookHandler; + /** Required when trustRungEnabled is true — both hooks from @grey/x402-middleware's trust-rung + * factories. NOT the general x402Gate (different slug/price). */ + trustRungGate?: X402Gate; /** E1-D: mounts POST /v1/mcp when present. Optional so existing callers (and most tests) are * unaffected; start.ts always passes it (MCP is unconditional — only the trust rung is gated). */ mcp?: McpRouteDeps; @@ -27,21 +27,21 @@ export interface BuildServerOptions { export function buildServer( deps: HandlerDeps, - x402PreHandler: preHandlerHookHandler, + x402Gate: X402Gate, opts: BuildServerOptions = {}, ): FastifyInstance { const trustRungEnabled = opts.trustRungEnabled ?? false; const app = Fastify({ logger: false }); installValidatorCompiler(app); registerProbes(app, deps); - registerOfferingRoutes(app, deps, x402PreHandler); // paid POST × 7, behind the x402 gate + registerOfferingRoutes(app, deps, x402Gate); // paid POST × 7, behind the x402 gate registerResourceRoutes(app, deps); // free GET × 2 registerDiscoveryRoutes(app, { trustRungEnabled }); // E1-B: free Bazaar discovery index, GET × 2 if (trustRungEnabled) { - if (!opts.trustRungPreHandler) { - throw new Error('buildServer: trustRungEnabled requires opts.trustRungPreHandler'); + if (!opts.trustRungGate) { + throw new Error('buildServer: trustRungEnabled requires opts.trustRungGate'); } - registerTrustRungRoute(app, deps, opts.trustRungPreHandler); // E1-C, default off + registerTrustRungRoute(app, deps, opts.trustRungGate); // E1-C, default off } if (opts.mcp) registerMcpRoute(app, deps, opts.mcp); // E1-D: paid MCP tools, POST × 1 return app; diff --git a/packages/grey-core/src/server/routes/offerings.ts b/packages/grey-core/src/server/routes/offerings.ts index 2c8c9d7..906d80b 100644 --- a/packages/grey-core/src/server/routes/offerings.ts +++ b/packages/grey-core/src/server/routes/offerings.ts @@ -2,26 +2,36 @@ // $grey marker (→ offeringRequestValidators), runs behind the x402 gate, calls the cache-read // handler, and wraps the payload in a GreyResponseEnvelope. // -// CDP/Bazaar alignment Phase 1, Task 2: the x402 gate is wired as `preValidation`, NOT -// `preHandler`. Fastify's request lifecycle is onRequest -> preParsing -> preValidation -> -// [body/query/params SCHEMA VALIDATION] -> preHandler -> handler — so a `preHandler`-wired gate -// runs AFTER schema validation, meaning a request without a schema-valid body 400s before ever -// reaching the point where a 402-with-Bazaar-metadata would be returned. That defeats any -// discovery crawler that doesn't already know the input shape (the exact scenario CDP's own -// validator hits — see CDP-BAZAAR-COMPATIBILITY-AUDIT-REPORT-KOV.md). `preValidation` runs -// BEFORE schema validation, so: no X-PAYMENT header -> 402 immediately, body content irrelevant. -// A request that DOES carry payment but an invalid body now settles first, then 400s on schema — -// consistent with this codebase's already-established "settlement stands even if something after -// it fails" posture (see preHandler.ts's own header comment: "a post-settlement handler error -// still leaves the payment standing"); this is the same posture, one step earlier. +// CDP/Bazaar alignment Phase 1 revision: the x402 gate is TWO hooks, not one. Fastify's request +// lifecycle is onRequest -> preParsing -> preValidation -> [body/query/params SCHEMA VALIDATION] +// -> preHandler -> handler. `preValidation` gets the lightweight, body-independent +// makeX402PaymentPresenceCheck (@grey/x402-middleware): no X-PAYMENT header -> 402 immediately, +// body content irrelevant — this is what lets a discovery crawler that doesn't already know the +// input shape still get a 402-with-Bazaar-metadata instead of a bare schema 400 (the exact +// scenario CDP's own validator hits — see CDP-BAZAAR-COMPATIBILITY-AUDIT-REPORT-KOV.md). +// `preHandler` gets the real makeX402PreHandler (decode/verify/settle), which runs AFTER schema +// validation — so a request that carries a payment header but an invalid body still 400s on +// schema BEFORE settlement, same buyer protection as before CDP/Bazaar alignment ever touched +// this file (see CDP-BAZAAR-PHASE1-REVISION-split-gate-KOV-directive.md for why the original +// single-hook `preValidation` move over-corrected: it moved settlement itself ahead of schema +// validation, charging buyers for requests that were always going to fail validation anyway). import { randomUUID } from 'node:crypto'; -import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; +import type { FastifyInstance, preHandlerHookHandler, preValidationHookHandler } from 'fastify'; import type { PaidOfferingSlug } from '@grey/schemas/responses'; import { priceUsdFor } from '@grey/x402-middleware'; import type { HandlerDeps } from '../../deps'; import { offeringHandlers } from '../../handlers'; import { buildEnvelope } from '../../envelope/build'; +/** The x402 gate is two hooks, not one — see this file's header comment. `preValidation` is + * body-independent (header-presence only, runs before schema validation); `preHandler` carries + * the real verify+settle logic (runs after schema validation). Shared by offerings.ts and + * trustRung.ts — same shape, different underlying @grey/x402-middleware factories. */ +export interface X402Gate { + preValidation: preValidationHookHandler; + preHandler: preHandlerHookHandler; +} + // Exported so other surfaces over the SAME x402 rail (e.g. server/routes/mcp.ts, E1-D) reuse this // exact list instead of re-declaring it — one place names "the 7 normal paid offerings". export const PAID: PaidOfferingSlug[] = [ @@ -37,14 +47,15 @@ export const PAID: PaidOfferingSlug[] = [ export function registerOfferingRoutes( app: FastifyInstance, deps: HandlerDeps, - x402PreHandler: preHandlerHookHandler, + x402Gate: X402Gate, ): void { for (const slug of PAID) { app.post( `/v1/offerings/${slug}`, { schema: { body: { $grey: { kind: 'request', offering: slug } } }, - preValidation: x402PreHandler, + preValidation: x402Gate.preValidation, + preHandler: x402Gate.preHandler, }, async (req, reply) => { const start = deps.clock().getTime(); @@ -52,14 +63,21 @@ export function registerOfferingRoutes( // on any failure never reaches here) — record revenue now, not speculatively. A ledger // write failure must never cost the buyer their already-paid-for response (fail open, log). try { - await deps.revenueEvents.create({ channel: 'x402', offering: slug, revenueUsd: priceUsdFor(slug) }); + await deps.revenueEvents.create({ + channel: 'x402', + offering: slug, + revenueUsd: priceUsdFor(slug), + }); } catch (err) { deps.logger.warn('revenue ledger write failed (non-fatal)', { slug, error: (err as Error).message, }); } - const result = await offeringHandlers[slug]({ offeringId: slug, requirement: req.body }, deps); + const result = await offeringHandlers[slug]( + { offeringId: slug, requirement: req.body }, + deps, + ); const env = buildEnvelope({ offering: slug, payload: result.payload as never, diff --git a/packages/grey-core/src/server/routes/trustRung.ts b/packages/grey-core/src/server/routes/trustRung.ts index b042b25..8025aeb 100644 --- a/packages/grey-core/src/server/routes/trustRung.ts +++ b/packages/grey-core/src/server/routes/trustRung.ts @@ -6,26 +6,29 @@ // exist — a request to this path 404s before Fastify has any handler to dispatch to (Invariant // #34: unreachable, not merely gated inside a reachable handler). import { randomUUID } from 'node:crypto'; -import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; +import type { FastifyInstance } from 'fastify'; import { TRUST_RUNG_SLUG, trustRungPriceUsd } from '@grey/x402-middleware'; import type { HandlerDeps } from '../../deps'; import { offeringHandlers } from '../../handlers'; import { buildEnvelope } from '../../envelope/build'; +import type { X402Gate } from './offerings'; export function registerTrustRungRoute( app: FastifyInstance, deps: HandlerDeps, - // NOT the general x402PreHandler — preHandler.ts's slugFromUrl deliberately doesn't recognize - // this slug, so this MUST be @grey/x402-middleware's makeTrustRungPreHandler(...) output. - trustRungPreHandler: preHandlerHookHandler, + // NOT the general offerings.ts gate — preHandler.ts's slugFromUrl deliberately doesn't + // recognize this slug, so this MUST be built from @grey/x402-middleware's trust-rung-scoped + // makeTrustRungPaymentPresenceCheck(...)/makeTrustRungPreHandler(...) factories. + trustRungGate: X402Gate, ): void { app.post( `/v1/offerings/${TRUST_RUNG_SLUG}`, { schema: { body: { $grey: { kind: 'request', offering: TRUST_RUNG_SLUG } } }, - // CDP/Bazaar alignment Phase 1, Task 2: `preValidation`, not `preHandler` — see - // offerings.ts's header comment for why (runs before Fastify's body-schema validation). - preValidation: trustRungPreHandler, + // CDP/Bazaar alignment Phase 1 revision — see offerings.ts's header comment: the gate is + // two hooks, `preValidation` (body-independent) + `preHandler` (verify+settle). + preValidation: trustRungGate.preValidation, + preHandler: trustRungGate.preHandler, }, async (req, reply) => { const start = deps.clock().getTime(); diff --git a/packages/grey-core/src/start.ts b/packages/grey-core/src/start.ts index edc26ba..d6c318d 100644 --- a/packages/grey-core/src/start.ts +++ b/packages/grey-core/src/start.ts @@ -6,10 +6,12 @@ import { loadX402Config, makeRelayerClients, makeX402PreHandler, + makeX402PaymentPresenceCheck, priceUsdFor, PAID_SLUGS, trustRungEnabled, makeTrustRungPreHandler, + makeTrustRungPaymentPresenceCheck, } from '@grey/x402-middleware'; import { createHandlerDeps } from './deps'; import { X402Adapter } from './channels/x402Adapter'; @@ -18,23 +20,32 @@ const deps = createHandlerDeps(); // Build the paid-route gate. The relayer key is loaded + used entirely inside // @grey/x402-middleware (invariant #19) — start.ts never names or handles the key. +// CDP/Bazaar alignment Phase 1 revision: the gate is two hooks — see offerings.ts's header +// comment for why (preValidation's body-independent header check must run before Fastify's +// schema validation; the real verify+settle logic stays on preHandler, after it). const x402Config = loadX402Config(); const relayer = makeRelayerClients(x402Config); -const x402PreHandler = makeX402PreHandler(x402Config, { - wallet: relayer.wallet, - publicClient: relayer.publicClient, - logger: deps.logger, -}); +const x402Gate = { + preValidation: makeX402PaymentPresenceCheck(x402Config), + preHandler: makeX402PreHandler(x402Config, { + wallet: relayer.wallet, + publicClient: relayer.publicClient, + logger: deps.logger, + }), +}; // E1-C, Invariant #34: the ONE place trustRungEnabled() is read for the x402 channel — start.ts is // the boot boundary, same posture as x402Config/relayer above. Default off; Forces-gated to flip. const trustRungOn = trustRungEnabled(); -const trustRungPreHandler = trustRungOn - ? makeTrustRungPreHandler(x402Config, { - wallet: relayer.wallet, - publicClient: relayer.publicClient, - logger: deps.logger, - }) +const trustRungGate = trustRungOn + ? { + preValidation: makeTrustRungPaymentPresenceCheck(x402Config), + preHandler: makeTrustRungPreHandler(x402Config, { + wallet: relayer.wallet, + publicClient: relayer.publicClient, + logger: deps.logger, + }), + } : undefined; // M6 Phase A: x402 now boots THROUGH the ChannelIngress seam. The adapter runs the SAME @@ -42,11 +53,11 @@ const trustRungPreHandler = trustRungOn const port = Number(process.env.GREY_CORE_PORT ?? 3002); const adapter = new X402Adapter({ deps, - gate: x402PreHandler, + gate: x402Gate, port, relayerAddress: relayer.relayerAddress, trustRungEnabled: trustRungOn, - trustRungPreHandler, + trustRungGate, // E1-D: MCP is unconditional (unlike the trust rung) — reuses the SAME relayer clients as the // HTTP gate, verify/settle against the same USDC contract, just a different transport. mcp: { x402Config, wallet: relayer.wallet, publicClient: relayer.publicClient }, diff --git a/packages/grey-core/test/_helpers.ts b/packages/grey-core/test/_helpers.ts index 0b78639..51b97fb 100644 --- a/packages/grey-core/test/_helpers.ts +++ b/packages/grey-core/test/_helpers.ts @@ -3,9 +3,10 @@ // AND binds the payload to the offering's response schema (allOf[if/then]). So a malformed handler // payload fails here. (Internal-impl test organization per Pattern 1 Tier B / spec §4.3 fixtures.) import { expect } from 'vitest'; -import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; +import type { FastifyInstance, preHandlerHookHandler, preValidationHookHandler } from 'fastify'; import { envelopeValidator } from '@grey/schemas/validators'; import { buildServer } from '../src/server'; +import type { X402Gate } from '../src/server/routes/offerings'; import type { HandlerDeps, GreyCoreConfig } from '../src/deps'; import type { WhitepaperRow, VerificationRow, ClaimRow } from '../src/handlers/types'; import type { TieredDiscoveryResult } from '@grey/pipeline'; @@ -20,8 +21,16 @@ export const TEST_CONFIG: GreyCoreConfig = { }; /** Pass-through x402 gate for handler-logic tests — lets paid routes through so they test the - * handler/envelope, not payment. The real gate is exercised separately in x402-routes.test.ts. */ + * handler/envelope, not payment. The real gate is exercised separately in x402-routes.test.ts. + * CDP/Bazaar alignment Phase 1 revision: the gate is two hooks now — passThroughX402 remains + * exported (some tests still pass it directly as the preHandler half), and passThroughX402Gate + * bundles both halves for callers that need the full X402Gate shape. */ export const passThroughX402: preHandlerHookHandler = async () => {}; +const passThroughPreValidation: preValidationHookHandler = async () => {}; +export const passThroughX402Gate: X402Gate = { + preValidation: passThroughPreValidation, + preHandler: passThroughX402, +}; const TS = new Date('2026-06-14T00:00:00.000Z'); @@ -47,7 +56,9 @@ export function verificationRow(over: Partial = {}): Verificati return { id: 'v-1', whitepaperId: 'wp-1', - structuralAnalysisJson: { mica: { claimsMicaCompliance: 'NO', micaCompliant: 'YES', micaSummary: 'compliant' } }, + structuralAnalysisJson: { + mica: { claimsMicaCompliance: 'NO', micaCompliant: 'YES', micaSummary: 'compliant' }, + }, structuralScore: 4, confidenceScore: 82, hypeTechRatio: 1.2, @@ -121,7 +132,11 @@ export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { discover: async (): Promise => stubs.discover ?? null, }; const revenueEvents = { - create: async (data: { channel: string; offering: string; revenueUsd: number }): Promise => { + create: async (data: { + channel: string; + offering: string; + revenueUsd: number; + }): Promise => { stubs.revenueEventsSink?.push(data); return { id: 'revenue-test', settledAt: new Date(), requestId: null, ...data }; }, @@ -142,7 +157,7 @@ export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { export function makeApp( stubs: RepoStubs = {}, - gate: preHandlerHookHandler = passThroughX402, + gate: X402Gate = passThroughX402Gate, opts: Parameters[2] = {}, ): FastifyInstance { return buildServer(fakeDeps(stubs), gate, opts); diff --git a/packages/grey-core/test/channels/x402Adapter.test.ts b/packages/grey-core/test/channels/x402Adapter.test.ts index b488c7f..0cb50a6 100644 --- a/packages/grey-core/test/channels/x402Adapter.test.ts +++ b/packages/grey-core/test/channels/x402Adapter.test.ts @@ -4,7 +4,11 @@ // (FDQ-66(a) boot-wrapper — it does NOT drive route mounting). The real HTTP byte-identity proof // over the built dist lives in scripts/dist-boot-smoke.mjs. import { describe, it, expect, afterEach } from 'vitest'; -import { loadX402Config, makeX402PreHandler } from '@grey/x402-middleware'; +import { + loadX402Config, + makeX402PreHandler, + makeX402PaymentPresenceCheck, +} from '@grey/x402-middleware'; import { X402Adapter } from '../../src/channels/x402Adapter'; import type { ChannelIngress } from '../../src/channels/ingress'; import { fakeDeps, TEST_CONFIG } from '../_helpers'; @@ -17,15 +21,20 @@ const cfg = loadX402Config({ }); // Mock relayer clients — never reached on the no-payment paths (402 precedes settle). -const gate = makeX402PreHandler(cfg, { - wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, - publicClient: { - readContract: async () => false, - simulateContract: async () => ({ request: {} }), - waitForTransactionReceipt: async () => ({ status: 'success' as const }), - }, - now: () => 1_000_000_000_000, -}); +// CDP/Bazaar alignment Phase 1 revision: the gate is two hooks — see offerings.ts's header +// comment. The new preValidation half + the unchanged preHandler half. +const gate = { + preValidation: makeX402PaymentPresenceCheck(cfg), + preHandler: makeX402PreHandler(cfg, { + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, + now: () => 1_000_000_000_000, + }), +}; function makeAdapter(): X402Adapter { // Port 0 → the OS assigns a free ephemeral port (no collisions across parallel tests). fakeDeps diff --git a/packages/grey-core/test/probes.test.ts b/packages/grey-core/test/probes.test.ts index 87a7675..9972f66 100644 --- a/packages/grey-core/test/probes.test.ts +++ b/packages/grey-core/test/probes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { buildServer } from '../src/server'; import type { HandlerDeps, GreyCoreConfig } from '../src/deps'; -import { passThroughX402 } from './_helpers'; +import { passThroughX402Gate } from './_helpers'; const CONFIG: GreyCoreConfig = { version: '0.1.0-test', @@ -34,7 +34,7 @@ function fakeDeps(overrides: Partial = {}): HandlerDeps { describe('probes (app.inject)', () => { it('GET /health → 200 ok + version + numeric uptimeSec', async () => { - const app = buildServer(fakeDeps(), passThroughX402); + const app = buildServer(fakeDeps(), passThroughX402Gate); const res = await app.inject({ method: 'GET', url: '/health' }); expect(res.statusCode).toBe(200); const body = res.json(); @@ -45,7 +45,7 @@ describe('probes (app.inject)', () => { }); it('GET /identity → DID + agent shape', async () => { - const app = buildServer(fakeDeps(), passThroughX402); + const app = buildServer(fakeDeps(), passThroughX402Gate); const res = await app.inject({ method: 'GET', url: '/identity' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ @@ -58,7 +58,7 @@ describe('probes (app.inject)', () => { }); it('GET /openapi → 200 application/yaml with the spec body', async () => { - const app = buildServer(fakeDeps(), passThroughX402); + const app = buildServer(fakeDeps(), passThroughX402Gate); const res = await app.inject({ method: 'GET', url: '/openapi' }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('application/yaml'); diff --git a/packages/grey-core/test/revenueLedger.test.ts b/packages/grey-core/test/revenueLedger.test.ts index 7f956fe..64a9464 100644 --- a/packages/grey-core/test/revenueLedger.test.ts +++ b/packages/grey-core/test/revenueLedger.test.ts @@ -3,7 +3,7 @@ // ledger write failure must never cost the buyer their already-paid-for response. import { describe, it, expect } from 'vitest'; import { loadX402Config } from '@grey/x402-middleware'; -import { makeApp, passThroughX402 } from './_helpers'; +import { makeApp, passThroughX402Gate } from './_helpers'; const cfg = loadX402Config({ X402_NETWORK: 'eip155:84532', @@ -35,8 +35,12 @@ describe('revenue ledger — recorded at settlement (E1-F)', () => { it('a 402 (no payment, real gate) records NOTHING — settlement never happened', async () => { const sink: Array = []; - const { makeX402PreHandler } = await import('@grey/x402-middleware'); - const gate = makeX402PreHandler(cfg, relayerStubs); + const { makeX402PreHandler, makeX402PaymentPresenceCheck } = + await import('@grey/x402-middleware'); + const gate = { + preValidation: makeX402PaymentPresenceCheck(cfg), + preHandler: makeX402PreHandler(cfg, relayerStubs), + }; const app = makeApp({ revenueEventsSink: sink as never }, gate); const res = await app.inject({ method: 'POST', @@ -52,9 +56,9 @@ describe('revenue ledger — recorded at settlement (E1-F)', () => { // passThroughX402 here too — same "settlement already handled" convention as the first test // above; makeTrustRungPreHandler's own verify/settle gating is covered by the 402-records- // nothing test and by x402-middleware's own trustRung.test.ts. - const app = makeApp({ revenueEventsSink: sink }, passThroughX402, { + const app = makeApp({ revenueEventsSink: sink }, passThroughX402Gate, { trustRungEnabled: true, - trustRungPreHandler: passThroughX402, + trustRungGate: passThroughX402Gate, }); await app.inject({ method: 'POST', diff --git a/packages/grey-core/test/trustRung.test.ts b/packages/grey-core/test/trustRung.test.ts index 7115261..5abebf7 100644 --- a/packages/grey-core/test/trustRung.test.ts +++ b/packages/grey-core/test/trustRung.test.ts @@ -3,8 +3,12 @@ // (the offering route itself, and the discovery/capability listing), and that flipping the // explicit opt-in makes it correctly reachable (proving the block is a real gate, not dead code). import { describe, it, expect } from 'vitest'; -import { makeTrustRungPreHandler, loadX402Config } from '@grey/x402-middleware'; -import { makeApp, passThroughX402 } from './_helpers'; +import { + makeTrustRungPreHandler, + makeTrustRungPaymentPresenceCheck, + loadX402Config, +} from '@grey/x402-middleware'; +import { makeApp, passThroughX402Gate } from './_helpers'; const cfg = loadX402Config({ X402_NETWORK: 'eip155:84532', @@ -12,14 +16,19 @@ const cfg = loadX402Config({ BASE_RPC_URL: 'http://127.0.0.1:8545', X402_RELAYER_PRIVATE_KEY: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', }); -const trustRungGate = makeTrustRungPreHandler(cfg, { - wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, - publicClient: { - readContract: async () => false, - simulateContract: async () => ({ request: {} }), - waitForTransactionReceipt: async () => ({ status: 'success' as const }), - }, -}); +// CDP/Bazaar alignment Phase 1 revision: the gate is two hooks — see offerings.ts's header +// comment. The new preValidation half + the unchanged preHandler half. +const trustRungGate = { + preValidation: makeTrustRungPaymentPresenceCheck(cfg), + preHandler: makeTrustRungPreHandler(cfg, { + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, + }), +}; describe('trust rung — unreachable by default (E1-C, Invariant #34, B-1)', () => { it('POST /v1/offerings/legitimacy_scan_trust_rung 404s — the route does not exist', async () => { @@ -52,9 +61,9 @@ describe('trust rung — unreachable by default (E1-C, Invariant #34, B-1)', () describe('trust rung — correctly reachable when explicitly enabled (proves the block is real)', () => { it('POST without payment returns 402 with the $0.10 price, not 404 — route genuinely mounted', async () => { - const app = makeApp({}, passThroughX402, { + const app = makeApp({}, passThroughX402Gate, { trustRungEnabled: true, - trustRungPreHandler: trustRungGate, + trustRungGate, }); const res = await app.inject({ method: 'POST', @@ -67,9 +76,9 @@ describe('trust rung — correctly reachable when explicitly enabled (proves the }); it('CDP/Bazaar alignment Phase 1: an empty body with no payment still gets a 402, not a 400 (same fix as the normal 7 routes)', async () => { - const app = makeApp({}, passThroughX402, { + const app = makeApp({}, passThroughX402Gate, { trustRungEnabled: true, - trustRungPreHandler: trustRungGate, + trustRungGate, }); const res = await app.inject({ method: 'POST', @@ -82,9 +91,9 @@ describe('trust rung — correctly reachable when explicitly enabled (proves the }); it('is listed in discovery once enabled', async () => { - const app = makeApp({}, passThroughX402, { + const app = makeApp({}, passThroughX402Gate, { trustRungEnabled: true, - trustRungPreHandler: trustRungGate, + trustRungGate, }); const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); const body = res.json() as { services: Array<{ slug: string }> }; @@ -92,11 +101,11 @@ describe('trust rung — correctly reachable when explicitly enabled (proves the expect(body.services).toHaveLength(8); // 7 enabled + the trust rung, still minus the 2 not-yet-offered }); - it('buildServer throws if trustRungEnabled is true without a trustRungPreHandler (fail closed on misconfiguration)', async () => { + it('buildServer throws if trustRungEnabled is true without a trustRungGate (fail closed on misconfiguration)', async () => { const { buildServer } = await import('../src/server'); const { fakeDeps } = await import('./_helpers'); - expect(() => buildServer(fakeDeps(), passThroughX402, { trustRungEnabled: true })).toThrow( - /trustRungPreHandler/, + expect(() => buildServer(fakeDeps(), passThroughX402Gate, { trustRungEnabled: true })).toThrow( + /trustRungGate/, ); }); }); diff --git a/packages/grey-core/test/x402-routes.test.ts b/packages/grey-core/test/x402-routes.test.ts index 4006e9e..ea2c1de 100644 --- a/packages/grey-core/test/x402-routes.test.ts +++ b/packages/grey-core/test/x402-routes.test.ts @@ -3,7 +3,11 @@ // clean 402 (never 500), and the free GET resources are ungated. The valid-payment→settle→200 // path is unit-covered in @grey/x402-middleware's preHandler.test + the anvil integration. import { describe, it, expect } from 'vitest'; -import { loadX402Config, makeX402PreHandler } from '@grey/x402-middleware'; +import { + loadX402Config, + makeX402PreHandler, + makeX402PaymentPresenceCheck, +} from '@grey/x402-middleware'; import { makeApp } from './_helpers'; const cfg = loadX402Config({ @@ -14,15 +18,20 @@ const cfg = loadX402Config({ }); // Mock clients — never reached on the no-payment paths (402 precedes settle). -const gate = makeX402PreHandler(cfg, { - wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, - publicClient: { - readContract: async () => false, - simulateContract: async () => ({ request: {} }), - waitForTransactionReceipt: async () => ({ status: 'success' as const }), - }, - now: () => 1_000_000_000_000, -}); +// CDP/Bazaar alignment Phase 1 revision: the gate is two hooks — see offerings.ts's header +// comment. The new preValidation half + the unchanged preHandler half. +const gate = { + preValidation: makeX402PaymentPresenceCheck(cfg), + preHandler: makeX402PreHandler(cfg, { + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, + now: () => 1_000_000_000_000, + }), +}; const PRICE: Record = { legitimacy_scan: '250000',