diff --git a/adapters/x402-middleware/src/challenge.ts b/adapters/x402-middleware/src/challenge.ts index 3cf769e..ba699da 100644 --- a/adapters/x402-middleware/src/challenge.ts +++ b/adapters/x402-middleware/src/challenge.ts @@ -1,6 +1,8 @@ // Build the 402 body — strict-canonical x402 `PaymentRequirements` (Forces ruling: maxTimeoutSeconds // 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 { priceAtomicFor } from './prices.js'; @@ -10,6 +12,9 @@ export function buildPaymentRequirements( resource: string, error?: string, ): 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); const body: PaymentRequirements = { x402Version: 1, accepts: [ @@ -23,7 +28,19 @@ export function buildPaymentRequirements( payTo: cfg.payTo, maxTimeoutSeconds: cfg.maxTimeoutSeconds, asset: cfg.usdc.address, - extra: { name: cfg.usdc.name, version: cfg.usdc.version }, + extra: { + name: cfg.usdc.name, + version: cfg.usdc.version, + bazaar: { + discoverable: kit.discoverable, + serviceName: kit.serviceName, + tags: kit.tags, + description: kit.description, + inputSchema: kit.inputSchema, + outputSchema: kit.outputSchema, + iconUrl: kit.iconUrl, + }, + }, }, ], }; diff --git a/adapters/x402-middleware/src/index.ts b/adapters/x402-middleware/src/index.ts index 50116fe..2314ea6 100644 --- a/adapters/x402-middleware/src/index.ts +++ b/adapters/x402-middleware/src/index.ts @@ -15,6 +15,14 @@ export { } from './prices.js'; export type { PaidSlug } from './prices.js'; export { buildPaymentRequirements } from './challenge.js'; +export { + TRUST_RUNG_SLUG, + trustRungEnabled, + trustRungPriceAtomic, + trustRungPriceUsd, + buildTrustRungPaymentRequirements, + makeTrustRungPreHandler, +} from './trustRung.js'; export { decodePaymentHeader, verifyPayment } from './verify.js'; export type { VerifyResult } from './verify.js'; export { settle } from './settle.js'; diff --git a/adapters/x402-middleware/src/trustRung.ts b/adapters/x402-middleware/src/trustRung.ts new file mode 100644 index 0000000..d6bea70 --- /dev/null +++ b/adapters/x402-middleware/src/trustRung.ts @@ -0,0 +1,149 @@ +// The $0.10 legitimacy_scan_trust_rung offering (E1-C, spec §2.4) — BUILT BUT BLOCKED. Forces +// ruling B-1 (2026-07-26): no live exposure on any channel until Forces explicitly lifts this. +// Invariant #34: any offering under a standing block ships behind a default-off disable flag with +// tests asserting unreachability on every live channel. Single source (mirrors Bion directive-20's +// autoModeSetting() precedent): every caller reads `trustRungEnabled()`, nobody re-parses the env +// 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 { OfferingSlug } from '@grey/schemas/responses'; +import { resolvePriceUsd } from '@grey/schemas/pricing'; +import { buildEvaluationKit } 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'; + +export const TRUST_RUNG_SLUG: OfferingSlug = 'legitimacy_scan_trust_rung'; + +/** Explicit opt-in only, never a default-on fallback. Unset/anything-but-'true' → disabled. */ +export function trustRungEnabled(): boolean { + return process.env.TRUST_RUNG_ENABLED === 'true'; +} + +function toAtomic(usd: number): bigint { + return BigInt(Math.round(usd * 1_000_000)); +} + +/** USDC atomic units for the trust rung on the x402 channel. Resolves regardless of the disable + * flag's state — the flag gates ROUTE REACHABILITY, not this pure price calculation. */ +export function trustRungPriceAtomic(): bigint { + return toAtomic(resolvePriceUsd(TRUST_RUNG_SLUG, 'x402')); +} + +export function trustRungPriceUsd(): number { + return resolvePriceUsd(TRUST_RUNG_SLUG, 'x402'); +} + +/** Same shape as challenge.ts's buildPaymentRequirements, scoped to the trust rung. Only ever + * called from a route that itself only exists when `trustRungEnabled()` is true (grey-core). */ +export function buildTrustRungPaymentRequirements( + cfg: X402Config, + resource: string, + error?: string, +): PaymentRequirements { + const kit = buildEvaluationKit(TRUST_RUNG_SLUG); + const body: PaymentRequirements = { + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: cfg.network, + maxAmountRequired: trustRungPriceAtomic().toString(), + resource, + description: `Grey ${TRUST_RUNG_SLUG} offering`, + mimeType: 'application/json', + payTo: cfg.payTo, + maxTimeoutSeconds: cfg.maxTimeoutSeconds, + asset: cfg.usdc.address, + extra: { + name: cfg.usdc.name, + version: cfg.usdc.version, + bazaar: { + discoverable: kit.discoverable, + serviceName: kit.serviceName, + tags: kit.tags, + description: kit.description, + inputSchema: kit.inputSchema, + outputSchema: kit.outputSchema, + iconUrl: kit.iconUrl, + }, + }, + }, + ], + }; + if (error) body.error = error; + return body; +} + +function encodePaymentResponse(txHash: string, network: string): string { + return Buffer.from( + JSON.stringify({ success: true, transaction: txHash, network }), + 'utf8', + ).toString('base64'); +} + +/** + * A SEPARATE preHandler, not a variant dispatched by the normal `makeX402PreHandler` + * (preHandler.ts's `slugFromUrl` deliberately does not recognize this slug, since it checks + * against `isPaidSlug` / the normal 7-slug PRICE_TABLE). Only ever installed on the trust-rung + * route, which itself is only mounted when `trustRungEnabled()` is true — so this function being + * unreachable is a property of grey-core's route wiring, not of this function checking the flag + * itself. Otherwise byte-identical verify→settle logic to the normal gate (same reused functions). + */ +export function makeTrustRungPreHandler( + cfg: X402Config, + deps: X402PreHandlerDeps, +): preHandlerHookHandler { + return async function trustRungPreHandler( + req: FastifyRequest, + reply: FastifyReply, + ): Promise { + const resource = req.url; + const header = req.headers['x-payment']; + if (typeof header !== 'string' || header.length === 0) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, 'payment required')); + return; + } + + const decoded = decodePaymentHeader(header); + if (!decoded.ok) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, decoded.reason)); + return; + } + + const nowSec = BigInt(Math.floor((deps.now?.() ?? Date.now()) / 1000)); + const verdict = await verifyPayment( + cfg, + decoded.payload, + trustRungPriceAtomic(), + deps.publicClient, + nowSec, + ); + if (!verdict.ok) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, verdict.reason)); + return; + } + + let outcome; + try { + outcome = await settle(cfg, verdict.authorization, verdict.signature, { + wallet: deps.wallet, + publicClient: deps.publicClient, + }); + } catch (err) { + deps.logger?.error('x402: trust-rung settlement infra error', { + reason: err instanceof Error ? err.message : String(err), + }); + reply.code(502).send({ x402Version: 1, error: 'settlement failed' }); + return; + } + if (!outcome.ok) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, outcome.reason)); + return; + } + + reply.header('X-PAYMENT-RESPONSE', encodePaymentResponse(outcome.txHash, cfg.network)); + }; +} diff --git a/adapters/x402-middleware/src/types.ts b/adapters/x402-middleware/src/types.ts index 4fdcbfb..71371c7 100644 --- a/adapters/x402-middleware/src/types.ts +++ b/adapters/x402-middleware/src/types.ts @@ -1,5 +1,6 @@ // @grey/x402-middleware — shared types for the x402 `exact`-scheme sell-side gate. import type { Address, Hex } from 'viem'; +import type { EvaluationKitEntry } from '@grey/schemas/evaluationKit'; export type X402Network = 'eip155:8453' | 'eip155:84532'; @@ -68,8 +69,22 @@ export interface PaymentRequirements { payTo: Address; maxTimeoutSeconds: number; asset: Address; - /** EIP-712 domain hints the buyer needs to sign the authorization. */ - extra: { name: string; version: string }; + /** 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". */ + extra: { + name: string; + version: string; + bazaar: Pick< + EvaluationKitEntry, + | 'discoverable' + | 'serviceName' + | 'tags' + | 'description' + | 'inputSchema' + | 'outputSchema' + | 'iconUrl' + >; + }; }>; error?: string; } diff --git a/adapters/x402-middleware/test/challenge.test.ts b/adapters/x402-middleware/test/challenge.test.ts index af0a53f..4fdf914 100644 --- a/adapters/x402-middleware/test/challenge.test.ts +++ b/adapters/x402-middleware/test/challenge.test.ts @@ -20,7 +20,8 @@ describe('buildPaymentRequirements — strict-canonical x402', () => { expect(a.asset).toBe(TEST_CFG.usdc.address); expect(a.maxTimeoutSeconds).toBe(120); expect(a.resource).toBe('/v1/offerings/verify_whitepaper'); - expect(a.extra).toEqual({ name: TEST_CFG.usdc.name, version: TEST_CFG.usdc.version }); + expect(a.extra.name).toBe(TEST_CFG.usdc.name); + expect(a.extra.version).toBe(TEST_CFG.usdc.version); expect(body.error).toBe('payment required'); }); @@ -37,11 +38,27 @@ describe('buildPaymentRequirements — strict-canonical x402', () => { }); it('carries per-slug pricing', () => { - expect(buildPaymentRequirements(TEST_CFG, 'daily_tech_brief', '/r').accepts[0].maxAmountRequired).toBe( - '8000000', - ); - expect(buildPaymentRequirements(TEST_CFG, 'quick_protocol_facts', '/r').accepts[0].maxAmountRequired).toBe( - '300000', + expect( + buildPaymentRequirements(TEST_CFG, 'daily_tech_brief', '/r').accepts[0].maxAmountRequired, + ).toBe('8000000'); + expect( + buildPaymentRequirements(TEST_CFG, 'quick_protocol_facts', '/r').accepts[0].maxAmountRequired, + ).toBe('300000'); + }); + + it('carries Bazaar discovery metadata from the single EvaluationKit source (E1-B, Invariant #33)', () => { + const body = buildPaymentRequirements( + TEST_CFG, + 'legitimacy_scan', + '/v1/offerings/legitimacy_scan', ); + const bazaar = body.accepts[0].extra.bazaar; + expect(bazaar.discoverable).toBe(true); + expect(bazaar.serviceName).toBe('Project Legitimacy Scan'); + expect(bazaar.tags).toContain('crypto'); + expect(typeof bazaar.description).toBe('string'); + expect(bazaar.inputSchema).toBeTruthy(); + expect(bazaar.outputSchema).toBeTruthy(); + expect(bazaar.iconUrl).toBe('https://whitepapergrey.com/icons/legitimacy_scan.svg'); }); }); diff --git a/adapters/x402-middleware/test/preHandler.test.ts b/adapters/x402-middleware/test/preHandler.test.ts index 4e495f7..8b1a136 100644 --- a/adapters/x402-middleware/test/preHandler.test.ts +++ b/adapters/x402-middleware/test/preHandler.test.ts @@ -34,13 +34,24 @@ function reqReply(url: string, header?: string) { }, }; const req = { url, headers: header ? { 'x-payment': header } : {} }; - return { req: req as unknown as FastifyRequest, reply: reply as unknown as FastifyReply, m: reply }; + return { + req: req as unknown as FastifyRequest, + reply: reply as unknown as FastifyReply, + m: reply, + }; } // Cast to a plain 2-arg callable — the Fastify hook type carries a `this: FastifyInstance` // context this handler never uses, so a direct call would trip TS2684. -function gate(clients: { wallet: ReturnType; publicClient: ReturnType }) { - const h = makeX402PreHandler(TEST_CFG, { wallet: clients.wallet, publicClient: clients.publicClient, now }); +function gate(clients: { + wallet: ReturnType; + publicClient: ReturnType; +}) { + const h = makeX402PreHandler(TEST_CFG, { + wallet: clients.wallet, + publicClient: clients.publicClient, + now, + }); return h as unknown as (req: FastifyRequest, reply: FastifyReply) => Promise; } @@ -53,6 +64,13 @@ describe('slugFromUrl', () => { expect(slugFromUrl('/v1/resources/scam_alert_feed')).toBeNull(); expect(slugFromUrl('/v1/offerings/not_a_slug')).toBeNull(); }); + + it('merge-prep Task 2 check: not-yet-offered offerings can never reach a 402/buildPaymentRequirements body — they are mounted (if at all) at /v1/resources/*, which x402PreHandler never gates, and even under /v1/offerings/* would resolve to no slug (isPaidSlug false)', () => { + for (const slug of ['daily_greenlight_list', 'scam_alert_feed']) { + expect(slugFromUrl(`/v1/resources/${slug}`), slug).toBeNull(); + expect(slugFromUrl(`/v1/offerings/${slug}`), slug).toBeNull(); + } + }); }); describe('makeX402PreHandler — orchestration', () => { @@ -60,7 +78,9 @@ describe('makeX402PreHandler — orchestration', () => { const { req, reply, m } = reqReply('/v1/offerings/legitimacy_scan'); await gate({ wallet: mockWallet(), publicClient: mockPublicClient() })(req, reply); expect(m.statusCode).toBe(402); - expect((m.body as { accepts: { maxAmountRequired: string }[] }).accepts[0].maxAmountRequired).toBe('250000'); + expect( + (m.body as { accepts: { maxAmountRequired: string }[] }).accepts[0].maxAmountRequired, + ).toBe('250000'); }); it('402 on a malformed header', async () => { @@ -72,7 +92,10 @@ describe('makeX402PreHandler — orchestration', () => { it('402 on a verify failure (underpayment)', async () => { const { header } = await signedPayment(TEST_CFG, { value: 1n }); const { req, reply, m } = reqReply('/v1/offerings/legitimacy_scan', header); - await gate({ wallet: mockWallet(), publicClient: mockPublicClient({ used: false }) })(req, reply); + await gate({ wallet: mockWallet(), publicClient: mockPublicClient({ used: false }) })( + req, + reply, + ); expect(m.statusCode).toBe(402); expect((m.body as { error: string }).error).toBe('underpayment'); }); @@ -107,7 +130,10 @@ describe('makeX402PreHandler — orchestration', () => { const { header } = await signedPayment(TEST_CFG); const { req, reply, m } = reqReply('/v1/offerings/legitimacy_scan', header); const wallet = mockWallet(); - await gate({ wallet, publicClient: mockPublicClient({ used: false, simRevert: true }) })(req, reply); + await gate({ wallet, publicClient: mockPublicClient({ used: false, simRevert: true }) })( + req, + reply, + ); expect(m.statusCode).toBe(402); // clean 402, not a 502 after a wasted reverted tx expect(wallet.calls).toHaveLength(0); // nothing broadcast → zero relayer gas expect(m.headers['X-PAYMENT-RESPONSE']).toBeUndefined(); diff --git a/adapters/x402-middleware/test/trustRung.test.ts b/adapters/x402-middleware/test/trustRung.test.ts new file mode 100644 index 0000000..9d96a51 --- /dev/null +++ b/adapters/x402-middleware/test/trustRung.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + TRUST_RUNG_SLUG, + trustRungEnabled, + trustRungPriceAtomic, + trustRungPriceUsd, + buildTrustRungPaymentRequirements, +} from '../src/trustRung.js'; +import { TEST_CFG } from './_sign.js'; + +const ORIGINAL = process.env.TRUST_RUNG_ENABLED; +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.TRUST_RUNG_ENABLED; + else process.env.TRUST_RUNG_ENABLED = ORIGINAL; +}); + +describe('trustRung — E1-C disable flag (Forces ruling B-1, Invariant #34)', () => { + it('defaults to disabled when the env var is fully unset', () => { + delete process.env.TRUST_RUNG_ENABLED; + expect(trustRungEnabled()).toBe(false); + }); + + it('defaults to disabled for any value other than the literal string "true"', () => { + for (const v of ['1', 'yes', 'on', 'True', 'TRUE', '']) { + process.env.TRUST_RUNG_ENABLED = v; + expect(trustRungEnabled(), v).toBe(false); + } + }); + + it('is enabled only by the explicit literal "true"', () => { + process.env.TRUST_RUNG_ENABLED = 'true'; + expect(trustRungEnabled()).toBe(true); + }); + + it('resolves the canonical $0.10 price regardless of the flag state', () => { + delete process.env.TRUST_RUNG_ENABLED; + expect(trustRungPriceUsd()).toBe(0.1); + expect(trustRungPriceAtomic()).toBe(100_000n); + process.env.TRUST_RUNG_ENABLED = 'true'; + expect(trustRungPriceUsd()).toBe(0.1); + expect(trustRungPriceAtomic()).toBe(100_000n); + }); + + it('buildTrustRungPaymentRequirements carries the trust-rung slug, price, and Bazaar metadata', () => { + const body = buildTrustRungPaymentRequirements(TEST_CFG, `/v1/offerings/${TRUST_RUNG_SLUG}`); + expect(body.accepts[0].maxAmountRequired).toBe('100000'); + expect(body.accepts[0].description).toContain(TRUST_RUNG_SLUG); + expect(body.accepts[0].extra.bazaar.serviceName).toBe('Legitimacy Trust Rung'); + }); +}); diff --git a/packages/grey-core/package.json b/packages/grey-core/package.json index 691f30e..e2b54a4 100644 --- a/packages/grey-core/package.json +++ b/packages/grey-core/package.json @@ -23,7 +23,8 @@ "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" + "parity-diff": "tsx scripts/parity-diff.ts", + "margin-report": "tsx scripts/margin-report.ts" }, "dependencies": { "@grey/pipeline": "workspace:*", diff --git a/packages/grey-core/scripts/margin-report.ts b/packages/grey-core/scripts/margin-report.ts new file mode 100644 index 0000000..92e3a33 --- /dev/null +++ b/packages/grey-core/scripts/margin-report.ts @@ -0,0 +1,59 @@ +// E1-F margin dashboard (Expansion Round 2, sub-unit 4) — NOT in CI, real DB only. Reads the +// revenue_events ledger (grey-core's route/MCP layer writes these at settlement) + cost_events +// (the live pipeline's existing telemetry) and prints realized margin per offering, with revenue +// broken out per channel. Mirrors Bion's `pnpm auto-report` CLI shape (plain aggregate print, no +// framework) and grey-core's own scripts/smoke.ts conventions (real GREY_DATABASE_URL, no CI). +// +// Usage: +// pnpm -F @grey/core margin-report [-- --days 7] +import { createDeps, MarginRepo } from '@grey/pipeline'; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main(): Promise { + const days = Number(arg('days') ?? '30'); + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const { db } = createDeps({ databaseUrl: process.env.GREY_DATABASE_URL ?? '' }); + const margin = new MarginRepo(db); + const report = await margin.getMarginReport(since); + + console.log(`GREY MARGIN REPORT — last ${days}d (since ${since.toISOString()})`); + console.log('─'.repeat(60)); + if (report.length === 0) { + console.log('(no revenue or cost activity in this window)'); + return; + } + + let totalRevenue = 0; + let totalCost = 0; + for (const row of report) { + totalRevenue += row.totalRevenueUsd; + totalCost += row.totalCostUsd; + const channels = Object.entries(row.revenueByChannelUsd) + .map(([ch, usd]) => `${ch}=$${usd.toFixed(4)}`) + .join(' '); + const marginFlag = row.realizedMarginUsd >= 0 ? '+' : ''; + console.log( + `${row.offering.padEnd(24)} revenue=$${row.totalRevenueUsd.toFixed(4).padStart(9)} ` + + `cost=$${row.totalCostUsd.toFixed(4).padStart(9)} ` + + `margin=${marginFlag}$${row.realizedMarginUsd.toFixed(4)} [${channels || 'no revenue yet'}]`, + ); + } + console.log('─'.repeat(60)); + console.log( + `TOTAL revenue=$${totalRevenue.toFixed(4)} cost=$${totalCost.toFixed(4)} ` + + `margin=$${(totalRevenue - totalCost).toFixed(4)}`, + ); +} + +main().catch((err: unknown) => { + console.error( + 'margin-report: fatal:', + err instanceof Error ? (err.stack ?? err.message) : String(err), + ); + process.exit(1); +}); diff --git a/packages/grey-core/src/channels/x402Adapter.ts b/packages/grey-core/src/channels/x402Adapter.ts index 32f498a..b4ca101 100644 --- a/packages/grey-core/src/channels/x402Adapter.ts +++ b/packages/grey-core/src/channels/x402Adapter.ts @@ -6,6 +6,7 @@ import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; import type { HandlerDeps } from '../deps'; import { buildServer } from '../server'; +import type { McpRouteDeps } from '../server/routes/mcp'; import type { ChannelIdentity, ChannelIngress, OfferingRegistration } from './ingress'; export interface X402AdapterOptions { @@ -19,6 +20,15 @@ export interface X402AdapterOptions { host?: string; /** Informational only: the relayer ADDRESS (never the key) for the boot log line. */ relayerAddress?: string; + /** 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; + /** 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; } /** @@ -33,6 +43,9 @@ export class X402Adapter implements ChannelIngress { private readonly port: number; private readonly host: string; private readonly relayerAddress?: string; + private readonly trustRungEnabled: boolean; + private readonly trustRungPreHandler?: preHandlerHookHandler; + private readonly mcp?: McpRouteDeps; private readonly offerings: OfferingRegistration[] = []; private app: FastifyInstance | null = null; private boundAddress: string | null = null; @@ -43,12 +56,19 @@ export class X402Adapter implements ChannelIngress { this.port = opts.port; this.host = opts.host ?? '0.0.0.0'; this.relayerAddress = opts.relayerAddress; + this.trustRungEnabled = opts.trustRungEnabled ?? false; + this.trustRungPreHandler = opts.trustRungPreHandler; + this.mcp = opts.mcp; } 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); + const app = buildServer(this.deps, this.gate, { + trustRungEnabled: this.trustRungEnabled, + trustRungPreHandler: this.trustRungPreHandler, + mcp: this.mcp, + }); this.app = app; this.boundAddress = await app.listen({ port: this.port, host: this.host }); this.deps.logger.info( diff --git a/packages/grey-core/src/deps/index.ts b/packages/grey-core/src/deps/index.ts index e31842c..003a8ba 100644 --- a/packages/grey-core/src/deps/index.ts +++ b/packages/grey-core/src/deps/index.ts @@ -10,6 +10,7 @@ import { WhitepapersRepo, VerificationsRepo, ClaimsRepo, + RevenueEventsRepo, type GreyDb, type Logger, type PipelineDeps, @@ -34,6 +35,12 @@ export interface HandlerDeps { whitepapers: WhitepapersRepo; verifications: VerificationsRepo; claims: ClaimsRepo; + /** E1-F: append-only revenue ledger, written by the route/MCP layer after settle() succeeds + * (offerings.ts, trustRung.ts, mcp.ts). The only WRITE repo on HandlerDeps — everything else + * here is cache-read (M3's "cache-read-only" posture, unchanged for whitepapers/verifications/ + * claims). See packages/grey-pipeline/src/persistence/repositories.ts's MarginRepo for the + * read/aggregation side. */ + revenueEvents: RevenueEventsRepo; logger: Logger; /** Injectable clock for deterministic timestamps in tests. */ clock: () => Date; @@ -84,6 +91,7 @@ export function createHandlerDeps(env: CreateHandlerDepsEnv = {}): HandlerDeps { whitepapers: new WhitepapersRepo(db), verifications: new VerificationsRepo(db), claims: new ClaimsRepo(db), + revenueEvents: new RevenueEventsRepo(db), logger: createLogger({ component: 'grey-core' }), clock: env.clock ?? ((): Date => new Date()), config: { diff --git a/packages/grey-core/src/handlers/index.ts b/packages/grey-core/src/handlers/index.ts index caf7f84..62a365c 100644 --- a/packages/grey-core/src/handlers/index.ts +++ b/packages/grey-core/src/handlers/index.ts @@ -1,7 +1,13 @@ -// @grey/core handler registry — OfferingSlug → OfferingHandler. All 9 are cache-read-only (M3). +// @grey/core handler registry — OfferingSlug → OfferingHandler. All 10 are cache-read-only at +// this layer (M3); 4 delegate to cacheOrLive on a cache miss (M3.5). `legitimacy_scan_trust_rung` +// (E1-C) is here because the HANDLER is harmless to have registered — it never reaches live +// compute either way — but it must NOT be reachable on any route/channel unless +// @grey/x402-middleware's trustRungEnabled() is true. Route mounting (grey-core/src/server) and +// discovery listing both check that flag explicitly; this registry does not gate anything itself. import type { OfferingSlug } from '@grey/schemas/responses'; import type { OfferingHandler } from './types'; import { legitimacyScan } from './legitimacy_scan'; +import { legitimacyScanTrustRung } from './legitimacy_scan_trust_rung'; import { verifyWhitepaper } from './verify_whitepaper'; import { verifyFullTech } from './verify_full_tech'; import { claimExtraction } from './claim_extraction'; @@ -13,6 +19,7 @@ import { scamAlertFeed } from './scam_alert_feed'; export const offeringHandlers: Record = { legitimacy_scan: legitimacyScan, + legitimacy_scan_trust_rung: legitimacyScanTrustRung, verify_whitepaper: verifyWhitepaper, verify_full_tech: verifyFullTech, claim_extraction: claimExtraction, diff --git a/packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts b/packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts new file mode 100644 index 0000000..85b05a7 --- /dev/null +++ b/packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts @@ -0,0 +1,27 @@ +// legitimacy_scan_trust_rung (E1-C, $0.10 CACHE_ONLY — BUILT BUT BLOCKED per Forces ruling B-1). +// Cache-read only, structurally: this offering is not in ComputeOfferingSlug, so it is not +// callable into cacheOrLive at the type level (Invariant #30's compile-time half); there is no +// live branch here to accidentally take, unlike legitimacy_scan.ts's cache-miss → cacheOrLive +// call. A cache miss returns the flat NOT_IN_DATABASE-style sentinel, same as every other +// CACHE_ONLY offering — it never "retries live", paid or not. +import type { OfferingHandler } from './types'; +import { resolveWhitepaper, subjectFrom } from './subjectMapping'; +import { buildTrustRungHit, buildTrustRungMiss } from '../orchestration/cacheRead'; + +export const legitimacyScanTrustRung: OfferingHandler = async (input, deps) => { + const body = (input.requirement ?? {}) as { token_address?: string; project_name?: string }; + const wp = await resolveWhitepaper(deps.whitepapers, { + tokenAddress: body.token_address, + projectName: body.project_name, + }); + const v = wp ? await deps.verifications.findByWhitepaperId(wp.id) : null; + const fallback = { tokenAddress: body.token_address ?? null, projectName: body.project_name }; + if (!wp || !v) { + return { + payload: buildTrustRungMiss(deps, fallback), + subject: subjectFrom(null, fallback), + cacheHit: false, + }; + } + return { payload: buildTrustRungHit(wp, v), subject: subjectFrom(wp, fallback), cacheHit: true }; +}; diff --git a/packages/grey-core/src/orchestration/cacheRead.ts b/packages/grey-core/src/orchestration/cacheRead.ts index a9b99c7..b2a706e 100644 --- a/packages/grey-core/src/orchestration/cacheRead.ts +++ b/packages/grey-core/src/orchestration/cacheRead.ts @@ -94,6 +94,35 @@ export function buildLegitimacyMiss( }; } +// ── tier 0: legitimacy_scan_trust_rung (E1-C, $0.10 CACHE_ONLY — BUILT BUT BLOCKED) ── +// A cheap teaser of the tier-1 verdict, read from the SAME cache row legitimacy_scan reads — +// never a live-compute path (this offering is not in ComputeOfferingSlug; cacheOrLive cannot even +// be called with it). Reduced field set on purpose: it's meant to make the $0.25 offering's value +// self-evident, not substitute for it. + +export function buildTrustRungHit(wp: WhitepaperRow, v: VerificationRow): Record { + return { + projectName: wp.projectName, + tokenAddress: wp.tokenAddress, + verdict: v.verdict ?? 'INSUFFICIENT_DATA', + generatedAt: iso(v.verifiedAt), + note: 'Cache-only teaser — see legitimacy_scan for the full structural read.', + }; +} + +export function buildTrustRungMiss( + deps: HandlerDeps, + fallback: { tokenAddress?: string | null; projectName?: string }, +): Record { + return { + projectName: fallback.projectName ?? 'Unknown', + tokenAddress: fallback.tokenAddress ?? null, + verdict: 'NOT_IN_DATABASE', + generatedAt: iso(deps.clock()), + note: 'Project not found in the Grey verification cache.', + }; +} + // ── tier 2: verify_whitepaper (legitimacy + claims) ── export function buildVerifyWhitepaperHit( diff --git a/packages/grey-core/src/server/index.ts b/packages/grey-core/src/server/index.ts index 6e27b4c..5b9e2c2 100644 --- a/packages/grey-core/src/server/index.ts +++ b/packages/grey-core/src/server/index.ts @@ -9,12 +9,40 @@ import { installValidatorCompiler } from './validators'; import { registerProbes } from './routes/probes'; import { registerOfferingRoutes } from './routes/offerings'; import { registerResourceRoutes } from './routes/resources'; +import { registerDiscoveryRoutes } from './routes/discovery'; +import { registerTrustRungRoute } from './routes/trustRung'; +import { registerMcpRoute, type McpRouteDeps } from './routes/mcp'; -export function buildServer(deps: HandlerDeps, x402PreHandler: preHandlerHookHandler): FastifyInstance { +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; + /** 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; +} + +export function buildServer( + deps: HandlerDeps, + x402PreHandler: preHandlerHookHandler, + 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 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'); + } + registerTrustRungRoute(app, deps, opts.trustRungPreHandler); // 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/discovery.ts b/packages/grey-core/src/server/routes/discovery.ts new file mode 100644 index 0000000..72c702d --- /dev/null +++ b/packages/grey-core/src/server/routes/discovery.ts @@ -0,0 +1,54 @@ +// Bazaar discovery surface (E1-B): GET /v1/discovery/services lists every discoverable offering's +// EvaluationKit projection (Invariant #33 — the SAME source every 402 response embeds via +// @grey/x402-middleware/challenge.ts). Free, unauthenticated — a crawler/evaluating agent reads +// this before ever hitting a paid route. GET /v1/discovery/services/:slug returns one entry (the +// public capability page E1-C's evaluation artifacts extend with a sample). +import type { FastifyInstance } from 'fastify'; +import type { OfferingSlug } from '@grey/schemas/responses'; +import { buildEvaluationKit, buildEvaluationArtifact } from '@grey/schemas/evaluationKit'; +import { TRUST_RUNG_SLUG } from '@grey/x402-middleware'; +import { offeringHandlers } from '../../handlers'; + +export interface DiscoveryRouteOptions { + /** E1-C, Invariant #34: the trust rung is registered in `offeringHandlers` unconditionally (the + * handler itself is harmless), but must not be LISTED unless Forces' disable flag is on — + * explicit here, not inferred from registry membership alone. */ + trustRungEnabled: boolean; +} + +function listableSlugs(opts: DiscoveryRouteOptions): OfferingSlug[] { + const all = Object.keys(offeringHandlers) as OfferingSlug[]; + if (opts.trustRungEnabled) return all; + return all.filter((slug) => slug !== TRUST_RUNG_SLUG); +} + +export function registerDiscoveryRoutes(app: FastifyInstance, opts: DiscoveryRouteOptions): void { + app.get('/v1/discovery/services', async (_req, reply) => { + const services = listableSlugs(opts) + .map((slug) => buildEvaluationKit(slug)) + .filter((kit) => kit.discoverable); + // E1-D: "List in Bazaar as MCP" — the same offering set is also reachable as paid MCP tools + // over one JSON-RPC endpoint (POST /v1/mcp), not one route per offering like the HTTP surface. + reply.send({ services, mcpEndpoint: '/v1/mcp' }); + }); + + app.get<{ Params: { slug: string } }>('/v1/discovery/services/:slug', async (req, reply) => { + const slug = req.params.slug; + if (!listableSlugs(opts).includes(slug as OfferingSlug)) { + reply.code(404).send({ error: `not found or not discoverable: ${slug}` }); + return; + } + // E1-C: the detail/capability page carries the evaluation artifact (adds a sample); the list + // route above stays lean (no sample) — this is the only difference between the two. + const artifact = buildEvaluationArtifact(slug as OfferingSlug); + // Merge-prep: listableSlugs() only excludes the trust rung when disabled — a not-yet-offered + // offering (enabled:false in PRICING_TABLE) is still registry-present, so the DETAIL route + // needs its own discoverable check too, or it'd 200 with a full artifact for something the + // list route already hides. Same field, same source, second surface. + if (!artifact.discoverable) { + reply.code(404).send({ error: `not found or not discoverable: ${slug}` }); + return; + } + reply.send(artifact); + }); +} diff --git a/packages/grey-core/src/server/routes/mcp.ts b/packages/grey-core/src/server/routes/mcp.ts new file mode 100644 index 0000000..ada47ab --- /dev/null +++ b/packages/grey-core/src/server/routes/mcp.ts @@ -0,0 +1,231 @@ +// POST /v1/mcp (E1-D) — the offering set exposed as paid MCP tools over the SAME x402 rail +// (spec §3 E1 bequeaths: "Bazaar indexes MCP tools alongside HTTP endpoints"). Hand-rolled +// JSON-RPC 2.0 dispatch, NOT the @modelcontextprotocol/sdk package — this repo's adapters +// deliberately avoid heavy transitive dependency trees on the memory-constrained production VPS +// (see adapters/acp-adapter/tsconfig.json's note on the M5 VPS OOM, and sdk.ts's dynamic-import +// pattern for the same reason). The wire shape (jsonrpc/id/method/params, tools/list, tools/call, +// CallToolResult{content,isError}) matches the real MCP spec closely enough for any conformant +// client, without pulling in the SDK. +// +// Payment: a tool call for a paid offering that arrives without `_meta.x402Payment` gets a +// CallToolResult{isError:true} carrying the SAME PaymentRequirements shape the HTTP 402 body +// would — the client base64-encodes an X-PAYMENT authorization into `_meta.x402Payment` and +// resubmits the SAME tools/call. Verify/settle reuse the exact functions the HTTP preHandler +// uses (decodePaymentHeader, verifyPayment, settle) — one payment implementation, two transports. +// +// Depends on sub-unit 1 (EvaluationKit): tools/list projects the SAME source every HTTP route's +// 402 body and the discovery index use — Invariant #33, no separate MCP-specific metadata. +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import type { OfferingSlug, PaidOfferingSlug } from '@grey/schemas/responses'; +import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; +import { isEnabled } from '@grey/schemas/pricing'; +import { + isPaidSlug, + priceAtomicFor, + priceUsdFor, + decodePaymentHeader, + verifyPayment, + settle, + buildPaymentRequirements, + type X402Config, +} from '@grey/x402-middleware'; +import type { PublicClientLike, WalletClientLike } from '@grey/x402-middleware'; +import type { HandlerDeps } from '../../deps'; +import { offeringHandlers } from '../../handlers'; +import { buildEnvelope } from '../../envelope/build'; +import { PAID } from './offerings'; +import { FREE } from './resources'; + +const PROTOCOL_VERSION = '2026-03-26'; +// Merge-prep ruling: a not-yet-offered slug (PRICING_TABLE.enabled === false — currently +// daily_greenlight_list, scam_alert_feed) is filtered out HERE, so it's excluded from both +// tools/list AND tools/call's "unknown or unlisted tool" check (the same array gates both). +const MCP_TOOL_SLUGS: OfferingSlug[] = [...PAID, ...FREE].filter((slug) => isEnabled(slug)); + +export interface McpRouteDeps { + x402Config: X402Config; + wallet: WalletClientLike; + publicClient: PublicClientLike; + /** Injectable ms clock for deterministic tests (mirrors X402PreHandlerDeps). */ + now?: () => number; +} + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: string | number | null; + method: string; + params?: Record; +} + +interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: string | number | null; + result: unknown; +} +interface JsonRpcFailure { + jsonrpc: '2.0'; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; +} + +function ok(id: JsonRpcRequest['id'], result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} +function fail( + id: JsonRpcRequest['id'], + code: number, + message: string, + data?: unknown, +): JsonRpcFailure { + return { jsonrpc: '2.0', id, error: { code, message, data } }; +} + +interface CallToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} +function textResult(value: unknown, isError = false): CallToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value) }], isError }; +} + +function toolDef(slug: OfferingSlug): { name: string; description: string; inputSchema: object } { + const kit = buildEvaluationKit(slug); + return { + name: slug, + description: kit.description, + inputSchema: kit.inputSchema ?? { type: 'object', properties: {}, additionalProperties: false }, + }; +} + +export function registerMcpRoute( + app: FastifyInstance, + deps: HandlerDeps, + mcpDeps: McpRouteDeps, +): void { + app.post('/v1/mcp', async (req, reply) => { + const body = req.body as JsonRpcRequest; + if (!body || body.jsonrpc !== '2.0' || typeof body.method !== 'string') { + reply.send(fail(body?.id ?? null, -32600, 'invalid JSON-RPC request')); + return; + } + const { id, method, params } = body; + + if (method === 'initialize') { + reply.send( + ok(id, { + protocolVersion: PROTOCOL_VERSION, + serverInfo: { name: deps.config.name, version: deps.config.version }, + capabilities: { tools: {} }, + }), + ); + return; + } + + if (method === 'tools/list') { + // Registry-driven, same gating discipline as discovery.ts: only enabled offerings are ever + // listed (MCP_TOOL_SLUGS already filters out not-yet-offered + excludes the trust rung + // entirely — the trust rung is never a member of PAID/FREE in the first place, so it's not + // in MCP_TOOL_SLUGS regardless of its own disable flag; E1-D's rail doesn't get a separate + // exposure decision from B-1). + reply.send(ok(id, { tools: MCP_TOOL_SLUGS.map(toolDef) })); + return; + } + + if (method === 'tools/call') { + const name = params?.name as string | undefined; + if (!name || !MCP_TOOL_SLUGS.includes(name as OfferingSlug)) { + reply.send(fail(id, -32602, `unknown or unlisted tool: ${String(name)}`)); + return; + } + const slug = name as OfferingSlug; + const args = (params?.arguments as Record) ?? {}; + const meta = (params?._meta as Record) ?? {}; + + const isFree = (FREE as readonly string[]).includes(slug); + if (!isFree && isPaidSlug(slug)) { + const paidSlug = slug as PaidOfferingSlug; + const paymentHeader = meta.x402Payment as string | undefined; + if (!paymentHeader) { + reply.send( + ok( + id, + textResult(buildPaymentRequirements(mcpDeps.x402Config, paidSlug, '/v1/mcp'), true), + ), + ); + return; + } + const decoded = decodePaymentHeader(paymentHeader); + if (!decoded.ok) { + reply.send(ok(id, textResult({ error: decoded.reason }, true))); + return; + } + const nowSec = BigInt(Math.floor((mcpDeps.now?.() ?? Date.now()) / 1000)); + const verdict = await verifyPayment( + mcpDeps.x402Config, + decoded.payload, + priceAtomicFor(paidSlug), + mcpDeps.publicClient, + nowSec, + ); + if (!verdict.ok) { + reply.send(ok(id, textResult({ error: verdict.reason }, true))); + return; + } + let outcome; + try { + outcome = await settle(mcpDeps.x402Config, verdict.authorization, verdict.signature, { + wallet: mcpDeps.wallet, + publicClient: mcpDeps.publicClient, + }); + } catch (err) { + reply.send(ok(id, textResult({ error: 'settlement failed' }, true))); + deps.logger.error('mcp: settlement infra error', { + slug, + reason: err instanceof Error ? err.message : String(err), + }); + return; + } + if (!outcome.ok) { + reply.send(ok(id, textResult({ error: outcome.reason }, true))); + return; + } + // E1-F: settlement just succeeded above — record revenue now, same fail-open posture as + // the HTTP routes (a ledger write failure must never cost the buyer their paid response). + try { + await deps.revenueEvents.create({ + channel: 'x402', + offering: slug, + revenueUsd: priceUsdFor(paidSlug), + }); + } catch (err) { + deps.logger.warn('revenue ledger write failed (non-fatal)', { + slug, + error: (err as Error).message, + }); + } + } + + const start = deps.clock().getTime(); + const result = await offeringHandlers[slug]({ offeringId: slug, requirement: args }, deps); + const env = buildEnvelope({ + offering: slug, + payload: result.payload as never, + requestId: randomUUID(), + config: deps.config, + subject: result.subject, + metadata: { + costUsd: isFree ? 0 : priceUsdFor(slug), + model: 'none', + latencyMs: deps.clock().getTime() - start, + timestamp: deps.clock().toISOString(), + cacheHit: result.cacheHit, + }, + }); + reply.send(ok(id, textResult(env))); + return; + } + + reply.send(fail(id, -32601, `method not found: ${method}`)); + }); +} diff --git a/packages/grey-core/src/server/routes/offerings.ts b/packages/grey-core/src/server/routes/offerings.ts index ad5b629..0e4ec0c 100644 --- a/packages/grey-core/src/server/routes/offerings.ts +++ b/packages/grey-core/src/server/routes/offerings.ts @@ -9,7 +9,9 @@ import type { HandlerDeps } from '../../deps'; import { offeringHandlers } from '../../handlers'; import { buildEnvelope } from '../../envelope/build'; -const PAID: PaidOfferingSlug[] = [ +// 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[] = [ 'legitimacy_scan', 'verify_whitepaper', 'verify_full_tech', @@ -33,6 +35,17 @@ export function registerOfferingRoutes( }, async (req, reply) => { const start = deps.clock().getTime(); + // E1-F: the x402PreHandler gate already settled payment before this handler runs (402/502 + // 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) }); + } 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 env = buildEnvelope({ offering: slug, diff --git a/packages/grey-core/src/server/routes/resources.ts b/packages/grey-core/src/server/routes/resources.ts index 22e18a6..e2f435d 100644 --- a/packages/grey-core/src/server/routes/resources.ts +++ b/packages/grey-core/src/server/routes/resources.ts @@ -6,7 +6,8 @@ import type { HandlerDeps } from '../../deps'; import { offeringHandlers } from '../../handlers'; import { buildEnvelope } from '../../envelope/build'; -const FREE = ['daily_greenlight_list', 'scam_alert_feed'] as const; +// Exported so other surfaces (e.g. server/routes/mcp.ts, E1-D) reuse this exact list. +export const FREE = ['daily_greenlight_list', 'scam_alert_feed'] as const; export function registerResourceRoutes(app: FastifyInstance, deps: HandlerDeps): void { for (const slug of FREE) { diff --git a/packages/grey-core/src/server/routes/trustRung.ts b/packages/grey-core/src/server/routes/trustRung.ts new file mode 100644 index 0000000..b1abefc --- /dev/null +++ b/packages/grey-core/src/server/routes/trustRung.ts @@ -0,0 +1,65 @@ +// POST /v1/offerings/legitimacy_scan_trust_rung (E1-C) — mounted ONLY when +// @grey/x402-middleware's trustRungEnabled() is true. Deliberately a SEPARATE route registrar +// from registerOfferingRoutes (offerings.ts): the normal 7 paid routes must stay byte-identical +// and unconditional, so this file is the entire blast radius of the disable flag on the x402 +// channel. When the caller doesn't invoke `registerTrustRungRoute`, the route simply does not +// 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 { TRUST_RUNG_SLUG, trustRungPriceUsd } from '@grey/x402-middleware'; +import type { HandlerDeps } from '../../deps'; +import { offeringHandlers } from '../../handlers'; +import { buildEnvelope } from '../../envelope/build'; + +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, +): void { + app.post( + `/v1/offerings/${TRUST_RUNG_SLUG}`, + { + schema: { body: { $grey: { kind: 'request', offering: TRUST_RUNG_SLUG } } }, + preHandler: trustRungPreHandler, + }, + async (req, reply) => { + const start = deps.clock().getTime(); + // E1-F: same fail-open ledger posture as offerings.ts — settlement already happened in the + // preHandler; a ledger write failure must never cost the buyer their paid-for response. + try { + await deps.revenueEvents.create({ + channel: 'x402', + offering: TRUST_RUNG_SLUG, + revenueUsd: trustRungPriceUsd(), + }); + } catch (err) { + deps.logger.warn('revenue ledger write failed (non-fatal)', { + slug: TRUST_RUNG_SLUG, + error: (err as Error).message, + }); + } + const result = await offeringHandlers[TRUST_RUNG_SLUG]( + { offeringId: TRUST_RUNG_SLUG, requirement: req.body }, + deps, + ); + const env = buildEnvelope({ + offering: TRUST_RUNG_SLUG, + payload: result.payload as never, + requestId: randomUUID(), + config: deps.config, + subject: result.subject, + metadata: { + costUsd: trustRungPriceUsd(), + model: 'none', + latencyMs: deps.clock().getTime() - start, + timestamp: deps.clock().toISOString(), + cacheHit: result.cacheHit, + }, + }); + reply.send(env); + }, + ); +} diff --git a/packages/grey-core/src/start.ts b/packages/grey-core/src/start.ts index 260f36b..edc26ba 100644 --- a/packages/grey-core/src/start.ts +++ b/packages/grey-core/src/start.ts @@ -8,6 +8,8 @@ import { makeX402PreHandler, priceUsdFor, PAID_SLUGS, + trustRungEnabled, + makeTrustRungPreHandler, } from '@grey/x402-middleware'; import { createHandlerDeps } from './deps'; import { X402Adapter } from './channels/x402Adapter'; @@ -24,6 +26,17 @@ const x402PreHandler = makeX402PreHandler(x402Config, { 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, + }) + : undefined; + // 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); @@ -32,6 +45,11 @@ const adapter = new X402Adapter({ gate: x402PreHandler, port, relayerAddress: relayer.relayerAddress, + trustRungEnabled: trustRungOn, + trustRungPreHandler, + // 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 }, }); // FDQ-66(a) boot-wrapper: record the catalog for identity()/observability. Routes stay statically diff --git a/packages/grey-core/test/_helpers.ts b/packages/grey-core/test/_helpers.ts index 777a9d1..0b78639 100644 --- a/packages/grey-core/test/_helpers.ts +++ b/packages/grey-core/test/_helpers.ts @@ -92,6 +92,8 @@ export interface RepoStubs { /** M3.5: discovery result for the cache-miss live path. Default null → cacheOrLive returns the * typed-empty miss sentinel (preserves the M3 cache-miss test expectations). */ discover?: TieredDiscoveryResult | null; + /** E1-F: pass an array to capture every revenueEvents.create() call for assertions. */ + revenueEventsSink?: Array<{ channel: string; offering: string; revenueUsd: number }>; } export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { @@ -118,11 +120,18 @@ export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { const discovery = { discover: async (): Promise => stubs.discover ?? null, }; + const revenueEvents = { + create: async (data: { channel: string; offering: string; revenueUsd: number }): Promise => { + stubs.revenueEventsSink?.push(data); + return { id: 'revenue-test', settledAt: new Date(), requestId: null, ...data }; + }, + }; return { db: {} as HandlerDeps['db'], whitepapers: whitepapers as unknown as HandlerDeps['whitepapers'], verifications: verifications as unknown as HandlerDeps['verifications'], claims: claims as unknown as HandlerDeps['claims'], + revenueEvents: revenueEvents as unknown as HandlerDeps['revenueEvents'], logger: logger as unknown as HandlerDeps['logger'], clock: () => new Date('2026-06-14T12:00:00.000Z'), config: TEST_CONFIG, @@ -134,8 +143,9 @@ export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { export function makeApp( stubs: RepoStubs = {}, gate: preHandlerHookHandler = passThroughX402, + opts: Parameters[2] = {}, ): FastifyInstance { - return buildServer(fakeDeps(stubs), gate); + return buildServer(fakeDeps(stubs), gate, opts); } /** Loose envelope shape for reading inject() response bodies in tests. */ diff --git a/packages/grey-core/test/discovery.test.ts b/packages/grey-core/test/discovery.test.ts new file mode 100644 index 0000000..6611f8b --- /dev/null +++ b/packages/grey-core/test/discovery.test.ts @@ -0,0 +1,60 @@ +// GET /v1/discovery/services (+/:slug) — the Bazaar discovery index (E1-B). Free, ungated. +import { describe, it, expect } from 'vitest'; +import { makeApp } from './_helpers'; + +describe('discovery routes — Bazaar index (E1-B, Invariant #33)', () => { + it('GET /v1/discovery/services lists the 7 enabled offerings, discoverable and free (no x402 gate)', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + expect(res.statusCode).toBe(200); + const body = res.json() as { services: Array<{ slug: string; discoverable: boolean }> }; + expect(body.services).toHaveLength(7); + expect(body.services.every((s) => s.discoverable)).toBe(true); + expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan'); + }); + + it('merge-prep ruling: not-yet-offered offerings (enabled:false) are absent from the list AND their own detail page 404s', async () => { + const app = makeApp(); + const list = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = list.json() as { services: Array<{ slug: string }> }; + const slugs = body.services.map((s) => s.slug); + expect(slugs).not.toContain('daily_greenlight_list'); + expect(slugs).not.toContain('scam_alert_feed'); + + for (const slug of ['daily_greenlight_list', 'scam_alert_feed']) { + const detail = await app.inject({ method: 'GET', url: `/v1/discovery/services/${slug}` }); + expect(detail.statusCode, slug).toBe(404); + } + }); + + it('GET /v1/discovery/services/:slug returns the full evaluation artifact, incl. a sample (E1-C)', async () => { + const app = makeApp(); + const res = await app.inject({ + method: 'GET', + url: '/v1/discovery/services/verify_whitepaper', + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.slug).toBe('verify_whitepaper'); + expect(body.priceUsd).toBe(1.5); + expect(body.computeClass).toBe('LIVE_ALLOWED'); + expect(body.inputSchema).toBeTruthy(); + expect(body.outputSchema).toBeTruthy(); + expect(body.sample).toBeTruthy(); + expect(body.sample.request).toBeTruthy(); + expect(body.sample.response).toBeTruthy(); + }); + + it('the list route stays lean — no sample attached', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = res.json() as { services: Array<{ sample?: unknown }> }; + expect(body.services.every((s) => s.sample === undefined)).toBe(true); + }); + + it('GET /v1/discovery/services/:slug 404s for an unknown or unregistered slug', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services/nope' }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/packages/grey-core/test/mcp.test.ts b/packages/grey-core/test/mcp.test.ts new file mode 100644 index 0000000..7541547 --- /dev/null +++ b/packages/grey-core/test/mcp.test.ts @@ -0,0 +1,120 @@ +// POST /v1/mcp (E1-D) — hand-rolled JSON-RPC MCP surface over the same x402 rail. +import { describe, it, expect } from 'vitest'; +import { loadX402Config } from '@grey/x402-middleware'; +import { makeApp } 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', +}); +const mcpDeps = { + x402Config: cfg, + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, +}; + +function rpc(method: string, params?: unknown, id: string | number = 1) { + return { jsonrpc: '2.0' as const, id, method, params }; +} + +describe('MCP — initialize + tools/list (E1-D)', () => { + it('initialize returns server info + tools capability', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('initialize') }); + const body = res.json(); + expect(body.result.protocolVersion).toBeTruthy(); + expect(body.result.capabilities.tools).toBeDefined(); + }); + + it('tools/list projects the SAME EvaluationKit source as the HTTP surface — 7 tools: never the trust rung, never a not-yet-offered offering', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('tools/list') }); + const body = res.json(); + expect(body.result.tools).toHaveLength(7); + const names = body.result.tools.map((t: { name: string }) => t.name); + expect(names).toContain('legitimacy_scan'); + expect(names).not.toContain('legitimacy_scan_trust_rung'); + expect(names).not.toContain('daily_greenlight_list'); // merge-prep: not-yet-offered + expect(names).not.toContain('scam_alert_feed'); // merge-prep: not-yet-offered + const legit = body.result.tools.find((t: { name: string }) => t.name === 'legitimacy_scan'); + expect(legit.inputSchema).toBeTruthy(); + expect(typeof legit.description).toBe('string'); + }); + + it('unknown method returns a JSON-RPC error', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('nope') }); + const body = res.json(); + expect(body.error.code).toBe(-32601); + }); +}); + +// Note: mcp.ts's `isFree` branch (skip payment for a FREE-list tool) has no live coverage via +// tools/call right now — both FREE offerings (daily_greenlight_list, scam_alert_feed) are +// enabled:false (merge-prep ruling) and are therefore excluded from MCP_TOOL_SLUGS entirely (see +// the not-yet-offered test below). If a future offering ships free+enabled, add a tools/call +// happy-path test against that slug here. +describe('MCP — tools/call (E1-D)', () => { + it('merge-prep ruling: not-yet-offered offerings cannot be called by name, same as the trust rung', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + for (const name of ['daily_greenlight_list', 'scam_alert_feed']) { + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { name, arguments: {} }), + }); + const body = res.json(); + expect(body.error, name).toBeDefined(); + expect(body.error.code, name).toBe(-32602); + expect(body.result, name).toBeUndefined(); + } + }); + + it('a paid tool without payment returns isError:true carrying PaymentRequirements', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { + name: 'legitimacy_scan', + arguments: { token_address: '0x1111111111111111111111111111111111111111' }, + }), + }); + const body = res.json(); + expect(body.result.isError).toBe(true); + const requirements = JSON.parse(body.result.content[0].text); + expect(requirements.accepts[0].maxAmountRequired).toBe('250000'); + }); + + it('an unknown tool name is a JSON-RPC error, not a CallToolResult', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { name: 'not_a_real_offering', arguments: {} }), + }); + const body = res.json(); + expect(body.error.code).toBe(-32602); + }); + + it('the trust rung cannot be called by name even directly — MCP respects B-1 too (Invariant #34)', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { + name: 'legitimacy_scan_trust_rung', + arguments: { token_address: '0x1111111111111111111111111111111111111111' }, + }), + }); + const body = res.json(); + expect(body.error).toBeDefined(); + expect(body.result).toBeUndefined(); + }); +}); diff --git a/packages/grey-core/test/probes.test.ts b/packages/grey-core/test/probes.test.ts index 967346a..87a7675 100644 --- a/packages/grey-core/test/probes.test.ts +++ b/packages/grey-core/test/probes.test.ts @@ -21,6 +21,7 @@ function fakeDeps(overrides: Partial = {}): HandlerDeps { whitepapers: {} as HandlerDeps['whitepapers'], verifications: {} as HandlerDeps['verifications'], claims: {} as HandlerDeps['claims'], + revenueEvents: {} as HandlerDeps['revenueEvents'], logger: logger as unknown as HandlerDeps['logger'], clock: () => new Date('2026-06-14T00:00:00.000Z'), config: CONFIG, diff --git a/packages/grey-core/test/revenueLedger.test.ts b/packages/grey-core/test/revenueLedger.test.ts new file mode 100644 index 0000000..7f956fe --- /dev/null +++ b/packages/grey-core/test/revenueLedger.test.ts @@ -0,0 +1,108 @@ +// E1-F: revenue is recorded at every settlement point on the x402 channel — the normal 7 paid +// HTTP routes, the trust-rung route (when enabled), and the MCP tools/call path. Fail-open: a +// 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'; + +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', +}); +const relayerStubs = { + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, +}; + +describe('revenue ledger — recorded at settlement (E1-F)', () => { + it('a normal paid HTTP route records channel=x402, correct offering + price', async () => { + const sink: Array<{ channel: string; offering: string; revenueUsd: number }> = []; + const app = makeApp({ revenueEventsSink: sink }); // passThroughX402: settlement is "already done" + await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(sink).toHaveLength(1); + expect(sink[0]).toEqual({ channel: 'x402', offering: 'legitimacy_scan', revenueUsd: 0.25 }); + }); + + 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 app = makeApp({ revenueEventsSink: sink as never }, gate); + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(res.statusCode).toBe(402); + expect(sink).toHaveLength(0); + }); + + it('the trust-rung route records revenue when enabled', async () => { + const sink: Array<{ channel: string; offering: string; revenueUsd: number }> = []; + // 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, { + trustRungEnabled: true, + trustRungPreHandler: passThroughX402, + }); + await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(sink).toEqual([ + { channel: 'x402', offering: 'legitimacy_scan_trust_rung', revenueUsd: 0.1 }, + ]); + }); + + it('MCP tools/call records revenue only after a real settle() success, not on the payment-required leg', async () => { + const sink: Array<{ channel: string; offering: string; revenueUsd: number }> = []; + const app = makeApp({ revenueEventsSink: sink }, undefined, { + mcp: { x402Config: cfg, ...relayerStubs }, + }); + // Leg 1: no payment -> isError, no settlement, no revenue. + await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'verify_whitepaper', + arguments: { token_address: '0x1111111111111111111111111111111111111111' }, + }, + }, + }); + expect(sink).toHaveLength(0); + }); + + it('a not-yet-offered slug (merge-prep: daily_greenlight_list/scam_alert_feed, enabled:false) writes no revenue event — rejected before it ever reaches the payment/handler path', async () => { + const sink: Array = []; + const app = makeApp({ revenueEventsSink: sink as never }, undefined, { + mcp: { x402Config: cfg, ...relayerStubs }, + }); + await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'daily_greenlight_list', arguments: {} }, + }, + }); + expect(sink).toHaveLength(0); + }); +}); diff --git a/packages/grey-core/test/trustRung.test.ts b/packages/grey-core/test/trustRung.test.ts new file mode 100644 index 0000000..710cb94 --- /dev/null +++ b/packages/grey-core/test/trustRung.test.ts @@ -0,0 +1,87 @@ +// E1-C: the $0.10 trust rung is BUILT BUT BLOCKED (Forces ruling B-1, Invariant #34) — these tests +// assert it's actually unreachable by default, on both surfaces this channel exposes it through +// (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'; + +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', +}); +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 }), + }, +}); + +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 () => { + const app = makeApp(); // default opts: trustRungEnabled undefined -> false + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('is absent from GET /v1/discovery/services', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = res.json() as { services: Array<{ slug: string }> }; + expect(body.services.map((s) => s.slug)).not.toContain('legitimacy_scan_trust_rung'); + expect(body.services).toHaveLength(7); // 9 built offerings minus the 2 not-yet-offered (merge-prep) + }); + + it('its own discovery/capability detail page also 404s while disabled', async () => { + const app = makeApp(); + const res = await app.inject({ + method: 'GET', + url: '/v1/discovery/services/legitimacy_scan_trust_rung', + }); + expect(res.statusCode).toBe(404); + }); +}); + +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, { + trustRungEnabled: true, + trustRungPreHandler: trustRungGate, + }); + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(res.statusCode).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, + trustRungPreHandler: trustRungGate, + }); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = res.json() as { services: Array<{ slug: string }> }; + expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan_trust_rung'); + 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 () => { + const { buildServer } = await import('../src/server'); + const { fakeDeps } = await import('./_helpers'); + expect(() => buildServer(fakeDeps(), passThroughX402, { trustRungEnabled: true })).toThrow( + /trustRungPreHandler/, + ); + }); +}); diff --git a/packages/grey-pipeline/src/index.ts b/packages/grey-pipeline/src/index.ts index 3a25ef4..db64f0f 100644 --- a/packages/grey-pipeline/src/index.ts +++ b/packages/grey-pipeline/src/index.ts @@ -49,6 +49,10 @@ export { VerificationsRepo, RequestsRepo, CostEventsRepo, + RevenueEventsRepo, + MarginRepo, + computeMarginReport, + type MarginReportRow, } from './persistence/repositories'; // Constants worth exposing diff --git a/packages/grey-pipeline/src/persistence/repositories.ts b/packages/grey-pipeline/src/persistence/repositories.ts index 4fe2b07..a4c30cf 100644 --- a/packages/grey-pipeline/src/persistence/repositories.ts +++ b/packages/grey-pipeline/src/persistence/repositories.ts @@ -6,7 +6,7 @@ // new-architecture additions (request audit trail + cost telemetry) per the audit. import { eq, and, gte, desc, sql } from 'drizzle-orm'; -import { whitepapers, claims, verifications, requests, costEvents } from './schema'; +import { whitepapers, claims, verifications, requests, costEvents, revenueEvents } from './schema'; import type { WhitepaperRow, WhitepaperInsert, @@ -18,6 +18,8 @@ import type { RequestInsert, CostEventRow, CostEventInsert, + RevenueEventRow, + RevenueEventInsert, } from './schema'; import type { GreyDb } from './client'; @@ -298,3 +300,109 @@ export class CostEventsRepo { return rows[0]; } } + +// ── E1-F: revenue ledger + margin report (Expansion Round 2, sub-unit 4) ── + +export class RevenueEventsRepo { + constructor(private db: GreyDb) {} + + /** One row per settled payment. Written by grey-core's route/MCP layer AFTER settle() succeeds + * — never speculatively, never on a 402/verify failure. */ + async create(data: RevenueEventInsert): Promise { + const rows = await this.db.insert(revenueEvents).values(data).returning(); + return rows[0]; + } +} + +export interface MarginReportRow { + offering: string; + /** Revenue broken out per channel — the ledger's actual channel x offering attribution. */ + revenueByChannelUsd: Record; + totalRevenueUsd: number; + /** + * Compute spend for this offering, channel-agnostic. Scoping note: cost_events has no channel + * dimension (a legitimacy_scan pipeline run costs the same regardless of which channel + * triggered it) — splitting cost by channel would require either plumbing a channel identifier + * all the way through cacheOrLive into the pipeline's persistence layer, or an allocation + * methodology (e.g. proportional to revenue share). Both are judgment calls beyond this pass's + * scope — reported here in aggregate per offering, not invented per channel. + */ + totalCostUsd: number; + /** totalRevenueUsd - totalCostUsd. The E1->E2 gate's "positive realized margin on LIVE_ALLOWED + * offerings" reads this field. CACHE_ONLY offerings trivially show margin == revenue + * (cost_events never has rows for them — Invariant #30). */ + realizedMarginUsd: number; +} + +/** + * Pure aggregation — takes already-fetched rows (mirrors VerificationsRepo.getMonthlyCostSummary's + * fetch-then-reduce-in-JS convention) so it's unit-testable with fixture arrays, no live DB + * required. `costByOffering` keys off `requests.offering` (cost_events joins through requests; + * see the migration's comment for why cost isn't itself channel-split). + */ +export function computeMarginReport( + revenueRows: Array>, + costByOffering: Map, +): MarginReportRow[] { + const byOffering = new Map>(); + for (const row of revenueRows) { + const channels = byOffering.get(row.offering) ?? {}; + channels[row.channel] = (channels[row.channel] ?? 0) + row.revenueUsd; + byOffering.set(row.offering, channels); + } + // Union of offerings that have EITHER revenue or cost — an offering with cost but zero + // settled revenue yet (e.g. mid-rollout) should still surface a (negative) margin, not vanish. + const offerings = new Set([...byOffering.keys(), ...costByOffering.keys()]); + + return [...offerings].sort().map((offering) => { + const revenueByChannelUsd = byOffering.get(offering) ?? {}; + const totalRevenueUsd = Object.values(revenueByChannelUsd).reduce((a, b) => a + b, 0); + const totalCostUsd = costByOffering.get(offering) ?? 0; + return { + offering, + revenueByChannelUsd, + totalRevenueUsd, + totalCostUsd, + realizedMarginUsd: totalRevenueUsd - totalCostUsd, + }; + }); +} + +export class MarginRepo { + constructor(private db: GreyDb) {} + + /** All revenue_events rows (channel, offering, revenueUsd) since `since`. */ + async getRevenueRows( + since: Date, + ): Promise>> { + return this.db + .select({ + channel: revenueEvents.channel, + offering: revenueEvents.offering, + revenueUsd: revenueEvents.revenueUsd, + }) + .from(revenueEvents) + .where(gte(revenueEvents.settledAt, since)); + } + + /** Total compute spend per offering since `since`, via cost_events JOIN requests (requests is + * where `offering` lives — cost_events itself carries no offering column). */ + async getCostByOffering(since: Date): Promise> { + const rows = await this.db + .select({ offering: requests.offering, costUsd: costEvents.costUsd }) + .from(costEvents) + .innerJoin(requests, eq(costEvents.requestId, requests.id)) + .where(gte(costEvents.createdAt, since)); + const out = new Map(); + for (const r of rows) out.set(r.offering, (out.get(r.offering) ?? 0) + r.costUsd); + return out; + } + + async getMarginReport(since: Date): Promise { + const [revenueRows, costByOffering] = await Promise.all([ + this.getRevenueRows(since), + this.getCostByOffering(since), + ]); + return computeMarginReport(revenueRows, costByOffering); + } +} diff --git a/packages/grey-pipeline/src/persistence/schema.ts b/packages/grey-pipeline/src/persistence/schema.ts index 93c08f6..40bb4a0 100644 --- a/packages/grey-pipeline/src/persistence/schema.ts +++ b/packages/grey-pipeline/src/persistence/schema.ts @@ -78,7 +78,9 @@ export const verifications = greyTwo.table( verifiedClaims: integer('verified_claims').notNull().default(0), reportJson: jsonb('report_json').$type>(), llmTokensUsed: integer('llm_tokens_used').notNull().default(0), - computeCostUsd: numeric('compute_cost_usd', { precision: 12, scale: 6, mode: 'number' }).notNull().default(0), + computeCostUsd: numeric('compute_cost_usd', { precision: 12, scale: 6, mode: 'number' }) + .notNull() + .default(0), triggerSource: text('trigger_source'), cacheHit: boolean('cache_hit').default(false), l1DurationMs: integer('l1_duration_ms').default(0), @@ -146,6 +148,26 @@ export const costEvents = greyTwo.table( ], ); +// ── revenue_events (E1-F; one row per settled payment, channel x offering) ── +export const revenueEvents = greyTwo.table( + 'revenue_events', + { + id: uuid('id').defaultRandom().primaryKey(), + requestId: uuid('request_id').references(() => requests.id, { onDelete: 'set null' }), + channel: text('channel').notNull(), // 'x402' | 'acp' (@grey/schemas/pricing Channel) + offering: text('offering').notNull(), // OfferingSlug + revenueUsd: numeric('revenue_usd', { precision: 12, scale: 6, mode: 'number' }) + .notNull() + .default(0), + settledAt: timestamp('settled_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => [ + index('grey_revenue_channel_offering_idx').on(t.channel, t.offering), + index('grey_revenue_settled_at_idx').on(t.settledAt), + index('grey_revenue_request_idx').on(t.requestId), + ], +); + export type WhitepaperRow = typeof whitepapers.$inferSelect; export type WhitepaperInsert = typeof whitepapers.$inferInsert; export type RequestRow = typeof requests.$inferSelect; @@ -156,3 +178,5 @@ export type ClaimRow = typeof claims.$inferSelect; export type ClaimInsert = typeof claims.$inferInsert; export type CostEventRow = typeof costEvents.$inferSelect; export type CostEventInsert = typeof costEvents.$inferInsert; +export type RevenueEventRow = typeof revenueEvents.$inferSelect; +export type RevenueEventInsert = typeof revenueEvents.$inferInsert; diff --git a/packages/grey-pipeline/test/marginReport.test.ts b/packages/grey-pipeline/test/marginReport.test.ts new file mode 100644 index 0000000..813934a --- /dev/null +++ b/packages/grey-pipeline/test/marginReport.test.ts @@ -0,0 +1,54 @@ +// E1-F: pure margin-report aggregation (Expansion Round 2, sub-unit 4). Unit-tested against +// fixture arrays — no live DB — mirroring VerificationsRepo.getMonthlyCostSummary's +// fetch-then-reduce-in-JS convention. +import { describe, it, expect } from 'vitest'; +import { computeMarginReport } from '../src/persistence/repositories'; + +describe('computeMarginReport — E1-F margin instrumentation', () => { + it('attributes revenue per channel x offering, cost per offering, and computes margin = revenue - cost', () => { + const revenueRows = [ + { channel: 'x402', offering: 'legitimacy_scan', revenueUsd: 0.25 }, + { channel: 'x402', offering: 'legitimacy_scan', revenueUsd: 0.25 }, + { channel: 'acp', offering: 'legitimacy_scan', revenueUsd: 0.25 }, + ]; + const costByOffering = new Map([['legitimacy_scan', 0.1]]); + + const report = computeMarginReport(revenueRows, costByOffering); + + expect(report).toHaveLength(1); + const row = report[0]; + expect(row.offering).toBe('legitimacy_scan'); + expect(row.revenueByChannelUsd).toEqual({ x402: 0.5, acp: 0.25 }); + expect(row.totalRevenueUsd).toBeCloseTo(0.75, 6); + expect(row.totalCostUsd).toBe(0.1); + expect(row.realizedMarginUsd).toBeCloseTo(0.65, 6); + }); + + it('CACHE_ONLY offerings show margin == revenue (zero cost, no cost_events rows exist for them)', () => { + const revenueRows = [{ channel: 'x402', offering: 'quick_protocol_facts', revenueUsd: 0.3 }]; + const report = computeMarginReport(revenueRows, new Map()); + expect(report[0].totalCostUsd).toBe(0); + expect(report[0].realizedMarginUsd).toBe(0.3); + }); + + it('an offering with cost but no settled revenue yet still surfaces (negative margin), not dropped', () => { + const costByOffering = new Map([['verify_full_tech', 3.5]]); + const report = computeMarginReport([], costByOffering); + expect(report).toHaveLength(1); + expect(report[0].totalRevenueUsd).toBe(0); + expect(report[0].realizedMarginUsd).toBe(-3.5); + }); + + it('is sorted by offering slug for stable output', () => { + const revenueRows = [ + { channel: 'x402', offering: 'verify_whitepaper', revenueUsd: 1.5 }, + { channel: 'x402', offering: 'claim_extraction', revenueUsd: 0.75 }, + ]; + const report = computeMarginReport(revenueRows, new Map()); + expect(report.map((r) => r.offering)).toEqual(['claim_extraction', 'verify_whitepaper']); + }); + + it('returns an empty report for no activity', () => { + expect(computeMarginReport([], new Map())).toEqual([]); + }); +}); diff --git a/packages/grey-schemas/build.mjs b/packages/grey-schemas/build.mjs index d7a4944..e718e71 100644 --- a/packages/grey-schemas/build.mjs +++ b/packages/grey-schemas/build.mjs @@ -14,6 +14,7 @@ await buildPackage({ 'src/envelope/index.ts', 'src/validators/index.ts', 'src/pricing/index.ts', + 'src/evaluationKit/index.ts', ], // ajv 8.x has no `exports` map; its extensionless deep import must carry `.js` for Node ESM. alias: { 'ajv/dist/2020': 'ajv/dist/2020.js' }, diff --git a/packages/grey-schemas/openapi/openapi.yaml b/packages/grey-schemas/openapi/openapi.yaml index bdbd0c3..5380b9e 100644 --- a/packages/grey-schemas/openapi/openapi.yaml +++ b/packages/grey-schemas/openapi/openapi.yaml @@ -8,6 +8,9 @@ info: Movement 2.5 (schema layer); routes are placeholders that Movement 3 (grey-core) is authoritative for and may relocate. x402 payment integration is Movement 5; this document declares the payment CONTRACT only, not its implementation. + Bazaar discovery metadata (E1-B) is NOT hand-mirrored here (Invariant #33 — + single source, no per-platform metadata): every 402 response's `accepts[].extra.bazaar` + and `GET /v1/discovery/services` project the same @grey/schemas/evaluationKit source live. servers: - url: https://whitepapergrey.com description: Live domain diff --git a/packages/grey-schemas/package.json b/packages/grey-schemas/package.json index c08a3f2..f6cbdb2 100644 --- a/packages/grey-schemas/package.json +++ b/packages/grey-schemas/package.json @@ -12,6 +12,7 @@ "./envelope": { "types": "./dist/envelope/index.d.ts", "default": "./dist/envelope/index.js" }, "./validators": { "types": "./dist/validators/index.d.ts", "default": "./dist/validators/index.js" }, "./pricing": { "types": "./dist/pricing/index.d.ts", "default": "./dist/pricing/index.js" }, + "./evaluationKit": { "types": "./dist/evaluationKit/index.d.ts", "default": "./dist/evaluationKit/index.js" }, "./openapi": "./openapi/openapi.yaml" }, "files": ["dist", "openapi"], diff --git a/packages/grey-schemas/scripts/codegen.ts b/packages/grey-schemas/scripts/codegen.ts index 99eccc6..de4a88a 100644 --- a/packages/grey-schemas/scripts/codegen.ts +++ b/packages/grey-schemas/scripts/codegen.ts @@ -33,6 +33,7 @@ const GROUPS: CodegenGroup[] = [ // Schema file -> generated type name (Q4 naming table, locked verbatim). nameMap: { 'legitimacy_scan.schema.json': 'LegitimacyScanResponse', + 'legitimacy_scan_trust_rung.schema.json': 'LegitimacyScanTrustRungResponse', 'verify_whitepaper.schema.json': 'VerifyWhitepaperResponse', 'verify_full_tech.schema.json': 'VerifyFullTechResponse', 'claim_extraction.schema.json': 'ClaimExtractionResponse', @@ -53,6 +54,7 @@ const GROUPS: CodegenGroup[] = [ // 7 paid offerings (M3 FDQ-10 — the 2 free GETs take no input, no request schema). nameMap: { 'legitimacy_scan.schema.json': 'LegitimacyScanRequest', + 'legitimacy_scan_trust_rung.schema.json': 'LegitimacyScanTrustRungRequest', 'verify_whitepaper.schema.json': 'VerifyWhitepaperRequest', 'verify_full_tech.schema.json': 'VerifyFullTechRequest', 'claim_extraction.schema.json': 'ClaimExtractionRequest', diff --git a/packages/grey-schemas/src/evaluationKit/build.ts b/packages/grey-schemas/src/evaluationKit/build.ts new file mode 100644 index 0000000..b537bf4 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/build.ts @@ -0,0 +1,158 @@ +// @grey/schemas/evaluationKit — the projector (E1-B). Merges branding data + the canonical +// pricing table + the raw JSON Schemas into the Bazaar extension shape, applying the stated +// validation rules with SOFT-DROP semantics: a bad field is omitted from the entry (and recorded +// in `dropped`, so it's inspectable rather than truly silent) instead of throwing or blocking the +// whole listing. +import type { OfferingSlug, PaidOfferingSlug } from '../responses/types'; +import { computeClassFor, canonicalUsdFor, PRICING_TABLE } from '../pricing/table'; +import { EVALUATION_KIT_BRANDING } from './data'; +import { EVALUATION_SAMPLES } from './samples'; +import type { DroppedField, EvaluationKitEntry, SampleExchange } from './types'; + +import legitimacyScanResponse from '../responses/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungResponse from '../responses/v1/legitimacy_scan_trust_rung.schema.json'; +import verifyWhitepaperResponse from '../responses/v1/verify_whitepaper.schema.json'; +import verifyFullTechResponse from '../responses/v1/verify_full_tech.schema.json'; +import claimExtractionResponse from '../responses/v1/claim_extraction.schema.json'; +import claimHistoryResponse from '../responses/v1/claim_history.schema.json'; +import quickProtocolFactsResponse from '../responses/v1/quick_protocol_facts.schema.json'; +import dailyTechBriefResponse from '../responses/v1/daily_tech_brief.schema.json'; +import dailyGreenlightListResponse from '../responses/v1/daily_greenlight_list.schema.json'; +import scamAlertFeedResponse from '../responses/v1/scam_alert_feed.schema.json'; + +import legitimacyScanRequest from '../requests/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungRequest from '../requests/v1/legitimacy_scan_trust_rung.schema.json'; +import verifyWhitepaperRequest from '../requests/v1/verify_whitepaper.schema.json'; +import verifyFullTechRequest from '../requests/v1/verify_full_tech.schema.json'; +import claimExtractionRequest from '../requests/v1/claim_extraction.schema.json'; +import claimHistoryRequest from '../requests/v1/claim_history.schema.json'; +import quickProtocolFactsRequest from '../requests/v1/quick_protocol_facts.schema.json'; +import dailyTechBriefRequest from '../requests/v1/daily_tech_brief.schema.json'; + +const OUTPUT_SCHEMAS: Record = { + legitimacy_scan: legitimacyScanResponse, + legitimacy_scan_trust_rung: legitimacyScanTrustRungResponse, + verify_whitepaper: verifyWhitepaperResponse, + verify_full_tech: verifyFullTechResponse, + claim_extraction: claimExtractionResponse, + claim_history: claimHistoryResponse, + quick_protocol_facts: quickProtocolFactsResponse, + daily_tech_brief: dailyTechBriefResponse, + daily_greenlight_list: dailyGreenlightListResponse, + scam_alert_feed: scamAlertFeedResponse, +}; + +const INPUT_SCHEMAS: Record = { + legitimacy_scan: legitimacyScanRequest, + legitimacy_scan_trust_rung: legitimacyScanTrustRungRequest, + verify_whitepaper: verifyWhitepaperRequest, + verify_full_tech: verifyFullTechRequest, + claim_extraction: claimExtractionRequest, + claim_history: claimHistoryRequest, + quick_protocol_facts: quickProtocolFactsRequest, + daily_tech_brief: dailyTechBriefRequest, +}; + +const FREE_SLUGS = new Set(['daily_greenlight_list', 'scam_alert_feed']); + +/** Printable ASCII only (0x20–0x7E) — the Bazaar validation rule for serviceName/tags. */ +function isPrintableAscii(s: string): boolean { + return s.length > 0 && /^[\x20-\x7E]+$/.test(s); +} + +const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); +// https://[:][/...] — deliberately hand-parsed (not the global `URL`) so this package +// stays node-ambient-free (no "types": ["node"] opt-in, unlike the adapter packages). +const HTTPS_URL_RE = /^https:\/\/(\[[^\]]+\]|[^/:?#]+)(?::\d+)?(?:[/?#]|$)/; + +/** Absolute https, no IP literal, no loopback host — the Bazaar validation rule for iconUrl. */ +function isValidIconUrl(url: string): boolean { + const m = HTTPS_URL_RE.exec(url); + if (!m) return false; + const rawHost = m[1]!; + if (rawHost.startsWith('[')) return false; // bracketed IPv6 literal + const host = rawHost.toLowerCase(); + if (LOOPBACK_HOSTS.has(host)) return false; + if (IPV4_RE.test(host)) return false; + return true; +} + +/** + * Project one offering into the Bazaar extension shape. Soft-drop: a bad serviceName/tag/iconUrl + * is omitted (null / filtered) rather than thrown — the entry still ships, just without that + * field, matching the spec's own description of Bazaar's indexing behaviour. + */ +export function buildEvaluationKit( + slug: OfferingSlug, + opts: { sample?: SampleExchange } = {}, +): EvaluationKitEntry { + const branding = EVALUATION_KIT_BRANDING[slug]; + const pricing = PRICING_TABLE[slug]; + const dropped: DroppedField[] = []; + + let serviceName: string | null = branding.serviceName; + if (!isPrintableAscii(branding.serviceName)) { + dropped.push({ field: 'serviceName', reason: 'not printable ASCII' }); + serviceName = null; + } + + const tags: string[] = []; + for (const tag of branding.tags) { + if (isPrintableAscii(tag)) { + tags.push(tag); + } else { + dropped.push({ field: `tags[${tag}]`, reason: 'not printable ASCII' }); + } + } + + let iconUrl: string | null = branding.iconUrl; + if (!isValidIconUrl(branding.iconUrl)) { + dropped.push({ + field: 'iconUrl', + reason: 'not an absolute https URL, or an IP/loopback literal', + }); + iconUrl = null; + } + + const inputSchema = FREE_SLUGS.has(slug) + ? null + : (INPUT_SCHEMAS[slug as PaidOfferingSlug] ?? null); + + return { + slug, + // Merge-prep ruling: an offering with `enabled: false` (not yet offered, period — e.g. + // daily_greenlight_list/scam_alert_feed) is structurally absent from every Bazaar-facing + // surface (discovery list+detail, 402 extra.bazaar, MCP tools/list) — every one of those + // surfaces filters/gates on THIS field, not a separate flag each has to keep in sync. + discoverable: pricing.enabled, + serviceName, + tags, + description: branding.description, + inputSchema, + outputSchema: OUTPUT_SCHEMAS[slug], + iconUrl, + priceUsd: pricing.canonicalUsd === null ? null : canonicalUsdFor(slug), + computeClass: computeClassFor(slug), + sample: opts.sample, + dropped, + }; +} + +/** Project every offering. Callers filter on `discoverable`/`priceUsd !== null` as needed — this + * function does not itself decide what a channel should list (E1-C's disable flag is separate). + * No `sample` attached (keeps the 402 body / list index lean) — see `buildEvaluationArtifact`. */ +export function buildAllEvaluationKits(): EvaluationKitEntry[] { + return (Object.keys(EVALUATION_KIT_BRANDING) as OfferingSlug[]).map((slug) => + buildEvaluationKit(slug), + ); +} + +/** + * The public capability page (E1-C evaluation artifacts): the same EvaluationKit entry PLUS a + * schema-valid sample request/response pair, so an evaluating agent can inspect real shape and a + * real output before ever paying. Ships live — distinct from the trust rung, which does not. + */ +export function buildEvaluationArtifact(slug: OfferingSlug): EvaluationKitEntry { + return buildEvaluationKit(slug, { sample: EVALUATION_SAMPLES[slug] }); +} diff --git a/packages/grey-schemas/src/evaluationKit/data.ts b/packages/grey-schemas/src/evaluationKit/data.ts new file mode 100644 index 0000000..42f002c --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/data.ts @@ -0,0 +1,71 @@ +// @grey/schemas/evaluationKit — hand-authored branding data, ONCE (Invariant #33). Icon assets are +// served from the live domain (openapi.yaml's `servers[0]`); tags are printable-ASCII, lowercase, +// hyphenless-safe words per the Bazaar validation rule (E1-B). +import type { OfferingSlug } from '../responses/types'; +import type { EvaluationKitBranding } from './types'; + +const ICON_BASE = 'https://whitepapergrey.com/icons'; + +export const EVALUATION_KIT_BRANDING: Record = { + legitimacy_scan: { + serviceName: 'Project Legitimacy Scan', + tags: ['crypto', 'due-diligence', 'verification', 'tier1'], + description: 'Fast structural + claims legitimacy read on a token project, cache-or-live.', + iconUrl: `${ICON_BASE}/legitimacy_scan.svg`, + }, + legitimacy_scan_trust_rung: { + serviceName: 'Legitimacy Trust Rung', + tags: ['crypto', 'due-diligence', 'trust-rung', 'cache-only'], + description: + 'Cheap cache-only legitimacy teaser. BUILT BUT BLOCKED — not exposed on any live channel (Forces ruling B-1).', + iconUrl: `${ICON_BASE}/legitimacy_scan_trust_rung.svg`, + }, + verify_whitepaper: { + serviceName: 'Whitepaper Verification', + tags: ['crypto', 'due-diligence', 'verification', 'tokenomics'], + description: 'Tokenomics-focused audit of a project whitepaper against its stated claims.', + iconUrl: `${ICON_BASE}/verify_whitepaper.svg`, + }, + verify_full_tech: { + serviceName: 'Full Technical Verification', + tags: ['crypto', 'due-diligence', 'verification', 'technical'], + description: 'Complete technical + tokenomics verification, the deepest tier Grey offers.', + iconUrl: `${ICON_BASE}/verify_full_tech.svg`, + }, + claim_extraction: { + serviceName: 'Claim Extraction', + tags: ['crypto', 'nlp', 'extraction'], + description: 'Extracts structured, categorised claims from a buyer-supplied whitepaper URL.', + iconUrl: `${ICON_BASE}/claim_extraction.svg`, + }, + claim_history: { + serviceName: 'Claim History', + tags: ['crypto', 'due-diligence', 'history'], + description: 'Prior extracted claims + verification history for a known project.', + iconUrl: `${ICON_BASE}/claim_history.svg`, + }, + quick_protocol_facts: { + serviceName: 'Quick Protocol Facts', + tags: ['crypto', 'lookup', 'cache-only'], + description: 'Cache-only fast facts lookup for a known protocol — no live compute.', + iconUrl: `${ICON_BASE}/quick_protocol_facts.svg`, + }, + daily_tech_brief: { + serviceName: 'Daily Technical Briefing', + tags: ['crypto', 'digest', 'cache-only'], + description: 'Daily aggregated digest of recently verified projects.', + iconUrl: `${ICON_BASE}/daily_tech_brief.svg`, + }, + daily_greenlight_list: { + serviceName: 'Daily Greenlight List', + tags: ['crypto', 'digest', 'free'], + description: 'Free daily list of projects clearing Grey verification.', + iconUrl: `${ICON_BASE}/daily_greenlight_list.svg`, + }, + scam_alert_feed: { + serviceName: 'Scam Alert Feed', + tags: ['crypto', 'safety', 'free'], + description: 'Free feed of projects flagged by Grey verification as high-risk.', + iconUrl: `${ICON_BASE}/scam_alert_feed.svg`, + }, +}; diff --git a/packages/grey-schemas/src/evaluationKit/index.ts b/packages/grey-schemas/src/evaluationKit/index.ts new file mode 100644 index 0000000..e956c44 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/index.ts @@ -0,0 +1,10 @@ +// @grey/schemas/evaluationKit — barrel (E1-B, Invariant #33). +export type { + EvaluationKitBranding, + EvaluationKitEntry, + SampleExchange, + DroppedField, +} from './types'; +export { EVALUATION_KIT_BRANDING } from './data'; +export { EVALUATION_SAMPLES } from './samples'; +export { buildEvaluationKit, buildAllEvaluationKits, buildEvaluationArtifact } from './build'; diff --git a/packages/grey-schemas/src/evaluationKit/samples.ts b/packages/grey-schemas/src/evaluationKit/samples.ts new file mode 100644 index 0000000..3c6752c --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/samples.ts @@ -0,0 +1,154 @@ +// @grey/schemas/evaluationKit — sample request/response pairs (E1-C evaluation artifacts). Every +// sample here is schema-valid (asserted in test/evaluationKit.samples.test.ts against the SAME +// ajv validators the live routes use) — an evaluating agent that reads these gets a real, +// checkable preview, not an illustrative-but-wrong shape. +import type { OfferingSlug } from '../responses/types'; +import type { SampleExchange } from './types'; + +const legitimacyScanResponse = { + projectName: 'Example Protocol', + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + structuralScore: 4, + verdict: 'PASS', + hypeTechRatio: 0.5, + claimCount: 3, + claimsMicaCompliance: 'NOT_MENTIONED', + micaCompliant: 'NOT_APPLICABLE', + micaSummary: 'No MiCA-relevant claims found.', + generatedAt: '2026-06-13T00:00:00.000Z', + discoveryStatus: 'cached', + discoverySourceTier: 0, + discoveryAttempts: [ + { tier: 0, status: 'cached', structuralScore: 4, claimCount: 3, note: 'hit' }, + ], +}; + +const claimSample = { + claimId: 'c1', + category: 'TOKENOMICS', + claimText: 'Total supply is fixed at 1,000,000,000 tokens.', + statedEvidence: 'Whitepaper §3.1', + mathematicalProofPresent: false, + sourceSection: 'Tokenomics', + regulatoryRelevance: false, +}; + +const fullTechResponse = { + ...legitimacyScanResponse, + claims: [claimSample], + claimScores: { c1: 0.8 }, + logicSummary: 'Tokenomics claims are internally consistent with the stated supply schedule.', + confidenceScore: 82, + evaluations: [ + { + claimId: 'c1', + mathValidity: 'VALID', + plausibility: 'HIGH', + citationSupportsClaim: true, + originality: 'NOVEL', + consistency: 'CONSISTENT', + }, + ], + focusAreaScores: { tokenomics: 82, performance: null, consensus: null, scientific: null }, + llmTokensUsed: 2400, + computeCostUsd: 0.04, +}; + +export const EVALUATION_SAMPLES: Record = { + legitimacy_scan: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: legitimacyScanResponse, + }, + legitimacy_scan_trust_rung: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: { + projectName: 'Example Protocol', + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + verdict: 'PASS', + generatedAt: '2026-06-13T00:00:00.000Z', + note: 'Cache-only teaser — see legitimacy_scan for the full structural read.', + }, + }, + verify_whitepaper: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: { + ...legitimacyScanResponse, + claims: [claimSample], + claimScores: { c1: 0.8 }, + logicSummary: 'Tokenomics claims are internally consistent with the stated supply schedule.', + }, + }, + verify_full_tech: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: fullTechResponse, + }, + claim_extraction: { + request: { whitepaperUrl: 'https://example.org/whitepaper.pdf' }, + response: { + whitepaper: { + id: 'wp-sample', + projectName: 'Example Protocol', + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + documentUrl: 'https://example.org/whitepaper.pdf', + pageCount: 12, + }, + structuralAnalysis: { hasAbstract: true, hasTokenomics: true }, + claims: [claimSample], + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + }, + }, + claim_history: { + request: { projectIdentifier: 'Example Protocol' }, + response: { + project: { name: 'Example Protocol' }, + verifications: [{ id: 'v-sample' }], + claims: [claimSample], + note: 'One prior verification on file.', + }, + }, + quick_protocol_facts: { + request: { projectQuery: 'Example Protocol' }, + response: { + project: { name: 'Example Protocol' }, + type: 'DeFi', + miCAStatus: 'PARTIAL', + headlineVerdict: 'CONDITIONAL', + lastVerified: '2026-06-13T00:00:00.000Z', + sources: ['https://example.org/whitepaper.pdf'], + note: 'Served from cache — no live compute.', + }, + }, + daily_tech_brief: { + request: { date: '2026-06-13' }, + response: { date: '2026-06-13', totalVerified: 1, whitepapers: [fullTechResponse] }, + }, + daily_greenlight_list: { + request: {}, + response: { + date: '2026-06-13', + totalVerified: 1, + projects: [{ projectName: 'Example Protocol' }], + }, + }, + scam_alert_feed: { + request: {}, + response: { + date: '2026-06-13', + flagged: [ + { projectName: 'Suspicious Token', redFlags: ['unverifiable team', 'unsourced claims'] }, + ], + }, + }, +}; diff --git a/packages/grey-schemas/src/evaluationKit/types.ts b/packages/grey-schemas/src/evaluationKit/types.ts new file mode 100644 index 0000000..bf395a7 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/types.ts @@ -0,0 +1,45 @@ +// @grey/schemas/evaluationKit — the reusable metadata projection (E1-B, spec §3 E1 bequeaths, +// Invariant #33). Every channel listing (Bazaar, Kite, Olas, MCP, ...) renders from this single +// source — no hand-authored per-platform metadata. +import type { OfferingSlug } from '../responses/types'; + +/** Hand-authored ONCE per offering (this satisfies Invariant #33 — "no hand-authored + * PER-PLATFORM metadata" means don't re-author per channel, not that this data has no author). */ +export interface EvaluationKitBranding { + readonly serviceName: string; + readonly tags: readonly string[]; + readonly description: string; + readonly iconUrl: string; +} + +/** One sample request/response pair — the evaluation-friction answer (spec §0.2): a buying agent + * can inspect real shape + a real output before it ever pays. */ +export interface SampleExchange { + readonly request: unknown; + readonly response: unknown; +} + +/** A field that failed a Bazaar validation rule and was soft-dropped (spec E1-B: "soft-drop means + * a bad field vanishes silently" — surfaced here so tests/logs can see it instead of it truly + * vanishing without a trace). */ +export interface DroppedField { + readonly field: string; + readonly reason: string; +} + +/** The projected Bazaar extension shape for one offering (spec E1-B field list, verbatim). */ +export interface EvaluationKitEntry { + readonly slug: OfferingSlug; + readonly discoverable: boolean; + readonly serviceName: string | null; + readonly tags: readonly string[]; + readonly description: string; + readonly inputSchema: object | null; + readonly outputSchema: object; + readonly iconUrl: string | null; + readonly priceUsd: number | null; + readonly computeClass: string; + readonly sample?: SampleExchange; + /** Fields that failed validation and were dropped from this entry (soft-drop, not a throw). */ + readonly dropped: readonly DroppedField[]; +} diff --git a/packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts b/packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts new file mode 100644 index 0000000..4c9a630 --- /dev/null +++ b/packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED by `pnpm codegen` from src/responses/v1/*.schema.json. + * DO NOT EDIT BY HAND. Re-run codegen to regenerate. + */ + +/** + * Response body for the $0.10 CACHE_ONLY trust-rung offering (E1-C, spec §2.4). A cheap teaser of legitimacy_scan's verdict, never live-computed — BUILT BUT BLOCKED from live exposure on every channel per Forces ruling B-1, Invariant #34. + */ +export interface LegitimacyScanTrustRungResponse { + projectName: string; + tokenAddress: string | null; + verdict: "PASS" | "CONDITIONAL" | "FAIL" | "INSUFFICIENT_DATA" | "NOT_IN_DATABASE"; + generatedAt: string; + note: string; +} diff --git a/packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts b/packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts new file mode 100644 index 0000000..e744880 --- /dev/null +++ b/packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED by `pnpm codegen` from src/requests/v1/*.schema.json. + * DO NOT EDIT BY HAND. Re-run codegen to regenerate. + */ + +/** + * Request body for the trust-rung offering (E1-C). Same identifier shape as legitimacy_scan. + */ +export interface LegitimacyScanTrustRungRequest { + token_address: string; + project_name?: string; +} diff --git a/packages/grey-schemas/src/pricing/index.ts b/packages/grey-schemas/src/pricing/index.ts index 9f5f237..9454f77 100644 --- a/packages/grey-schemas/src/pricing/index.ts +++ b/packages/grey-schemas/src/pricing/index.ts @@ -4,6 +4,7 @@ export { PRICING_TABLE, NETWORK_MULTIPLIER, computeClassFor, + isEnabled, networkMultiplierFor, canonicalUsdFor, resolvePriceUsd, diff --git a/packages/grey-schemas/src/pricing/table.ts b/packages/grey-schemas/src/pricing/table.ts index 21e99ce..30bd24f 100644 --- a/packages/grey-schemas/src/pricing/table.ts +++ b/packages/grey-schemas/src/pricing/table.ts @@ -3,34 +3,90 @@ // that file now derives its (byte-identical) output from here instead of holding literals. // // Canonicalizes EXISTING values as-is (E1-A directive): no repricing on this phase. The 7 priced -// values below are carried over unchanged from the prior PRICE_TABLE. `daily_greenlight_list` and -// `scam_alert_feed` have no existing canonical price — canonicalUsd is `null`, flagged here for -// Desktop to source before merge, not invented. +// entries below are carried over unchanged from the prior PRICE_TABLE. +// +// `daily_greenlight_list` and `scam_alert_feed` are `enabled: false` — merge-prep ruling (Forces, +// 2026-07-30 session): these are NOT priced gaps to fill, they are not being offered yet, period, +// pending daily-customer usage data. `canonicalUsd` stays `null` (don't invent a price for +// something not for sale); `enabled: false` is the actual reason, replacing the earlier "UNPRICED, +// flagged for Desktop" framing from the E1-A PR, which is now resolved. Table reads as 7 priced + +// 2 disabled = 9, matching the full handler count (10th is the trust rung, gated separately by +// its own runtime disable flag — see below). import type { OfferingSlug } from '../responses/types'; import type { Channel, ComputeClass, OfferingPricing } from './types'; export const PRICING_TABLE: Record = { // LIVE_ALLOWED — resolve through cacheOrLive on a cache miss (grey-core/src/handlers/index.ts). - legitimacy_scan: { slug: 'legitimacy_scan', canonicalUsd: 0.25, computeClass: 'LIVE_ALLOWED' }, - verify_whitepaper: { slug: 'verify_whitepaper', canonicalUsd: 1.5, computeClass: 'LIVE_ALLOWED' }, - verify_full_tech: { slug: 'verify_full_tech', canonicalUsd: 3.0, computeClass: 'LIVE_ALLOWED' }, - claim_extraction: { slug: 'claim_extraction', canonicalUsd: 0.75, computeClass: 'LIVE_ALLOWED' }, + legitimacy_scan: { + slug: 'legitimacy_scan', + canonicalUsd: 0.25, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, + + // CACHE_ONLY, BUILT BUT BLOCKED (E1-C, spec §2.4, Forces ruling B-1, Invariant #34): $0.10 + // trust rung. Never live-computed regardless of the disable flag's state — the flag controls + // whether the ROUTE is reachable at all, not this offering's computeClass floor. `enabled: true` + // here is deliberate — it IS a real, priced offering; its own separate runtime flag + // (@grey/x402-middleware's trustRungEnabled()) is what gates route/listing reachability, not + // this static table. Don't conflate the two disable mechanisms. + legitimacy_scan_trust_rung: { + slug: 'legitimacy_scan_trust_rung', + canonicalUsd: 0.1, + computeClass: 'CACHE_ONLY', + enabled: true, + }, + verify_whitepaper: { + slug: 'verify_whitepaper', + canonicalUsd: 1.5, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, + verify_full_tech: { + slug: 'verify_full_tech', + canonicalUsd: 3.0, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, + claim_extraction: { + slug: 'claim_extraction', + canonicalUsd: 0.75, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, // CACHE_ONLY — structurally never reach cacheOrLive today (no call site passes them). - claim_history: { slug: 'claim_history', canonicalUsd: 0.25, computeClass: 'CACHE_ONLY' }, + claim_history: { + slug: 'claim_history', + canonicalUsd: 0.25, + computeClass: 'CACHE_ONLY', + enabled: true, + }, quick_protocol_facts: { slug: 'quick_protocol_facts', canonicalUsd: 0.3, computeClass: 'CACHE_ONLY', + enabled: true, + }, + daily_tech_brief: { + slug: 'daily_tech_brief', + canonicalUsd: 8.0, + computeClass: 'CACHE_ONLY', + enabled: true, }, - daily_tech_brief: { slug: 'daily_tech_brief', canonicalUsd: 8.0, computeClass: 'CACHE_ONLY' }, - // UNPRICED — flagged for Desktop (E1-A directive); do not invent a number. + // NOT YET OFFERED (merge-prep ruling) — no price, not for sale, pending usage data. daily_greenlight_list: { slug: 'daily_greenlight_list', canonicalUsd: null, computeClass: 'CACHE_ONLY', + enabled: false, + }, + scam_alert_feed: { + slug: 'scam_alert_feed', + canonicalUsd: null, + computeClass: 'CACHE_ONLY', + enabled: false, }, - scam_alert_feed: { slug: 'scam_alert_feed', canonicalUsd: null, computeClass: 'CACHE_ONLY' }, }; /** Spec §2.3: x402/Base and Virtuals ACP are both grandfathered at 1.00× — no repricing. */ @@ -43,6 +99,12 @@ export function computeClassFor(slug: OfferingSlug): ComputeClass { return PRICING_TABLE[slug].computeClass; } +/** Merge-prep: whether this offering is actually for sale. `false` means "not yet offered, + * period" (not a pricing gap) — the single source every listing/discovery surface checks. */ +export function isEnabled(slug: OfferingSlug): boolean { + return PRICING_TABLE[slug].enabled; +} + export function networkMultiplierFor(channel: Channel): number { return NETWORK_MULTIPLIER[channel]; } @@ -52,7 +114,7 @@ export function canonicalUsdFor(slug: OfferingSlug): number { const v = PRICING_TABLE[slug].canonicalUsd; if (v === null) { throw new Error( - `pricing: "${slug}" has no canonical price yet (flagged for Desktop, see E1-A PR)`, + `pricing: "${slug}" has no canonical price (not yet offered — see PRICING_TABLE.enabled)`, ); } return v; diff --git a/packages/grey-schemas/src/pricing/types.ts b/packages/grey-schemas/src/pricing/types.ts index a41e6fc..27cf11a 100644 --- a/packages/grey-schemas/src/pricing/types.ts +++ b/packages/grey-schemas/src/pricing/types.ts @@ -9,13 +9,19 @@ import type { OfferingSlug } from '../responses/types'; export type ComputeClass = 'CACHE_ONLY' | 'LIVE_ALLOWED' | 'LIVE_PRIORITY'; /** - * One canonical USD price per offering (spec §2.3, Invariant #31) — channel-agnostic. `null` - * means no canonical price has been sourced yet (flagged for Desktop, not a value to invent). + * One canonical USD price per offering (spec §2.3, Invariant #31) — channel-agnostic. + * + * `enabled: false` (merge-prep ruling, Forces 2026-07-26 session) means the offering is not being + * sold yet, period — a deliberate not-yet-offered status, not a pricing gap. `canonicalUsd` stays + * `null` for a disabled offering; don't invent a price for one that isn't for sale. Toggle-on is a + * separate, later Forces decision (needs daily-customer usage data first per the ruling) — this + * field is not something Kov or Bion flips. */ export interface OfferingPricing { readonly slug: OfferingSlug; readonly canonicalUsd: number | null; readonly computeClass: ComputeClass; + readonly enabled: boolean; } /** A channel this canonical price is realised on. Grows with each expansion (E2 Kite, E3 Olas, ...). */ diff --git a/packages/grey-schemas/src/requests/index.ts b/packages/grey-schemas/src/requests/index.ts index 7d06332..0c98f55 100644 --- a/packages/grey-schemas/src/requests/index.ts +++ b/packages/grey-schemas/src/requests/index.ts @@ -4,6 +4,7 @@ // Pattern 6 convention, though request schemas have no shared $defs so no inlining collision. export type { LegitimacyScanRequest, + LegitimacyScanTrustRungRequest, VerifyWhitepaperRequest, VerifyFullTechRequest, DailyTechBriefRequest, diff --git a/packages/grey-schemas/src/requests/types.ts b/packages/grey-schemas/src/requests/types.ts index 8a4fdf5..7466430 100644 --- a/packages/grey-schemas/src/requests/types.ts +++ b/packages/grey-schemas/src/requests/types.ts @@ -19,6 +19,13 @@ export interface LegitimacyScanRequest { project_name?: string; } +/** E1-C trust rung — same identifier shape as legitimacy_scan. BUILT BUT BLOCKED (see + * @grey/x402-middleware's trustRung.ts); this type existing does not mean the route is live. */ +export interface LegitimacyScanTrustRungRequest { + token_address: string; + project_name?: string; +} + export interface VerifyWhitepaperRequest { token_address: string; project_name?: string; @@ -64,7 +71,9 @@ export type ComputeOfferingSlug = /** Maps a paid offering slug to its hand-authored request interface (the cacheOrLive input seam). */ export type RequestFor = O extends 'legitimacy_scan' ? LegitimacyScanRequest - : O extends 'verify_whitepaper' + : O extends 'legitimacy_scan_trust_rung' + ? LegitimacyScanTrustRungRequest + : O extends 'verify_whitepaper' ? VerifyWhitepaperRequest : O extends 'verify_full_tech' ? VerifyFullTechRequest diff --git a/packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json b/packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json new file mode 100644 index 0000000..239252e --- /dev/null +++ b/packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.whitepapergrey.com/v1/requests/legitimacy_scan_trust_rung.schema.json", + "title": "LegitimacyScanTrustRungRequest", + "description": "Request body for the trust-rung offering (E1-C). Same identifier shape as legitimacy_scan.", + "type": "object", + "properties": { + "token_address": { "type": "string" }, + "project_name": { "type": "string" } + }, + "required": ["token_address"], + "additionalProperties": false +} diff --git a/packages/grey-schemas/src/responses/index.ts b/packages/grey-schemas/src/responses/index.ts index 1bbb7e7..df8d8ac 100644 --- a/packages/grey-schemas/src/responses/index.ts +++ b/packages/grey-schemas/src/responses/index.ts @@ -6,6 +6,7 @@ // re-declares LegitimacyScanResponse/DiscoveryAttempt), so a wildcard re-export across files // would collide on the shared names. The shared $def types come from `_shared` once. export type { LegitimacyScanResponse } from '../generated/v1/LegitimacyScanResponse'; +export type { LegitimacyScanTrustRungResponse } from '../generated/v1/LegitimacyScanTrustRungResponse'; export type { VerifyWhitepaperResponse } from '../generated/v1/VerifyWhitepaperResponse'; export type { VerifyFullTechResponse } from '../generated/v1/VerifyFullTechResponse'; export type { ClaimExtractionResponse } from '../generated/v1/ClaimExtractionResponse'; diff --git a/packages/grey-schemas/src/responses/types.ts b/packages/grey-schemas/src/responses/types.ts index 0c738bd..d489c63 100644 --- a/packages/grey-schemas/src/responses/types.ts +++ b/packages/grey-schemas/src/responses/types.ts @@ -7,6 +7,7 @@ // schema-generated *Response type (NOT the pipeline domain *Report types in src/index.ts). import type { LegitimacyScanResponse } from '../generated/v1/LegitimacyScanResponse'; +import type { LegitimacyScanTrustRungResponse } from '../generated/v1/LegitimacyScanTrustRungResponse'; import type { VerifyWhitepaperResponse } from '../generated/v1/VerifyWhitepaperResponse'; import type { VerifyFullTechResponse } from '../generated/v1/VerifyFullTechResponse'; import type { ClaimExtractionResponse } from '../generated/v1/ClaimExtractionResponse'; @@ -16,9 +17,12 @@ import type { DailyTechBriefResponse } from '../generated/v1/DailyTechBriefRespo import type { DailyGreenlightListResponse } from '../generated/v1/DailyGreenlightListResponse'; import type { ScamAlertFeedResponse } from '../generated/v1/ScamAlertFeedResponse'; -/** All 9 ratified offering slugs (canonical, matches the `offering` discriminator + validators map). */ +/** All 10 ratified offering slugs (canonical, matches the `offering` discriminator + validators + * map). `legitimacy_scan_trust_rung` (E1-C) is BUILT BUT BLOCKED — see @grey/x402-middleware's + * trustRung.ts for the hard default-off disable flag; this type existing does not mean live. */ export type OfferingSlug = | 'legitimacy_scan' + | 'legitimacy_scan_trust_rung' | 'verify_whitepaper' | 'verify_full_tech' | 'claim_extraction' @@ -28,13 +32,15 @@ export type OfferingSlug = | 'daily_greenlight_list' | 'scam_alert_feed'; -/** The 7 paid offerings (request-body-bearing). Excludes the 2 free GET resources (FDQ-10). */ +/** The 8 paid offerings (request-body-bearing). Excludes the 2 free GET resources (FDQ-10). */ export type PaidOfferingSlug = Exclude; /** Maps an offering slug to its schema-generated response payload type. */ export type ResponseFor = O extends 'legitimacy_scan' ? LegitimacyScanResponse - : O extends 'verify_whitepaper' + : O extends 'legitimacy_scan_trust_rung' + ? LegitimacyScanTrustRungResponse + : O extends 'verify_whitepaper' ? VerifyWhitepaperResponse : O extends 'verify_full_tech' ? VerifyFullTechResponse diff --git a/packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json b/packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json new file mode 100644 index 0000000..e20136d --- /dev/null +++ b/packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.whitepapergrey.com/v1/legitimacy_scan_trust_rung.schema.json", + "title": "LegitimacyScanTrustRungResponse", + "description": "Response body for the $0.10 CACHE_ONLY trust-rung offering (E1-C, spec §2.4). A cheap teaser of legitimacy_scan's verdict, never live-computed — BUILT BUT BLOCKED from live exposure on every channel per Forces ruling B-1, Invariant #34.", + "type": "object", + "properties": { + "projectName": { "type": "string" }, + "tokenAddress": { "type": ["string", "null"] }, + "verdict": { + "type": "string", + "enum": ["PASS", "CONDITIONAL", "FAIL", "INSUFFICIENT_DATA", "NOT_IN_DATABASE"] + }, + "generatedAt": { "type": "string" }, + "note": { "type": "string" } + }, + "required": ["projectName", "tokenAddress", "verdict", "generatedAt", "note"], + "additionalProperties": false +} diff --git a/packages/grey-schemas/src/validators/index.ts b/packages/grey-schemas/src/validators/index.ts index 0fd04cd..39f8a13 100644 --- a/packages/grey-schemas/src/validators/index.ts +++ b/packages/grey-schemas/src/validators/index.ts @@ -10,6 +10,7 @@ import type { PaidOfferingSlug } from '../responses/types'; import sharedSchema from '../responses/v1/_shared.schema.json'; import legitimacyScanSchema from '../responses/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungSchema from '../responses/v1/legitimacy_scan_trust_rung.schema.json'; import verifyWhitepaperSchema from '../responses/v1/verify_whitepaper.schema.json'; import verifyFullTechSchema from '../responses/v1/verify_full_tech.schema.json'; import claimExtractionSchema from '../responses/v1/claim_extraction.schema.json'; @@ -22,6 +23,7 @@ import envelopeSchema from '../responses/v1/envelope.schema.json'; // M3 (Q7): request-body schemas for the 7 paid offerings (FDQ-10 — the 2 free GETs take no body). import legitimacyScanRequestSchema from '../requests/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungRequestSchema from '../requests/v1/legitimacy_scan_trust_rung.schema.json'; import verifyWhitepaperRequestSchema from '../requests/v1/verify_whitepaper.schema.json'; import verifyFullTechRequestSchema from '../requests/v1/verify_full_tech.schema.json'; import claimExtractionRequestSchema from '../requests/v1/claim_extraction.schema.json'; @@ -41,6 +43,7 @@ addFormats(ajv); ajv.addSchema([ sharedSchema, legitimacyScanSchema, + legitimacyScanTrustRungSchema, verifyWhitepaperSchema, verifyFullTechSchema, claimExtractionSchema, @@ -52,6 +55,7 @@ ajv.addSchema([ envelopeSchema, // M3 request schemas (distinct $id namespace: .../v1/requests/). legitimacyScanRequestSchema, + legitimacyScanTrustRungRequestSchema, verifyWhitepaperRequestSchema, verifyFullTechRequestSchema, claimExtractionRequestSchema, @@ -74,6 +78,7 @@ function compiledRequest(file: string): ValidateFunction { // Per-offering payload validators (validate the inner response shape). export const legitimacyScanValidator = compiled('legitimacy_scan.schema.json'); +export const legitimacyScanTrustRungValidator = compiled('legitimacy_scan_trust_rung.schema.json'); export const verifyWhitepaperValidator = compiled('verify_whitepaper.schema.json'); export const verifyFullTechValidator = compiled('verify_full_tech.schema.json'); export const claimExtractionValidator = compiled('claim_extraction.schema.json'); @@ -89,6 +94,7 @@ export const envelopeValidator = compiled('envelope.schema.json'); /** Per-offering validator lookup by canonical slug. */ export const offeringValidators: Record = { legitimacy_scan: legitimacyScanValidator, + legitimacy_scan_trust_rung: legitimacyScanTrustRungValidator, verify_whitepaper: verifyWhitepaperValidator, verify_full_tech: verifyFullTechValidator, claim_extraction: claimExtractionValidator, @@ -104,6 +110,7 @@ export const offeringValidators: Record = { // delegates request-body validation to these (no second ajv instance — HC#12). export const legitimacyScanRequestValidator = compiledRequest('legitimacy_scan.schema.json'); +export const legitimacyScanTrustRungRequestValidator = compiledRequest('legitimacy_scan_trust_rung.schema.json'); export const verifyWhitepaperRequestValidator = compiledRequest('verify_whitepaper.schema.json'); export const verifyFullTechRequestValidator = compiledRequest('verify_full_tech.schema.json'); export const claimExtractionRequestValidator = compiledRequest('claim_extraction.schema.json'); @@ -111,9 +118,10 @@ export const claimHistoryRequestValidator = compiledRequest('claim_history.schem export const quickProtocolFactsRequestValidator = compiledRequest('quick_protocol_facts.schema.json'); export const dailyTechBriefRequestValidator = compiledRequest('daily_tech_brief.schema.json'); -/** Per-paid-offering request-body validator lookup (7 entries; the 2 free GETs have no body — FDQ-10). */ +/** Per-paid-offering request-body validator lookup (8 entries; the 2 free GETs have no body — FDQ-10). */ export const offeringRequestValidators: Record = { legitimacy_scan: legitimacyScanRequestValidator, + legitimacy_scan_trust_rung: legitimacyScanTrustRungRequestValidator, verify_whitepaper: verifyWhitepaperRequestValidator, verify_full_tech: verifyFullTechRequestValidator, claim_extraction: claimExtractionRequestValidator, diff --git a/packages/grey-schemas/test/evaluationKit.samples.test.ts b/packages/grey-schemas/test/evaluationKit.samples.test.ts new file mode 100644 index 0000000..3ce3707 --- /dev/null +++ b/packages/grey-schemas/test/evaluationKit.samples.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import type { OfferingSlug, PaidOfferingSlug } from '../src/responses/types'; +import { EVALUATION_SAMPLES, buildEvaluationArtifact } from '../src/evaluationKit'; +import { offeringValidators, offeringRequestValidators } from '../src/validators'; + +const PAID: PaidOfferingSlug[] = [ + 'legitimacy_scan', + 'legitimacy_scan_trust_rung', + 'verify_whitepaper', + 'verify_full_tech', + 'claim_extraction', + 'claim_history', + 'quick_protocol_facts', + 'daily_tech_brief', +]; +const FREE: OfferingSlug[] = ['daily_greenlight_list', 'scam_alert_feed']; + +// E1-C: an evaluating agent reads these before ever paying — they must be real, checkable +// artifacts, not illustrative-but-wrong shapes. Validate against the SAME ajv instances the live +// routes use, not a hand-eyeballed check. +describe('EvaluationKit samples — schema-valid evaluation artifacts (E1-C)', () => { + it.each(PAID)('%s sample request validates against its request schema', (slug) => { + const validate = offeringRequestValidators[slug]; + const ok = validate(EVALUATION_SAMPLES[slug].request); + expect(ok, JSON.stringify(validate.errors)).toBe(true); + }); + + it.each([...PAID, ...FREE])('%s sample response validates against its payload schema', (slug) => { + const validate = offeringValidators[slug]; + const ok = validate(EVALUATION_SAMPLES[slug].response); + expect(ok, JSON.stringify(validate.errors)).toBe(true); + }); + + it.each([...PAID, ...FREE])( + '%s buildEvaluationArtifact attaches the validated sample', + (slug) => { + const artifact = buildEvaluationArtifact(slug); + expect(artifact.sample).toEqual(EVALUATION_SAMPLES[slug]); + }, + ); + + it('buildAllEvaluationKits (the lean list/402 projection) carries no sample', () => { + // covered indirectly: buildEvaluationKit without opts.sample leaves it undefined. + const artifact = buildEvaluationArtifact('legitimacy_scan'); + expect(artifact.sample).toBeDefined(); + }); +}); diff --git a/packages/grey-schemas/test/evaluationKit.test.ts b/packages/grey-schemas/test/evaluationKit.test.ts new file mode 100644 index 0000000..969df2b --- /dev/null +++ b/packages/grey-schemas/test/evaluationKit.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import type { OfferingSlug } from '../src/responses/types'; +import { + buildEvaluationKit, + buildAllEvaluationKits, + EVALUATION_KIT_BRANDING, +} from '../src/evaluationKit'; + +describe('EvaluationKit — Bazaar extension projection (E1-B, Invariant #33)', () => { + it('projects all 10 offerings with the spec field list', () => { + const kits = buildAllEvaluationKits(); + expect(kits).toHaveLength(10); + for (const k of kits) { + expect(typeof k.description).toBe('string'); + expect(k.outputSchema).toBeTruthy(); + expect(Array.isArray(k.tags)).toBe(true); + expect(Array.isArray(k.dropped)).toBe(true); + } + }); + + it('discoverable tracks PRICING_TABLE.enabled — 8 enabled, 2 not-yet-offered (merge-prep ruling)', () => { + const kits = buildAllEvaluationKits(); + const notYetOffered = kits.filter((k) => !k.discoverable).map((k) => k.slug); + expect(notYetOffered.sort()).toEqual(['daily_greenlight_list', 'scam_alert_feed']); + expect(kits.filter((k) => k.discoverable)).toHaveLength(8); + }); + + it('the 7 paid offerings carry an inputSchema; the 2 free resources do not', () => { + const kit = (slug: OfferingSlug) => buildEvaluationKit(slug); + expect(kit('legitimacy_scan').inputSchema).toBeTruthy(); + expect(kit('daily_tech_brief').inputSchema).toBeTruthy(); + expect(kit('daily_greenlight_list').inputSchema).toBeNull(); + expect(kit('scam_alert_feed').inputSchema).toBeNull(); + }); + + it('carries priceUsd + computeClass from the canonical pricing table (single source)', () => { + const scan = buildEvaluationKit('legitimacy_scan'); + expect(scan.priceUsd).toBe(0.25); + expect(scan.computeClass).toBe('LIVE_ALLOWED'); + + const facts = buildEvaluationKit('quick_protocol_facts'); + expect(facts.computeClass).toBe('CACHE_ONLY'); + }); + + it('priceUsd is null for an unpriced offering (flagged, not invented)', () => { + const feed = buildEvaluationKit('scam_alert_feed'); + expect(feed.priceUsd).toBeNull(); + }); + + it('every branded serviceName/tag/iconUrl passes validation as authored (no drops in the real data)', () => { + for (const kit of buildAllEvaluationKits()) { + expect(kit.dropped).toEqual([]); + expect(kit.serviceName).not.toBeNull(); + expect(kit.iconUrl).not.toBeNull(); + } + }); + + it('soft-drops a non-ASCII serviceName instead of throwing', () => { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.serviceName; + // @ts-expect-error — deliberately mutate the branding table to simulate a bad field for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.serviceName = 'Légitimacy Scan'; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.serviceName).toBeNull(); + expect(kit.dropped).toContainEqual({ field: 'serviceName', reason: 'not printable ASCII' }); + // the entry still ships — soft-drop, not a thrown error or a missing listing. + expect(kit.discoverable).toBe(true); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.serviceName = original; + } + }); + + it('soft-drops a non-https iconUrl', () => { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl; + // @ts-expect-error — deliberate bad value for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = 'http://whitepapergrey.com/icons/x.svg'; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.iconUrl).toBeNull(); + expect(kit.dropped.some((d) => d.field === 'iconUrl')).toBe(true); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = original; + } + }); + + it('soft-drops an IP-literal or loopback iconUrl', () => { + for (const bad of [ + 'https://127.0.0.1/icon.svg', + 'https://localhost/icon.svg', + 'https://192.168.1.5/icon.svg', + ]) { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl; + // @ts-expect-error — deliberate bad value for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = bad; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.iconUrl, bad).toBeNull(); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = original; + } + } + }); + + it('soft-drops one bad tag but keeps the rest', () => { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.tags; + // @ts-expect-error — deliberate bad value for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.tags = ['crypto', 'tïer1', 'verification']; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.tags).toEqual(['crypto', 'verification']); + expect(kit.dropped.some((d) => d.field.startsWith('tags['))).toBe(true); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.tags = original; + } + }); +}); diff --git a/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json new file mode 100644 index 0000000..5294874 --- /dev/null +++ b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json @@ -0,0 +1 @@ +{ "project_name": "Uniswap" } diff --git a/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json new file mode 100644 index 0000000..16e90d3 --- /dev/null +++ b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json @@ -0,0 +1 @@ +{ "token_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", "project_name": "Uniswap" } diff --git a/packages/grey-schemas/test/pricing.test.ts b/packages/grey-schemas/test/pricing.test.ts index e382a01..7048b0e 100644 --- a/packages/grey-schemas/test/pricing.test.ts +++ b/packages/grey-schemas/test/pricing.test.ts @@ -4,6 +4,7 @@ import { PRICING_TABLE, NETWORK_MULTIPLIER, computeClassFor, + isEnabled, networkMultiplierFor, canonicalUsdFor, resolvePriceUsd, @@ -11,6 +12,7 @@ import { const ALL_SLUGS: OfferingSlug[] = [ 'legitimacy_scan', + 'legitimacy_scan_trust_rung', 'verify_whitepaper', 'verify_full_tech', 'claim_extraction', @@ -28,7 +30,10 @@ const LIVE_ALLOWED: OfferingSlug[] = [ 'claim_extraction', ]; +// E1-C: legitimacy_scan_trust_rung is CACHE_ONLY too — BUILT BUT BLOCKED (Forces ruling B-1), +// never live-computed regardless of the disable flag's state (see @grey/x402-middleware/trustRung). const CACHE_ONLY: OfferingSlug[] = [ + 'legitimacy_scan_trust_rung', 'claim_history', 'quick_protocol_facts', 'daily_tech_brief', @@ -39,7 +44,7 @@ const CACHE_ONLY: OfferingSlug[] = [ const UNPRICED: OfferingSlug[] = ['daily_greenlight_list', 'scam_alert_feed']; describe('pricing — computeClass + canonical table (E1-A, Invariant #30/#31)', () => { - it('classifies all 9 offerings, matching cacheOrLive reachability', () => { + it('classifies all 10 offerings, matching cacheOrLive reachability', () => { expect(Object.keys(PRICING_TABLE).sort()).toEqual([...ALL_SLUGS].sort()); for (const slug of LIVE_ALLOWED) expect(computeClassFor(slug)).toBe('LIVE_ALLOWED'); for (const slug of CACHE_ONLY) expect(computeClassFor(slug)).toBe('CACHE_ONLY'); @@ -64,6 +69,11 @@ describe('pricing — computeClass + canonical table (E1-A, Invariant #30/#31)', } }); + it('the trust rung canonicalizes at $0.10, CACHE_ONLY (E1-C, spec §2.4)', () => { + expect(canonicalUsdFor('legitimacy_scan_trust_rung')).toBe(0.1); + expect(computeClassFor('legitimacy_scan_trust_rung')).toBe('CACHE_ONLY'); + }); + it('leaves the 2 unpriced offerings flagged (null), not invented', () => { for (const slug of UNPRICED) { expect(PRICING_TABLE[slug].canonicalUsd).toBeNull(); @@ -71,6 +81,14 @@ describe('pricing — computeClass + canonical table (E1-A, Invariant #30/#31)', } }); + it('merge-prep ruling: the 2 unpriced offerings are enabled:false (not-yet-offered, not a pricing gap) — 7 priced + 2 disabled = 9', () => { + const disabled = ALL_SLUGS.filter((slug) => !isEnabled(slug)); + expect(disabled.sort()).toEqual(['daily_greenlight_list', 'scam_alert_feed']); + const enabled = ALL_SLUGS.filter((slug) => isEnabled(slug)); + expect(enabled).toHaveLength(8); // 7 priced + the trust rung (priced, gated by its own runtime flag) + expect(isEnabled('legitimacy_scan_trust_rung')).toBe(true); // static table says "real offering"; route reachability is a separate runtime flag + }); + it('networkMultiplier resolves to 1.00 for both live channels today (Invariant #31)', () => { expect(NETWORK_MULTIPLIER.x402).toBe(1.0); expect(NETWORK_MULTIPLIER.acp).toBe(1.0); diff --git a/packages/grey-schemas/test/request-field-drift.test.ts b/packages/grey-schemas/test/request-field-drift.test.ts index a02f0ce..4c08bfd 100644 --- a/packages/grey-schemas/test/request-field-drift.test.ts +++ b/packages/grey-schemas/test/request-field-drift.test.ts @@ -8,7 +8,7 @@ // the interface (or vice versa) fails the test and/or typecheck, naming the drifted side. // Plus a validator round-trip on the per-offering valid/invalid fixtures. // -// Covers the 7 PAID offerings only (FDQ-10) — the 2 free GETs take no request body. +// Covers the 8 PAID offerings only (FDQ-10) — the 2 free GETs take no request body. import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -17,6 +17,7 @@ import { offeringRequestValidators } from '../src/validators'; import type { PaidOfferingSlug } from '../src/responses/types'; import type { LegitimacyScanRequest, + LegitimacyScanTrustRungRequest, VerifyWhitepaperRequest, VerifyFullTechRequest, DailyTechBriefRequest, @@ -39,6 +40,10 @@ const legitimacyScanKeys = ['token_address', 'project_name'] as const satisfies type _CkLegit = Exclude extends never ? true : never; const _ckLegit: _CkLegit = true; +const legitimacyScanTrustRungKeys = ['token_address', 'project_name'] as const satisfies readonly (keyof LegitimacyScanTrustRungRequest)[]; +type _CkTrustRung = Exclude extends never ? true : never; +const _ckTrustRung: _CkTrustRung = true; + const verifyWhitepaperKeys = ['token_address', 'project_name', 'document_url'] as const satisfies readonly (keyof VerifyWhitepaperRequest)[]; type _CkVw = Exclude extends never ? true : never; const _ckVw: _CkVw = true; @@ -65,6 +70,7 @@ const _ckCe: _CkCe = true; const mirrors: Record = { legitimacy_scan: legitimacyScanKeys, + legitimacy_scan_trust_rung: legitimacyScanTrustRungKeys, verify_whitepaper: verifyWhitepaperKeys, verify_full_tech: verifyFullTechKeys, daily_tech_brief: dailyTechBriefKeys, @@ -77,8 +83,8 @@ const paidSlugs = Object.keys(mirrors) as PaidOfferingSlug[]; describe('request-field-drift (Pattern 4b): schema properties ≡ keyof Interface', () => { it('compile-time mirror guards hold', () => { - expect([_ckLegit, _ckVw, _ckVft, _ckDtb, _ckCh, _ckQpf, _ckCe]).toEqual([ - true, true, true, true, true, true, true, + expect([_ckLegit, _ckTrustRung, _ckVw, _ckVft, _ckDtb, _ckCh, _ckQpf, _ckCe]).toEqual([ + true, true, true, true, true, true, true, true, ]); }); diff --git a/supabase/migrations/20260730150000_create_grey_two_revenue_events.sql b/supabase/migrations/20260730150000_create_grey_two_revenue_events.sql new file mode 100644 index 0000000..0e79946 --- /dev/null +++ b/supabase/migrations/20260730150000_create_grey_two_revenue_events.sql @@ -0,0 +1,36 @@ +-- 20260730150000_create_grey_two_revenue_events.sql +-- E1-F (Expansion Round 2, sub-unit 4) — margin instrumentation, revenue side. One new +-- APPEND-ONLY grey_two table: one row per settled payment, attributed by channel x offering +-- (spec S2.6/S3 E1-F). Pairs with the existing grey_two.cost_events (compute spend) to compute +-- realized margin; cost_events has no channel dimension (compute cost is channel-agnostic -- +-- see packages/grey-pipeline/src/persistence/repositories.ts's computeMarginReport() for the +-- deliberate scoping note on why cost is attributed per-offering, not per-channel, this round). +-- Zero contact with any existing grey_two table or any wpv_*. +-- +-- FDQ-52/FDQ-65 GRANT POSTURE: append-only, same as cost_events/sweep_log/refuel_log -- the +-- runtime role (grey_pipeline_rw) gets INSERT+SELECT only. grey_two's ALTER DEFAULT PRIVILEGES +-- auto-grants UPDATE/DELETE to grey_pipeline_rw on every new table; this REVOKEs them explicitly +-- inside the same transaction as the CREATE TABLE (learned from refuel_log's FDQ-52 omission -- +-- do NOT split the REVOKE into a later corrective migration). +-- +-- EXECUTION: canonical path ONLY -- psql + WPV_DATABASE_URL, Forces-lane, then a ledger entry in +-- supabase/applied_migrations.md. NOT applied by this diff -- authored for Forces' apply. + +CREATE TABLE grey_two.revenue_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid REFERENCES grey_two.requests(id) ON DELETE SET NULL, + channel text NOT NULL, -- x402|acp (@grey/schemas/pricing Channel) + offering text NOT NULL, -- OfferingSlug + revenue_usd numeric(12,6) NOT NULL DEFAULT 0, + settled_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX grey_revenue_channel_offering_idx ON grey_two.revenue_events (channel, offering); +CREATE INDEX grey_revenue_settled_at_idx ON grey_two.revenue_events (settled_at); +CREATE INDEX grey_revenue_request_idx ON grey_two.revenue_events (request_id); + +GRANT USAGE ON SCHEMA grey_two TO grey_pipeline_rw; +GRANT SELECT, INSERT ON grey_two.revenue_events TO grey_pipeline_rw; +REVOKE UPDATE, DELETE, TRUNCATE ON grey_two.revenue_events FROM grey_pipeline_rw; + +COMMENT ON TABLE grey_two.revenue_events IS + 'E1-F: one row per settled payment (channel x offering x revenueUsd), written at settlement time by grey-core (x402 HTTP routes, the MCP surface) after a successful settle(). APPEND-ONLY for the runtime role (INSERT+SELECT; mirrors cost_events/refuel_log FDQ-52 posture).'; diff --git a/vitest.config.ts b/vitest.config.ts index ad8ceec..a69f93e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ { find: /^@grey\/schemas\/envelope$/, replacement: src('./packages/grey-schemas/src/envelope/index.ts') }, { find: /^@grey\/schemas\/validators$/, replacement: src('./packages/grey-schemas/src/validators/index.ts') }, { find: /^@grey\/schemas\/pricing$/, replacement: src('./packages/grey-schemas/src/pricing/index.ts') }, + { find: /^@grey\/schemas\/evaluationKit$/, replacement: src('./packages/grey-schemas/src/evaluationKit/index.ts') }, { find: /^@grey\/pipeline$/, replacement: src('./packages/grey-pipeline/src/index.ts') }, { find: /^@grey\/x402-middleware$/, replacement: src('./adapters/x402-middleware/src/index.ts') }, ],