From 05c9a4125a370e11ee58d05fd8dc257db430db6f Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Sun, 19 Jul 2026 16:48:01 -0400 Subject: [PATCH] feat(m6-phase-a): ChannelIngress interface + x402 conformance adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M6 Phase A — introduce the reusable channel-adapter seam and make x402 the first conforming channel, with zero change to per-request payment behavior. - grey-core/src/channels/ingress.ts: ChannelIngress (start/stop/registerOffering/ identity) + ChannelIdentity + OfferingRegistration. Slim, lifecycle+catalog only (A1); confirm/deliver/validation/envelope stay adapter-internal. Reuses the shared HandlerInput — no duplicated handler types. - grey-core/src/channels/x402Adapter.ts: X402Adapter implements ChannelIngress as a boot-wiring shell over the existing buildServer(deps, gate) + listen. Zero per-request payment logic; the relayer key never enters grey-core (invariant #19). FDQ-66(a) boot-wrapper: registerOffering records the catalog for identity()/ observability only — routes stay statically mounted from PAID (offerings.ts). - grey-core/src/index.ts: export ChannelIngress/OfferingRegistration/ChannelIdentity + X402Adapter/X402AdapterOptions. - grey-core/src/start.ts: boot x402 THROUGH X402Adapter.start() in place of the inline buildServer+listen (real conformance, not paper). Registers the 7 PAID offerings via the single price source (invariant #20). No per-request code changed. - test/channels/x402Adapter.test.ts: conformance — interface satisfaction, identity() payTo/DID, registerOffering catalog, and a real bound socket proving /health→200 and a paid route→402. - scripts/dist-boot-smoke.ts (+ package.json script): M5-pattern dist-boot smoke — boots built dist/start.js and asserts /health→200 and paid→402 byte-identical. Zero-touch held: verify/settle/challenge/preHandler, handlers/*, offerings.ts route logic + PAID static mounting all unchanged. Zero-spend, no chain. Green: typecheck + lint + build clean; 96 tests pass (16 files, +5 new); dist-boot smoke PASS (/health 200; legitimacy_scan 402 exact/eip155:84532/250000). Co-Authored-By: Claude Opus 4.8 --- packages/grey-core/package.json | 1 + packages/grey-core/scripts/dist-boot-smoke.ts | 94 +++++++++++++++ packages/grey-core/src/channels/ingress.ts | 44 +++++++ .../grey-core/src/channels/x402Adapter.ts | 84 ++++++++++++++ packages/grey-core/src/index.ts | 6 + packages/grey-core/src/start.ts | 37 ++++-- .../test/channels/x402Adapter.test.ts | 109 ++++++++++++++++++ 7 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 packages/grey-core/scripts/dist-boot-smoke.ts create mode 100644 packages/grey-core/src/channels/ingress.ts create mode 100644 packages/grey-core/src/channels/x402Adapter.ts create mode 100644 packages/grey-core/test/channels/x402Adapter.test.ts diff --git a/packages/grey-core/package.json b/packages/grey-core/package.json index e09b3b7..691f30e 100644 --- a/packages/grey-core/package.json +++ b/packages/grey-core/package.json @@ -22,6 +22,7 @@ "dev": "tsx watch src/start.ts", "start": "tsx src/start.ts", "smoke": "tsx scripts/smoke.ts", + "dist-boot-smoke": "tsx scripts/dist-boot-smoke.ts", "parity-diff": "tsx scripts/parity-diff.ts" }, "dependencies": { diff --git a/packages/grey-core/scripts/dist-boot-smoke.ts b/packages/grey-core/scripts/dist-boot-smoke.ts new file mode 100644 index 0000000..304bcc9 --- /dev/null +++ b/packages/grey-core/scripts/dist-boot-smoke.ts @@ -0,0 +1,94 @@ +// M6 Phase A dist-boot smoke (M5 pattern). Boots the BUILT dist/start.js (the systemd ExecStart +// target) exactly as production would, and proves the x402 boot rewire changed nothing observable: +// • GET /health → 200 { status: "ok" } +// • POST /v1/offerings/legitimacy_scan → 402 exact-scheme requirements (payTo/network/amount) +// The server now boots THROUGH X402Adapter.start(); this asserts the paid-route contract is +// byte-identical to the pre-adapter inline buildServer+listen. Zero spend, no chain, no real DB +// (the 402 precedes any handler/DB touch; boot is lazy — no pg/RPC/Anthropic connection is made). +// +// Usage: pnpm -F @grey/core dist-boot-smoke (run AFTER `pnpm -F @grey/core build`) +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const PORT = 3999; +const BASE = `http://127.0.0.1:${PORT}`; +const DIST_START = resolve(import.meta.dirname, '../dist/start.js'); + +// Boot env: valid-shaped but inert. loadX402Config fail-closes on missing/invalid, so all fields +// are present; none trigger a network call at boot. The anvil #1 key is a well-known throwaway. +const env: NodeJS.ProcessEnv = { + ...process.env, + GREY_CORE_PORT: String(PORT), + X402_NETWORK: 'eip155:84532', + BASE_X402_PAY_TO: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + BASE_RPC_URL: 'http://127.0.0.1:8545', + X402_RELAYER_PRIVATE_KEY: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + GREY_DATABASE_URL: 'postgres://smoke:smoke@127.0.0.1:5432/smoke', // never queried on these paths +}; + +const child = spawn(process.execPath, [DIST_START], { env, stdio: ['ignore', 'pipe', 'pipe'] }); +let log = ''; +child.stdout.on('data', (d: Buffer) => (log += d.toString())); +child.stderr.on('data', (d: Buffer) => (log += d.toString())); + +function fail(msg: string): never { + console.error(`[dist-boot-smoke] FAIL: ${msg}`); + if (log.trim()) console.error('--- child output ---\n' + log.trim()); + try { + child.kill('SIGKILL'); + } catch { + /* already dead */ + } + process.exit(1); +} + +async function waitForHealth(): Promise { + for (let i = 0; i < 60; i++) { + if (child.exitCode !== null) fail(`process exited early (code ${child.exitCode})`); + try { + const r = await fetch(`${BASE}/health`); + if (r.status === 200) return r; + } catch { + /* not up yet */ + } + await sleep(250); + } + fail('server did not answer /health within ~15s'); +} + +async function main(): Promise { + const health = await waitForHealth(); + const hbody = (await health.json()) as { status?: string }; + if (hbody.status !== 'ok') fail(`/health body.status !== "ok" (got ${JSON.stringify(hbody)})`); + console.log(`[dist-boot-smoke] /health → 200 ${JSON.stringify(hbody)}`); + + const paid = await fetch(`${BASE}/v1/offerings/legitimacy_scan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token_address: '0x1111111111111111111111111111111111111111' }), + }); + if (paid.status !== 402) fail(`paid route expected 402, got ${paid.status}`); + const pbody = (await paid.json()) as { + x402Version?: number; + accepts?: { scheme?: string; network?: string; maxAmountRequired?: string; payTo?: string }[]; + }; + const a = pbody.accepts?.[0] ?? {}; + const ok = + pbody.x402Version === 1 && + a.scheme === 'exact' && + a.network === 'eip155:84532' && + a.maxAmountRequired === '250000' && + a.payTo === '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; + if (!ok) fail(`402 requirements mismatch: ${JSON.stringify(pbody)}`); + console.log( + `[dist-boot-smoke] POST /v1/offerings/legitimacy_scan → 402 ` + + `{scheme:${a.scheme}, network:${a.network}, maxAmountRequired:${a.maxAmountRequired}, payTo:${a.payTo}}`, + ); + + console.log('[dist-boot-smoke] PASS — dist boots through X402Adapter; /health 200 + paid 402 byte-identical.'); + child.kill('SIGTERM'); + process.exit(0); +} + +main().catch((e: unknown) => fail(e instanceof Error ? e.message : String(e))); diff --git a/packages/grey-core/src/channels/ingress.ts b/packages/grey-core/src/channels/ingress.ts new file mode 100644 index 0000000..9a616d8 --- /dev/null +++ b/packages/grey-core/src/channels/ingress.ts @@ -0,0 +1,44 @@ +// M6 Phase A — the `ChannelIngress` seam. Grey earns on multiple channels (x402 today, ACP next) +// over ONE channel-agnostic core: the shared `offeringHandlers[slug](input, deps)` map, already +// public from `@grey/core`. A channel adapter owns transport, confirm/deliver, validation, and +// envelope; the interface below is lifecycle + catalog ONLY (A1). Confirm/deliver/validation/ +// envelope are deliberately absent — they are adapter-internal because both built channels +// self-drive (x402 delivers inside its Fastify route; ACP fuses confirm+deliver in one funded-job +// handler), so no channel-neutral settlement verb fits. +import type { HandlerInput } from '../handlers/types'; // reuse the shared handler input — do not redefine + +/** Receiver-side identity for a channel (Q3 — identity is receiver-side only; KYA/credentials are + * the W402 lane, out of scope here). */ +export interface ChannelIdentity { + /** Where value settles TO on this channel. x402: payTo `0x394e…`; ACP: seller wallet `0xa966…`. */ + receivingAddress: string; + /** Grey's on-chain ERC-8004 DID — the unifying identity layer across channels + * (`did:erc8004:8453:58618`). Optional: a channel may advertise a raw address only. */ + did?: string; +} + +/** Catalog advertisement for one offering on a channel. + * NOTE: no `handler` field — the shared handler is resolved from the existing + * `offeringHandlers[slug]` map (`handlers/index.ts`), which IS the handler source. + * registerOffering advertises the catalog + price; it does not transport handlers. */ +export interface OfferingRegistration { + /** Must key into `offeringHandlers` / the single price table (`@grey/x402-middleware` prices.ts). */ + slug: string; + /** Single price source (invariant #20 — `PRICE_TABLE`). No price literal lives on the adapter. */ + priceUsd: number; + /** Optional adapter-side pre-clearance run before the shared handler (validation is adapter-owned: + * x402 uses the Fastify `$grey` body schema; ACP an `InputValidator` before `setBudget`). */ + validateInput?: (input: HandlerInput) => void | Promise; +} + +/** The seam: lifecycle + catalog only (A1). Confirm/deliver/validation/envelope are adapter-internal. */ +export interface ChannelIngress { + /** Bring the channel up (bind the transport / connect the marketplace). */ + start(): Promise; + /** Bring the channel down cleanly (close the transport / disconnect). */ + stop(): Promise; + /** Advertise one offering's catalog entry to the channel (see OfferingRegistration). */ + registerOffering(reg: OfferingRegistration): void; + /** This channel's receiver-side identity. */ + identity(): ChannelIdentity; +} diff --git a/packages/grey-core/src/channels/x402Adapter.ts b/packages/grey-core/src/channels/x402Adapter.ts new file mode 100644 index 0000000..32f498a --- /dev/null +++ b/packages/grey-core/src/channels/x402Adapter.ts @@ -0,0 +1,84 @@ +// M6 Phase A — `X402Adapter implements ChannelIngress`: a BOOT-WIRING SHELL over what start.ts +// already does. It contains ZERO per-request payment logic — the preHandler/verify/settle/challenge +// 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 { HandlerDeps } from '../deps'; +import { buildServer } from '../server'; +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; + /** Listen port (start.ts passes GREY_CORE_PORT ?? 3002). */ + port: number; + /** Listen host. Defaults to 0.0.0.0 (the production bind). */ + host?: string; + /** Informational only: the relayer ADDRESS (never the key) for the boot log line. */ + relayerAddress?: string; +} + +/** + * x402 channel adapter. FDQ-66(a) boot-wrapper: `registerOffering` records the catalog for + * `identity()`/observability only — routes stay statically mounted from `PAID` (server/routes/ + * offerings.ts). start()/stop() are the ONLY lifecycle; the per-request path is 100% the existing + * Fastify server, byte-identical to the pre-adapter inline `buildServer`+`listen`. + */ +export class X402Adapter implements ChannelIngress { + private readonly deps: HandlerDeps; + private readonly gate: preHandlerHookHandler; + private readonly port: number; + private readonly host: string; + private readonly relayerAddress?: string; + private readonly offerings: OfferingRegistration[] = []; + private app: FastifyInstance | null = null; + private boundAddress: string | null = null; + + constructor(opts: X402AdapterOptions) { + this.deps = opts.deps; + this.gate = opts.gate; + this.port = opts.port; + this.host = opts.host ?? '0.0.0.0'; + this.relayerAddress = opts.relayerAddress; + } + + async start(): Promise { + if (this.app) throw new Error('X402Adapter: already started'); + // The SAME call start.ts made inline — the seam adds no per-request code. + const app = buildServer(this.deps, this.gate); + this.app = app; + this.boundAddress = await app.listen({ port: this.port, host: this.host }); + this.deps.logger.info( + `grey-core listening on ${this.boundAddress} (x402 gate active` + + (this.relayerAddress ? `, relayer ${this.relayerAddress})` : ')'), + ); + } + + async stop(): Promise { + if (!this.app) return; + await this.app.close(); + this.app = null; + this.boundAddress = null; + } + + registerOffering(reg: OfferingRegistration): void { + // FDQ-66(a) boot-wrapper: record for identity()/observability only — NO route change. + this.offerings.push(reg); + } + + identity(): ChannelIdentity { + // Receiver-side identity from the read-only config surface (deps/index.ts: payTo + DID). + return { receivingAddress: this.deps.config.payTo, did: this.deps.config.did }; + } + + /** Observability accessors (not on the slim ChannelIngress interface). */ + listOfferings(): readonly OfferingRegistration[] { + return this.offerings; + } + address(): string | null { + return this.boundAddress; + } +} diff --git a/packages/grey-core/src/index.ts b/packages/grey-core/src/index.ts index 6ba81ab..05804b0 100644 --- a/packages/grey-core/src/index.ts +++ b/packages/grey-core/src/index.ts @@ -16,3 +16,9 @@ export type { GreySchemaMarker } from './server/validators'; // Handlers (M3 Phase C) — ingress-agnostic offering handlers (the M5 ACP adapter reuses them). export { offeringHandlers } from './handlers'; export type { HandlerInput, HandlerResult, OfferingHandler } from './handlers/types'; + +// Channels (M6 Phase A) — the ChannelIngress seam + the x402 reference adapter. The ACP adapter +// (Phase C) implements the same interface over the same shared offeringHandlers map. +export type { ChannelIngress, OfferingRegistration, ChannelIdentity } from './channels/ingress'; +export { X402Adapter } from './channels/x402Adapter'; +export type { X402AdapterOptions } from './channels/x402Adapter'; diff --git a/packages/grey-core/src/start.ts b/packages/grey-core/src/start.ts index 1224fbc..260f36b 100644 --- a/packages/grey-core/src/start.ts +++ b/packages/grey-core/src/start.ts @@ -2,9 +2,15 @@ // runtime deps (real GREY_DATABASE_URL via @grey/pipeline) + the x402 payment gate, then starts // the Fastify server. Fails closed: loadX402Config throws if the payment env is missing/invalid, // so grey-core never serves a paid route without a working gate. -import { loadX402Config, makeRelayerClients, makeX402PreHandler } from '@grey/x402-middleware'; -import { buildServer } from './server'; +import { + loadX402Config, + makeRelayerClients, + makeX402PreHandler, + priceUsdFor, + PAID_SLUGS, +} from '@grey/x402-middleware'; import { createHandlerDeps } from './deps'; +import { X402Adapter } from './channels/x402Adapter'; const deps = createHandlerDeps(); @@ -18,13 +24,24 @@ const x402PreHandler = makeX402PreHandler(x402Config, { logger: deps.logger, }); -const app = buildServer(deps, x402PreHandler); +// M6 Phase A: x402 now boots THROUGH the ChannelIngress seam. The adapter runs the SAME +// buildServer(deps, gate) + listen path this file used inline — zero per-request change. const port = Number(process.env.GREY_CORE_PORT ?? 3002); +const adapter = new X402Adapter({ + deps, + gate: x402PreHandler, + port, + relayerAddress: relayer.relayerAddress, +}); + +// FDQ-66(a) boot-wrapper: record the catalog for identity()/observability. Routes stay statically +// mounted from PAID (server/routes/offerings.ts) — this does NOT drive route registration. Prices +// come from the single source (invariant #20). +for (const slug of PAID_SLUGS) { + adapter.registerOffering({ slug, priceUsd: priceUsdFor(slug) }); +} -app - .listen({ port, host: '0.0.0.0' }) - .then((addr) => deps.logger.info(`grey-core listening on ${addr} (x402 gate active, relayer ${relayer.relayerAddress})`)) - .catch((err: unknown) => { - deps.logger.error('grey-core failed to start', {}, err); - process.exit(1); - }); +adapter.start().catch((err: unknown) => { + deps.logger.error('grey-core failed to start', {}, err); + process.exit(1); +}); diff --git a/packages/grey-core/test/channels/x402Adapter.test.ts b/packages/grey-core/test/channels/x402Adapter.test.ts new file mode 100644 index 0000000..b488c7f --- /dev/null +++ b/packages/grey-core/test/channels/x402Adapter.test.ts @@ -0,0 +1,109 @@ +// M6 Phase A conformance: X402Adapter satisfies ChannelIngress and, when started, runs the SAME +// server the pre-adapter start.ts ran inline — /health→200 and a paid route→402 through a real +// bound socket. identity() surfaces the configured payTo/DID; registerOffering records the catalog +// (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 { X402Adapter } from '../../src/channels/x402Adapter'; +import type { ChannelIngress } from '../../src/channels/ingress'; +import { fakeDeps, TEST_CONFIG } from '../_helpers'; + +const cfg = loadX402Config({ + X402_NETWORK: 'eip155:84532', + BASE_X402_PAY_TO: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + BASE_RPC_URL: 'http://127.0.0.1:8545', + X402_RELAYER_PRIVATE_KEY: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', +}); + +// 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, +}); + +function makeAdapter(): X402Adapter { + // Port 0 → the OS assigns a free ephemeral port (no collisions across parallel tests). fakeDeps + // has no live DB/Anthropic handles, so start/stop leave no open resources. + return new X402Adapter({ deps: fakeDeps(), gate, port: 0, host: '127.0.0.1' }); +} + +let started: X402Adapter | null = null; +afterEach(async () => { + if (started) await started.stop(); + started = null; +}); + +describe('X402Adapter — ChannelIngress conformance (M6 Phase A)', () => { + it('satisfies the ChannelIngress interface', () => { + const adapter: ChannelIngress = makeAdapter(); + expect(typeof adapter.start).toBe('function'); + expect(typeof adapter.stop).toBe('function'); + expect(typeof adapter.registerOffering).toBe('function'); + expect(typeof adapter.identity).toBe('function'); + }); + + it('identity() returns the configured receiving address (payTo) + DID', () => { + const adapter = makeAdapter(); + expect(adapter.identity()).toEqual({ + receivingAddress: TEST_CONFIG.payTo, + did: TEST_CONFIG.did, + }); + }); + + it('registerOffering records the catalog (boot-wrapper; no route change)', () => { + const adapter = makeAdapter(); + expect(adapter.listOfferings()).toHaveLength(0); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + adapter.registerOffering({ slug: 'verify_full_tech', priceUsd: 3.0 }); + expect(adapter.listOfferings()).toEqual([ + { slug: 'legitimacy_scan', priceUsd: 0.25 }, + { slug: 'verify_full_tech', priceUsd: 3.0 }, + ]); + }); + + it('start() runs the real server through the seam: /health→200 and a paid route→402', async () => { + const adapter = makeAdapter(); + started = adapter; + await adapter.start(); + const base = adapter.address(); + expect(base).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + + const health = await fetch(`${base}/health`); + expect(health.status).toBe(200); + expect(((await health.json()) as { status: string }).status).toBe('ok'); + + const paid = await fetch(`${base}/v1/offerings/legitimacy_scan`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token_address: '0x1111111111111111111111111111111111111111' }), + }); + expect(paid.status).toBe(402); + const body = (await paid.json()) as { + x402Version: number; + accepts: { scheme: string; network: string; maxAmountRequired: string; payTo: string }[]; + }; + expect(body.x402Version).toBe(1); + expect(body.accepts[0].scheme).toBe('exact'); + expect(body.accepts[0].network).toBe('eip155:84532'); + expect(body.accepts[0].maxAmountRequired).toBe('250000'); + expect(body.accepts[0].payTo).toBe(cfg.payTo); + }); + + it('stop() releases the socket; double-start is rejected', async () => { + const adapter = makeAdapter(); + started = adapter; + await adapter.start(); + await expect(adapter.start()).rejects.toThrow(/already started/); + await adapter.stop(); + expect(adapter.address()).toBeNull(); + // idempotent stop + await expect(adapter.stop()).resolves.toBeUndefined(); + started = null; + }); +});