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
48 changes: 48 additions & 0 deletions adapters/acp-adapter/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions adapters/acp-adapter/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
130 changes: 130 additions & 0 deletions adapters/acp-adapter/scripts/tier1-offline-smoke.ts
Original file line number Diff line number Diff line change
@@ -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<AcpJob> {
return this.job;
}
async setBudget(): Promise<void> {}
async submit(d: string): Promise<void> {
this.submitted.push(d);
}
async sendMessage(c: string): Promise<void> {
this.messages.push(c);
}
async reject(): Promise<void> {}
}

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<void> {
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<string, OfferingHandler>,
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<string, unknown> };
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)));
Loading