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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions adapters/acp-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@grey/core": "workspace:*",
"@grey/pipeline": "workspace:*",
"@grey/schemas": "workspace:*",
"@grey/x402-middleware": "workspace:*",
"@virtuals-protocol/acp-node-v2": "^0.0.4",
"pg": "^8.22.0",
Expand Down
10 changes: 6 additions & 4 deletions adapters/acp-adapter/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { offeringHandlers, createHandlerDeps } from '@grey/core';
import { PAID_SLUGS, priceUsdFor } from '@grey/x402-middleware';
import { PAID_SLUGS } from '@grey/x402-middleware';
import { loadConfig } from './config.js';
import { priceUsdForAcp } from './pricing.js';
import { AcpAdapter } from './acpAdapter.js';
import { createRealSdkBundle } from './sdk.js';
import { createLogger } from './logger.js';
Expand Down Expand Up @@ -60,10 +61,11 @@ async function main(): Promise<void> {
reputationReconciler,
});

// Register the 7 paid offerings from the single price source (invariant #20), BEFORE start() —
// no boot-buffer needed (one process; no cross-plugin registration race).
// Register the 7 paid offerings from the single canonical price source (Invariant #31),
// resolved through ACP's own networkMultiplier — BEFORE start() (one process; no cross-plugin
// registration race).
for (const slug of PAID_SLUGS) {
adapter.registerOffering({ slug, priceUsd: priceUsdFor(slug) });
adapter.registerOffering({ slug, priceUsd: priceUsdForAcp(slug) });
}

