spendos 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +4 -0
- package/.env.example +30 -0
- package/AGENTS.md +212 -0
- package/BOOTSTRAP.md +55 -0
- package/Dockerfile +52 -0
- package/HEARTBEAT.md +7 -0
- package/IDENTITY.md +23 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SOUL.md +202 -0
- package/SUBMISSION.md +128 -0
- package/TOOLS.md +40 -0
- package/USER.md +17 -0
- package/acp-seller/bin/acp.ts +807 -0
- package/acp-seller/config.json +34 -0
- package/acp-seller/package.json +55 -0
- package/acp-seller/src/commands/agent.ts +328 -0
- package/acp-seller/src/commands/bounty.ts +1189 -0
- package/acp-seller/src/commands/deploy.ts +414 -0
- package/acp-seller/src/commands/job.ts +217 -0
- package/acp-seller/src/commands/profile.ts +71 -0
- package/acp-seller/src/commands/resource.ts +91 -0
- package/acp-seller/src/commands/search.ts +327 -0
- package/acp-seller/src/commands/sell.ts +883 -0
- package/acp-seller/src/commands/serve.ts +258 -0
- package/acp-seller/src/commands/setup.ts +399 -0
- package/acp-seller/src/commands/token.ts +88 -0
- package/acp-seller/src/commands/wallet.ts +123 -0
- package/acp-seller/src/lib/api.ts +118 -0
- package/acp-seller/src/lib/auth.ts +291 -0
- package/acp-seller/src/lib/bounty.ts +257 -0
- package/acp-seller/src/lib/client.ts +42 -0
- package/acp-seller/src/lib/config.ts +240 -0
- package/acp-seller/src/lib/open.ts +41 -0
- package/acp-seller/src/lib/openclawCron.ts +138 -0
- package/acp-seller/src/lib/output.ts +104 -0
- package/acp-seller/src/lib/wallet.ts +81 -0
- package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
- package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
- package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
- package/acp-seller/src/seller/runtime/logger.ts +36 -0
- package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
- package/acp-seller/src/seller/runtime/offerings.ts +277 -0
- package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
- package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
- package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
- package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
- package/acp-seller/src/seller/runtime/seller.ts +1041 -0
- package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
- package/acp-seller/src/seller/runtime/startup.ts +270 -0
- package/acp-seller/src/seller/runtime/types.ts +62 -0
- package/acp-seller/tsconfig.json +20 -0
- package/bin/spendos.js +23 -0
- package/contracts/SpendOSAudit.sol +29 -0
- package/dist/mcp-server.mjs +153 -0
- package/jobs/translate.json +7 -0
- package/jobs/tweet-gen.json +7 -0
- package/openclaw.json +41 -0
- package/package.json +49 -0
- package/plugins/spendos-events/index.ts +78 -0
- package/plugins/spendos-events/package.json +14 -0
- package/policies/enforce-bounds.mjs +71 -0
- package/public/index.html +509 -0
- package/public/landing.html +241 -0
- package/railway.json +12 -0
- package/railway.toml +12 -0
- package/scripts/deploy.ts +48 -0
- package/scripts/test-x402-mainnet.ts +30 -0
- package/scripts/xmtp-listener.ts +61 -0
- package/setup.sh +278 -0
- package/skills/spendos/skill.md +26 -0
- package/src/agent.ts +152 -0
- package/src/audit.ts +166 -0
- package/src/governance.ts +367 -0
- package/src/job-registry.ts +306 -0
- package/src/mcp-public.ts +145 -0
- package/src/mcp-server.ts +171 -0
- package/src/opportunity-scanner.ts +138 -0
- package/src/server.ts +870 -0
- package/src/venice-x402.ts +234 -0
- package/src/xmtp.ts +109 -0
- package/src/zerion.ts +58 -0
- package/start.sh +168 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Seller runtime — main entrypoint.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// npx tsx src/seller/runtime/seller.ts
|
|
7
|
+
// (or) acp serve start
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import { pathToFileURL } from "url";
|
|
11
|
+
import { connectAcpSocket } from "./acpSocket.js";
|
|
12
|
+
import { acceptOrRejectJob, requestPayment, deliverJob } from "./sellerApi.js";
|
|
13
|
+
import {
|
|
14
|
+
loadOffering,
|
|
15
|
+
listOfferings,
|
|
16
|
+
logOfferingsStatus,
|
|
17
|
+
assertCanonicalOfferingsOrThrow,
|
|
18
|
+
} from "./offerings.js";
|
|
19
|
+
import { AcpJobPhase, MemoType, type AcpJobEventData } from "./types.js";
|
|
20
|
+
import {
|
|
21
|
+
verifyPaymentProof,
|
|
22
|
+
verifyPaymentOnChain,
|
|
23
|
+
} from "./paymentVerification.js";
|
|
24
|
+
import type { ExecuteJobResult } from "./offeringTypes.js";
|
|
25
|
+
import { getMyAgentInfo } from "../../lib/wallet.js";
|
|
26
|
+
import {
|
|
27
|
+
checkForExistingProcess,
|
|
28
|
+
writePidToConfig,
|
|
29
|
+
removePidFromConfig,
|
|
30
|
+
sanitizeAgentName,
|
|
31
|
+
} from "../../lib/config.js";
|
|
32
|
+
import { validateStartupOrExit } from "./startup.js";
|
|
33
|
+
|
|
34
|
+
function setupCleanupHandlers(): void {
|
|
35
|
+
const cleanup = () => {
|
|
36
|
+
removePidFromConfig();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
process.on("exit", cleanup);
|
|
40
|
+
process.on("SIGINT", () => {
|
|
41
|
+
cleanup();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
process.on("SIGTERM", () => {
|
|
45
|
+
cleanup();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
});
|
|
48
|
+
process.on("uncaughtException", (err) => {
|
|
49
|
+
console.error("[seller] Uncaught exception:", err);
|
|
50
|
+
cleanup();
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
54
|
+
console.error(
|
|
55
|
+
"[seller] Unhandled rejection at:",
|
|
56
|
+
promise,
|
|
57
|
+
"reason:",
|
|
58
|
+
reason,
|
|
59
|
+
);
|
|
60
|
+
cleanup();
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// -- Config --
|
|
66
|
+
|
|
67
|
+
const ACP_URL = process.env.ACP_SOCKET_URL || "https://acpx.virtuals.io";
|
|
68
|
+
const JOB_TIMEOUT_MS = Number(process.env.ACP_JOB_TIMEOUT_MS) || 180_000; // 3 min default
|
|
69
|
+
const MAX_CONCURRENT_JOBS = Number(process.env.ACP_MAX_CONCURRENT_JOBS) || 3;
|
|
70
|
+
const MAX_CONCURRENT_REQUESTS =
|
|
71
|
+
Number(process.env.ACP_MAX_CONCURRENT_REQUESTS) || 10;
|
|
72
|
+
const VERIFY_PAYMENT_ONCHAIN =
|
|
73
|
+
process.env.ACP_VERIFY_PAYMENT_ONCHAIN?.toLowerCase() === "true";
|
|
74
|
+
let agentDirName: string = "";
|
|
75
|
+
let sellerWalletAddressLower = "";
|
|
76
|
+
const availableOfferings = new Set<string>();
|
|
77
|
+
const seenJobPhaseEvents = new Map<string, number>();
|
|
78
|
+
const JOB_PHASE_EVENT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
79
|
+
const MAX_TRACKED_JOB_PHASE_EVENTS = 10_000;
|
|
80
|
+
|
|
81
|
+
// Concurrency guard: prevent overlapping processing of the same jobId
|
|
82
|
+
const inFlightJobs = new Set<number>();
|
|
83
|
+
|
|
84
|
+
// -- Timeout helper --
|
|
85
|
+
|
|
86
|
+
class JobTimeoutError extends Error {
|
|
87
|
+
constructor(jobId: number, offeringName: string, timeoutMs: number) {
|
|
88
|
+
super(
|
|
89
|
+
`Job ${jobId} timed out after ${timeoutMs}ms executing offering "${offeringName}"`,
|
|
90
|
+
);
|
|
91
|
+
this.name = "JobTimeoutError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function withTimeout<T>(
|
|
96
|
+
promise: Promise<T>,
|
|
97
|
+
timeoutMs: number,
|
|
98
|
+
jobId: number,
|
|
99
|
+
offeringName: string,
|
|
100
|
+
): Promise<T> {
|
|
101
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
102
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
103
|
+
timer = setTimeout(
|
|
104
|
+
() => reject(new JobTimeoutError(jobId, offeringName, timeoutMs)),
|
|
105
|
+
timeoutMs,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -- Bounded concurrency --
|
|
112
|
+
|
|
113
|
+
class JobSemaphore {
|
|
114
|
+
private running = 0;
|
|
115
|
+
private waitQueue: Array<() => void> = [];
|
|
116
|
+
|
|
117
|
+
constructor(private readonly maxConcurrent: number) {}
|
|
118
|
+
|
|
119
|
+
async acquire(jobId: number): Promise<void> {
|
|
120
|
+
if (this.running < this.maxConcurrent) {
|
|
121
|
+
this.running++;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
console.log(
|
|
125
|
+
`[seller] Job ${jobId} queued — waiting for a concurrency slot ` +
|
|
126
|
+
`(${this.running}/${this.maxConcurrent} active)`,
|
|
127
|
+
);
|
|
128
|
+
return new Promise<void>((resolve) => {
|
|
129
|
+
this.waitQueue.push(() => {
|
|
130
|
+
this.running++;
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
release(): void {
|
|
137
|
+
this.running--;
|
|
138
|
+
const next = this.waitQueue.shift();
|
|
139
|
+
if (next) next();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get activeCount(): number {
|
|
143
|
+
return this.running;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get waitingCount(): number {
|
|
147
|
+
return this.waitQueue.length;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const jobSemaphore = new JobSemaphore(MAX_CONCURRENT_JOBS);
|
|
152
|
+
// Separate, higher-limit semaphore for REQUEST phase (accept/reject).
|
|
153
|
+
// REQUEST is fast (single API call) but still needs a bound to prevent
|
|
154
|
+
// unbounded concurrency under burst conditions (e.g. socket reconnect replay).
|
|
155
|
+
const requestSemaphore = new JobSemaphore(MAX_CONCURRENT_REQUESTS);
|
|
156
|
+
|
|
157
|
+
// -- Job handling --
|
|
158
|
+
|
|
159
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
160
|
+
return typeof value === "object" && value !== null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseMemoJson(content: string): Record<string, unknown> | undefined {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(content);
|
|
166
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pluckOfferingName(
|
|
173
|
+
payload: Record<string, unknown>,
|
|
174
|
+
): string | undefined {
|
|
175
|
+
const candidates = [
|
|
176
|
+
payload.name,
|
|
177
|
+
payload.offering,
|
|
178
|
+
payload.offeringName,
|
|
179
|
+
payload.service,
|
|
180
|
+
payload.serviceName,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
for (const candidate of candidates) {
|
|
184
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
185
|
+
return candidate.trim();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pluckRequirements(
|
|
193
|
+
payload: Record<string, unknown>,
|
|
194
|
+
): Record<string, unknown> | undefined {
|
|
195
|
+
const requirementKeys = [
|
|
196
|
+
"requirement",
|
|
197
|
+
"requirements",
|
|
198
|
+
"serviceRequirements",
|
|
199
|
+
"input",
|
|
200
|
+
"data",
|
|
201
|
+
"params",
|
|
202
|
+
"payload",
|
|
203
|
+
"request",
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
for (const key of requirementKeys) {
|
|
207
|
+
const candidate = payload[key];
|
|
208
|
+
if (isRecord(candidate)) {
|
|
209
|
+
return normalizeRequirementsShape(candidate, payload);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (hasWalletKey(payload)) {
|
|
214
|
+
return normalizeRequirementsShape(payload, payload);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isNonEmptyWalletValue(value: unknown): value is string {
|
|
221
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function hasWalletKey(source: Record<string, unknown>): boolean {
|
|
225
|
+
return (
|
|
226
|
+
typeof source.wallet === "string" ||
|
|
227
|
+
typeof source.address === "string" ||
|
|
228
|
+
typeof source.walletAddress === "string"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pickWalletValue(source: Record<string, unknown>): string | undefined {
|
|
233
|
+
const candidates = [source.walletAddress, source.wallet, source.address];
|
|
234
|
+
for (const candidate of candidates) {
|
|
235
|
+
if (isNonEmptyWalletValue(candidate)) {
|
|
236
|
+
return candidate.trim();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeRequirementsShape(
|
|
243
|
+
requirement: Record<string, unknown>,
|
|
244
|
+
payload: Record<string, unknown>,
|
|
245
|
+
): Record<string, unknown> {
|
|
246
|
+
if (Array.isArray(requirement)) {
|
|
247
|
+
return requirement;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const normalized: Record<string, unknown> = { ...requirement };
|
|
251
|
+
const hoistedWallet = pickWalletValue(normalized);
|
|
252
|
+
if (hoistedWallet) {
|
|
253
|
+
normalized.walletAddress = hoistedWallet;
|
|
254
|
+
delete normalized.wallet;
|
|
255
|
+
delete normalized.address;
|
|
256
|
+
}
|
|
257
|
+
return normalized;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getMemoPayloads(
|
|
261
|
+
data: AcpJobEventData,
|
|
262
|
+
): Array<Record<string, unknown>> {
|
|
263
|
+
const orderedMemos = [
|
|
264
|
+
...data.memos.filter((m) => m.nextPhase === AcpJobPhase.NEGOTIATION),
|
|
265
|
+
...data.memos.filter((m) => m.nextPhase !== AcpJobPhase.NEGOTIATION),
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const payloads = orderedMemos
|
|
269
|
+
.map((memo) => parseMemoJson(memo.content))
|
|
270
|
+
.filter((payload): payload is Record<string, unknown> => Boolean(payload));
|
|
271
|
+
|
|
272
|
+
if (isRecord(data.context)) {
|
|
273
|
+
payloads.push(data.context);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return payloads;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolveOfferingName(data: AcpJobEventData): string | undefined {
|
|
280
|
+
for (const payload of getMemoPayloads(data)) {
|
|
281
|
+
const offeringName = pluckOfferingName(payload);
|
|
282
|
+
if (offeringName) {
|
|
283
|
+
return offeringName;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function resolveServiceRequirements(
|
|
291
|
+
data: AcpJobEventData,
|
|
292
|
+
): Record<string, any> {
|
|
293
|
+
for (const payload of getMemoPayloads(data)) {
|
|
294
|
+
const requirement = pluckRequirements(payload);
|
|
295
|
+
if (requirement) {
|
|
296
|
+
return requirement;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function shouldDeferWalletValidation(
|
|
304
|
+
_offeringName: string,
|
|
305
|
+
_reason: string | undefined,
|
|
306
|
+
): boolean {
|
|
307
|
+
// Reject missing/invalid wallet at REQUEST so users are not charged for
|
|
308
|
+
// guaranteed-failing jobs in TRANSACTION.
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isGuardianWalletOffering(offeringName: string): boolean {
|
|
313
|
+
return (
|
|
314
|
+
/^x402janus_(scan(_quick|_standard|_deep)?|forensic_intelligence|deep_intelligence|approvals|revoke(_batch)?)$/.test(
|
|
315
|
+
offeringName,
|
|
316
|
+
) || /^guardian_scan_(quick|standard|deep)$/.test(offeringName)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function hasWalletField(requirements: Record<string, unknown>): boolean {
|
|
321
|
+
return (
|
|
322
|
+
isNonEmptyWalletValue(requirements.walletAddress) ||
|
|
323
|
+
isNonEmptyWalletValue(requirements.wallet) ||
|
|
324
|
+
isNonEmptyWalletValue(requirements.address)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function withClientWalletFallback(
|
|
329
|
+
offeringName: string | undefined,
|
|
330
|
+
requirements: Record<string, unknown>,
|
|
331
|
+
data: AcpJobEventData,
|
|
332
|
+
phase: "REQUEST" | "TRANSACTION",
|
|
333
|
+
jobId: number,
|
|
334
|
+
): Record<string, unknown> {
|
|
335
|
+
if (
|
|
336
|
+
!offeringName ||
|
|
337
|
+
!isGuardianWalletOffering(offeringName) ||
|
|
338
|
+
hasWalletField(requirements)
|
|
339
|
+
) {
|
|
340
|
+
return requirements;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const clientAddress =
|
|
344
|
+
typeof data.clientAddress === "string" ? data.clientAddress.trim() : "";
|
|
345
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(clientAddress)) {
|
|
346
|
+
return requirements;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log(
|
|
350
|
+
`[seller] Job ${jobId} using clientAddress as wallet fallback for "${offeringName}" in ${phase}`,
|
|
351
|
+
);
|
|
352
|
+
return {
|
|
353
|
+
...requirements,
|
|
354
|
+
walletAddress: clientAddress,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function toValidationResult(
|
|
359
|
+
validationResult: boolean | { valid: boolean; reason?: string },
|
|
360
|
+
): { valid: boolean; reason?: string } {
|
|
361
|
+
if (typeof validationResult === "boolean") {
|
|
362
|
+
return {
|
|
363
|
+
valid: validationResult,
|
|
364
|
+
reason: validationResult ? undefined : "Validation failed",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return validationResult;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function trackAndCheckDuplicate(jobId: number, phase: AcpJobPhase): boolean {
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
const eventKey = `${jobId}:${phase}`;
|
|
374
|
+
|
|
375
|
+
// Purge expired entries (Map is insertion-ordered so oldest are first)
|
|
376
|
+
for (const [key, seenAt] of seenJobPhaseEvents) {
|
|
377
|
+
if (now - seenAt > JOB_PHASE_EVENT_TTL_MS) {
|
|
378
|
+
seenJobPhaseEvents.delete(key);
|
|
379
|
+
} else {
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (seenJobPhaseEvents.has(eventKey)) {
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
seenJobPhaseEvents.set(eventKey, now);
|
|
389
|
+
|
|
390
|
+
// Hard cap: batch-evict oldest 10% when threshold exceeded to avoid
|
|
391
|
+
// unbounded growth if TTL purge alone can't keep up with throughput.
|
|
392
|
+
if (seenJobPhaseEvents.size > MAX_TRACKED_JOB_PHASE_EVENTS) {
|
|
393
|
+
const evictCount = Math.max(
|
|
394
|
+
1,
|
|
395
|
+
Math.floor(MAX_TRACKED_JOB_PHASE_EVENTS * 0.1),
|
|
396
|
+
);
|
|
397
|
+
const iter = seenJobPhaseEvents.keys();
|
|
398
|
+
for (let i = 0; i < evictCount; i++) {
|
|
399
|
+
const next = iter.next();
|
|
400
|
+
if (next.done) break;
|
|
401
|
+
seenJobPhaseEvents.delete(next.value);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function getWalletFromRequirements(
|
|
409
|
+
requirements: Record<string, unknown>,
|
|
410
|
+
): string {
|
|
411
|
+
return pickWalletValue(requirements) ?? "";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function buildValidationErrorDeliverable(
|
|
415
|
+
offeringName: string,
|
|
416
|
+
reason: string,
|
|
417
|
+
requirements: Record<string, unknown>,
|
|
418
|
+
): ExecuteJobResult["deliverable"] {
|
|
419
|
+
return {
|
|
420
|
+
type: "guardian_scan_error",
|
|
421
|
+
value: {
|
|
422
|
+
success: false,
|
|
423
|
+
error: "Invalid scan requirements",
|
|
424
|
+
reason,
|
|
425
|
+
offering: offeringName,
|
|
426
|
+
wallet: getWalletFromRequirements(requirements),
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function buildExecutionErrorDeliverable(
|
|
432
|
+
offeringName: string,
|
|
433
|
+
err: unknown,
|
|
434
|
+
requirements: Record<string, unknown>,
|
|
435
|
+
): ExecuteJobResult["deliverable"] {
|
|
436
|
+
const reason =
|
|
437
|
+
err instanceof Error && err.message ? err.message : "Internal error";
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
type: isGuardianWalletOffering(offeringName)
|
|
441
|
+
? "guardian_scan_error"
|
|
442
|
+
: "execution_error",
|
|
443
|
+
value: {
|
|
444
|
+
success: false,
|
|
445
|
+
error: "Job execution failed",
|
|
446
|
+
reason,
|
|
447
|
+
offering: offeringName,
|
|
448
|
+
wallet: getWalletFromRequirements(requirements),
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function handleNewTask(data: AcpJobEventData): Promise<void> {
|
|
454
|
+
const jobId = data.id;
|
|
455
|
+
|
|
456
|
+
if (
|
|
457
|
+
sellerWalletAddressLower &&
|
|
458
|
+
typeof data.providerAddress === "string" &&
|
|
459
|
+
data.providerAddress.toLowerCase() !== sellerWalletAddressLower
|
|
460
|
+
) {
|
|
461
|
+
console.log(
|
|
462
|
+
`[seller] Ignoring job ${jobId}: provider mismatch (job=${data.providerAddress}, self=${sellerWalletAddressLower})`,
|
|
463
|
+
);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (trackAndCheckDuplicate(jobId, data.phase)) {
|
|
468
|
+
console.log(
|
|
469
|
+
`[seller] Duplicate event ignored for job ${jobId} phase=${AcpJobPhase[data.phase] ?? data.phase}`,
|
|
470
|
+
);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (inFlightJobs.has(jobId)) {
|
|
475
|
+
console.log(
|
|
476
|
+
`[seller] Job ${jobId} already in-flight — skipping concurrent execution`,
|
|
477
|
+
);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
inFlightJobs.add(jobId);
|
|
481
|
+
|
|
482
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
483
|
+
console.log(
|
|
484
|
+
`[seller] New task jobId=${jobId} phase=${AcpJobPhase[data.phase] ?? data.phase}`,
|
|
485
|
+
);
|
|
486
|
+
console.log(` client=${data.clientAddress} price=${data.price}`);
|
|
487
|
+
console.log(` context=${JSON.stringify(data.context)}`);
|
|
488
|
+
console.log(`${"=".repeat(60)}`);
|
|
489
|
+
|
|
490
|
+
// Step 1: Accept / reject
|
|
491
|
+
if (data.phase === AcpJobPhase.REQUEST) {
|
|
492
|
+
const requestStartMs = Date.now();
|
|
493
|
+
|
|
494
|
+
if (!data.memoToSign) {
|
|
495
|
+
console.log(
|
|
496
|
+
`[seller] Job ${jobId} REQUEST phase — no memoToSign, skipping`,
|
|
497
|
+
);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const negotiationMemo = data.memos.find(
|
|
502
|
+
(m) => m.id == Number(data.memoToSign),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (!negotiationMemo) {
|
|
506
|
+
console.log(
|
|
507
|
+
`[seller] Job ${jobId} REQUEST phase — memo ${data.memoToSign} not found in ${data.memos.length} memos`,
|
|
508
|
+
);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (negotiationMemo.nextPhase !== AcpJobPhase.NEGOTIATION) {
|
|
513
|
+
console.log(
|
|
514
|
+
`[seller] Job ${jobId} REQUEST phase — memo nextPhase=${AcpJobPhase[negotiationMemo.nextPhase] ?? negotiationMemo.nextPhase}, expected NEGOTIATION`,
|
|
515
|
+
);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const offeringName = resolveOfferingName(data);
|
|
520
|
+
const requirements = withClientWalletFallback(
|
|
521
|
+
offeringName,
|
|
522
|
+
resolveServiceRequirements(data),
|
|
523
|
+
data,
|
|
524
|
+
"REQUEST",
|
|
525
|
+
jobId,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
console.log(
|
|
529
|
+
`[seller] Job ${jobId} — offering="${offeringName}" agentDir="${agentDirName}" requirementKeys=${JSON.stringify(Object.keys(requirements))}`,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
if (!offeringName) {
|
|
533
|
+
await acceptOrRejectJob(jobId, {
|
|
534
|
+
accept: false,
|
|
535
|
+
reason: "Invalid offering name",
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!availableOfferings.has(offeringName)) {
|
|
541
|
+
await acceptOrRejectJob(jobId, {
|
|
542
|
+
accept: false,
|
|
543
|
+
reason: "Offering unavailable",
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let accepted = false;
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
const { config, handlers } = await loadOffering(
|
|
552
|
+
offeringName,
|
|
553
|
+
agentDirName,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
if (handlers.validateRequirements) {
|
|
557
|
+
const validationResult = handlers.validateRequirements(requirements);
|
|
558
|
+
const parsedResult = toValidationResult(validationResult);
|
|
559
|
+
|
|
560
|
+
if (!parsedResult.valid) {
|
|
561
|
+
const rejectionReason = parsedResult.reason || "Validation failed";
|
|
562
|
+
const shouldDeferValidation = shouldDeferWalletValidation(
|
|
563
|
+
offeringName,
|
|
564
|
+
rejectionReason,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
if (shouldDeferValidation) {
|
|
568
|
+
console.log(
|
|
569
|
+
`[seller] Deferring wallet validation for job ${jobId} offering="${offeringName}" until TRANSACTION phase`,
|
|
570
|
+
);
|
|
571
|
+
} else {
|
|
572
|
+
console.log(
|
|
573
|
+
`[seller] Validation failed for offering "${offeringName}" — rejecting: ${rejectionReason}`,
|
|
574
|
+
);
|
|
575
|
+
await acceptOrRejectJob(jobId, {
|
|
576
|
+
accept: false,
|
|
577
|
+
reason: rejectionReason,
|
|
578
|
+
});
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
await acceptOrRejectJob(jobId, {
|
|
585
|
+
accept: true,
|
|
586
|
+
reason: "Job accepted",
|
|
587
|
+
});
|
|
588
|
+
accepted = true;
|
|
589
|
+
console.log(
|
|
590
|
+
`[seller] Job ${jobId} — accepted in ${Date.now() - requestStartMs}ms`,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const funds =
|
|
594
|
+
config.requiredFunds && handlers.requestAdditionalFunds
|
|
595
|
+
? handlers.requestAdditionalFunds(requirements)
|
|
596
|
+
: undefined;
|
|
597
|
+
|
|
598
|
+
const paymentReason = handlers.requestPayment
|
|
599
|
+
? handlers.requestPayment(requirements)
|
|
600
|
+
: (funds?.content ?? "Request accepted");
|
|
601
|
+
|
|
602
|
+
await requestPayment(jobId, {
|
|
603
|
+
content: paymentReason,
|
|
604
|
+
payableDetail: funds
|
|
605
|
+
? {
|
|
606
|
+
amount: funds.amount,
|
|
607
|
+
tokenAddress: funds.tokenAddress,
|
|
608
|
+
recipient: funds.recipient,
|
|
609
|
+
}
|
|
610
|
+
: undefined,
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.error(`[seller] Error processing job ${jobId}:`, err);
|
|
614
|
+
|
|
615
|
+
if (!accepted) {
|
|
616
|
+
await acceptOrRejectJob(jobId, {
|
|
617
|
+
accept: false,
|
|
618
|
+
reason: "Internal error",
|
|
619
|
+
}).catch((rejectErr) => {
|
|
620
|
+
console.log(
|
|
621
|
+
`[seller] Failed to reject job ${jobId} after processing error:`,
|
|
622
|
+
rejectErr,
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
} else {
|
|
626
|
+
console.log(
|
|
627
|
+
`[seller] Job ${jobId} already accepted; skipping reject after processing error.`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Handle TRANSACTION (deliver)
|
|
634
|
+
if (data.phase === AcpJobPhase.TRANSACTION) {
|
|
635
|
+
const offeringName = resolveOfferingName(data);
|
|
636
|
+
const requirements = withClientWalletFallback(
|
|
637
|
+
offeringName,
|
|
638
|
+
resolveServiceRequirements(data),
|
|
639
|
+
data,
|
|
640
|
+
"TRANSACTION",
|
|
641
|
+
jobId,
|
|
642
|
+
);
|
|
643
|
+
console.log(
|
|
644
|
+
`[seller] Job ${jobId} TRANSACTION wallet resolved: ${pickWalletValue(requirements) ?? "NONE"}`,
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
// C-01 fix: Verify payment proof exists before executing.
|
|
648
|
+
// The TRANSACTION phase should only arrive after the client has paid,
|
|
649
|
+
// but we must not trust the ACP platform's phase transition blindly.
|
|
650
|
+
const paymentCheck = verifyPaymentProof(data.memos);
|
|
651
|
+
if (!paymentCheck.verified) {
|
|
652
|
+
console.error(
|
|
653
|
+
`[seller] Job ${jobId} — ${paymentCheck.reason} Refusing to execute.`,
|
|
654
|
+
);
|
|
655
|
+
await deliverJob(jobId, {
|
|
656
|
+
deliverable: {
|
|
657
|
+
type:
|
|
658
|
+
offeringName && isGuardianWalletOffering(offeringName)
|
|
659
|
+
? "guardian_scan_error"
|
|
660
|
+
: "execution_error",
|
|
661
|
+
value: {
|
|
662
|
+
success: false,
|
|
663
|
+
error: "Payment verification failed",
|
|
664
|
+
reason: paymentCheck.reason ?? "Missing or invalid payment proof",
|
|
665
|
+
jobId,
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
}).catch((err) => {
|
|
669
|
+
console.error(
|
|
670
|
+
`[seller] Failed to deliver payment error for job ${jobId}:`,
|
|
671
|
+
err,
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const paymentContent = paymentCheck.memo!.content.trim();
|
|
678
|
+
console.log(
|
|
679
|
+
`[seller] Job ${jobId} — payment proof format verified ` +
|
|
680
|
+
`(type=${MemoType[paymentCheck.memo!.memoType]}, content=${paymentContent.slice(0, 20)}...)`,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// C-01 Phase 2: On-chain payment verification.
|
|
684
|
+
// Confirms the tx hash corresponds to a real, successful USDC transfer
|
|
685
|
+
// on Base with the correct amount and recipient.
|
|
686
|
+
const onChainFailureDeliverableType =
|
|
687
|
+
offeringName && isGuardianWalletOffering(offeringName)
|
|
688
|
+
? "guardian_scan_error"
|
|
689
|
+
: "execution_error";
|
|
690
|
+
|
|
691
|
+
const deliverOnChainVerificationError = async (reason: string) => {
|
|
692
|
+
await deliverJob(jobId, {
|
|
693
|
+
deliverable: {
|
|
694
|
+
type: onChainFailureDeliverableType,
|
|
695
|
+
value: {
|
|
696
|
+
success: false,
|
|
697
|
+
error: "On-chain payment verification failed",
|
|
698
|
+
reason,
|
|
699
|
+
jobId,
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
}).catch((err) => {
|
|
703
|
+
console.error(
|
|
704
|
+
`[seller] Failed to deliver on-chain payment error for job ${jobId}:`,
|
|
705
|
+
err,
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
if (VERIFY_PAYMENT_ONCHAIN) {
|
|
711
|
+
try {
|
|
712
|
+
// Convert price (USDC with 6 decimals) to token units.
|
|
713
|
+
// data.price is a number like 0.5 meaning 0.5 USDC.
|
|
714
|
+
const priceInTokenUnits =
|
|
715
|
+
typeof data.price === "number" && data.price > 0
|
|
716
|
+
? BigInt(Math.round(data.price * 1e6))
|
|
717
|
+
: undefined;
|
|
718
|
+
|
|
719
|
+
console.log(
|
|
720
|
+
`[seller] Job ${jobId} — verifying payment on-chain ` +
|
|
721
|
+
`(recipient=${sellerWalletAddressLower || "any"}, minAmount=${priceInTokenUnits ?? "any"})`,
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
const onChainResult = await verifyPaymentOnChain(
|
|
725
|
+
paymentContent as `0x${string}`,
|
|
726
|
+
{
|
|
727
|
+
expectedRecipient: sellerWalletAddressLower || undefined,
|
|
728
|
+
expectedMinAmount: priceInTokenUnits,
|
|
729
|
+
},
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
if (!onChainResult.verified) {
|
|
733
|
+
console.error(
|
|
734
|
+
`[seller] Job ${jobId} — on-chain payment verification FAILED: ${onChainResult.reason}`,
|
|
735
|
+
);
|
|
736
|
+
await deliverOnChainVerificationError(
|
|
737
|
+
onChainResult.reason ??
|
|
738
|
+
"On-chain payment verification returned verified=false",
|
|
739
|
+
);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
console.log(
|
|
744
|
+
`[seller] Job ${jobId} — on-chain payment VERIFIED ` +
|
|
745
|
+
`(from=${onChainResult.from}, to=${onChainResult.to}, amount=${onChainResult.amount}, block=${onChainResult.blockNumber})`,
|
|
746
|
+
);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
749
|
+
console.error(
|
|
750
|
+
`[seller] Job ${jobId} — on-chain verification error: ${message}`,
|
|
751
|
+
);
|
|
752
|
+
await deliverOnChainVerificationError(
|
|
753
|
+
`On-chain verification error: ${message}`,
|
|
754
|
+
);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
console.log(
|
|
759
|
+
`[seller] Job ${jobId} — on-chain payment verification SKIPPED ` +
|
|
760
|
+
`(set ACP_VERIFY_PAYMENT_ONCHAIN=true to enable)`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const sigilxPaymentMetadata = {
|
|
765
|
+
acpJobId: jobId,
|
|
766
|
+
buyerWallet: data.clientAddress,
|
|
767
|
+
transferId: paymentContent,
|
|
768
|
+
};
|
|
769
|
+
const enrichedRequirements = {
|
|
770
|
+
...requirements,
|
|
771
|
+
__sigilxPayment: sigilxPaymentMetadata,
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
if (offeringName) {
|
|
775
|
+
if (!availableOfferings.has(offeringName)) {
|
|
776
|
+
console.error(
|
|
777
|
+
`[seller] Job ${jobId} in TRANSACTION references unavailable offering "${offeringName}"`,
|
|
778
|
+
);
|
|
779
|
+
await deliverJob(jobId, {
|
|
780
|
+
deliverable: {
|
|
781
|
+
type: isGuardianWalletOffering(offeringName)
|
|
782
|
+
? "guardian_scan_error"
|
|
783
|
+
: "execution_error",
|
|
784
|
+
value: {
|
|
785
|
+
success: false,
|
|
786
|
+
error: `Offering "${offeringName}" is not available`,
|
|
787
|
+
reason: "OFFERING_UNAVAILABLE",
|
|
788
|
+
jobId,
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
}).catch((err) => {
|
|
792
|
+
console.error(
|
|
793
|
+
`[seller] Failed to deliver unavailable-offering error for job ${jobId}:`,
|
|
794
|
+
err,
|
|
795
|
+
);
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
let executionResult: ExecuteJobResult | null = null;
|
|
801
|
+
try {
|
|
802
|
+
const { handlers } = await loadOffering(offeringName, agentDirName);
|
|
803
|
+
|
|
804
|
+
if (handlers.validateRequirements) {
|
|
805
|
+
const validationResult =
|
|
806
|
+
handlers.validateRequirements(enrichedRequirements);
|
|
807
|
+
const parsedResult = toValidationResult(validationResult);
|
|
808
|
+
|
|
809
|
+
if (!parsedResult.valid) {
|
|
810
|
+
const rejectionReason = parsedResult.reason || "Validation failed";
|
|
811
|
+
console.log(
|
|
812
|
+
`[seller] Job ${jobId} failed TRANSACTION validation for "${offeringName}": ${rejectionReason}`,
|
|
813
|
+
);
|
|
814
|
+
await deliverJob(jobId, {
|
|
815
|
+
deliverable: buildValidationErrorDeliverable(
|
|
816
|
+
offeringName,
|
|
817
|
+
rejectionReason,
|
|
818
|
+
enrichedRequirements,
|
|
819
|
+
),
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
console.log(
|
|
826
|
+
`[seller] Executing offering "${offeringName}" for job ${jobId} (TRANSACTION phase, timeout=${JOB_TIMEOUT_MS}ms)...`,
|
|
827
|
+
);
|
|
828
|
+
executionResult = await withTimeout(
|
|
829
|
+
handlers.executeJob(enrichedRequirements),
|
|
830
|
+
JOB_TIMEOUT_MS,
|
|
831
|
+
jobId,
|
|
832
|
+
offeringName,
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
await deliverJob(jobId, {
|
|
836
|
+
deliverable: executionResult.deliverable,
|
|
837
|
+
payableDetail: executionResult.payableDetail,
|
|
838
|
+
});
|
|
839
|
+
console.log(`[seller] Job ${jobId} — delivered.`);
|
|
840
|
+
} catch (err) {
|
|
841
|
+
console.error(`[seller] Error delivering job ${jobId}:`, err);
|
|
842
|
+
if (!executionResult) {
|
|
843
|
+
await deliverJob(jobId, {
|
|
844
|
+
deliverable: buildExecutionErrorDeliverable(
|
|
845
|
+
offeringName,
|
|
846
|
+
err,
|
|
847
|
+
enrichedRequirements,
|
|
848
|
+
),
|
|
849
|
+
}).catch((deliverErr) => {
|
|
850
|
+
console.log(
|
|
851
|
+
`[seller] Failed to deliver execution error for job ${jobId}:`,
|
|
852
|
+
deliverErr,
|
|
853
|
+
);
|
|
854
|
+
});
|
|
855
|
+
} else {
|
|
856
|
+
console.log(
|
|
857
|
+
`[seller] Delivery failed after successful execution for job ${jobId}; ` +
|
|
858
|
+
"not sending synthetic execution error deliverable.",
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
} else {
|
|
863
|
+
console.error(
|
|
864
|
+
`[seller] Job ${jobId} in TRANSACTION but no offering resolved`,
|
|
865
|
+
);
|
|
866
|
+
await deliverJob(jobId, {
|
|
867
|
+
deliverable: {
|
|
868
|
+
type: "execution_error",
|
|
869
|
+
value: {
|
|
870
|
+
success: false,
|
|
871
|
+
error: "Could not resolve offering name from job memos",
|
|
872
|
+
reason: "OFFERING_UNRESOLVED",
|
|
873
|
+
jobId,
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
}).catch((err) => {
|
|
877
|
+
console.error(
|
|
878
|
+
`[seller] Failed to deliver unresolved-offering error for job ${jobId}:`,
|
|
879
|
+
err,
|
|
880
|
+
);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
console.log(
|
|
887
|
+
`[seller] Job ${jobId} in phase ${AcpJobPhase[data.phase] ?? data.phase} — no action needed`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// -- Main --
|
|
892
|
+
|
|
893
|
+
async function main() {
|
|
894
|
+
checkForExistingProcess();
|
|
895
|
+
|
|
896
|
+
writePidToConfig(process.pid);
|
|
897
|
+
|
|
898
|
+
setupCleanupHandlers();
|
|
899
|
+
|
|
900
|
+
let walletAddress: string;
|
|
901
|
+
try {
|
|
902
|
+
const agentData = await getMyAgentInfo();
|
|
903
|
+
walletAddress = agentData.walletAddress;
|
|
904
|
+
sellerWalletAddressLower = walletAddress.toLowerCase();
|
|
905
|
+
agentDirName = sanitizeAgentName(agentData.name);
|
|
906
|
+
if (agentDirName === "") {
|
|
907
|
+
console.error(
|
|
908
|
+
`[seller] sanitizeAgentName produced invalid directory name "${agentDirName}" for agent name "${agentData.name}". Cannot resolve offering paths safely.`,
|
|
909
|
+
);
|
|
910
|
+
process.exit(1);
|
|
911
|
+
}
|
|
912
|
+
console.log(`[seller] Agent: ${agentData.name} (dir: ${agentDirName})`);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
console.error("[seller] Failed to resolve agent info:", err);
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Preflight: validate env vars (GUARDIAN_INTERNAL_API_TOKEN, etc.) and offerings
|
|
919
|
+
validateStartupOrExit(agentDirName);
|
|
920
|
+
|
|
921
|
+
const offerings = listOfferings(agentDirName);
|
|
922
|
+
assertCanonicalOfferingsOrThrow(agentDirName, offerings);
|
|
923
|
+
|
|
924
|
+
availableOfferings.clear();
|
|
925
|
+
for (const offering of offerings) {
|
|
926
|
+
availableOfferings.add(offering);
|
|
927
|
+
}
|
|
928
|
+
logOfferingsStatus(agentDirName, offerings);
|
|
929
|
+
|
|
930
|
+
// Pre-warm offering handlers: cache the dynamic import() so the first
|
|
931
|
+
// incoming job doesn't pay the module-transpile + load penalty.
|
|
932
|
+
const warmStartMs = Date.now();
|
|
933
|
+
await Promise.all(
|
|
934
|
+
offerings.map(async (name) => {
|
|
935
|
+
try {
|
|
936
|
+
await loadOffering(name, agentDirName);
|
|
937
|
+
} catch (err) {
|
|
938
|
+
console.warn(`[seller] Failed to pre-warm offering "${name}":`, err);
|
|
939
|
+
}
|
|
940
|
+
}),
|
|
941
|
+
);
|
|
942
|
+
console.log(
|
|
943
|
+
`[seller] Pre-warmed ${offerings.length} offering handlers in ${Date.now() - warmStartMs}ms`,
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
console.log(
|
|
947
|
+
`[seller] Concurrency: requests=${MAX_CONCURRENT_REQUESTS}, jobs=${MAX_CONCURRENT_JOBS}, timeout=${JOB_TIMEOUT_MS}ms per job`,
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
connectAcpSocket({
|
|
951
|
+
acpUrl: ACP_URL,
|
|
952
|
+
walletAddress,
|
|
953
|
+
callbacks: {
|
|
954
|
+
onNewTask: (data) => {
|
|
955
|
+
if (data.phase === AcpJobPhase.REQUEST) {
|
|
956
|
+
// REQUEST phase (accept/reject + payment request) uses its own
|
|
957
|
+
// lighter semaphore so it is never blocked behind long-running
|
|
958
|
+
// TRANSACTION executions that would push it past the ACP TTL.
|
|
959
|
+
requestSemaphore
|
|
960
|
+
.acquire(data.id)
|
|
961
|
+
.then(() => handleNewTask(data))
|
|
962
|
+
.catch((err) =>
|
|
963
|
+
console.error(
|
|
964
|
+
"[seller] Unhandled error in handleNewTask (REQUEST):",
|
|
965
|
+
err,
|
|
966
|
+
),
|
|
967
|
+
)
|
|
968
|
+
.finally(() => {
|
|
969
|
+
inFlightJobs.delete(data.id);
|
|
970
|
+
requestSemaphore.release();
|
|
971
|
+
});
|
|
972
|
+
} else {
|
|
973
|
+
// TRANSACTION phase: actual job execution, bounded concurrency.
|
|
974
|
+
jobSemaphore
|
|
975
|
+
.acquire(data.id)
|
|
976
|
+
.then(() => handleNewTask(data))
|
|
977
|
+
.catch((err) =>
|
|
978
|
+
console.error("[seller] Unhandled error in handleNewTask:", err),
|
|
979
|
+
)
|
|
980
|
+
.finally(() => {
|
|
981
|
+
inFlightJobs.delete(data.id);
|
|
982
|
+
jobSemaphore.release();
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
onEvaluate: (data) => {
|
|
987
|
+
console.log(
|
|
988
|
+
`[seller] onEvaluate received for job ${data.id} — no action (evaluation handled externally)`,
|
|
989
|
+
);
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
if (VERIFY_PAYMENT_ONCHAIN) {
|
|
995
|
+
console.log(
|
|
996
|
+
"[seller] On-chain payment verification ENABLED (ACP_VERIFY_PAYMENT_ONCHAIN=true)",
|
|
997
|
+
);
|
|
998
|
+
} else {
|
|
999
|
+
console.warn(
|
|
1000
|
+
"[seller] WARNING: On-chain payment verification DISABLED. " +
|
|
1001
|
+
"Set ACP_VERIFY_PAYMENT_ONCHAIN=true to verify payments on Base before executing jobs.",
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
console.log("[seller] Seller runtime is running. Waiting for jobs...\n");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export { JobTimeoutError };
|
|
1009
|
+
|
|
1010
|
+
export const __testing = {
|
|
1011
|
+
handleNewTask,
|
|
1012
|
+
resolveOfferingName,
|
|
1013
|
+
resolveServiceRequirements,
|
|
1014
|
+
trackAndCheckDuplicate,
|
|
1015
|
+
resetSeenEvents: () => seenJobPhaseEvents.clear(),
|
|
1016
|
+
resetInFlightJobs: () => inFlightJobs.clear(),
|
|
1017
|
+
getInFlightJobs: () => inFlightJobs,
|
|
1018
|
+
setSellerWallet: (addr: string) => {
|
|
1019
|
+
sellerWalletAddressLower = addr.toLowerCase();
|
|
1020
|
+
},
|
|
1021
|
+
setAgentDirName: (name: string) => {
|
|
1022
|
+
agentDirName = name;
|
|
1023
|
+
},
|
|
1024
|
+
setAvailableOfferings: (names: string[]) => {
|
|
1025
|
+
availableOfferings.clear();
|
|
1026
|
+
for (const n of names) availableOfferings.add(n);
|
|
1027
|
+
},
|
|
1028
|
+
JOB_TIMEOUT_MS,
|
|
1029
|
+
MAX_CONCURRENT_JOBS,
|
|
1030
|
+
jobSemaphore,
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
if (
|
|
1034
|
+
process.argv[1] &&
|
|
1035
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
1036
|
+
) {
|
|
1037
|
+
main().catch((err) => {
|
|
1038
|
+
console.error("[seller] Fatal error:", err);
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
});
|
|
1041
|
+
}
|