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.
Files changed (90) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +30 -0
  3. package/AGENTS.md +212 -0
  4. package/BOOTSTRAP.md +55 -0
  5. package/Dockerfile +52 -0
  6. package/HEARTBEAT.md +7 -0
  7. package/IDENTITY.md +23 -0
  8. package/LICENSE +21 -0
  9. package/README.md +162 -0
  10. package/SOUL.md +202 -0
  11. package/SUBMISSION.md +128 -0
  12. package/TOOLS.md +40 -0
  13. package/USER.md +17 -0
  14. package/acp-seller/bin/acp.ts +807 -0
  15. package/acp-seller/config.json +34 -0
  16. package/acp-seller/package.json +55 -0
  17. package/acp-seller/src/commands/agent.ts +328 -0
  18. package/acp-seller/src/commands/bounty.ts +1189 -0
  19. package/acp-seller/src/commands/deploy.ts +414 -0
  20. package/acp-seller/src/commands/job.ts +217 -0
  21. package/acp-seller/src/commands/profile.ts +71 -0
  22. package/acp-seller/src/commands/resource.ts +91 -0
  23. package/acp-seller/src/commands/search.ts +327 -0
  24. package/acp-seller/src/commands/sell.ts +883 -0
  25. package/acp-seller/src/commands/serve.ts +258 -0
  26. package/acp-seller/src/commands/setup.ts +399 -0
  27. package/acp-seller/src/commands/token.ts +88 -0
  28. package/acp-seller/src/commands/wallet.ts +123 -0
  29. package/acp-seller/src/lib/api.ts +118 -0
  30. package/acp-seller/src/lib/auth.ts +291 -0
  31. package/acp-seller/src/lib/bounty.ts +257 -0
  32. package/acp-seller/src/lib/client.ts +42 -0
  33. package/acp-seller/src/lib/config.ts +240 -0
  34. package/acp-seller/src/lib/open.ts +41 -0
  35. package/acp-seller/src/lib/openclawCron.ts +138 -0
  36. package/acp-seller/src/lib/output.ts +104 -0
  37. package/acp-seller/src/lib/wallet.ts +81 -0
  38. package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
  39. package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
  40. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
  41. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
  42. package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
  43. package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
  44. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
  45. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
  46. package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
  47. package/acp-seller/src/seller/runtime/logger.ts +36 -0
  48. package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
  49. package/acp-seller/src/seller/runtime/offerings.ts +277 -0
  50. package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
  51. package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
  52. package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
  53. package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
  54. package/acp-seller/src/seller/runtime/seller.ts +1041 -0
  55. package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
  56. package/acp-seller/src/seller/runtime/startup.ts +270 -0
  57. package/acp-seller/src/seller/runtime/types.ts +62 -0
  58. package/acp-seller/tsconfig.json +20 -0
  59. package/bin/spendos.js +23 -0
  60. package/contracts/SpendOSAudit.sol +29 -0
  61. package/dist/mcp-server.mjs +153 -0
  62. package/jobs/translate.json +7 -0
  63. package/jobs/tweet-gen.json +7 -0
  64. package/openclaw.json +41 -0
  65. package/package.json +49 -0
  66. package/plugins/spendos-events/index.ts +78 -0
  67. package/plugins/spendos-events/package.json +14 -0
  68. package/policies/enforce-bounds.mjs +71 -0
  69. package/public/index.html +509 -0
  70. package/public/landing.html +241 -0
  71. package/railway.json +12 -0
  72. package/railway.toml +12 -0
  73. package/scripts/deploy.ts +48 -0
  74. package/scripts/test-x402-mainnet.ts +30 -0
  75. package/scripts/xmtp-listener.ts +61 -0
  76. package/setup.sh +278 -0
  77. package/skills/spendos/skill.md +26 -0
  78. package/src/agent.ts +152 -0
  79. package/src/audit.ts +166 -0
  80. package/src/governance.ts +367 -0
  81. package/src/job-registry.ts +306 -0
  82. package/src/mcp-public.ts +145 -0
  83. package/src/mcp-server.ts +171 -0
  84. package/src/opportunity-scanner.ts +138 -0
  85. package/src/server.ts +870 -0
  86. package/src/venice-x402.ts +234 -0
  87. package/src/xmtp.ts +109 -0
  88. package/src/zerion.ts +58 -0
  89. package/start.sh +168 -0
  90. 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
+ }