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
1 change: 1 addition & 0 deletions packages/grey-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
94 changes: 94 additions & 0 deletions packages/grey-core/scripts/dist-boot-smoke.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<void> {
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)));
44 changes: 44 additions & 0 deletions packages/grey-core/src/channels/ingress.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

/** 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<void>;
/** Bring the channel down cleanly (close the transport / disconnect). */
stop(): Promise<void>;
/** Advertise one offering's catalog entry to the channel (see OfferingRegistration). */
registerOffering(reg: OfferingRegistration): void;
/** This channel's receiver-side identity. */
identity(): ChannelIdentity;
}
84 changes: 84 additions & 0 deletions packages/grey-core/src/channels/x402Adapter.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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;
}
}
6 changes: 6 additions & 0 deletions packages/grey-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
37 changes: 27 additions & 10 deletions packages/grey-core/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
});
Loading