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,823 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ AcpJobPhase,
4
+ MemoType,
5
+ type AcpJobEventData,
6
+ type AcpMemoData,
7
+ } from "./types.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Mock external deps — seller.ts imports these directly
11
+ // ---------------------------------------------------------------------------
12
+ vi.mock("./sellerApi.js", () => ({
13
+ acceptOrRejectJob: vi.fn().mockResolvedValue(undefined),
14
+ requestPayment: vi.fn().mockResolvedValue(undefined),
15
+ deliverJob: vi.fn().mockResolvedValue(undefined),
16
+ }));
17
+
18
+ const mockExecuteJob = vi.fn().mockResolvedValue({
19
+ deliverable: { type: "guardian_scan_result", value: { success: true } },
20
+ });
21
+ const mockValidateReqs = vi.fn().mockReturnValue({ valid: true });
22
+
23
+ vi.mock("./offerings.js", () => ({
24
+ loadOffering: vi.fn().mockResolvedValue({
25
+ config: {
26
+ name: "x402janus_forensic_intelligence",
27
+ description: "test",
28
+ jobFee: 1,
29
+ jobFeeType: "fixed",
30
+ requiredFunds: false,
31
+ },
32
+ handlers: {
33
+ executeJob: (...args: unknown[]) => mockExecuteJob(...args),
34
+ validateRequirements: (...args: unknown[]) => mockValidateReqs(...args),
35
+ },
36
+ }),
37
+ listOfferings: vi.fn().mockReturnValue([]),
38
+ logOfferingsStatus: vi.fn(),
39
+ }));
40
+
41
+ vi.mock("../../lib/wallet.js", () => ({
42
+ getMyAgentInfo: vi.fn().mockResolvedValue({
43
+ walletAddress: "0xSELLER",
44
+ name: "test-agent",
45
+ }),
46
+ }));
47
+
48
+ vi.mock("../../lib/config.js", () => ({
49
+ checkForExistingProcess: vi.fn(),
50
+ writePidToConfig: vi.fn(),
51
+ removePidFromConfig: vi.fn(),
52
+ sanitizeAgentName: vi.fn((name: string) => name),
53
+ }));
54
+
55
+ import { acceptOrRejectJob, requestPayment, deliverJob } from "./sellerApi.js";
56
+ import { loadOffering } from "./offerings.js";
57
+ import { __testing } from "./seller.js";
58
+
59
+ const {
60
+ handleNewTask,
61
+ resetSeenEvents,
62
+ resetInFlightJobs,
63
+ setSellerWallet,
64
+ setAgentDirName,
65
+ setAvailableOfferings,
66
+ } = __testing;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+ const VALID_WALLET = "0x1234567890abcdef1234567890abcdef12345678";
72
+ const SELLER_ADDR = "0xseller";
73
+ const X402_GUARDIAN_OFFERINGS = ["x402janus_forensic_intelligence"] as const;
74
+
75
+ function makeNegotiationMemo(
76
+ content: Record<string, unknown>,
77
+ id = 100,
78
+ ): AcpMemoData {
79
+ return {
80
+ id,
81
+ memoType: MemoType.MESSAGE,
82
+ content: JSON.stringify(content),
83
+ nextPhase: AcpJobPhase.NEGOTIATION,
84
+ };
85
+ }
86
+
87
+ function makePaymentMemo(
88
+ txHash = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
89
+ ): AcpMemoData {
90
+ return {
91
+ id: 200,
92
+ memoType: MemoType.PAYABLE_TRANSFER,
93
+ content: txHash,
94
+ nextPhase: AcpJobPhase.TRANSACTION,
95
+ };
96
+ }
97
+
98
+ function makeJobEvent(
99
+ overrides: Partial<AcpJobEventData> = {},
100
+ ): AcpJobEventData {
101
+ return {
102
+ id: 1,
103
+ phase: AcpJobPhase.REQUEST,
104
+ clientAddress: "0xclient",
105
+ providerAddress: SELLER_ADDR,
106
+ evaluatorAddress: "0xeval",
107
+ price: 10,
108
+ memos: [],
109
+ context: {},
110
+ ...overrides,
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Tests
116
+ // ---------------------------------------------------------------------------
117
+ describe("seller runtime — handleNewTask", () => {
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+ resetSeenEvents();
121
+ resetInFlightJobs();
122
+ setSellerWallet(SELLER_ADDR);
123
+ setAgentDirName("x402janus");
124
+ setAvailableOfferings([...X402_GUARDIAN_OFFERINGS]);
125
+ mockExecuteJob.mockResolvedValue({
126
+ deliverable: {
127
+ type: "guardian_scan_result",
128
+ value: { success: true, wallet: VALID_WALLET },
129
+ },
130
+ });
131
+ mockValidateReqs.mockReturnValue({ valid: true });
132
+ });
133
+
134
+ // =======================================================================
135
+ // 1. Idempotency guard
136
+ // =======================================================================
137
+ describe("idempotency guard", () => {
138
+ it("processes first event for a jobId+phase", async () => {
139
+ const memo = makeNegotiationMemo({
140
+ name: "x402janus_forensic_intelligence",
141
+ requirement: { wallet: VALID_WALLET },
142
+ });
143
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
144
+
145
+ await handleNewTask(data);
146
+
147
+ expect(acceptOrRejectJob).toHaveBeenCalledTimes(1);
148
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
149
+ 1,
150
+ expect.objectContaining({ accept: true }),
151
+ );
152
+ });
153
+
154
+ it("ignores duplicate event for same jobId+phase", async () => {
155
+ const memo = makeNegotiationMemo({
156
+ name: "x402janus_forensic_intelligence",
157
+ requirement: { wallet: VALID_WALLET },
158
+ });
159
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
160
+
161
+ await handleNewTask(data);
162
+ expect(acceptOrRejectJob).toHaveBeenCalledTimes(1);
163
+
164
+ vi.clearAllMocks();
165
+ await handleNewTask(data); // duplicate
166
+
167
+ expect(acceptOrRejectJob).not.toHaveBeenCalled();
168
+ expect(requestPayment).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it("processes same jobId with different phase separately", async () => {
172
+ const memo = makeNegotiationMemo({
173
+ name: "x402janus_forensic_intelligence",
174
+ requirement: { wallet: VALID_WALLET },
175
+ });
176
+
177
+ // REQUEST phase
178
+ const requestData = makeJobEvent({
179
+ id: 42,
180
+ phase: AcpJobPhase.REQUEST,
181
+ memoToSign: 100,
182
+ memos: [memo],
183
+ });
184
+ await handleNewTask(requestData);
185
+ expect(acceptOrRejectJob).toHaveBeenCalledTimes(1);
186
+
187
+ vi.clearAllMocks();
188
+ // In production the socket callback's finally() clears inFlightJobs;
189
+ // in unit tests we must clear it manually between sequential calls.
190
+ resetInFlightJobs();
191
+
192
+ // TRANSACTION phase — same jobId, different phase
193
+ const txData = makeJobEvent({
194
+ id: 42,
195
+ phase: AcpJobPhase.TRANSACTION,
196
+ memos: [memo, makePaymentMemo()],
197
+ });
198
+ await handleNewTask(txData);
199
+
200
+ // Should be processed (not skipped as duplicate)
201
+ expect(deliverJob).toHaveBeenCalledTimes(1);
202
+ });
203
+ });
204
+
205
+ // =======================================================================
206
+ // 2. Provider guard
207
+ // =======================================================================
208
+ describe("provider guard", () => {
209
+ it("ignores jobs addressed to a different provider", async () => {
210
+ const memo = makeNegotiationMemo({
211
+ name: "x402janus_forensic_intelligence",
212
+ requirement: { wallet: VALID_WALLET },
213
+ });
214
+ const data = makeJobEvent({
215
+ providerAddress: "0xOTHER_SELLER",
216
+ memoToSign: 100,
217
+ memos: [memo],
218
+ });
219
+
220
+ await handleNewTask(data);
221
+
222
+ expect(acceptOrRejectJob).not.toHaveBeenCalled();
223
+ expect(loadOffering).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("processes jobs when providerAddress matches (case-insensitive)", async () => {
227
+ const memo = makeNegotiationMemo({
228
+ name: "x402janus_forensic_intelligence",
229
+ requirement: { wallet: VALID_WALLET },
230
+ });
231
+ const data = makeJobEvent({
232
+ providerAddress: "0xSELLER", // uppercase
233
+ memoToSign: 100,
234
+ memos: [memo],
235
+ });
236
+
237
+ await handleNewTask(data);
238
+
239
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
240
+ 1,
241
+ expect.objectContaining({ accept: true }),
242
+ );
243
+ });
244
+ });
245
+
246
+ // =======================================================================
247
+ // 3. REQUEST phase
248
+ // =======================================================================
249
+ describe("REQUEST phase", () => {
250
+ it("accepts job with valid offering and wallet", async () => {
251
+ const memo = makeNegotiationMemo({
252
+ name: "x402janus_forensic_intelligence",
253
+ requirement: { wallet: VALID_WALLET },
254
+ });
255
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
256
+
257
+ await handleNewTask(data);
258
+
259
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
260
+ 1,
261
+ expect.objectContaining({ accept: true }),
262
+ );
263
+ expect(requestPayment).toHaveBeenCalledTimes(1);
264
+ });
265
+
266
+ it("rejects scan job at REQUEST when wallet is missing", async () => {
267
+ mockValidateReqs.mockReturnValue({
268
+ valid: false,
269
+ reason: "wallet (or address) is required",
270
+ });
271
+
272
+ const memo = makeNegotiationMemo({
273
+ name: "x402janus_forensic_intelligence",
274
+ // no requirement.wallet
275
+ });
276
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
277
+
278
+ await handleNewTask(data);
279
+
280
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
281
+ 1,
282
+ expect.objectContaining({
283
+ accept: false,
284
+ reason: "wallet (or address) is required",
285
+ }),
286
+ );
287
+ expect(requestPayment).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it.each(X402_GUARDIAN_OFFERINGS)(
291
+ "rejects %s at REQUEST when wallet is missing",
292
+ async (offeringName) => {
293
+ mockValidateReqs.mockReturnValue({
294
+ valid: false,
295
+ reason: "wallet (or address) is required",
296
+ });
297
+
298
+ const memo = makeNegotiationMemo({ name: offeringName });
299
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
300
+
301
+ await handleNewTask(data);
302
+
303
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
304
+ 1,
305
+ expect.objectContaining({
306
+ accept: false,
307
+ reason: "wallet (or address) is required",
308
+ }),
309
+ );
310
+ expect(requestPayment).not.toHaveBeenCalled();
311
+ },
312
+ );
313
+
314
+ it("uses clientAddress fallback at REQUEST when wallet is omitted", async () => {
315
+ mockValidateReqs.mockImplementation((req: unknown) => {
316
+ const requirement = req as Record<string, unknown>;
317
+ expect(requirement.walletAddress).toBe(VALID_WALLET);
318
+ return { valid: true };
319
+ });
320
+
321
+ const memo = makeNegotiationMemo({
322
+ name: "x402janus_forensic_intelligence",
323
+ });
324
+ const data = makeJobEvent({
325
+ memoToSign: 100,
326
+ memos: [memo],
327
+ clientAddress: VALID_WALLET,
328
+ });
329
+
330
+ await handleNewTask(data);
331
+
332
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
333
+ 1,
334
+ expect.objectContaining({ accept: true }),
335
+ );
336
+ expect(requestPayment).toHaveBeenCalledTimes(1);
337
+ });
338
+
339
+ it("uses clientAddress fallback at REQUEST when wallet is empty string", async () => {
340
+ mockValidateReqs.mockImplementation((req: unknown) => {
341
+ const requirement = req as Record<string, unknown>;
342
+ expect(requirement.walletAddress).toBe(VALID_WALLET);
343
+ return { valid: true };
344
+ });
345
+
346
+ const memo = makeNegotiationMemo({
347
+ name: "x402janus_forensic_intelligence",
348
+ requirement: { wallet: "" },
349
+ });
350
+ const data = makeJobEvent({
351
+ memoToSign: 100,
352
+ memos: [memo],
353
+ clientAddress: VALID_WALLET,
354
+ });
355
+
356
+ await handleNewTask(data);
357
+
358
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
359
+ 1,
360
+ expect.objectContaining({ accept: true }),
361
+ );
362
+ expect(requestPayment).toHaveBeenCalledTimes(1);
363
+ });
364
+
365
+ it("does not hoist top-level wallet into flattened requirements when nested object omits wallet", async () => {
366
+ mockValidateReqs.mockImplementation((req: unknown) => {
367
+ const requirement = req as Record<string, unknown>;
368
+ expect(requirement.walletAddress).toBeUndefined();
369
+ expect(requirement.wallet).toBeUndefined();
370
+ expect(requirement.target).toBe("0xabc");
371
+ return {
372
+ valid: false,
373
+ reason: "wallet (or address) is required",
374
+ };
375
+ });
376
+
377
+ const memo = makeNegotiationMemo({
378
+ name: "x402janus_forensic_intelligence",
379
+ requirements: { target: "0xabc" },
380
+ wallet: VALID_WALLET,
381
+ });
382
+ const data = makeJobEvent({
383
+ memoToSign: 100,
384
+ memos: [memo],
385
+ });
386
+
387
+ await handleNewTask(data);
388
+
389
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
390
+ 1,
391
+ expect.objectContaining({
392
+ accept: false,
393
+ reason: "wallet (or address) is required",
394
+ }),
395
+ );
396
+ expect(requestPayment).not.toHaveBeenCalled();
397
+ });
398
+
399
+ it("injects clientAddress when nested requirements are present and top-level wallet is empty", async () => {
400
+ mockValidateReqs.mockImplementation((req: unknown) => {
401
+ const requirement = req as Record<string, unknown>;
402
+ expect(requirement.walletAddress).toBe(VALID_WALLET);
403
+ expect(requirement.target).toBe("0xdef");
404
+ return { valid: true };
405
+ });
406
+
407
+ const memo = makeNegotiationMemo({
408
+ name: "x402janus_forensic_intelligence",
409
+ requirements: { target: "0xdef" },
410
+ wallet: "",
411
+ });
412
+ const data = makeJobEvent({
413
+ memoToSign: 100,
414
+ memos: [memo],
415
+ clientAddress: VALID_WALLET,
416
+ });
417
+
418
+ await handleNewTask(data);
419
+
420
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
421
+ 1,
422
+ expect.objectContaining({ accept: true }),
423
+ );
424
+ expect(requestPayment).toHaveBeenCalledTimes(1);
425
+ });
426
+
427
+ it("preserves array requirements without coercing numeric keys into an object", async () => {
428
+ setAvailableOfferings(["array_offering"]);
429
+ const requirements = ["alpha", "beta"];
430
+
431
+ mockValidateReqs.mockImplementation((req: unknown) => {
432
+ expect(Array.isArray(req)).toBe(true);
433
+ expect(req).toEqual(requirements);
434
+ return { valid: true };
435
+ });
436
+
437
+ const memo = makeNegotiationMemo({
438
+ name: "array_offering",
439
+ requirements,
440
+ });
441
+ const data = makeJobEvent({
442
+ memoToSign: 100,
443
+ memos: [memo],
444
+ });
445
+
446
+ await handleNewTask(data);
447
+
448
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
449
+ 1,
450
+ expect.objectContaining({ accept: true }),
451
+ );
452
+ expect(requestPayment).toHaveBeenCalledTimes(1);
453
+ });
454
+
455
+ it("rejects unknown offering cleanly with explicit reason", async () => {
456
+ const memo = makeNegotiationMemo({ name: "indigo" });
457
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
458
+
459
+ await handleNewTask(data);
460
+
461
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
462
+ 1,
463
+ expect.objectContaining({
464
+ accept: false,
465
+ reason: "Offering unavailable",
466
+ }),
467
+ );
468
+ // loadOffering should NOT be called — rejected before loading
469
+ expect(loadOffering).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it("rejects REQUEST when loadOffering throws", async () => {
473
+ vi.mocked(loadOffering).mockRejectedValueOnce(new Error("load failed"));
474
+
475
+ const memo = makeNegotiationMemo({
476
+ name: "x402janus_forensic_intelligence",
477
+ requirement: { wallet: VALID_WALLET },
478
+ });
479
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
480
+
481
+ await handleNewTask(data);
482
+
483
+ expect(acceptOrRejectJob).toHaveBeenCalledTimes(1);
484
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
485
+ 1,
486
+ expect.objectContaining({
487
+ accept: false,
488
+ reason: "Internal error",
489
+ }),
490
+ );
491
+ expect(requestPayment).not.toHaveBeenCalled();
492
+ });
493
+
494
+ it("does not reject REQUEST when payment request fails after accept", async () => {
495
+ vi.mocked(requestPayment).mockRejectedValueOnce(
496
+ new Error("payment request failed"),
497
+ );
498
+
499
+ const memo = makeNegotiationMemo({
500
+ name: "x402janus_forensic_intelligence",
501
+ requirement: { wallet: VALID_WALLET },
502
+ });
503
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
504
+
505
+ await handleNewTask(data);
506
+
507
+ expect(acceptOrRejectJob).toHaveBeenCalledTimes(1);
508
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
509
+ 1,
510
+ expect.objectContaining({
511
+ accept: true,
512
+ reason: "Job accepted",
513
+ }),
514
+ );
515
+ expect(requestPayment).toHaveBeenCalledTimes(1);
516
+ });
517
+
518
+ it("rejects when offering name is missing entirely", async () => {
519
+ const memo = makeNegotiationMemo({
520
+ requirement: { wallet: VALID_WALLET },
521
+ });
522
+ const data = makeJobEvent({ memoToSign: 100, memos: [memo] });
523
+
524
+ await handleNewTask(data);
525
+
526
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
527
+ 1,
528
+ expect.objectContaining({
529
+ accept: false,
530
+ reason: "Invalid offering name",
531
+ }),
532
+ );
533
+ });
534
+ });
535
+
536
+ // =======================================================================
537
+ // 4. TRANSACTION phase
538
+ // =======================================================================
539
+ describe("TRANSACTION phase", () => {
540
+ it("executes job when wallet present, offering valid, payment verified", async () => {
541
+ const memo = makeNegotiationMemo({
542
+ name: "x402janus_forensic_intelligence",
543
+ requirement: { wallet: VALID_WALLET },
544
+ });
545
+ const data = makeJobEvent({
546
+ id: 10,
547
+ phase: AcpJobPhase.TRANSACTION,
548
+ memos: [memo, makePaymentMemo()],
549
+ });
550
+
551
+ await handleNewTask(data);
552
+
553
+ expect(mockValidateReqs).toHaveBeenCalled();
554
+ expect(mockExecuteJob).toHaveBeenCalled();
555
+ expect(deliverJob).toHaveBeenCalledWith(
556
+ 10,
557
+ expect.objectContaining({
558
+ deliverable: expect.objectContaining({
559
+ type: "guardian_scan_result",
560
+ }),
561
+ }),
562
+ );
563
+ });
564
+
565
+ it("refuses to execute when wallet is missing at TRANSACTION — delivers error", async () => {
566
+ mockValidateReqs.mockReturnValue({
567
+ valid: false,
568
+ reason: "wallet (or address) is required",
569
+ });
570
+
571
+ const memo = makeNegotiationMemo({
572
+ name: "x402janus_forensic_intelligence",
573
+ // no wallet in requirement
574
+ });
575
+ const data = makeJobEvent({
576
+ id: 11,
577
+ phase: AcpJobPhase.TRANSACTION,
578
+ memos: [memo, makePaymentMemo()],
579
+ });
580
+
581
+ await handleNewTask(data);
582
+
583
+ // executeJob must NOT be called
584
+ expect(mockExecuteJob).not.toHaveBeenCalled();
585
+ // deliverJob should be called with an error deliverable
586
+ expect(deliverJob).toHaveBeenCalledWith(
587
+ 11,
588
+ expect.objectContaining({
589
+ deliverable: expect.objectContaining({
590
+ type: "guardian_scan_error",
591
+ }),
592
+ }),
593
+ );
594
+ });
595
+
596
+ it.each(["x402janus_forensic_intelligence"] as const)(
597
+ "refuses %s at TRANSACTION when wallet is missing — delivers error",
598
+ async (offeringName) => {
599
+ mockValidateReqs.mockReturnValue({
600
+ valid: false,
601
+ reason: "wallet (or address) is required",
602
+ });
603
+
604
+ const memo = makeNegotiationMemo({ name: offeringName });
605
+ const data = makeJobEvent({
606
+ id: 111,
607
+ phase: AcpJobPhase.TRANSACTION,
608
+ memos: [memo, makePaymentMemo()],
609
+ });
610
+
611
+ await handleNewTask(data);
612
+
613
+ expect(mockExecuteJob).not.toHaveBeenCalled();
614
+ expect(deliverJob).toHaveBeenCalledWith(
615
+ 111,
616
+ expect.objectContaining({
617
+ deliverable: expect.objectContaining({
618
+ type: "guardian_scan_error",
619
+ }),
620
+ }),
621
+ );
622
+ },
623
+ );
624
+
625
+ it("refuses to execute when no payment proof exists", async () => {
626
+ const memo = makeNegotiationMemo({
627
+ name: "x402janus_forensic_intelligence",
628
+ requirement: { wallet: VALID_WALLET },
629
+ });
630
+ // No payment memo
631
+ const data = makeJobEvent({
632
+ id: 12,
633
+ phase: AcpJobPhase.TRANSACTION,
634
+ memos: [memo],
635
+ });
636
+
637
+ await handleNewTask(data);
638
+
639
+ expect(mockExecuteJob).not.toHaveBeenCalled();
640
+ expect(deliverJob).toHaveBeenCalledWith(
641
+ 12,
642
+ expect.objectContaining({
643
+ deliverable: expect.objectContaining({
644
+ type: "guardian_scan_error",
645
+ value: expect.objectContaining({
646
+ success: false,
647
+ error: "Payment verification failed",
648
+ }),
649
+ }),
650
+ }),
651
+ );
652
+ });
653
+
654
+ it("uses clientAddress fallback at TRANSACTION when wallet is omitted", async () => {
655
+ mockValidateReqs.mockImplementation((req: unknown) => {
656
+ const requirement = req as Record<string, unknown>;
657
+ expect(requirement.walletAddress).toBe(VALID_WALLET);
658
+ return { valid: true };
659
+ });
660
+
661
+ const memo = makeNegotiationMemo({
662
+ name: "x402janus_forensic_intelligence",
663
+ });
664
+ const data = makeJobEvent({
665
+ id: 18,
666
+ phase: AcpJobPhase.TRANSACTION,
667
+ memos: [memo, makePaymentMemo()],
668
+ clientAddress: VALID_WALLET,
669
+ });
670
+
671
+ await handleNewTask(data);
672
+
673
+ expect(mockExecuteJob).toHaveBeenCalledWith(
674
+ expect.objectContaining({ walletAddress: VALID_WALLET }),
675
+ );
676
+ expect(deliverJob).toHaveBeenCalledTimes(1);
677
+ });
678
+
679
+ it("refuses to execute for unavailable offering at TRANSACTION", async () => {
680
+ const memo = makeNegotiationMemo({ name: "indigo" });
681
+ const data = makeJobEvent({
682
+ id: 13,
683
+ phase: AcpJobPhase.TRANSACTION,
684
+ memos: [memo, makePaymentMemo()],
685
+ });
686
+
687
+ await handleNewTask(data);
688
+
689
+ expect(mockExecuteJob).not.toHaveBeenCalled();
690
+ expect(deliverJob).toHaveBeenCalledWith(
691
+ 13,
692
+ expect.objectContaining({
693
+ deliverable: expect.objectContaining({
694
+ type: "execution_error",
695
+ value: expect.objectContaining({
696
+ success: false,
697
+ error: 'Offering "indigo" is not available',
698
+ reason: "OFFERING_UNAVAILABLE",
699
+ }),
700
+ }),
701
+ }),
702
+ );
703
+ });
704
+
705
+ it("delivers TRANSACTION error when executeJob throws", async () => {
706
+ mockExecuteJob.mockRejectedValueOnce(new Error("boom"));
707
+
708
+ const memo = makeNegotiationMemo({
709
+ name: "x402janus_forensic_intelligence",
710
+ requirement: { wallet: VALID_WALLET },
711
+ });
712
+ const data = makeJobEvent({
713
+ id: 14,
714
+ phase: AcpJobPhase.TRANSACTION,
715
+ memos: [memo, makePaymentMemo()],
716
+ });
717
+
718
+ await handleNewTask(data);
719
+
720
+ expect(acceptOrRejectJob).not.toHaveBeenCalled();
721
+ expect(deliverJob).toHaveBeenCalledWith(
722
+ 14,
723
+ expect.objectContaining({
724
+ deliverable: expect.objectContaining({
725
+ type: "guardian_scan_error",
726
+ value: expect.objectContaining({
727
+ success: false,
728
+ error: "Job execution failed",
729
+ reason: "boom",
730
+ offering: "x402janus_forensic_intelligence",
731
+ wallet: VALID_WALLET,
732
+ }),
733
+ }),
734
+ }),
735
+ );
736
+ });
737
+
738
+ it("uses walletAddress when wallet is blank in execution error deliverable", async () => {
739
+ mockExecuteJob.mockRejectedValueOnce(new Error("boom"));
740
+
741
+ const memo = makeNegotiationMemo({
742
+ name: "x402janus_forensic_intelligence",
743
+ requirement: { wallet: "", walletAddress: VALID_WALLET },
744
+ });
745
+ const data = makeJobEvent({
746
+ id: 140,
747
+ phase: AcpJobPhase.TRANSACTION,
748
+ memos: [memo, makePaymentMemo()],
749
+ });
750
+
751
+ await handleNewTask(data);
752
+
753
+ expect(deliverJob).toHaveBeenCalledWith(
754
+ 140,
755
+ expect.objectContaining({
756
+ deliverable: expect.objectContaining({
757
+ value: expect.objectContaining({
758
+ wallet: VALID_WALLET,
759
+ }),
760
+ }),
761
+ }),
762
+ );
763
+ });
764
+
765
+ it("uses generic execution_error type for non-guardian offerings", async () => {
766
+ setAvailableOfferings([...X402_GUARDIAN_OFFERINGS, "indigo"]);
767
+ mockExecuteJob.mockRejectedValueOnce(new Error("boom"));
768
+
769
+ const memo = makeNegotiationMemo({
770
+ name: "indigo",
771
+ requirement: { wallet: VALID_WALLET },
772
+ });
773
+ const data = makeJobEvent({
774
+ id: 141,
775
+ phase: AcpJobPhase.TRANSACTION,
776
+ memos: [memo, makePaymentMemo()],
777
+ });
778
+
779
+ await handleNewTask(data);
780
+
781
+ expect(deliverJob).toHaveBeenCalledWith(
782
+ 141,
783
+ expect.objectContaining({
784
+ deliverable: expect.objectContaining({
785
+ type: "execution_error",
786
+ }),
787
+ }),
788
+ );
789
+ });
790
+ });
791
+
792
+ // =======================================================================
793
+ // 5. REQUEST-phase wallet validation guard
794
+ // =======================================================================
795
+ describe("REQUEST-phase wallet validation", () => {
796
+ it("rejects REQUEST with missing wallet so users are not charged", async () => {
797
+ mockValidateReqs.mockReturnValue({
798
+ valid: false,
799
+ reason: "wallet (or address) is required",
800
+ });
801
+
802
+ const requestMemo = makeNegotiationMemo({
803
+ name: "x402janus_forensic_intelligence",
804
+ });
805
+ const requestData = makeJobEvent({
806
+ id: 99,
807
+ phase: AcpJobPhase.REQUEST,
808
+ memoToSign: 100,
809
+ memos: [requestMemo],
810
+ });
811
+
812
+ await handleNewTask(requestData);
813
+ expect(acceptOrRejectJob).toHaveBeenCalledWith(
814
+ 99,
815
+ expect.objectContaining({
816
+ accept: false,
817
+ reason: "wallet (or address) is required",
818
+ }),
819
+ );
820
+ expect(requestPayment).not.toHaveBeenCalled();
821
+ });
822
+ });
823
+ });