log.info('acp-adapter: starting', {
Expand Down
14 changes: 14 additions & 0 deletions adapters/acp-adapter/src/pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// ACP's own price resolver (E1-A / Invariant #31) — resolves the ACP channel's networkMultiplier
// (1.00×, grandfathered, no repricing) against @grey/schemas/pricing, the single canonical source.
// Mirrors adapters/x402-middleware/src/prices.ts's resolution, kept separate per-adapter so a
// future channel-specific multiplier (this file's `acp` vs. x402's `x402`) is a config entry in
// @grey/schemas, not new code in either adapter.
import type { PaidOfferingSlug } from '@grey/schemas/responses';
import { resolvePriceUsd } from '@grey/schemas/pricing';

const CHANNEL = 'acp' as const;

/** USD price for a paid offering on the ACP channel. Throws on an unpriced offering (fail-closed). */
export function priceUsdForAcp(slug: PaidOfferingSlug): number {
return resolvePriceUsd(slug, CHANNEL);
}
23 changes: 23 additions & 0 deletions adapters/acp-adapter/test/pricing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { priceUsdForAcp } from '../src/pricing.js';

// E1-A: ACP resolves its own channel multiplier (1.00×, grandfathered) against the canonical
// table in @grey/schemas/pricing — same values as before, now derived rather than hardcoded.
const EXPECTED: Record<string, number> = {
legitimacy_scan: 0.25,
verify_whitepaper: 1.5,
verify_full_tech: 3.0,
claim_extraction: 0.75,
claim_history: 0.25,
quick_protocol_facts: 0.3,
daily_tech_brief: 8.0,
};

describe('priceUsdForAcp — ACP adapter boundary price resolution (Invariant #31)', () => {
it.each(Object.entries(EXPECTED))(
'%s resolves to the grandfathered 1.00× canonical price',
(slug, usd) => {
expect(priceUsdForAcp(slug as never)).toBe(usd);
},
);
});
1 change: 1 addition & 0 deletions adapters/x402-middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test": "vitest run"
},
"dependencies": {
"@grey/schemas": "workspace:*",
"fastify": "^5.8.5",
"viem": "^2.53.1"
}
Expand Down
69 changes: 44 additions & 25 deletions adapters/x402-middleware/src/prices.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,48 @@
// THE single price source (invariant #20). Every route price, the 402 `maxAmountRequired`,
// and grey-core's envelope `costUsd` derive from this one table. No price literal lives
// anywhere else. USD values are the authoritative OpenAPI `x-x402-pricing`; atomic units are
// USDC (6 decimals). Kept as a string USD label + bigint atomic so no float rounding creeps in.
// x402's price resolver (E1-A / Invariant #31). @grey/schemas/pricing is THE single canonical
// price source (supersedes this file's old PRICE_TABLE literal, invariant #20 → #31); this file
// resolves x402's networkMultiplier (1.00× today) at the adapter boundary and converts the
// resolved USD to USDC atomic units. No price literal lives here anymore — PRICE_TABLE below is
// derived, kept only so existing consumers (route registration, tests) don't need to change
// shape. Every route price, the 402 `maxAmountRequired`, and grey-core's envelope `costUsd`
// derive from this resolution. Atomic units are USDC (6 decimals).
import type { PaidOfferingSlug } from '@grey/schemas/responses';
import { resolvePriceUsd } from '@grey/schemas/pricing';
import type { X402Network, UsdcAsset } from './types.js';

export type PaidSlug =
| 'legitimacy_scan'
| 'verify_whitepaper'
| 'verify_full_tech'
| 'claim_extraction'
| 'claim_history'
| 'quick_protocol_facts'
| 'daily_tech_brief';

export const PRICE_TABLE: Record<PaidSlug, { readonly usd: string; readonly atomic: bigint }> = {
legitimacy_scan: { usd: '0.25', atomic: 250_000n },
verify_whitepaper: { usd: '1.50', atomic: 1_500_000n },
verify_full_tech: { usd: '3.00', atomic: 3_000_000n },
claim_extraction: { usd: '0.75', atomic: 750_000n },
claim_history: { usd: '0.25', atomic: 250_000n },
quick_protocol_facts: { usd: '0.30', atomic: 300_000n },
daily_tech_brief: { usd: '8.00', atomic: 8_000_000n },
};
export type PaidSlug = PaidOfferingSlug;

const CHANNEL = 'x402' as const;

/** USD → USDC atomic units (6-dec), rounded to avoid float drift (e.g. 0.30 * 1e6 in IEEE754). */
function toAtomic(usd: number): bigint {
return BigInt(Math.round(usd * 1_000_000));
}

function usdLabel(usd: number): string {
return usd.toFixed(2);
}

const PAID_SLUG_ORDER: PaidSlug[] = [
'legitimacy_scan',
'verify_whitepaper',
'verify_full_tech',
'claim_extraction',
'claim_history',
'quick_protocol_facts',
'daily_tech_brief',
];

/** Derived from @grey/schemas/pricing — NOT the source. Kept as a stable {usd,atomic} shape for
* existing consumers/tests; recomputed from the canonical table + x402's networkMultiplier. */
export const PRICE_TABLE: Record<PaidSlug, { readonly usd: string; readonly atomic: bigint }> =
Object.fromEntries(
PAID_SLUG_ORDER.map((slug) => {
const usd = resolvePriceUsd(slug, CHANNEL);
return [slug, { usd: usdLabel(usd), atomic: toAtomic(usd) }];
}),
) as Record<PaidSlug, { readonly usd: string; readonly atomic: bigint }>;

export const PAID_SLUGS = Object.keys(PRICE_TABLE) as PaidSlug[];
export const PAID_SLUGS = [...PAID_SLUG_ORDER];

export function isPaidSlug(slug: string): slug is PaidSlug {
return Object.prototype.hasOwnProperty.call(PRICE_TABLE, slug);
Expand All @@ -32,13 +51,13 @@ export function isPaidSlug(slug: string): slug is PaidSlug {
/** USDC atomic units (6-dec) required for a slug. Throws on unknown slug (fail-closed). */
export function priceAtomicFor(slug: string): bigint {
if (!isPaidSlug(slug)) throw new Error(`x402: no price for slug ${slug}`);
return PRICE_TABLE[slug].atomic;
return toAtomic(resolvePriceUsd(slug, CHANNEL));
}

/** USD price for a slug (grey-core envelope `costUsd`). Throws on unknown slug (fail-closed). */
export function priceUsdFor(slug: string): number {
if (!isPaidSlug(slug)) throw new Error(`x402: no price for slug ${slug}`);
return Number(PRICE_TABLE[slug].usd);
return resolvePriceUsd(slug as PaidSlug, CHANNEL);
}

/** Per-network USDC asset literals — the ONE place addresses + EIP-712 domains live.
Expand Down
11 changes: 11 additions & 0 deletions packages/grey-core/src/orchestration/cacheOrLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// (§16). The run variants (runL1/runL1L2/runFullPipeline) are ordinary pipeline exports. The sole
// sanctioned cross-boundary discovery-type reference lives in deps/index.ts (§15/§16).
import type { ComputeOfferingSlug, RequestFor, DiscoveryStatus, DiscoveryAttempt } from '@grey/schemas';
import { computeClassFor } from '@grey/schemas/pricing';
import {
runL1,
runL1L2,
Expand Down Expand Up @@ -102,6 +103,16 @@ export async function cacheOrLive<O extends ComputeOfferingSlug>(
input: RequestFor<O>,
deps: HandlerDeps,
): Promise<HandlerResult> {
// Invariant #30, defense-in-depth: `O extends ComputeOfferingSlug` already makes a CACHE_ONLY
// slug uncallable at compile time (only the 4 live-capable offerings satisfy that constraint) —
// this is the runtime fail-closed backstop against the class violation, same posture as
// Invariant #27's fail-open guards elsewhere, inverted (fail closed, not open).
const computeClass = computeClassFor(offering);
if (computeClass === 'CACHE_ONLY') {
throw new Error(
`cacheOrLive: refusing live compute for CACHE_ONLY offering "${offering}" (Invariant #30) — no offering may be served below its computeClass floor, including a paid retry.`,
);
}
const body = input as unknown as Record<string, unknown>;
const tokenAddress = (body.token_address as string | undefined) ?? null;
const projectName = body.project_name as string | undefined;
Expand Down
24 changes: 24 additions & 0 deletions packages/grey-core/test/cacheOrLive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,30 @@ describe('cacheOrLive — subject derivation', () => {
});
});

describe('cacheOrLive — Invariant #30 (CACHE_ONLY never triggers live compute)', () => {
const CACHE_ONLY_SLUGS = ['claim_history', 'quick_protocol_facts', 'daily_tech_brief', 'daily_greenlight_list', 'scam_alert_feed'] as const;

for (const slug of CACHE_ONLY_SLUGS) {
it(`${slug} is structurally unreachable from cacheOrLive() — compile-time AND runtime rejection`, async () => {
const deps = fakeDeps({ discover: discovered });
// The type system already makes this uncallable (O extends ComputeOfferingSlug excludes
// every CACHE_ONLY slug) — the cast below simulates a bypass to prove the runtime
// fail-closed assertion is the actual backstop, not just the type constraint.
await expect(
cacheOrLive(slug as never, { token_address: TOKEN } as never, deps),
).rejects.toThrow(/CACHE_ONLY/);
expect(runL1).not.toHaveBeenCalled();
expect(runL1L2).not.toHaveBeenCalled();
expect(runFullPipeline).not.toHaveBeenCalled();
});
}

it('rejects before any discovery/pipeline side effect runs, even on a "paid retry" shape', async () => {
const deps = fakeDeps({ discover: discovered });
await expect(cacheOrLive('scam_alert_feed' as never, {} as never, deps)).rejects.toThrow(/Invariant #30/);
});
});

describe('createHandlerDeps — M3.5 wiring sanity', () => {
it('constructs HandlerDeps carrying pipeline + discovery (§15)', () => {
const deps = createHandlerDeps({ databaseUrl: '' });
Expand Down
1 change: 1 addition & 0 deletions packages/grey-schemas/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ await buildPackage({
'src/requests/index.ts',
'src/envelope/index.ts',
'src/validators/index.ts',
'src/pricing/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' },
Expand Down
1 change: 1 addition & 0 deletions packages/grey-schemas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"./requests": { "types": "./dist/requests/index.d.ts", "default": "./dist/requests/index.js" },
"./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" },
"./openapi": "./openapi/openapi.yaml"
},
"files": ["dist", "openapi"],
Expand Down
10 changes: 10 additions & 0 deletions packages/grey-schemas/src/pricing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// @grey/schemas/pricing — computeClass + canonical pricing barrel (E1-A, Invariant #30/#31).
export type { ComputeClass, OfferingPricing, Channel } from './types';
export {
PRICING_TABLE,
NETWORK_MULTIPLIER,
computeClassFor,
networkMultiplierFor,
canonicalUsdFor,
resolvePriceUsd,
} from './table';
64 changes: 64 additions & 0 deletions packages/grey-schemas/src/pricing/table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @grey/schemas/pricing — the canonical price + computeClass table (E1-A). Supersedes
// adapters/x402-middleware/src/prices.ts's PRICE_TABLE as the single source (Invariant #31);
// 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.
import type { OfferingSlug } from '../responses/types';
import type { Channel, ComputeClass, OfferingPricing } from './types';

export const PRICING_TABLE: Record<OfferingSlug, OfferingPricing> = {
// 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' },

// CACHE_ONLY — structurally never reach cacheOrLive today (no call site passes them).
claim_history: { slug: 'claim_history', canonicalUsd: 0.25, computeClass: 'CACHE_ONLY' },
quick_protocol_facts: {
slug: 'quick_protocol_facts',
canonicalUsd: 0.3,
computeClass: 'CACHE_ONLY',
},
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.
daily_greenlight_list: {
slug: 'daily_greenlight_list',
canonicalUsd: null,
computeClass: 'CACHE_ONLY',
},
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. */
export const NETWORK_MULTIPLIER: Record<Channel, number> = {
x402: 1.0,
acp: 1.0,
};

export function computeClassFor(slug: OfferingSlug): ComputeClass {
return PRICING_TABLE[slug].computeClass;
}

export function networkMultiplierFor(channel: Channel): number {
return NETWORK_MULTIPLIER[channel];
}

/** Canonical USD price for a slug. Throws if unpriced (fail-closed — never silently 0/NaN). */
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)`,
);
}
return v;
}

/** Resolved USD price for a slug on a given channel: canonicalUsd × networkMultiplier. */
export function resolvePriceUsd(slug: OfferingSlug, channel: Channel): number {
return canonicalUsdFor(slug) * networkMultiplierFor(channel);
}
22 changes: 22 additions & 0 deletions packages/grey-schemas/src/pricing/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// @grey/schemas/pricing — computeClass + canonical pricing types (E1-A, Invariant #30/#31).
import type { OfferingSlug } from '../responses/types';

/**
* Anti-dilution class (spec §2.2, Invariant #30). CACHE_ONLY offerings may never trigger live
* compute, including on a paid retry — enforced at the `cacheOrLive` boundary in @grey/core.
* No offering currently carries LIVE_PRIORITY (no premium-queue variant exists yet).
*/
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).
*/
export interface OfferingPricing {
readonly slug: OfferingSlug;
readonly canonicalUsd: number | null;
readonly computeClass: ComputeClass;
}

/** A channel this canonical price is realised on. Grows with each expansion (E2 Kite, E3 Olas, ...). */
export type Channel = 'x402' | 'acp';
Loading