Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion adapters/x402-middleware/src/challenge.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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: [
Expand All @@ -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,
},
},
},
],
};
Expand Down
8 changes: 8 additions & 0 deletions adapters/x402-middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
149 changes: 149 additions & 0 deletions adapters/x402-middleware/src/trustRung.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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));
};
}
19 changes: 17 additions & 2 deletions adapters/x402-middleware/src/types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
29 changes: 23 additions & 6 deletions adapters/x402-middleware/test/challenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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');
});
});
38 changes: 32 additions & 6 deletions adapters/x402-middleware/test/preHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof mockWallet>; publicClient: ReturnType<typeof mockPublicClient> }) {
const h = makeX402PreHandler(TEST_CFG, { wallet: clients.wallet, publicClient: clients.publicClient, now });
function gate(clients: {
wallet: ReturnType<typeof mockWallet>;
publicClient: ReturnType<typeof mockPublicClient>;
}) {
const h = makeX402PreHandler(TEST_CFG, {
wallet: clients.wallet,
publicClient: clients.publicClient,
now,
});
return h as unknown as (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}

Expand All @@ -53,14 +64,23 @@ 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', () => {
it('402 + requirements when X-PAYMENT is absent', async () => {
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 () => {
Expand All @@ -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');
});
Expand Down Expand Up @@ -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();
Expand Down
Loading