From 37b1e4423066477b3d0d8bb36f5045b90940516d Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Fri, 24 Jul 2026 12:45:20 -0400 Subject: [PATCH 1/2] fix(m6): FDQ-70 root-cause SDK import fix via committed pnpm patch (drop tsx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @virtuals-protocol/acp-node-v2@0.0.4 ships bun-authored ESM with extensionless relative imports (dist/index.js: `export * from "./acpAgent"` …). bun tolerates them; strict Node ESM rejects them (ERR_MODULE_NOT_FOUND .../dist/acpAgent), so the standalone `node` adapter process could not load the SDK. Root-cause fix: a committed pnpm patch appending `.js` to all 52 relative specifiers across the SDK dist (every target verified to be an existing sibling .js file; zero dynamic import()/require; no exports map). pnpm reapplies it deterministically on every install, and fails loudly (ERR_PNPM_UNUSED_PATCH) on any version bump. Keeps plain Node at runtime — no `--import tsx` loader, tsx stays a devDependency. Adds zero packages to the tree; the FDQ-69b native-build decline (bufferutil/utf-8-validate/es5-ext) is intact. Proof: on plain Node the unpatched SDK throws ERR_MODULE_NOT_FOUND; patched, it imports with all key exports present (AcpAgent, PrivyAlchemyEvmProviderAdapter, AssetToken, SseTransport, JobSession) — no wallet action. Supersedes PR #30 (tsx-as-runtime), which is branched off main and NOT merged. Co-Authored-By: Claude Opus 4.8 --- ...virtuals-protocol__acp-node-v2@0.0.4.patch | 268 ++++++++++++++++++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 3 + 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 patches/@virtuals-protocol__acp-node-v2@0.0.4.patch diff --git a/patches/@virtuals-protocol__acp-node-v2@0.0.4.patch b/patches/@virtuals-protocol__acp-node-v2@0.0.4.patch new file mode 100644 index 0000000..85e630d --- /dev/null +++ b/patches/@virtuals-protocol__acp-node-v2@0.0.4.patch @@ -0,0 +1,268 @@ +diff --git a/dist/acpAgent.js b/dist/acpAgent.js +index 70f18cfae9638dba9b95002371647eef076fe9a3..20fbf1e7075cde3bce304fa8365aff07b5e828f1 100644 +--- a/dist/acpAgent.js ++++ b/dist/acpAgent.js +@@ -1,13 +1,13 @@ + import { encodeAbiParameters, zeroAddress } from "viem"; + import Ajv from "ajv"; +-import { createAcpClient, } from "./clientFactory"; +-import { EvmAcpClient } from "./clients/evmAcpClient"; +-import { FUND_TRANSFER_HOOK_ADDRESSES, getAddressForChain, MIN_SLA_MINS, BUFFER_SECONDS, } from "./core/constants"; +-import { AssetToken } from "./core/assetToken"; +-import { JobSession } from "./jobSession"; +-import { AcpApiClient } from "./events/acpApiClient"; +-import { AcpHttpClient } from "./events/acpHttpClient"; +-import { SseTransport } from "./events/sseTransport"; ++import { createAcpClient, } from "./clientFactory.js"; ++import { EvmAcpClient } from "./clients/evmAcpClient.js"; ++import { FUND_TRANSFER_HOOK_ADDRESSES, getAddressForChain, MIN_SLA_MINS, BUFFER_SECONDS, } from "./core/constants.js"; ++import { AssetToken } from "./core/assetToken.js"; ++import { JobSession } from "./jobSession.js"; ++import { AcpApiClient } from "./events/acpApiClient.js"; ++import { AcpHttpClient } from "./events/acpHttpClient.js"; ++import { SseTransport } from "./events/sseTransport.js"; + // --------------------------------------------------------------------------- + // AcpAgent + // --------------------------------------------------------------------------- +diff --git a/dist/acpJob.js b/dist/acpJob.js +index 470a5dbe667f137ee806a6171474c0857fa51bd9..19b6ec9a21d57ac9af1168a9aec90fb2b8957b12 100644 +--- a/dist/acpJob.js ++++ b/dist/acpJob.js +@@ -1,4 +1,4 @@ +-import { AssetToken } from "./core/assetToken"; ++import { AssetToken } from "./core/assetToken.js"; + const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + export class AcpIntent { + constructor(data) { +diff --git a/dist/clientFactory.js b/dist/clientFactory.js +index c1384b2b618b5793948d503126657d6369e9f770..65d8e65fe7a1affa0449c5b629835f903b373f14 100644 +--- a/dist/clientFactory.js ++++ b/dist/clientFactory.js +@@ -1,6 +1,6 @@ +-import { EvmAcpClient } from "./clients/evmAcpClient"; +-import { SolanaAcpClient } from "./clients/solanaAcpClient"; +-import { ACP_CONTRACT_ADDRESSES } from "./core/constants"; ++import { EvmAcpClient } from "./clients/evmAcpClient.js"; ++import { SolanaAcpClient } from "./clients/solanaAcpClient.js"; ++import { ACP_CONTRACT_ADDRESSES } from "./core/constants.js"; + export async function createAcpClient(input) { + if (isEvmProvider(input.provider)) { + return EvmAcpClient.create({ +diff --git a/dist/clients/evmAcpClient.js b/dist/clients/evmAcpClient.js +index 6fe315c6e32fc642544dadae6bd7611da9505254..d80d1258ebda89ffc5853a3d46d8e41e4fb07a65 100644 +--- a/dist/clients/evmAcpClient.js ++++ b/dist/clients/evmAcpClient.js +@@ -1,7 +1,7 @@ + import { encodeFunctionData, erc20Abi, keccak256, pad, toHex, zeroAddress, } from "viem"; +-import { BaseAcpClient } from "./baseAcpClient"; +-import { ACP_ABI } from "../core/acpAbi"; +-import { parseJobIdFromReceipt } from "../utils/events"; ++import { BaseAcpClient } from "./baseAcpClient.js"; ++import { ACP_ABI } from "../core/acpAbi.js"; ++import { parseJobIdFromReceipt } from "../utils/events.js"; + export class EvmAcpClient extends BaseAcpClient { + constructor(contractAddresses, provider) { + super(contractAddresses); +diff --git a/dist/clients/solanaAcpClient.js b/dist/clients/solanaAcpClient.js +index 6d9a90e332cabfe704b676205e6094317b7359d5..c97e4cf24b0825b83f6b084ee0a14557a6d4b23d 100644 +--- a/dist/clients/solanaAcpClient.js ++++ b/dist/clients/solanaAcpClient.js +@@ -1,5 +1,5 @@ + import { keccak256, toHex } from "viem"; +-import { BaseAcpClient } from "./baseAcpClient"; ++import { BaseAcpClient } from "./baseAcpClient.js"; + export class SolanaAcpClient extends BaseAcpClient { + constructor(contractAddresses, provider) { + super(contractAddresses); +diff --git a/dist/core/assetToken.js b/dist/core/assetToken.js +index 880dc0b44b21b0a3e37838ddd956a74cca334935..5e05537ad9f63d6acbef9bd063ab45dcdfc8e0f2 100644 +--- a/dist/core/assetToken.js ++++ b/dist/core/assetToken.js +@@ -1,5 +1,5 @@ + import { parseUnits } from "viem"; +-import { USDC_ADDRESSES, USDC_DECIMALS, USDC_SYMBOL, getAddressForChain } from "./constants"; ++import { USDC_ADDRESSES, USDC_DECIMALS, USDC_SYMBOL, getAddressForChain } from "./constants.js"; + export class AssetToken { + constructor(address, symbol, decimals, amount) { + this.address = address; +diff --git a/dist/events/acpApiClient.js b/dist/events/acpApiClient.js +index 152fe0187812be5c9e1aacf72e871863cf79a890..564e765805dfe5f2deab19d6311f223c779422c4 100644 +--- a/dist/events/acpApiClient.js ++++ b/dist/events/acpApiClient.js +@@ -1,4 +1,4 @@ +-import { AcpHttpClient } from "./acpHttpClient"; ++import { AcpHttpClient } from "./acpHttpClient.js"; + export class AcpApiClient extends AcpHttpClient { + constructor(opts = {}) { + super(opts); +diff --git a/dist/events/acpHttpClient.js b/dist/events/acpHttpClient.js +index 3a4ee77f49145bf50d35d16f754d1e4f935be54b..7d4f0b0e1fa2c45c19e212bd5eb7576be5b14857 100644 +--- a/dist/events/acpHttpClient.js ++++ b/dist/events/acpHttpClient.js +@@ -1,4 +1,4 @@ +-import { ACP_SERVER_URL } from "../core/constants"; ++import { ACP_SERVER_URL } from "../core/constants.js"; + export class AcpHttpClient { + constructor(opts = {}) { + this.ctx = null; +diff --git a/dist/events/socketTransport.js b/dist/events/socketTransport.js +index 3514bfe3143dedd021d960000419661f467479e1..2bbb341fdc3fdde73c6ed94babd47b3e98a5364c 100644 +--- a/dist/events/socketTransport.js ++++ b/dist/events/socketTransport.js +@@ -1,5 +1,5 @@ + import { io } from "socket.io-client"; +-import { AcpHttpClient } from "./acpHttpClient"; ++import { AcpHttpClient } from "./acpHttpClient.js"; + export class SocketTransport extends AcpHttpClient { + constructor(opts = {}) { + super(opts); +diff --git a/dist/events/sseTransport.js b/dist/events/sseTransport.js +index cfb8026f11643db1f09bdab53241d9a60e4dbe2a..a5aa8a2cc2af0dd5e486f20638c2e4a621c58c02 100644 +--- a/dist/events/sseTransport.js ++++ b/dist/events/sseTransport.js +@@ -1,5 +1,5 @@ + import { EventSource } from "eventsource"; +-import { AcpHttpClient } from "./acpHttpClient"; ++import { AcpHttpClient } from "./acpHttpClient.js"; + export class SseTransport extends AcpHttpClient { + constructor(opts = {}) { + super(opts); +diff --git a/dist/index.js b/dist/index.js +index 15281a74d8b417c4e2c20a8e59f3ec33623fa017..bd5c090e6af45a0c3c7a4a6bb969849c24501e90 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1,28 +1,28 @@ + // Primary API +-export * from "./acpAgent"; +-export * from "./acpJob"; +-export * from "./jobSession"; ++export * from "./acpAgent.js"; ++export * from "./acpJob.js"; ++export * from "./jobSession.js"; + // Client layer +-export * from "./clientFactory"; +-export * from "./clients/baseAcpClient"; +-export * from "./clients/evmAcpClient"; +-export * from "./clients/solanaAcpClient"; ++export * from "./clientFactory.js"; ++export * from "./clients/baseAcpClient.js"; ++export * from "./clients/evmAcpClient.js"; ++export * from "./clients/solanaAcpClient.js"; + // Core types +-export * from "./core/acpAbi"; +-export * from "./core/chains"; +-export * from "./core/constants"; +-export * from "./core/assetToken"; ++export * from "./core/acpAbi.js"; ++export * from "./core/chains.js"; ++export * from "./core/constants.js"; ++export * from "./core/assetToken.js"; + // Provider interfaces & adapters +-export * from "./providers/types"; +-export * from "./providers/evm/viemProviderAdapter"; +-export * from "./providers/evm/alchemyEvmProviderAdapter"; +-export * from "./providers/evm/privyAlchemyEvmProviderAdapter"; +-export * from "./providers/solana/solanaProviderAdapter"; ++export * from "./providers/types.js"; ++export * from "./providers/evm/viemProviderAdapter.js"; ++export * from "./providers/evm/alchemyEvmProviderAdapter.js"; ++export * from "./providers/evm/privyAlchemyEvmProviderAdapter.js"; ++export * from "./providers/solana/solanaProviderAdapter.js"; + // Transport & API +-export { AcpHttpClient } from "./events/acpHttpClient"; +-export { AcpApiClient } from "./events/acpApiClient"; +-export { SocketTransport } from "./events/socketTransport"; +-export { SseTransport } from "./events/sseTransport"; ++export { AcpHttpClient } from "./events/acpHttpClient.js"; ++export { AcpApiClient } from "./events/acpApiClient.js"; ++export { SocketTransport } from "./events/socketTransport.js"; ++export { SseTransport } from "./events/sseTransport.js"; + // Utilities +-export * from "./utils/events"; ++export * from "./utils/events.js"; + //# sourceMappingURL=index.js.map +\ No newline at end of file +diff --git a/dist/jobSession.js b/dist/jobSession.js +index 251090e7cbc0b47f5392a074c8d51a01f22c8cbe..2fde0f0227ab140ce1b3d9cca7e04b799cecb054 100644 +--- a/dist/jobSession.js ++++ b/dist/jobSession.js +@@ -1,5 +1,5 @@ +-import { AcpJob } from "./acpJob"; +-import { AssetToken } from "./core/assetToken"; ++import { AcpJob } from "./acpJob.js"; ++import { AssetToken } from "./core/assetToken.js"; + const EVENT_TO_STATUS = { + "job.created": "open", + "budget.set": "budget_set", +diff --git a/dist/providers/evm/alchemyEvmProviderAdapter.js b/dist/providers/evm/alchemyEvmProviderAdapter.js +index ffb9996fa72d8246d45643dd5b5e7ec3558dee9d..14e84064dc19a0fbbd2004da39b93be4bac004be 100644 +--- a/dist/providers/evm/alchemyEvmProviderAdapter.js ++++ b/dist/providers/evm/alchemyEvmProviderAdapter.js +@@ -1,7 +1,7 @@ + import { LocalAccountSigner } from "@aa-sdk/core"; + import { alchemy, defineAlchemyChain } from "@account-kit/infra"; + import { createModularAccountV2Client, } from "@account-kit/smart-contracts"; +-import { createEvmNetworkContext, EVM_MAINNET_CHAINS } from "../../core/chains"; ++import { createEvmNetworkContext, EVM_MAINNET_CHAINS } from "../../core/chains.js"; + export class AlchemyEvmProviderAdapter { + constructor(address, clients) { + this.providerName = "Alchemy"; +diff --git a/dist/providers/evm/privyAlchemyEvmProviderAdapter.js b/dist/providers/evm/privyAlchemyEvmProviderAdapter.js +index 28ce00cbd87caa51f5afb6dd50b47942d3a37e21..d231f7b716ae5e37695de3a693dd06edc7559f68 100644 +--- a/dist/providers/evm/privyAlchemyEvmProviderAdapter.js ++++ b/dist/providers/evm/privyAlchemyEvmProviderAdapter.js +@@ -1,10 +1,10 @@ + import { createPublicClient, http, toHex, } from "viem"; + import { getTransactionReceipt, readContract, getLogs, getBlockNumber, } from "viem/actions"; +-import { createEvmNetworkContext, EVM_MAINNET_CHAINS } from "../../core/chains"; ++import { createEvmNetworkContext, EVM_MAINNET_CHAINS } from "../../core/chains.js"; + import { formatRequestForAuthorizationSignature, generateAuthorizationSignature, } from "@privy-io/node"; + import { createSmartWalletClient, alchemyWalletTransport, } from "@alchemy/wallet-apis"; +-import { ACP_SERVER_URL, ALCHEMY_POLICY_ID, PRIVY_APP_ID, } from "../../core/constants"; +-import { ProviderAuthClient } from "../providerAuthClient"; ++import { ACP_SERVER_URL, ALCHEMY_POLICY_ID, PRIVY_APP_ID, } from "../../core/constants.js"; ++import { ProviderAuthClient } from "../providerAuthClient.js"; + function encodeSignableMessage(message) { + if (typeof message === "string") { + if (message.startsWith("0x")) { +diff --git a/dist/providers/evm/viemProviderAdapter.js b/dist/providers/evm/viemProviderAdapter.js +index 16a070e814087930f1d0be69a246677e4b56a156..d5c649c6199b04602ff3e7494c0d9871e9a09d0c 100644 +--- a/dist/providers/evm/viemProviderAdapter.js ++++ b/dist/providers/evm/viemProviderAdapter.js +@@ -1,4 +1,4 @@ +-import { createEvmNetworkContext } from "../../core/chains"; ++import { createEvmNetworkContext } from "../../core/chains.js"; + export class ViemProviderAdapter { + constructor(providerName) { + this.providerName = providerName; +diff --git a/dist/providers/providerAuthClient.js b/dist/providers/providerAuthClient.js +index c97ee2b5b41181e67fb0287ac5cb9fd577f3a41a..e72b33a1f462b568dbe662e6dd71779b88f14a0a 100644 +--- a/dist/providers/providerAuthClient.js ++++ b/dist/providers/providerAuthClient.js +@@ -1,4 +1,4 @@ +-import { ACP_SERVER_URL } from "../core/constants"; ++import { ACP_SERVER_URL } from "../core/constants.js"; + export class ProviderAuthClient { + constructor(opts) { + this.token = ""; +diff --git a/dist/providers/solana/solanaProviderAdapter.js b/dist/providers/solana/solanaProviderAdapter.js +index 244b64abcc058cd47b6c0496ba039ceebfd7b882..2d00b9bde8592db6422f5f7d8a7615b7e363b816 100644 +--- a/dist/providers/solana/solanaProviderAdapter.js ++++ b/dist/providers/solana/solanaProviderAdapter.js +@@ -1,4 +1,4 @@ +-import { createSolanaNetworkContext } from "../../core/chains"; ++import { createSolanaNetworkContext } from "../../core/chains.js"; + export class SolanaProviderAdapter { + constructor(providerName) { + this.providerName = providerName; +diff --git a/dist/utils/events.js b/dist/utils/events.js +index 694da564429d5bee39e675dbd11eca0c0cc33ca3..dda4243e9ff2353b895756b430e8563cf52bd738 100644 +--- a/dist/utils/events.js ++++ b/dist/utils/events.js +@@ -1,5 +1,5 @@ + import { decodeEventLog } from "viem"; +-import { ACP_ABI } from "../core/acpAbi"; ++import { ACP_ABI } from "../core/acpAbi.js"; + function addressEq(a, b) { + return a.toLowerCase() === b.toLowerCase(); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c8dcf4..bc3c3e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@virtuals-protocol/acp-node-v2@0.0.4': 26623b3f61d2912475c80828c9a865f25808a4f6383aa30d2a4e3ee18fb7cb56 + importers: .: @@ -58,7 +61,7 @@ importers: version: link:../x402-middleware '@virtuals-protocol/acp-node-v2': specifier: ^0.0.4 - version: 0.0.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3) + version: 0.0.4(patch_hash=26623b3f61d2912475c80828c9a865f25808a4f6383aa30d2a4e3ee18fb7cb56)(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3) viem: specifier: ^2.53.1 version: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3) @@ -4209,7 +4212,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 eslint-visitor-keys: 5.0.1 - '@virtuals-protocol/acp-node-v2@0.0.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)': + '@virtuals-protocol/acp-node-v2@0.0.4(patch_hash=26623b3f61d2912475c80828c9a865f25808a4f6383aa30d2a4e3ee18fb7cb56)(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)': dependencies: '@aa-sdk/core': 4.88.4(typescript@5.9.3)(viem@2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) '@account-kit/infra': 4.88.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(viem@2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b9b1409..57116ea 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,3 +32,6 @@ allowBuilds: bufferutil: false utf-8-validate: false es5-ext: false + +patchedDependencies: + '@virtuals-protocol/acp-node-v2@0.0.4': patches/@virtuals-protocol__acp-node-v2@0.0.4.patch From acf3327cd508e85c4f2a10868ebab36ca77d3b14 Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Fri, 24 Jul 2026 12:46:19 -0400 Subject: [PATCH 2/2] fix(m6): FDQ-70b setBudget idempotency guard on the accept path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit job.created and requirement.message are DISTINCT eventTypes that both drive the accept path, so claimDispatch's per-event key admitted both, and markDecided fired only AFTER setBudget's await — a concurrent pair (SSE double-fire, or hydrateSessions re-firing a created-phase job at startup) could each pass the __decided check and each call setBudget (the #70220 double budget.set 0.25). Fix: claimAccept(chainId, jobId) — a synchronous check-and-set claimed in handleEntry BEFORE handleJobCreated's first await, released in a finally (same key shape and claim/release pattern as the funded inFlight guard). Exactly one setBudget; a transient accept failure still retries via a later event; __decided makes a successful accept permanent. An INVARIANT comment marks that markDecided must stay synchronous-adjacent to the setBudget await. submit needs no new guard: it is already claimed synchronously by claimDispatch (recentJobs[jobId:job.funded] + inFlight, set before any await) and re-checked against a fresh FUNDED status. The poll backstop routes THROUGH handleEntry (dispatchPolledJob) and emits job.funded only, so the accept chokepoint covers every dispatch origin. Tests (27/27): concurrent job.created+requirement.message → one setBudget; hydration re-fire → one; sequential-after-decided → no re-budget; concurrent job.funded → one submit; origin-independent accept guard (poll-shaped entry); real poll-timer vs SSE funded race → one submit. Verified the two accept-race tests fail (got 2) without the guard. Co-Authored-By: Claude Opus 4.8 --- adapters/acp-adapter/src/acpAdapter.ts | 36 ++++- adapters/acp-adapter/test/acpAdapter.test.ts | 136 ++++++++++++++++++- 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/adapters/acp-adapter/src/acpAdapter.ts b/adapters/acp-adapter/src/acpAdapter.ts index c036a09..e6d2b1b 100644 --- a/adapters/acp-adapter/src/acpAdapter.ts +++ b/adapters/acp-adapter/src/acpAdapter.ts @@ -73,6 +73,10 @@ export class AcpAdapter implements ChannelIngress { 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(); + // In-flight ACCEPT guard (FDQ-70b) — per `${chainId}:${jobId}` (same key shape as inFlight); one + // accept attempt at a time, released after the attempt. Closes the job.created / requirement.message + // double-dispatch race (see handleEntry). + private readonly acceptInFlight = new Set(); // Poll log-once. private readonly pollSeen = new Map(); @@ -167,6 +171,20 @@ export class AcpAdapter implements ChannelIngress { return true; } + /** FDQ-70b accept guard — a synchronous check-and-set (no await between `.has` and `.add`) so a + * concurrent `job.created` + `requirement.message` for one job yields exactly ONE accept attempt. + * Keyed `${chainId}:${jobId}` to match the funded `inFlight` claim; released by the caller once + * the attempt settles. */ + private claimAccept(chainId: number, jobId: string): boolean { + const key = `${chainId}:${jobId}`; + if (this.acceptInFlight.has(key)) return false; + this.acceptInFlight.add(key); + return true; + } + private releaseAccept(chainId: number, jobId: string): void { + this.acceptInFlight.delete(`${chainId}:${jobId}`); + } + 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). @@ -187,7 +205,19 @@ export class AcpAdapter implements ChannelIngress { case 'requirement.message': { const decidedKey = `${jobId}:__decided`; if (this.recentJobs.has(decidedKey)) break; - await this.handleJobCreated(session, entry, log); + // FDQ-70b: `job.created` and `requirement.message` are DISTINCT eventTypes, so claimDispatch's + // per-event key admits BOTH into the accept path — and markDecided only fires AFTER setBudget's + // await, so a concurrent pair (SSE double-fire, or hydration re-firing pre-existing jobs at + // startup) could each pass the __decided check and each call setBudget. Claim a single accept + // slot per jobId SYNCHRONOUSLY here, before handleJobCreated's first await → exactly one + // setBudget. Released in the finally so a transient accept FAILURE can still be retried by a + // later event; the __decided marker makes a SUCCESSFUL accept permanent. + if (!this.claimAccept(session.chainId, jobId)) break; + try { + await this.handleJobCreated(session, entry, log); + } finally { + this.releaseAccept(session.chainId, jobId); + } break; } case 'job.funded': @@ -281,6 +311,10 @@ export class AcpAdapter implements ChannelIngress { // Accept: propose the registered sticker price (no dynamic price resolver in the adapter). const price = this.offeringPrices.get(offeringId) ?? 0; void isPlainText; + // FDQ-70b INVARIANT: markDecided() must remain synchronous-adjacent to this await — + // do NOT insert an await or throwing statement between setBudget resolving and + // markDecided(). The accept claim releases in handleEntry's finally; if __decided + // were unset at that moment, a later event could re-accept an already-accepted job. try { await session.setBudget(this.sdk.assetUsdc(price, session.chainId)); log.info('Job accepted via setBudget', { offeringId, price }); diff --git a/adapters/acp-adapter/test/acpAdapter.test.ts b/adapters/acp-adapter/test/acpAdapter.test.ts index f4ddefd..8c830b8 100644 --- a/adapters/acp-adapter/test/acpAdapter.test.ts +++ b/adapters/acp-adapter/test/acpAdapter.test.ts @@ -5,7 +5,7 @@ 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 type { AcpJob, AcpRoomEntry, OfferingHandler } from '../src/acpTypes.js'; import { FakeSession, FakeAgent, @@ -178,6 +178,140 @@ describe('AcpAdapter — accept + delivery', () => { }); }); +describe('AcpAdapter — FDQ-70b setBudget idempotency (accept race)', () => { + // `job.created` and `requirement.message` are DISTINCT eventTypes that both trigger the accept + // path. Fired concurrently (SSE double-fire, or hydration re-firing a created-phase job at + // startup) they must still produce EXACTLY ONE setBudget. Deterministic: both handlers run to + // their first `await` synchronously, so the second observes the synchronous accept claim — no + // timers, no ordering luck. + it('concurrent job.created + requirement.message → exactly one setBudget', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + const session = new FakeSession({ + jobId: 'race-1', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + const p1 = adapter.handleEntry(session, systemEntry('job.created')); + const p2 = adapter.handleEntry(session, requirementEntry({ token_address: TOKEN })); + await Promise.all([p1, p2]); + expect(session.budgets).toEqual([{ __usdc: 0.25, chainId: 8453 }]); + expect(session.rejected).toHaveLength(0); + }); + + it('hydration-at-startup re-firing the accept pair via agent.on(entry) accepts exactly once', async () => { + const agent = new FakeAgent(); + const { adapter } = makeAdapter({ agent }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + running = adapter; + await adapter.start(); // installs the real onEntry callback hydrateSessions() would fire + const session = new FakeSession({ + jobId: 'hydra-1', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + // hydrateSessions fires 'entry' (fire-and-forget) on the pre-existing created-phase job. + agent.onEntry!(session, systemEntry('job.created')); + agent.onEntry!(session, requirementEntry({ token_address: TOKEN })); + await new Promise((r) => setTimeout(r, 0)); // drain the fire-and-forget handler chains + expect(session.budgets).toHaveLength(1); + running = null; + await adapter.stop(); + }); + + it('a fresh accept event AFTER a decided job does not re-budget (sequential + __decided)', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + const session = new FakeSession({ + jobId: 'seq-1', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + await adapter.handleEntry(session, systemEntry('job.created')); + await adapter.handleEntry(session, requirementEntry({ token_address: TOKEN })); + expect(session.budgets).toHaveLength(1); + }); + + // Evidence that submit needs NO new guard: it is already claimed synchronously by claimDispatch + // (recentJobs['jobId:job.funded'] + inFlight, both set before any await) and re-checked against a + // fresh FUNDED status. Concurrent SSE + poll job.funded therefore delivers exactly once. + it('concurrent job.funded (SSE + poll) → exactly one submit (submit already single-path)', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + const session = new FakeSession({ + jobId: 'fund-1', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + const p1 = adapter.handleEntry(session, systemEntry('job.funded')); + const p2 = adapter.handleEntry(session, systemEntry('job.funded')); + await Promise.all([p1, p2]); + expect(session.submitted).toHaveLength(1); + }); + + // Origin-independence of the ACCEPT guard. handleEntry is the SOLE entry to the accept path + // (setBudget @acpAdapter.ts:312 ← handleJobCreated:214 ← handleEntry, behind claimAccept), and the + // poll backstop routes THROUGH handleEntry too (dispatchPolledJob:551). Here a poll-SHAPED synthetic + // system entry (identical shape to dispatchPolledJob:544-549) carrying an accept-triggering + // job.created races an SSE requirement.message — a DISTINCT eventType that claimDispatch does NOT + // dedup, so only claimAccept stands between them. (In production poll emits job.funded only, so it + // cannot originate an accept; this proves the guard holds for that origin regardless.) + it('accept guard is origin-independent: poll-shaped job.created + SSE requirement.message → one setBudget', async () => { + const { adapter } = makeAdapter({ agent: new FakeAgent() }); + adapter.registerOffering({ slug: 'legitimacy_scan', priceUsd: 0.25 }); + const session = new FakeSession({ + jobId: 'xorigin-1', + job: fundedJob({ status: 'created' }), + entries: [requirementEntry({ token_address: TOKEN })], + }); + const pollShaped: AcpRoomEntry = { + kind: 'system', + onChainJobId: 'xorigin-1', + chainId: 8453, + event: { type: 'job.created', jobId: 'xorigin-1' }, + timestamp: 0, + }; + const p1 = adapter.handleEntry(session, pollShaped); + const p2 = adapter.handleEntry(session, requirementEntry({ token_address: TOKEN })); + await Promise.all([p1, p2]); + expect(session.budgets).toHaveLength(1); + }); + + // The genuinely-reachable multi-path race: the REAL poll timer dispatching the same funded job the + // SSE callback just fired. Both go through handleEntry → claimDispatch's synchronous job.funded + // claim admits exactly one. This is the #70220-class "SSE racing poll" proof on the path poll + // actually exercises. + it('cross-origin funded race: SSE job.funded + REAL poll dispatch (same job) → exactly one submit', async () => { + vi.useFakeTimers(); + const agent = new FakeAgent(); + const ourAddr = '0xa9667116b4f4e9f1bae85f93a21b4b8ea45de98f'; + agent.activeJobs = [{ chainId: 8453, onChainJobId: 'multi-1' }]; + agent.jobsById.set('multi-1', { + description: 'legitimacy_scan', + clientAddress: '0xbuyer', + providerAddress: ourAddr, + status: 'funded', + ...({ jobStatus: 'funded' } as object), + } as AcpJob); + const session = new FakeSession({ + jobId: 'multi-1', + job: fundedJob(), + entries: [requirementEntry({ token_address: TOKEN })], + }); + agent.sessions.set('8453:multi-1', session); // the session the poll's dispatchPolledJob will reuse + + const { adapter } = makeAdapter({ agent, pollIntervalMs: 1000 }); + running = adapter; + await adapter.start(); // installs the SSE onEntry callback + starts the poll + // SSE fires first (fire-and-forget, claims the synchronous job.funded slot before any await); + // the poll tick then dispatches the SAME job through handleEntry and is deduped. + agent.onEntry!(session, systemEntry('job.funded')); + await vi.advanceTimersByTimeAsync(1000); + expect(session.submitted).toHaveLength(1); + running = null; + await adapter.stop(); + }); +}); + 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() });