diff --git a/adapters/acp-adapter/README.md b/adapters/acp-adapter/README.md new file mode 100644 index 0000000..e606777 --- /dev/null +++ b/adapters/acp-adapter/README.md @@ -0,0 +1,48 @@ +# @grey/acp-adapter + +The **ACP marketplace as a grey-core `ChannelIngress`** (Movement 6). A standalone process that earns +through grey-core's shared offering handlers over Virtuals ACP — adapter #2 alongside the live x402 +channel. Ported from plugin-acp's `AcpService` (earning path only) against structural SDK shapes. + +## Build posture (deliberate — flagged in the M6 Phase C PR) + +- **`tsc` build** (not esbuild-bundled), like `grey-sweeper` / `x402-middleware`. `.js` extensions on + relative imports; ESM output run directly by node (`dist/main.js`). +- **The `@virtuals-protocol/acp-node-v2` SDK is a RUNTIME-ONLY external.** It is loaded in exactly one + file (`src/sdk.ts`) via a variable-specifier dynamic `import()` so **tsc never statically resolves + it** — the adapter core, its unit tests, the tier-1 offline smoke, and the dist build need **none** + of the SDK's heavy transitive tree (`@account-kit` / `@alchemy` / `@privy-io` / `socket.io` — the + tree that OOM'd the 1.9 GB VPS in M5). The adapter reaches the SDK only through the injected + `AcpSdkBundle` seam; `main.ts` builds the real one, tests inject a fake. +- **The SDK is therefore NOT a `package.json` dependency.** It is installed on the box at deploy time, + filtered + swap-armed + memory-checked, exactly as the ElizaOS agent has it: + `pnpm --filter @grey/acp-adapter add @virtuals-protocol/acp-node-v2@^0.0.4` (or provision it into the + adapter's `node_modules`). Building the dist needs none of it. + +## Env (`/etc/grey/acp-adapter.env`) + +| var | required | notes | +|-----|----------|-------| +| `ACP_AGENT_WALLET_ADDRESS` | yes | The ACP seller wallet `0xa966…` (Q6 — reused across the cutover). | +| `ACP_PRIVY_WALLET_ID` | yes | Privy wallet id (Virtuals Signers tab). | +| `ACP_PRIVY_SIGNER_KEY` | yes | Privy authorization key. **Secret** — never logged/reported. | +| `GREY_DATABASE_URL` | yes | `grey_pipeline_rw` runtime credential for the shared handlers. | +| `ANTHROPIC_API_KEY` | (live) | Read by `createHandlerDeps`; needed by the cache-miss live path. | +| `BASE_RPC_URL` | (live) | Chain reads for the discovery/crypto resolver. | +| `ACP_ADAPTER_OBSERVE_ONLY` | no | `true` → tier-2: subscribe + parse, **sign nothing** (FDQ-63 gate). | +| `ACP_ADAPTER_POLL_INTERVAL_MS` | no | Delivery poll backstop cadence (default 30000). | + +## Proof tiers + +- **Tier 1 — offline handler smoke** (committed): `pnpm -F @grey/acp-adapter tier1-smoke`. Synthetic + funded entry → NL parse → shared `offeringHandlers['legitimacy_scan']` (cache hit, offline) → + `{type:'object', value}` deliverable. No chain, no wallet, no SDK. +- **Tier 2 — observe-only SSE** (gated, touches the live wallet — run only after the FDQ-63 safety + report + a go): `ACP_ADAPTER_OBSERVE_ONLY=true`. +- **Tier 3 — first real job** = Phase D (cutover; not this phase). + +## Deploy (Phase D — do NOT activate in Phase C) + +`infra/systemd/grey-acp-adapter.service` ships installed-but-**disabled**. Becoming the seller is +Phase D (stop pm2 `grey`, then start this). **Never co-run** the two — same signer → on-chain +double-action. diff --git a/adapters/acp-adapter/package.json b/adapters/acp-adapter/package.json new file mode 100644 index 0000000..1227b89 --- /dev/null +++ b/adapters/acp-adapter/package.json @@ -0,0 +1,25 @@ +{ + "name": "@grey/acp-adapter", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint . --no-error-on-unmatched-pattern", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "test": "vitest run", + "tier1-smoke": "tsx scripts/tier1-offline-smoke.ts" + }, + "dependencies": { + "@grey/core": "workspace:*", + "@grey/pipeline": "workspace:*", + "@grey/x402-middleware": "workspace:*", + "viem": "^2.53.1" + }, + "devDependencies": { + "tsx": "^4.22.4" + } +} diff --git a/adapters/acp-adapter/scripts/tier1-offline-smoke.ts b/adapters/acp-adapter/scripts/tier1-offline-smoke.ts new file mode 100644 index 0000000..b5ca113 --- /dev/null +++ b/adapters/acp-adapter/scripts/tier1-offline-smoke.ts @@ -0,0 +1,130 @@ +// M6 Phase C — TIER 1 offline handler smoke (free, zero chain, zero creds, no SDK). Drives a +// synthetic FUNDED entry through the adapter's real dispatch path → NL parse → the SHARED grey-core +// offeringHandlers['legitimacy_scan'] (resolved offline as a cache HIT via minimal fake deps) → +// the {type:'object', value} deliverable envelope. Proves wiring/parser/handler/envelope with no +// chain, no wallet, no registration. Mirrors test/acpAdapter.test.ts's tier-1 case; runnable by hand. +// +// Usage: pnpm -F @grey/acp-adapter tier1-smoke +import process from 'node:process'; +import { offeringHandlers } from '@grey/core'; +import type { HandlerDeps } from '@grey/core'; +import { AcpAdapter } from '../src/acpAdapter.js'; +import { silentLogger } from '../src/logger.js'; +import type { + AcpJob, + AcpJobSession, + AcpRoomEntry, + AcpSdkBundle, + OfferingHandler, +} from '../src/acpTypes.js'; + +const TOKEN = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; // UNI +const TS = new Date('2026-06-14T00:00:00.000Z'); + +class RecordingSession implements AcpJobSession { + jobId = 'tier1-1'; + chainId = 8453; + roles = ['provider'] as const; + entries: AcpRoomEntry[] = [ + { kind: 'message', contentType: 'requirement', content: JSON.stringify({ token_address: TOKEN }) }, + ]; + job: AcpJob = { description: 'legitimacy_scan', clientAddress: '0xbuyer', status: 'funded', expiredAt: 4102444800 }; + submitted: string[] = []; + messages: string[] = []; + async fetchJob(): Promise { + return this.job; + } + async setBudget(): Promise {} + async submit(d: string): Promise { + this.submitted.push(d); + } + async sendMessage(c: string): Promise { + this.messages.push(c); + } + async reject(): Promise {} +} + +const throwingSdk: AcpSdkBundle = { + createAgent: async () => { + throw new Error('tier-1 must not touch the SDK'); + }, + assetUsdc: () => { + throw new Error('tier-1 must not touch the SDK'); + }, + newSession: () => { + throw new Error('tier-1 must not touch the SDK'); + }, +}; + +function cachedDeps(): HandlerDeps { + const wp = { id: 'wp-1', projectName: 'Uniswap', tokenAddress: TOKEN } as unknown; + const v = { + structuralScore: 4, + verdict: 'PASS', + hypeTechRatio: 1.2, + totalClaims: 2, + structuralAnalysisJson: { mica: { claimsMicaCompliance: 'NO', micaCompliant: 'YES', micaSummary: 'ok' } }, + verifiedAt: TS, + } as unknown; + return { + whitepapers: { + findByTokenAddress: async (a: string) => (a.toLowerCase() === TOKEN ? [wp] : []), + findByProjectName: async () => [], + findById: async () => wp, + }, + verifications: { findByWhitepaperId: async () => v }, + claims: { findByWhitepaperId: async () => [] }, + clock: () => TS, + config: { + version: '0.0.0', + did: 'did:erc8004:8453:58618', + name: 'Whitepaper Grey', + runtime: 'acp-adapter-tier1', + payTo: '0x0000000000000000000000000000000000000000', + network: 'eip155:8453', + }, + } as unknown as HandlerDeps; +} + +function fail(msg: string): never { + console.error(`[tier1-smoke] FAIL: ${msg}`); + process.exit(1); +} + +async function main(): Promise { + const adapter = new AcpAdapter({ + config: { + agentWalletAddress: '0xa9667116b4f4e9f1bae85f93a21b4b8ea45de98f', + privyWalletId: 'x', + privySignerKey: 'x', + databaseUrl: 'postgres://x', + observeOnly: false, + pollIntervalMs: 30_000, + }, + sdk: throwingSdk, + deps: cachedDeps(), + handlers: offeringHandlers as unknown as Record, + logger: silentLogger(), + }); + + const session = new RecordingSession(); + // Synthetic FUNDED entry — the same shape the SSE/poll paths deliver. + await adapter.handleEntry(session, { kind: 'system', event: { type: 'job.funded' } }); + + if (session.submitted.length !== 1) fail(`expected exactly 1 submit, got ${session.submitted.length}`); + const d = JSON.parse(session.submitted[0]) as { type: string; value: Record }; + if (d.type !== 'object') fail(`deliverable.type !== "object" (${d.type})`); + if (d.value.verdict !== 'PASS') fail(`expected cache-hit verdict PASS, got ${String(d.value.verdict)}`); + if (d.value.tokenAddress !== TOKEN) fail(`tokenAddress mismatch: ${String(d.value.tokenAddress)}`); + if (session.messages.length !== 2) fail(`expected the 2-part nudge, got ${session.messages.length}`); + + console.log('[tier1-smoke] synthetic job.funded → parse → shared legitimacy_scan handler → deliverable:'); + console.log( + `[tier1-smoke] {type:${d.type}, value:{verdict:${String(d.value.verdict)}, projectName:${String(d.value.projectName)}, ` + + `tokenAddress:${String(d.value.tokenAddress)}, structuralScore:${String(d.value.structuralScore)}}}`, + ); + console.log('[tier1-smoke] PASS — offline wiring proven: no chain, no wallet, no SDK.'); + process.exit(0); +} + +main().catch((e: unknown) => fail(e instanceof Error ? e.message : String(e))); diff --git a/adapters/acp-adapter/src/acpAdapter.ts b/adapters/acp-adapter/src/acpAdapter.ts new file mode 100644 index 0000000..c036a09 --- /dev/null +++ b/adapters/acp-adapter/src/acpAdapter.ts @@ -0,0 +1,593 @@ +// AcpAdapter — the ACP marketplace as a ChannelIngress (M6 Phase C). Standalone process; earning +// path only. It reuses grey-core's shared offering handlers verbatim (import { offeringHandlers, +// createHandlerDeps } from '@grey/core' — done in main.ts and passed in) and drives the ACP job +// lifecycle ported from plugin-acp's AcpService against the structural SDK shapes in acpTypes.ts. +// +// Ported (KEEP): SSE 'entry' subscription, poll backstop, claimDispatch dedup, "once funded, always +// submit, never reject", the two-part nudge. Dropped (A7): boot-buffer (offerings register before +// agent.start() — no cross-plugin load race in one process), the 2s/60s PM2-restart retry loop +// (systemd Restart=on-failure + fail-fast exit), and the HTTP 3001 handler. +// +// Two safety seams: +// • OBSERVE_ONLY (FDQ-63): the FIRST thing handleJobCreated/handleJobFunded do — suppresses every +// signing path (setBudget/submit/reject/nudge), covering hydration-fired entries. +// • reputationGate (B6): a nullable injected collaborator, every call guarded by if(this.gate); +// never hard-imported. Null in Phase C → exact "no gating" behavior; C′ wires a real impl. +import type { ChannelIngress, ChannelIdentity, OfferingRegistration, OfferingHandler } from '@grey/core'; +import type { + AcpSdkBundle, + AcpAgentLike, + AcpJobSession, + AcpRoomEntry, + AcpJobInfo, + BuyerReputationGate, + HandlerDeps, + SharedHandlerInput, +} from './acpTypes.js'; +import type { AcpAdapterConfig } from './config.js'; +import { GREY_DID } from './config.js'; +import { parseRequirement } from './parseRequirement.js'; +import { createLogger, type AdapterLogger } from './logger.js'; + +/** Dedup TTL — 5 minutes. */ +const DEDUP_TTL_MS = 5 * 60 * 1000; +/** Max dedup entries before a cleanup sweep. */ +const DEDUP_CLEANUP_THRESHOLD = 100; +/** Max time to wait for the requirement message after job.created/funded. */ +const REQUIREMENT_WAIT_MS = 5000; +/** Poll interval while waiting for the requirement message. */ +const REQUIREMENT_POLL_INTERVAL_MS = 100; +/** Defense-in-depth SLA fallback if a job carries no usable expiredAt (conservative — longest SLA). */ +const DEFAULT_SLA_MINUTES = 15; + +export interface AcpAdapterOptions { + config: AcpAdapterConfig; + /** The injected SDK operations (main.ts builds the real one; tests inject a fake). */ + sdk: AcpSdkBundle; + /** Shared handler deps (createHandlerDeps in main; fake repos in tests). */ + deps: HandlerDeps; + /** The shared offering handlers (offeringHandlers from @grey/core; fakes in tests). */ + handlers: Record; + logger?: AdapterLogger; + /** B6 seam — null in Phase C. */ + reputationGate?: BuyerReputationGate | null; +} + +export class AcpAdapter implements ChannelIngress { + private readonly config: AcpAdapterConfig; + private readonly sdk: AcpSdkBundle; + private readonly deps: HandlerDeps; + private readonly handlers: Record; + private readonly log: AdapterLogger; + private reputationGate: BuyerReputationGate | null; + + private agent: AcpAgentLike | null = null; + private pollTimer: NodeJS.Timeout | null = null; + + // Catalog (registerOffering) — slug → price, for identity()/observability + the accept budget. + private readonly offerings: OfferingRegistration[] = []; + private readonly offeringPrices = new Map(); + private readonly offeringSlaMinutes = new Map(); + + // Dedup — per `${jobId}:${eventType}` (TTL-swept). + private readonly recentJobs = new Map(); + // In-flight delivery guard — per `${chainId}:${jobId}` (never TTL-swept; released in the funded finally). + private readonly inFlight = new Set(); + // Poll log-once. + private readonly pollSeen = new Map(); + + constructor(opts: AcpAdapterOptions) { + this.config = opts.config; + this.sdk = opts.sdk; + this.deps = opts.deps; + this.handlers = opts.handlers; + this.log = opts.logger ?? createLogger({ component: 'acp-adapter' }); + this.reputationGate = opts.reputationGate ?? null; + } + + // ── ChannelIngress ───────────────────────── + + async start(): Promise { + if (this.agent) throw new Error('AcpAdapter: already started'); + this.agent = await this.sdk.createAgent( + { + agentWalletAddress: this.config.agentWalletAddress, + privyWalletId: this.config.privyWalletId, + privySignerKey: this.config.privySignerKey, + }, + (session, entry) => { + void this.handleEntry(session, entry); + }, + ); + await this.agent.start(); + this.startDeliveryPoll(); + this.log.info('AcpAdapter: started', { + observeOnly: this.config.observeOnly, + offerings: this.offerings.map((o) => o.slug), + receivingAddress: this.config.agentWalletAddress, + }); + } + + async stop(): Promise { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.agent) { + await this.agent.stop(); + this.agent = null; + } + } + + registerOffering(reg: OfferingRegistration): void { + this.offerings.push(reg); + this.offeringPrices.set(reg.slug, reg.priceUsd); + this.log.info('AcpAdapter: offering registered', { slug: reg.slug, priceUsd: reg.priceUsd }); + } + + identity(): ChannelIdentity { + return { receivingAddress: this.config.agentWalletAddress, did: GREY_DID }; + } + + /** Observability accessors (not on the slim ChannelIngress interface). */ + listOfferings(): readonly OfferingRegistration[] { + return this.offerings; + } + setReputationGate(gate: BuyerReputationGate | null): void { + this.reputationGate = gate; + } + + // ── Ingress / dispatch ───────────────────── + + private startDeliveryPoll(): void { + if (this.pollTimer) return; + // NOT unref'd: the poll interval is what keeps the standalone daemon alive between SSE events; + // stop() clears it for a clean exit. + this.pollTimer = setInterval(() => { + void this.runDeliveryPoll(); + }, this.config.pollIntervalMs); + } + + /** Shared dispatch-claim primitive — the first synchronous check-and-set any path performs for a + * job+event, before any await, so socket-vs-poll races resolve deterministically. */ + private claimDispatch(chainId: number, jobId: string, eventType: string): boolean { + const eventKey = `${jobId}:${eventType}`; + const flightKey = `${chainId}:${jobId}`; + if (this.recentJobs.has(eventKey) || (eventType === 'job.funded' && this.inFlight.has(flightKey))) { + return false; + } + this.recentJobs.set(eventKey, Date.now()); + if (eventType === 'job.funded') this.inFlight.add(flightKey); + if (this.recentJobs.size > DEDUP_CLEANUP_THRESHOLD) { + const now = Date.now(); + for (const [k, ts] of this.recentJobs) { + if (now - ts > DEDUP_TTL_MS) this.recentJobs.delete(k); + } + } + return true; + } + + async handleEntry(session: AcpJobSession, entry: AcpRoomEntry): Promise { + // Process system lifecycle events AND the initial requirement message (arrives as a separate + // room entry, contentType='requirement', after job.created). + const isRequirementMsg = entry.kind === 'message' && entry.contentType === 'requirement'; + if (!isRequirementMsg && entry.kind !== 'system') return; + + const eventType = isRequirementMsg ? 'requirement.message' : (entry.event?.type ?? ''); + const jobId = session.jobId; + if (!eventType) return; + + // Dedup + in-flight claim — shared by the SSE path and the poll path. + if (!this.claimDispatch(session.chainId, jobId, eventType)) return; + + const log = this.log.child({ operation: 'handleEntry', jobId, eventType }); + + switch (eventType) { + case 'job.created': + case 'requirement.message': { + const decidedKey = `${jobId}:__decided`; + if (this.recentJobs.has(decidedKey)) break; + await this.handleJobCreated(session, entry, log); + break; + } + case 'job.funded': + await this.handleJobFunded(session, log); + break; + case 'job.completed': + case 'job.rejected': + case 'job.expired': + log.info('Job terminal state'); + if (this.reputationGate) { + const terminal = + eventType === 'job.completed' ? 'completed' : eventType === 'job.rejected' ? 'rejected' : 'expired'; + void this.reputationGate + .onJobTerminal(session.jobId, session.chainId, terminal) + .catch((err) => log.warn('[reputation] onJobTerminal (socket path) threw', { error: errMsg(err) })); + } + break; + default: + log.debug('Unhandled event type'); + } + } + + // ── Accept phase ─────────────────────────── + + private async handleJobCreated( + session: AcpJobSession, + entry: AcpRoomEntry, + log: AdapterLogger, + ): Promise { + // ── OBSERVE_ONLY (FDQ-63): the FIRST thing — before ANY signing path. Covers hydration-fired + // entries (agent.start()→hydrateSessions fires 'entry' on pre-existing active jobs). ── + if (this.config.observeOnly) { + await this.observe('job.created', session, entry, log); + return; + } + + const job = await session.fetchJob(); + const offeringId = job.description ?? ''; + const decidedKey = `${session.jobId}:__decided`; + const markDecided = (): void => { + this.recentJobs.set(decidedKey, Date.now()); + }; + + if (!offeringId) { + log.info('No offering name — skipping (not a serviceable job)'); + return; + } + const handler = this.handlers[offeringId]; + if (!handler) { + log.warn('No handler registered for offering — rejecting', { offeringId }); + await session.reject(`Offering '${offeringId}' not supported by this agent`); + markDecided(); + return; + } + + const rawRequirement = await this.resolveRawRequirement(session, entry); + const { requirement, isPlainText } = parseRequirement(rawRequirement); + if (!hasSubject(requirement)) { + log.warn('No parseable requirement — rejecting'); + await session.reject('Could not parse service requirement — no token address or project name found'); + markDecided(); + return; + } + + // Buyer-reputation gate (B6) — after offering + requirement checks, before setBudget. Null in + // Phase C → skipped entirely. Fail-OPEN: a gate error accepts (never blocks honest buyers). + if (this.reputationGate) { + try { + const decision = await this.reputationGate.evaluateAcceptance({ + jobId: session.jobId, + chainId: session.chainId, + phase: 'created', + buyerAddress: job.clientAddress, + providerAddress: this.config.agentWalletAddress, + offeringName: offeringId, + }); + if (!decision.accept) { + const reason = decision.rejectReasonStructured + ? JSON.stringify(decision.rejectReasonStructured) + : (decision.rejectReasonText ?? 'Service unavailable for this buyer wallet'); + log.warn('[reputation-reject] buyer rejected by reputation gate', { buyer: job.clientAddress }); + await session.reject(reason); + markDecided(); + return; + } + } catch (err) { + log.warn('[reputation] evaluateAcceptance threw — accepting (fail-open)', { error: errMsg(err) }); + } + } + + // Accept: propose the registered sticker price (no dynamic price resolver in the adapter). + const price = this.offeringPrices.get(offeringId) ?? 0; + void isPlainText; + try { + await session.setBudget(this.sdk.assetUsdc(price, session.chainId)); + log.info('Job accepted via setBudget', { offeringId, price }); + markDecided(); + } catch (err) { + log.error('Failed to setBudget — attempting reject to avoid a hanging job', { error: errMsg(err) }); + try { + await session.reject('Internal error: failed to set budget'); + markDecided(); + } catch (rejectErr) { + log.error('Failed to reject after setBudget failure — job will expire on-chain', { + error: errMsg(rejectErr), + }); + } + } + } + + // ── Delivery phase ───────────────────────── + + private async handleJobFunded(session: AcpJobSession, log: AdapterLogger): Promise { + const flightKey = `${session.chainId}:${session.jobId}`; + try { + // ── OBSERVE_ONLY (FDQ-63): FIRST — before the submit path. ── + if (this.config.observeOnly) { + await this.observe('job.funded', session, undefined, log); + return; + } + + // Pre-submit re-check (optimization) — a FRESH fetch to skip a job already advanced past + // funded. Fail-OPEN: only skip on an AFFIRMATIVE non-funded status. + let job: Awaited> | null = null; + try { + job = await session.fetchJob(); + } catch (err) { + log.warn('Pre-submit re-check: fetchJob failed — proceeding with cached job', { error: errMsg(err) }); + job = session.job ?? null; + } + if (!job) { + log.error('No job available in funded phase — cannot deliver'); + return; + } + const statusStr = String((job as { status?: unknown }).status ?? '').toUpperCase(); + if (statusStr && statusStr !== 'FUNDED') { + log.info('Pre-submit re-check: job no longer funded — skipping (already handled)', { status: statusStr }); + return; + } + + const offeringId = job.description ?? ''; + const handler = offeringId ? this.handlers[offeringId] : undefined; + if (!offeringId || !handler) { + log.error('No offering/handler in funded phase — skipping delivery', { offeringId }); + return; + } + + const rawRequirement = await this.waitForRequirement(session); + const { requirement, isPlainText } = parseRequirement(rawRequirement); + const input: SharedHandlerInput = { + jobId: session.jobId, + offeringId, + buyerAddress: job.clientAddress, + requirement, + isPlainText, + }; + + // Post-acceptance rule: once funded, ALWAYS deliver. Never reject here. Handler errors become + // an INSUFFICIENT_DATA deliverable. + try { + const result = await handler(input, this.deps); + await session.submit(JSON.stringify({ type: 'object', value: result.payload })); + log.info('Job delivered via submit()', { offeringId }); + await this.postSubmitNudgeAndTrack( + session, + offeringId, + job.clientAddress, + (job as { expiredAt?: unknown }).expiredAt, + log, + ); + } catch (err) { + const errorMsg = errMsg(err); + log.error('Handler errored — delivering INSUFFICIENT_DATA envelope', { error: errorMsg }); + try { + await session.submit( + JSON.stringify({ + type: 'object', + value: { + verdict: 'INSUFFICIENT_DATA', + error: errorMsg, + generatedAt: new Date().toISOString(), + }, + }), + ); + log.info('Fallback INSUFFICIENT_DATA deliverable submitted'); + } catch (submitErr) { + log.error('CRITICAL: submit failed on error path', { error: errMsg(submitErr) }); + } + } + } finally { + this.inFlight.delete(flightKey); + } + } + + /** + * Post-submit: (1) send the two-part nudge asking the buyer to call complete(), and (2) record the + * job in the reputation tracker. Both best-effort + independent; neither can fail the (already + * complete) delivery. SLA source = the protocol's own job.expiredAt (matches on-chain), with the + * per-offering SLA + a 15min default only as defense-in-depth. + */ + private async postSubmitNudgeAndTrack( + session: AcpJobSession, + offeringId: string, + buyerAddress: string, + jobExpiredAt: unknown, + log: AdapterLogger, + ): Promise { + const submittedAt = new Date(); + const expSec = + typeof jobExpiredAt === 'bigint' + ? Number(jobExpiredAt) + : typeof jobExpiredAt === 'number' + ? jobExpiredAt + : typeof jobExpiredAt === 'string' + ? Number(jobExpiredAt) + : NaN; + let expiresAt: Date; + if (Number.isFinite(expSec) && expSec > 0) { + expiresAt = new Date(expSec * 1000); + } else { + const slaMinutes = this.offeringSlaMinutes.get(offeringId) ?? DEFAULT_SLA_MINUTES; + expiresAt = new Date(submittedAt.getTime() + slaMinutes * 60 * 1000); + log.warn('[reputation] job carried no usable expiredAt — SLA-default fallback', { offeringId, slaMinutes }); + } + + const nudgeText = + `Deliverable submitted for job ${session.jobId}. Please call complete() to finalize the ` + + `transaction within the SLA window. If you encountered an issue with the deliverable, please ` + + `call reject() with a reason instead.`; + const nudgeStructured = JSON.stringify({ + action: 'complete_required', + jobId: session.jobId, + slaExpiresAt: expiresAt.toISOString(), + providerAddress: this.config.agentWalletAddress, + }); + try { + await session.sendMessage(nudgeText, 'text'); + } catch (err) { + log.warn('[nudge] text message failed (continuing)', { error: errMsg(err) }); + } + try { + await session.sendMessage(nudgeStructured, 'structured'); + } catch (err) { + log.warn('[nudge] structured message failed (continuing)', { error: errMsg(err) }); + } + + if (this.reputationGate) { + try { + await this.reputationGate.onJobSubmitted( + session.jobId, + session.chainId, + buyerAddress, + offeringId, + submittedAt, + expiresAt, + ); + } catch (err) { + log.warn('[reputation] onJobSubmitted failed (delivery already complete)', { error: errMsg(err) }); + } + } + } + + // ── Observe-only (read-only) ─────────────── + + /** Read-only observation for tier-2: fetch + parse the job, log it, sign NOTHING. */ + private async observe( + phase: string, + session: AcpJobSession, + entry: AcpRoomEntry | undefined, + log: AdapterLogger, + ): Promise { + try { + const job = await session.fetchJob(); + const offeringId = job.description ?? ''; + const raw = entry?.contentType === 'requirement' ? entry.content : await this.waitForRequirement(session); + const { requirement } = parseRequirement(raw); + log.info('[observe-only] observed job — signing suppressed', { + phase, + offeringId, + buyer: job.clientAddress, + requirement, + }); + } catch (err) { + log.warn('[observe-only] observation read failed (no signing attempted)', { phase, error: errMsg(err) }); + } + } + + // ── Poll backstop ────────────────────────── + + private async runDeliveryPoll(): Promise { + if (!this.agent) return; + const ourAddr = this.config.agentWalletAddress.toLowerCase(); + let jobs: AcpJobInfo[]; + try { + jobs = await this.getActiveJobs(); + } catch (err) { + this.log.warn('[poll] getActiveJobs failed', { error: errMsg(err) }); + return; + } + const now = Date.now(); + if (this.pollSeen.size > DEDUP_CLEANUP_THRESHOLD) { + for (const [k, ts] of this.pollSeen) { + if (now - ts > DEDUP_TTL_MS) this.pollSeen.delete(k); + } + } + for (const job of jobs) { + const isFunded = String(job.phase).toUpperCase() === 'FUNDED'; + const isOurs = (job.providerAddress ?? '').toLowerCase() === ourAddr; + if (!isFunded || !isOurs) continue; + const jobId = String(job.jobId); + const chainId = job.chainId; + if (!this.pollSeen.has(jobId)) this.pollSeen.set(jobId, now); + // Read-only early-out (NOT a second dedup site — the authoritative claim is claimDispatch). + if (this.inFlight.has(`${chainId}:${jobId}`) || this.recentJobs.has(`${jobId}:job.funded`)) continue; + void this.dispatchPolledJob(chainId, jobId); + } + } + + private async dispatchPolledJob(chainId: number, jobId: string): Promise { + if (!this.agent) return; + try { + let session = this.agent.getSession(chainId, jobId) ?? null; + if (!session) { + const entries = await this.agent.getTransport().getHistory(chainId, jobId); + session = this.sdk.newSession(this.agent, this.config.agentWalletAddress, jobId, chainId, entries); + } + const syntheticEntry: AcpRoomEntry = { + kind: 'system', + onChainJobId: jobId, + chainId, + event: { type: 'job.funded', jobId }, + timestamp: Date.now(), + }; + await this.handleEntry(session, syntheticEntry); + } catch (err) { + this.log.warn('[poll] dispatch failed', { jobId, error: errMsg(err) }); + } + } + + private async getActiveJobs(): Promise { + if (!this.agent) return []; + const api = this.agent.getApi(); + const refs = await api.getActiveJobs(); + const jobs: AcpJobInfo[] = []; + for (const ref of refs) { + try { + const full = await api.getJob(ref.chainId, ref.onChainJobId); + if (full) { + jobs.push({ + jobId: ref.onChainJobId, + chainId: ref.chainId, + phase: String((full as { status?: unknown; jobStatus?: unknown }).jobStatus ?? full.status ?? 'unknown'), + buyerAddress: full.clientAddress ?? '', + providerAddress: full.providerAddress ?? '', + offeringName: full.description ?? '', + }); + } + } catch { + // Individual job fetch failed — skip. + } + } + return jobs; + } + + // ── Requirement resolution ───────────────── + + private async resolveRawRequirement(session: AcpJobSession, entry: AcpRoomEntry): Promise { + if (entry.kind === 'message' && entry.contentType === 'requirement') return entry.content; + return this.waitForRequirement(session); + } + + private async waitForRequirement(session: AcpJobSession): Promise { + const deadline = Date.now() + REQUIREMENT_WAIT_MS; + const findIn = (entries: readonly AcpRoomEntry[]): AcpRoomEntry | undefined => + entries.find((e) => e.kind === 'message' && e.contentType === 'requirement'); + + const fast = findIn(session.entries); + if (fast) return fast.content; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, REQUIREMENT_POLL_INTERVAL_MS)); + const found = findIn(session.entries); + if (found) return found.content; + } + + try { + const history = await this.agent?.getTransport().getHistory(session.chainId, session.jobId); + const found = history ? findIn(history) : undefined; + return found?.content; + } catch { + return undefined; + } + } +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** A requirement is serviceable if it resolved at least one subject key. */ +function hasSubject(r: { token_address?: string; project_name?: string }): boolean { + return Boolean(r.token_address || r.project_name); +} diff --git a/adapters/acp-adapter/src/acpTypes.ts b/adapters/acp-adapter/src/acpTypes.ts new file mode 100644 index 0000000..690ef3c --- /dev/null +++ b/adapters/acp-adapter/src/acpTypes.ts @@ -0,0 +1,141 @@ +// Structural interfaces for the slice of the @virtuals-protocol/acp-node-v2 SDK the adapter +// actually touches — the grey-sweeper `*Like` pattern (real viem clients cast to local shapes). +// The adapter core depends ONLY on these, never on the SDK's own types, so it typechecks/tests/ +// builds with none of the SDK's heavy transitive tree present. src/sdk.ts maps the real SDK to +// these shapes at runtime (the single place the SDK is loaded, via dynamic import). +import type { HandlerDeps, HandlerInput, OfferingHandler } from '@grey/core'; + +/** A room entry as read by handleEntry — system lifecycle event OR a requirement message. */ +export interface AcpRoomEntry { + kind: string; // 'system' | 'message' | … + /** message entries: 'requirement' etc. */ + contentType?: string; + /** message entries: the requirement content (string JSON or plain text, or an object). */ + content?: unknown; + /** system entries: the lifecycle event. */ + event?: { type: string; jobId?: string; client?: string; amount?: number }; + timestamp?: number; + onChainJobId?: string; + chainId?: number; +} + +/** The protocol-authoritative job record (REST shape; `status` carries the REST string at runtime). */ +export interface AcpJob { + description?: string; // the offering slug + clientAddress: string; // buyer + providerAddress?: string; + status?: unknown; // REST string e.g. "funded" (typed loose — the SDK's numeric-enum type lies about REST) + expiredAt?: unknown; // unix seconds (bigint|number|string) — SLA source of truth + budget?: unknown; +} + +/** A job session — the per-job room handle the adapter drives. */ +export interface AcpJobSession { + readonly jobId: string; + readonly chainId: number; + readonly roles: readonly string[]; + readonly entries: readonly AcpRoomEntry[]; + readonly job?: AcpJob | null; + fetchJob(): Promise; + /** Propose the budget (accept). `budget` is an opaque SDK AssetToken (bundle.assetUsdc). */ + setBudget(budget: unknown): Promise; + /** Deliver the (stringified) deliverable. */ + submit(deliverable: string): Promise; + /** Post-submit nudge messages. */ + sendMessage(content: string, contentType: string): Promise; + /** Pre-acceptance rejection. */ + reject(reason: string): Promise; +} + +/** A job reference from the REST active-jobs list. */ +export interface AcpJobRef { + chainId: number; + onChainJobId: string; +} + +/** The SDK agent handle. */ +export interface AcpAgentLike { + on(event: 'entry', cb: (session: AcpJobSession, entry: AcpRoomEntry) => void): void; + start(): Promise; + stop(): Promise; + getSession(chainId: number, jobId: string): AcpJobSession | null | undefined; + getTransport(): { getHistory(chainId: number, jobId: string): Promise }; + getApi(): { + getActiveJobs(): Promise; + getJob(chainId: number, jobId: string): Promise; + }; + getAddress(): Promise; +} + +/** Config the SDK bundle needs to construct the Privy non-custodial agent (Q6 — reuse 0xa966…). */ +export interface AcpAgentConfig { + agentWalletAddress: string; + privyWalletId: string; + privySignerKey: string; +} + +/** + * The injected SDK operations. main.ts builds the REAL bundle (src/sdk.ts, dynamic-imports the + * SDK); tests/tier-1 inject a fake. This is the ONLY seam through which SDK values enter the + * adapter — the adapter never imports the SDK directly. + */ +export interface AcpSdkBundle { + /** Construct + wire the Privy non-custodial agent over SSE (no on-chain write — FDQ-63). */ + createAgent( + config: AcpAgentConfig, + onEntry: (session: AcpJobSession, entry: AcpRoomEntry) => void, + ): Promise; + /** AssetToken.usdc(amount, chainId) — opaque budget value for setBudget. */ + assetUsdc(amount: number, chainId: number): unknown; + /** Construct a JobSession for a polled funded job with no hydrated session (mirrors hydrateSessions). */ + newSession( + agent: AcpAgentLike, + providerAddress: string, + jobId: string, + chainId: number, + entries: AcpRoomEntry[], + ): AcpJobSession; +} + +/** A simplified funded-job projection used by the poll backstop. */ +export interface AcpJobInfo { + jobId: string; + chainId: number; + phase: string; + buyerAddress: string; + providerAddress: string; + offeringName?: string; +} + +/** Terminal lifecycle states a tracked job can resolve to. */ +export type JobTerminalStatus = 'completed' | 'expired' | 'rejected'; + +/** + * Buyer-reputation gate seam (B6) — optional-by-construction. The adapter holds this as a nullable + * injected collaborator and guards every call with `if (this.reputationGate)`. NEVER hard-imported. + * In Phase C it is always null (→ exact "no gating" behavior); C′ wires a real impl to Phase B's + * grey_two tables. Interface kept minimal (the earning-path sites only); C′ may extend it. + */ +export interface BuyerReputationGate { + /** handleJobCreated, BEFORE setBudget. accept:false → the adapter relays the structured reject. */ + evaluateAcceptance(job: AcpJobInfo): Promise<{ + accept: boolean; + rejectReasonText?: string; + rejectReasonStructured?: Record; + }>; + /** handleJobFunded, immediately after a successful submit(). */ + onJobSubmitted( + jobId: string, + chainId: number, + buyerAddress: string, + offering: string, + submittedAt: Date, + expiresAt: Date, + ): Promise; + /** handleEntry terminal events (best-effort fast path). */ + onJobTerminal(jobId: string, chainId: number, terminal: JobTerminalStatus): Promise; +} + +/** The parsed-requirement input handed to the shared grey-core handlers. Re-uses HandlerInput. */ +export type SharedHandlerInput = HandlerInput; +export type { HandlerDeps, OfferingHandler }; diff --git a/adapters/acp-adapter/src/config.ts b/adapters/acp-adapter/src/config.ts new file mode 100644 index 0000000..5d77a78 --- /dev/null +++ b/adapters/acp-adapter/src/config.ts @@ -0,0 +1,50 @@ +// Fail-closed config load — mirrors grey-sweeper/x402-middleware discipline (hand-rolled, no zod). +// Any missing required env → throw → the systemd unit exits non-zero rather than running +// half-configured (the fail-fast exit that replaces plugin-acp's 2s/60s PM2-restart retry loop). +import process from 'node:process'; + +/** Grey's on-chain ERC-8004 DID — the unifying identity layer (Base mainnet, tokenId 58618). */ +export const GREY_DID = 'did:erc8004:8453:58618'; + +export interface AcpAdapterConfig { + /** The ACP seller wallet — reused across the cutover (Q6). Also the ChannelIngress receivingAddress. */ + agentWalletAddress: string; + privyWalletId: string; + privySignerKey: string; + /** grey_pipeline_rw runtime credential for the shared handlers' cache reads / live compute. */ + databaseUrl: string; + /** + * FDQ-63 safety gate. When true, EVERY signing path (setBudget/submit/reject/nudge) is suppressed + * at the top of the job handlers, covering hydration-fired entries — the adapter subscribes and + * parses live traffic but signs nothing (tier-2 observe-only, safe to co-run the same wallet). + */ + observeOnly: boolean; + /** Delivery poll backstop cadence (ms). */ + pollIntervalMs: number; +} + +type Env = Record; + +function required(env: Env, key: string): string { + const v = env[key]; + if (v === undefined || v.trim() === '') { + throw new Error(`acp-adapter: missing required env ${key}`); + } + return v.trim(); +} + +export function loadConfig(env: Env = process.env): AcpAdapterConfig { + const pollRaw = env.ACP_ADAPTER_POLL_INTERVAL_MS?.trim(); + const pollIntervalMs = pollRaw ? Number(pollRaw) : 30_000; + if (!Number.isInteger(pollIntervalMs) || pollIntervalMs <= 0) { + throw new Error(`acp-adapter: ACP_ADAPTER_POLL_INTERVAL_MS must be a positive integer, got "${pollRaw}"`); + } + return { + agentWalletAddress: required(env, 'ACP_AGENT_WALLET_ADDRESS'), + privyWalletId: required(env, 'ACP_PRIVY_WALLET_ID'), + privySignerKey: required(env, 'ACP_PRIVY_SIGNER_KEY'), + databaseUrl: required(env, 'GREY_DATABASE_URL'), + observeOnly: (env.ACP_ADAPTER_OBSERVE_ONLY?.trim() ?? '') === 'true', + pollIntervalMs, + }; +} diff --git a/adapters/acp-adapter/src/index.ts b/adapters/acp-adapter/src/index.ts new file mode 100644 index 0000000..3b4c7b2 --- /dev/null +++ b/adapters/acp-adapter/src/index.ts @@ -0,0 +1,22 @@ +// @grey/acp-adapter — the ACP marketplace as a ChannelIngress (M6). Package surface for reuse/tests. +export { AcpAdapter } from './acpAdapter.js'; +export type { AcpAdapterOptions } from './acpAdapter.js'; +export { loadConfig, GREY_DID } from './config.js'; +export type { AcpAdapterConfig } from './config.js'; +export { parseRequirement } from './parseRequirement.js'; +export type { ParsedRequirement, ParseResult } from './parseRequirement.js'; +export { createRealSdkBundle } from './sdk.js'; +export { createLogger, silentLogger } from './logger.js'; +export type { AdapterLogger } from './logger.js'; +export type { + AcpSdkBundle, + AcpAgentLike, + AcpJobSession, + AcpRoomEntry, + AcpJob, + AcpJobRef, + AcpJobInfo, + AcpAgentConfig, + BuyerReputationGate, + JobTerminalStatus, +} from './acpTypes.js'; diff --git a/adapters/acp-adapter/src/logger.ts b/adapters/acp-adapter/src/logger.ts new file mode 100644 index 0000000..cf8a32f --- /dev/null +++ b/adapters/acp-adapter/src/logger.ts @@ -0,0 +1,38 @@ +// Minimal structured logger (JSON lines → stderr), so the adapter has no @elizaos/core dependency. +// child() binds context fields onto every line (mirrors the plugin-acp logger.child usage). +import process from 'node:process'; + +export interface AdapterLogger { + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; + debug(msg: string, meta?: Record): void; + child(bound: Record): AdapterLogger; +} + +export function createLogger(bound: Record = {}): AdapterLogger { + const emit = (level: string, msg: string, meta?: Record): void => { + const line = { level, msg, ...bound, ...(meta ?? {}) }; + process.stderr.write(`${JSON.stringify(line)}\n`); + }; + return { + info: (m, meta) => emit('info', m, meta), + warn: (m, meta) => emit('warn', m, meta), + error: (m, meta) => emit('error', m, meta), + debug: (m, meta) => emit('debug', m, meta), + child: (extra) => createLogger({ ...bound, ...extra }), + }; +} + +/** A no-op logger for tests. */ +export function silentLogger(): AdapterLogger { + const noop = (): void => {}; + const l: AdapterLogger = { + info: noop, + warn: noop, + error: noop, + debug: noop, + child: () => l, + }; + return l; +} diff --git a/adapters/acp-adapter/src/main.ts b/adapters/acp-adapter/src/main.ts new file mode 100644 index 0000000..643c738 --- /dev/null +++ b/adapters/acp-adapter/src/main.ts @@ -0,0 +1,77 @@ +// @grey/acp-adapter production entry (systemd ExecStart → dist/main.js). Mirrors grey-sweeper's +// main.ts: fail-closed loadConfig(), build the shared handler deps + the real SDK bundle, register +// the 7 offerings, run until SIGTERM/SIGINT, then stop cleanly. Fail-fast: any missing env throws +// and the unit exits non-zero (replaces plugin-acp's 2s/60s PM2-restart retry loop). +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { offeringHandlers, createHandlerDeps } from '@grey/core'; +import { PAID_SLUGS, priceUsdFor } from '@grey/x402-middleware'; +import { loadConfig } from './config.js'; +import { AcpAdapter } from './acpAdapter.js'; +import { createRealSdkBundle } from './sdk.js'; +import { createLogger } from './logger.js'; + +async function main(): Promise { + const log = createLogger({ component: 'acp-adapter' }); + const config = loadConfig(); // fail-closed on any missing env + + // Shared grey-core handlers + deps — the WHOLE point (reuse, don't rebuild). createHandlerDeps + // opens the grey_pipeline_rw pool + the discovery/pipeline bundle used by the cache-miss path. + const deps = createHandlerDeps({ databaseUrl: config.databaseUrl }); + const sdk = await createRealSdkBundle(); + + const adapter = new AcpAdapter({ + config, + sdk, + deps, + handlers: offeringHandlers, + logger: log, + }); + + // 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). + for (const slug of PAID_SLUGS) { + adapter.registerOffering({ slug, priceUsd: priceUsdFor(slug) }); + } + + log.info('acp-adapter: starting', { + observeOnly: config.observeOnly, + receivingAddress: config.agentWalletAddress, + offerings: PAID_SLUGS.length, + }); + + await adapter.start(); + + // Run until a signal aborts; then stop cleanly. A ref'd poll timer keeps the loop alive. + await new Promise((resolve) => { + let stopping = false; + const shutdown = (sig: string): void => { + if (stopping) return; + stopping = true; + log.info(`acp-adapter: ${sig} received — stopping`); + adapter + .stop() + .then(() => log.info('acp-adapter: stopped cleanly')) + .catch((err: unknown) => + log.error('acp-adapter: error during stop', { + error: err instanceof Error ? err.message : String(err), + }), + ) + .finally(() => resolve()); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + }); + + process.exit(0); +} + +// Run only when executed directly (systemd), never when imported by a test. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err: unknown) => { + process.stderr.write( + `acp-adapter: fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`, + ); + process.exit(1); + }); +} diff --git a/adapters/acp-adapter/src/parseRequirement.ts b/adapters/acp-adapter/src/parseRequirement.ts new file mode 100644 index 0000000..8506f2f --- /dev/null +++ b/adapters/acp-adapter/src/parseRequirement.ts @@ -0,0 +1,101 @@ +// ACP NL requirement parser (N2/A6) — ported from plugin-acp AcpService.parseRequirement +// (:1280-1352). Emits a CLEAN { token_address?, project_name? } that keys directly into the +// shared grey-core handlers (legitimacy_scan.ts:10 reads body.token_address / body.project_name; +// subjectMapping resolves either). The WpvService-only `_requirementText`/`_signals`/`raw_instruction` +// stamps are DROPPED (gate-path, not earning). The known-protocol regex is compiled from +// @grey/pipeline's canonical KNOWN_PROTOCOL_NAMES — no third divergent copy. +import { KNOWN_PROTOCOL_NAMES, buildProtocolPattern } from '@grey/pipeline'; + +const KNOWN_PROTOCOL_PATTERN = buildProtocolPattern(KNOWN_PROTOCOL_NAMES); + +/** The clean subject the shared handlers consume. */ +export interface ParsedRequirement { + token_address?: string; + project_name?: string; +} + +export interface ParseResult { + requirement: ParsedRequirement; + /** True when the requirement was extracted from plain text rather than structured JSON/object. */ + isPlainText: boolean; +} + +/** Extract a project name from a plain-text instruction (3-stage, port of the original). */ +function extractProjectName(raw: string): string | undefined { + // Stage 1: known protocol pattern. + const protocolMatch = raw.match(KNOWN_PROTOCOL_PATTERN); + if (protocolMatch) return protocolMatch[0].trim(); + + // Stage 2: structural — last capitalized noun phrase before a parenthesized/bracketed address. + const addrPos = raw.search(/[([]\s*0x[0-9a-fA-F]/); + if (addrPos > 0) { + const before = raw.slice(0, addrPos).trim(); + const phrases = [ + ...before.matchAll( + /(?!(?:Verify|Analyze|Evaluate|Run|Check|Audit|Scan|Review|Perform|Do|Please|The|This|Assess|Inspect|Confirm|Determine|Test)\b)[A-Z][a-zA-Z0-9]*(?:\s+(?:v\d+|V\d+|[A-Z][a-zA-Z0-9]*|Finance|Protocol|Labs|Network|DAO|Exchange|Chain|Token|Bridge))*\b/g, + ), + ]; + if (phrases.length > 0) { + const last = phrases[phrases.length - 1][0].trim(); + if (last.length >= 2) return last; + } + } + + // Stage 3: generic name regex after an action verb. + const nameMatch = raw.match( + /(?:verify|evaluate|analyze|audit|check|review|scan)\s+([A-Z][a-zA-Z0-9\s.]+?)(?:\s*[([{]|\s*for\s|\s*,|\s*\.(?:\s|$)|\s*Token)/i, + ); + if (nameMatch) return nameMatch[1].trim(); + + return undefined; +} + +/** + * Parse an ACP service requirement into the clean handler subject. + * - object / JSON string → pass through the recognized fields (structured). + * - plain text with a 0x address → { token_address, project_name? } (isPlainText). + * - plain text with only a known protocol name → { project_name } (isPlainText). + * - unparseable → {} (caller rejects pre-acceptance). + */ +export function parseRequirement(raw: unknown): ParseResult { + const pick = (obj: Record): ParsedRequirement => { + const out: ParsedRequirement = {}; + const token = obj.token_address ?? obj.tokenAddress; + const name = obj.project_name ?? obj.projectName; + if (typeof token === 'string' && token.trim()) out.token_address = token.trim(); + if (typeof name === 'string' && name.trim()) out.project_name = name.trim(); + return out; + }; + + if (typeof raw === 'object' && raw !== null) { + return { requirement: pick(raw as Record), isPlainText: false }; + } + + if (typeof raw === 'string') { + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null) { + return { requirement: pick(parsed as Record), isPlainText: false }; + } + } catch { + // Not JSON — fall through to plain-text extraction. + } + + const evmMatch = raw.match(/\b(0x[0-9a-fA-F]{10,42})\b/); + if (evmMatch) { + const requirement: ParsedRequirement = { token_address: evmMatch[1] }; + const projectName = extractProjectName(raw); + if (projectName) requirement.project_name = projectName; + return { requirement, isPlainText: true }; + } + + const projectMatch = raw.match(KNOWN_PROTOCOL_PATTERN); + if (projectMatch) { + return { requirement: { project_name: projectMatch[0].trim() }, isPlainText: true }; + } + + return { requirement: {}, isPlainText: true }; + } + + return { requirement: {}, isPlainText: false }; +} diff --git a/adapters/acp-adapter/src/sdk.ts b/adapters/acp-adapter/src/sdk.ts new file mode 100644 index 0000000..14d287d --- /dev/null +++ b/adapters/acp-adapter/src/sdk.ts @@ -0,0 +1,66 @@ +// The ONE place the real @virtuals-protocol/acp-node-v2 SDK is loaded. It is a RUNTIME-ONLY +// external, resolved from node_modules on the box exactly as the ElizaOS agent resolves it. The +// import specifier is typed `string` (not a literal) so tsc does NOT statically resolve the SDK — +// the adapter core, its unit tests, and the dist build need none of the SDK's heavy transitive +// tree (@account-kit / @alchemy / @privy-io / socket.io — the M5 VPS OOM). main.ts calls this; +// tests inject a fake AcpSdkBundle instead. +import { base } from 'viem/chains'; +import type { AcpSdkBundle, AcpAgentLike, AcpJobSession, AcpRoomEntry, AcpAgentConfig } from './acpTypes.js'; + +const ACP_SDK_SPECIFIER = '@virtuals-protocol/acp-node-v2'; + +/** The subset of the SDK's runtime surface the bundle maps (kept structural — no SDK types). */ +interface RawAcpSdk { + AcpAgent: { create(opts: { provider: unknown; transport: unknown }): Promise }; + PrivyAlchemyEvmProviderAdapter: { + create(opts: { + walletAddress: `0x${string}`; + walletId: string; + signerPrivateKey: string; + chains: unknown[]; + }): Promise; + }; + AssetToken: { usdc(amount: number, chainId: number): unknown }; + SseTransport: new () => unknown; + JobSession: new ( + agent: AcpAgentLike, + walletAddress: string, + jobId: string, + chainId: number, + roles: string[], + entries: AcpRoomEntry[], + ) => AcpJobSession; +} + +export async function createRealSdkBundle(): Promise { + // `: string` (not a literal) → tsc treats the dynamic import as Promise, never resolving + // the module at compile time. Runtime resolves it from node_modules. + const spec: string = ACP_SDK_SPECIFIER; + const sdk = (await import(spec)) as unknown as RawAcpSdk; + + return { + async createAgent(config: AcpAgentConfig, onEntry): Promise { + // Q6: reuse the Privy non-custodial wallet 0xa966… — the SAME signer the pm2 agent uses, so + // the Virtuals registration + Agent ID + accrued history are preserved across the cutover. + const provider = await sdk.PrivyAlchemyEvmProviderAdapter.create({ + walletAddress: config.agentWalletAddress as `0x${string}`, + walletId: config.privyWalletId, + signerPrivateKey: config.privySignerKey, + chains: [base], + }); + // FDQ-63 (verified against the dist): create() + start() perform NO on-chain write — pure + // client construct + SSE subscribe + REST reads. The only write risk is the adapter's own + // setBudget/submit, which OBSERVE_ONLY suppresses. + const agent = await sdk.AcpAgent.create({ provider, transport: new sdk.SseTransport() }); + agent.on('entry', (session, entry) => onEntry(session, entry)); + return agent; + }, + assetUsdc(amount: number, chainId: number): unknown { + return sdk.AssetToken.usdc(amount, chainId); + }, + newSession(agent, providerAddress, jobId, chainId, entries): AcpJobSession { + // Mirrors the SDK's own hydrateSessions() for a polled funded job with no hydrated session. + return new sdk.JobSession(agent, providerAddress, jobId, chainId, ['provider'], entries); + }, + }; +} diff --git a/adapters/acp-adapter/test/_fakes.ts b/adapters/acp-adapter/test/_fakes.ts new file mode 100644 index 0000000..c412007 --- /dev/null +++ b/adapters/acp-adapter/test/_fakes.ts @@ -0,0 +1,190 @@ +// Test doubles for the ACP adapter — a fake JobSession/agent/SDK bundle (no real SDK loaded) and a +// minimal cached HandlerDeps so the REAL grey-core offeringHandlers resolve offline (cache hit). +import type { + AcpJob, + AcpJobSession, + AcpRoomEntry, + AcpAgentLike, + AcpJobRef, + AcpSdkBundle, +} from '../src/acpTypes.js'; +import type { HandlerDeps } from '@grey/core'; + +/** A recording fake session — captures every signing side effect. */ +export class FakeSession implements AcpJobSession { + jobId: string; + chainId: number; + roles: readonly string[] = ['provider']; + entries: AcpRoomEntry[]; + job: AcpJob | null; + + budgets: unknown[] = []; + submitted: string[] = []; + rejected: string[] = []; + messages: Array<{ content: string; contentType: string }> = []; + fetchJobCalls = 0; + + constructor(opts: { jobId: string; chainId?: number; job: AcpJob | null; entries?: AcpRoomEntry[] }) { + this.jobId = opts.jobId; + this.chainId = opts.chainId ?? 8453; + this.job = opts.job; + this.entries = opts.entries ?? []; + } + + async fetchJob(): Promise { + this.fetchJobCalls++; + if (!this.job) throw new Error('FakeSession: no job'); + return this.job; + } + async setBudget(b: unknown): Promise { + this.budgets.push(b); + } + async submit(d: string): Promise { + this.submitted.push(d); + } + async sendMessage(content: string, contentType: string): Promise { + this.messages.push({ content, contentType }); + } + async reject(reason: string): Promise { + this.rejected.push(reason); + } + + /** Convenience: the single submitted deliverable, parsed. */ + deliverable(): { type: string; value: Record } { + if (this.submitted.length !== 1) throw new Error(`expected 1 submit, got ${this.submitted.length}`); + return JSON.parse(this.submitted[0]) as { type: string; value: Record }; + } +} + +/** A requirement room entry carrying a JSON requirement. */ +export function requirementEntry(requirement: Record): AcpRoomEntry { + return { kind: 'message', contentType: 'requirement', content: JSON.stringify(requirement) }; +} + +/** A system lifecycle entry. */ +export function systemEntry(type: string): AcpRoomEntry { + return { kind: 'system', event: { type } }; +} + +/** A configurable fake agent for start()/poll tests. */ +export class FakeAgent implements AcpAgentLike { + started = false; + stopped = false; + onEntry: ((s: AcpJobSession, e: AcpRoomEntry) => void) | null = null; + activeJobs: AcpJobRef[] = []; + jobsById = new Map(); + sessions = new Map(); + historyByJob = new Map(); + + on(_event: 'entry', cb: (s: AcpJobSession, e: AcpRoomEntry) => void): void { + this.onEntry = cb; + } + async start(): Promise { + this.started = true; + } + async stop(): Promise { + this.stopped = true; + } + getSession(chainId: number, jobId: string): AcpJobSession | null | undefined { + return this.sessions.get(`${chainId}:${jobId}`); + } + getTransport(): { getHistory(chainId: number, jobId: string): Promise } { + return { + getHistory: async (chainId, jobId) => this.historyByJob.get(`${chainId}:${jobId}`) ?? [], + }; + } + getApi(): { + getActiveJobs(): Promise; + getJob(chainId: number, jobId: string): Promise; + } { + return { + getActiveJobs: async () => this.activeJobs, + getJob: async (_chainId, jobId) => this.jobsById.get(jobId) ?? null, + }; + } + async getAddress(): Promise { + return '0xa9667116b4f4e9f1bae85f93a21b4b8ea45de98f'; + } +} + +/** A fake SDK bundle over a fake agent. assetUsdc returns a recognizable opaque token. */ +export function fakeSdk(agent: AcpAgentLike, newSession?: AcpSdkBundle['newSession']): AcpSdkBundle { + return { + createAgent: async (_config, onEntry) => { + (agent as FakeAgent).onEntry = onEntry; + return agent; + }, + assetUsdc: (amount, chainId) => ({ __usdc: amount, chainId }), + newSession: + newSession ?? + ((_agent, _addr, jobId, chainId) => new FakeSession({ jobId, chainId, job: null })), + }; +} + +/** An SDK bundle whose ops throw — asserts a code path never touches the SDK. */ +export const throwingSdk: AcpSdkBundle = { + createAgent: async () => { + throw new Error('SDK.createAgent should not be called'); + }, + assetUsdc: () => { + throw new Error('SDK.assetUsdc should not be called'); + }, + newSession: () => { + throw new Error('SDK.newSession should not be called'); + }, +}; + +const TS = new Date('2026-06-14T00:00:00.000Z'); + +/** Minimal cached HandlerDeps so the REAL legitimacy_scan resolves a cache HIT offline. */ +export function cachedDeps(token: string): HandlerDeps { + const wp = { + id: 'wp-1', + projectName: 'Uniswap', + tokenAddress: token, + } as unknown; + const v = { + structuralScore: 4, + verdict: 'PASS', + hypeTechRatio: 1.2, + totalClaims: 2, + verifiedClaims: 2, + confidenceScore: 82, + structuralAnalysisJson: { mica: { claimsMicaCompliance: 'NO', micaCompliant: 'YES', micaSummary: 'ok' } }, + verifiedAt: TS, + } as unknown; + const deps = { + whitepapers: { + findByTokenAddress: async (a: string) => (a.toLowerCase() === token.toLowerCase() ? [wp] : []), + findByProjectName: async () => [], + findById: async () => wp, + }, + verifications: { + findByWhitepaperId: async () => v, + }, + claims: { findByWhitepaperId: async () => [] }, + clock: () => TS, + config: { + version: '0.0.0-test', + did: 'did:erc8004:8453:58618', + name: 'Whitepaper Grey', + runtime: 'acp-adapter-test', + payTo: '0x0000000000000000000000000000000000000000', + network: 'eip155:8453', + }, + }; + return deps as unknown as HandlerDeps; +} + +/** Standard test config. */ +export function testConfig(over: Record = {}): import('../src/config.js').AcpAdapterConfig { + return { + agentWalletAddress: '0xa9667116b4f4e9f1bae85f93a21b4b8ea45de98f', + privyWalletId: 'wallet-id', + privySignerKey: '0xsigner', + databaseUrl: 'postgres://x', + observeOnly: false, + pollIntervalMs: 30_000, + ...over, + }; +} diff --git a/adapters/acp-adapter/test/acpAdapter.test.ts b/adapters/acp-adapter/test/acpAdapter.test.ts new file mode 100644 index 0000000..f4ddefd --- /dev/null +++ b/adapters/acp-adapter/test/acpAdapter.test.ts @@ -0,0 +1,240 @@ +// AcpAdapter — ChannelIngress conformance + the ported dispatch behaviors. All against fakes; the +// SDK is never loaded. The funded-delivery test drives the REAL grey-core offeringHandlers offline +// (cache hit) — that IS the tier-1 wiring proof, also runnable via scripts/tier1-offline-smoke.ts. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { offeringHandlers } from '@grey/core'; +import { AcpAdapter } from '../src/acpAdapter.js'; +import type { ChannelIngress } from '@grey/core'; +import type { AcpJob, OfferingHandler } from '../src/acpTypes.js'; +import { + FakeSession, + FakeAgent, + fakeSdk, + throwingSdk, + cachedDeps, + testConfig, + requirementEntry, + systemEntry, +} from './_fakes.js'; +import { silentLogger } from '../src/logger.js'; + +const TOKEN = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; + +function fundedJob(over: Partial = {}): AcpJob { + return { + description: 'legitimacy_scan', + clientAddress: '0xbuyer0000000000000000000000000000000001', + status: 'funded', + expiredAt: 4102444800, // 2100-01-01 (far future, unix seconds) + ...over, + }; +} + +function makeAdapter(opts: { + observeOnly?: boolean; + handlers?: Record; + pollIntervalMs?: number; + agent?: FakeAgent; +}) { + const agent = opts.agent ?? new FakeAgent(); + const adapter = new AcpAdapter({ + config: testConfig({ observeOnly: opts.observeOnly ?? false, pollIntervalMs: opts.pollIntervalMs ?? 30_000 }), + sdk: opts.agent || opts.pollIntervalMs ? fakeSdk(agent) : throwingSdk, + deps: cachedDeps(TOKEN), + handlers: opts.handlers ?? (offeringHandlers as unknown as Record), + logger: silentLogger(), + }); + return { adapter, agent }; +} + +let running: AcpAdapter | null = null; +afterEach(async () => { + if (running) await running.stop(); + running = null; + vi.useRealTimers(); +}); + +describe('AcpAdapter — ChannelIngress conformance', () => { + it('satisfies the interface + identity() returns 0xa966… receiving address and the DID', () => { + const { adapter } = makeAdapter({}); + const ci: ChannelIngress = adapter; + expect(typeof ci.start).toBe('function'); + expect(typeof ci.stop).toBe('function'); + expect(adapter.identity()).toEqual({ + receivingAddress: '0xa9667116b4f4e9f1bae85f93a21b4b8ea45de98f', + did: 'did:erc8004:8453:58618', + }); + }); + + it('registerOffering records the catalog (observability)', () => { + const { adapter } = makeAdapter({}); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + adapter.registerOffering({ slug: 'verify_full_tech', priceUsd: 3 }); + expect(adapter.listOfferings()).toEqual([ + { slug: 'legitimacy_scan', priceUsd: 0.25 }, + { slug: 'verify_full_tech', priceUsd: 3 }, + ]); + }); + + it('start() creates + starts the agent through the injected SDK; stop() stops it', async () => { + const agent = new FakeAgent(); + const { adapter } = makeAdapter({ agent }); + running = adapter; + await adapter.start(); + expect(agent.started).toBe(true); + expect(agent.onEntry).toBeTypeOf('function'); + await expect(adapter.start()).rejects.toThrow(/already started/); + await adapter.stop(); + expect(agent.stopped).toBe(true); + running = null; + }); +}); + +describe('AcpAdapter — accept + delivery', () => { + it('job.created → parses requirement + accepts via setBudget at the registered price', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + const session = new FakeSession({ + jobId: '1', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.created')); + expect(session.budgets).toEqual([{ __usdc: 0.25, chainId: 8453 }]); + expect(session.rejected).toHaveLength(0); + }); + + it('job.created with an unparseable requirement → rejects pre-acceptance', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + const session = new FakeSession({ + jobId: '2', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ note: 'no address' })], + }); + await adapter.handleEntry(session, systemEntry('job.created')); + expect(session.budgets).toHaveLength(0); + expect(session.rejected).toHaveLength(1); + }); + + it('job.funded → runs the REAL shared handler + submits the {type:object,value} deliverable (tier-1)', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + const session = new FakeSession({ + jobId: '3', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.funded')); + const d = session.deliverable(); + expect(d.type).toBe('object'); + // The cache-hit legitimacy payload from grey-core's shared handler. + expect(d.value.verdict).toBe('PASS'); + expect(d.value.tokenAddress).toBe(TOKEN); + expect(d.value.projectName).toBe('Uniswap'); + // Post-submit nudge fired (best-effort, two messages). + expect(session.messages.map((m) => m.contentType)).toEqual(['text', 'structured']); + }); + + it('once funded, a handler throw still delivers an INSUFFICIENT_DATA envelope (never rejects)', async () => { + const handlers = { + legitimacy_scan: (async () => { + throw new Error('boom'); + }) as unknown as OfferingHandler, + }; + const { adapter } = makeAdapter({ handlers, agent: new FakeAgent() }); + const session = new FakeSession({ + jobId: '4', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.funded')); + const d = session.deliverable(); + expect(d.value.verdict).toBe('INSUFFICIENT_DATA'); + expect(d.value.error).toBe('boom'); + expect(session.rejected).toHaveLength(0); + }); + + it('claimDispatch dedups a repeated job.funded (delivers exactly once)', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + const session = new FakeSession({ + jobId: '5', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.funded')); + await adapter.handleEntry(session, systemEntry('job.funded')); + expect(session.submitted).toHaveLength(1); + }); + + it('pre-submit re-check skips delivery when the job is no longer FUNDED', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + const session = new FakeSession({ + jobId: '6', + job: fundedJob({ status: 'completed' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.funded')); + expect(session.submitted).toHaveLength(0); + }); +}); + +describe('AcpAdapter — OBSERVE_ONLY (FDQ-63 safety)', () => { + it('job.created in observe-only signs NOTHING (no setBudget, no reject)', async () => { + const { adapter } = makeAdapter({ observeOnly: true, agent: new FakeAgent() }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + const session = new FakeSession({ + jobId: '7', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.created')); + expect(session.budgets).toHaveLength(0); + expect(session.rejected).toHaveLength(0); + expect(session.submitted).toHaveLength(0); + expect(session.messages).toHaveLength(0); + // It DID read the job for observation (read-only, no signing). + expect(session.fetchJobCalls).toBeGreaterThan(0); + }); + + it('job.funded in observe-only submits NOTHING (suppression covers the delivery path)', async () => { + const { adapter } = makeAdapter({ observeOnly: true, agent: new FakeAgent() }); + const session = new FakeSession({ + jobId: '8', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.funded')); + expect(session.submitted).toHaveLength(0); + expect(session.messages).toHaveLength(0); + }); +}); + +describe('AcpAdapter — poll backstop', () => { + it('a FUNDED job for our wallet, seen only by the poll, is dispatched + delivered', async () => { + vi.useFakeTimers(); + const agent = new FakeAgent(); + const ourAddr = '0xa9667116b4f4e9f1bae85f93a21b4b8ea45de98f'; + agent.activeJobs = [{ chainId: 8453, onChainJobId: '99' }]; + agent.jobsById.set('99', { + description: 'legitimacy_scan', + clientAddress: '0xbuyer', + providerAddress: ourAddr, + status: 'funded', + ...({ jobStatus: 'funded' } as object), + } as AcpJob); + const polledSession = new FakeSession({ + jobId: '99', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + agent.sessions.set('8453:99', polledSession); + + const { adapter } = makeAdapter({ agent, pollIntervalMs: 1000 }); + running = adapter; + await adapter.start(); + await vi.advanceTimersByTimeAsync(1000); + + expect(polledSession.submitted).toHaveLength(1); + expect(polledSession.deliverable().value.verdict).toBe('PASS'); + }); +}); diff --git a/adapters/acp-adapter/test/parseRequirement.test.ts b/adapters/acp-adapter/test/parseRequirement.test.ts new file mode 100644 index 0000000..88a11ab --- /dev/null +++ b/adapters/acp-adapter/test/parseRequirement.test.ts @@ -0,0 +1,60 @@ +// NL requirement parser — the port must emit a CLEAN {token_address?, project_name?} that keys +// into the shared grey-core handlers, and the known-protocol regex must come from @grey/pipeline's +// canonical list (no third divergent copy). +import { describe, it, expect } from 'vitest'; +import { parseRequirement } from '../src/parseRequirement.js'; + +const TOKEN = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; + +describe('parseRequirement', () => { + it('structured object → picks token_address/project_name (isPlainText=false)', () => { + const r = parseRequirement({ token_address: TOKEN, project_name: 'Uniswap', extra: 'ignored' }); + expect(r).toEqual({ requirement: { token_address: TOKEN, project_name: 'Uniswap' }, isPlainText: false }); + }); + + it('accepts camelCase tokenAddress/projectName in structured input', () => { + const r = parseRequirement({ tokenAddress: TOKEN, projectName: 'Aave' }); + expect(r.requirement).toEqual({ token_address: TOKEN, project_name: 'Aave' }); + expect(r.isPlainText).toBe(false); + }); + + it('JSON string → parsed as structured', () => { + const r = parseRequirement(JSON.stringify({ token_address: TOKEN })); + expect(r).toEqual({ requirement: { token_address: TOKEN }, isPlainText: false }); + }); + + it('plain text with address + known protocol → token + name (isPlainText=true)', () => { + const r = parseRequirement(`Please verify Uniswap (${TOKEN}) for legitimacy`); + expect(r.isPlainText).toBe(true); + expect(r.requirement.token_address).toBe(TOKEN); + expect(r.requirement.project_name).toBe('Uniswap'); + }); + + it('plain text with address but no recognizable name → token only', () => { + const r = parseRequirement(`scan ${TOKEN}`); + expect(r.requirement.token_address).toBe(TOKEN); + expect(r.isPlainText).toBe(true); + }); + + it('plain text with only a known protocol name → project_name only', () => { + const r = parseRequirement('Analyze Chainlink please'); + expect(r.requirement).toEqual({ project_name: 'Chainlink' }); + expect(r.isPlainText).toBe(true); + }); + + it('drops the legacy raw_instruction / _signals stamps (clean subject only)', () => { + const r = parseRequirement(`verify Aave (${TOKEN})`); + expect(Object.keys(r.requirement).sort()).toEqual(['project_name', 'token_address']); + }); + + it('unparseable text → empty requirement (caller rejects)', () => { + const r = parseRequirement('hello there'); + expect(r.requirement).toEqual({}); + expect(r.isPlainText).toBe(true); + }); + + it('non-string/non-object → empty', () => { + expect(parseRequirement(42)).toEqual({ requirement: {}, isPlainText: false }); + expect(parseRequirement(null)).toEqual({ requirement: {}, isPlainText: false }); + }); +}); diff --git a/adapters/acp-adapter/tsconfig.json b/adapters/acp-adapter/tsconfig.json new file mode 100644 index 0000000..374cd30 --- /dev/null +++ b/adapters/acp-adapter/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + // Node runtime process: uses process + Buffer + timers. The ACP SDK is a RUNTIME-ONLY + // external, loaded via a variable-specifier dynamic import (src/sdk.ts) so tsc never + // statically resolves it — the adapter core, its tests, and the dist build need none of + // the SDK's heavy transitive tree (@account-kit/@alchemy/@privy/socket.io — the M5 VPS OOM). + "types": ["node"] + }, + "include": ["src"] +} diff --git a/adapters/acp-adapter/tsconfig.test.json b/adapters/acp-adapter/tsconfig.test.json new file mode 100644 index 0000000..df3c304 --- /dev/null +++ b/adapters/acp-adapter/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "test", "scripts"] +} diff --git a/infra/systemd/grey-acp-adapter.service b/infra/systemd/grey-acp-adapter.service new file mode 100644 index 0000000..6353a0e --- /dev/null +++ b/infra/systemd/grey-acp-adapter.service @@ -0,0 +1,26 @@ +# grey-acp-adapter — the ACP marketplace as a grey-core ChannelIngress (Movement 6). Holds the ACP +# Privy signer, so it runs as its OWN unit, independent of grey-core and the (to-be-retired) pm2 +# agent. Ships INSTALLED-BUT-DISABLED — becoming the live seller is Phase D's act (stop pm2 grey, +# then start this), NOT Phase C. NEVER co-run this and the pm2 `grey` agent: same signer 0xa966… +# → on-chain double-action. No HTTP surface (the plugin-acp 3001 handler was dropped). +[Unit] +Description=Grey ACP adapter (ChannelIngress — ACP marketplace seller) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=grey +WorkingDirectory=/opt/grey/grey +ExecStart=/usr/bin/node adapters/acp-adapter/dist/main.js +EnvironmentFile=/etc/grey/acp-adapter.env +Restart=on-failure +RestartSec=5 + +# Hardening (drop if a probe shows it breaks node/the SDK; report before removing). +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/packages/grey-pipeline/src/index.ts b/packages/grey-pipeline/src/index.ts index ca3c2c8..3a25ef4 100644 --- a/packages/grey-pipeline/src/index.ts +++ b/packages/grey-pipeline/src/index.ts @@ -53,3 +53,11 @@ export { // Constants worth exposing export { GREY_MODEL, LLM_PRICING, DEFAULT_SCORE_WEIGHTS } from './constants'; + +// Canonical protocol list (single source — M6: the ACP adapter's NL parser compiles its +// project-name regex from here rather than keeping a third divergent copy). +export { + KNOWN_PROTOCOL_NAMES, + KNOWN_PROTOCOL_PATTERN, + buildProtocolPattern, +} from './constants/protocols'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bc604c..01687c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,25 @@ importers: specifier: ^3 version: 3.2.6(@types/node@24.13.2)(tsx@4.22.4) + adapters/acp-adapter: + dependencies: + '@grey/core': + specifier: workspace:* + version: link:../../packages/grey-core + '@grey/pipeline': + specifier: workspace:* + version: link:../../packages/grey-pipeline + '@grey/x402-middleware': + specifier: workspace:* + version: link:../x402-middleware + viem: + specifier: ^2.53.1 + version: 2.53.1(typescript@5.9.3) + devDependencies: + tsx: + specifier: ^4.22.4 + version: 4.22.4 + adapters/x402-middleware: dependencies: fastify: @@ -159,11 +178,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}