@ophirai/sdk 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 (60) hide show
  1. package/README.md +139 -0
  2. package/dist/__tests__/buyer.test.d.ts +1 -0
  3. package/dist/__tests__/buyer.test.js +664 -0
  4. package/dist/__tests__/discovery.test.d.ts +1 -0
  5. package/dist/__tests__/discovery.test.js +188 -0
  6. package/dist/__tests__/escrow.test.d.ts +1 -0
  7. package/dist/__tests__/escrow.test.js +385 -0
  8. package/dist/__tests__/identity.test.d.ts +1 -0
  9. package/dist/__tests__/identity.test.js +222 -0
  10. package/dist/__tests__/integration.test.d.ts +1 -0
  11. package/dist/__tests__/integration.test.js +681 -0
  12. package/dist/__tests__/lockstep.test.d.ts +1 -0
  13. package/dist/__tests__/lockstep.test.js +320 -0
  14. package/dist/__tests__/messages.test.d.ts +1 -0
  15. package/dist/__tests__/messages.test.js +976 -0
  16. package/dist/__tests__/negotiation.test.d.ts +1 -0
  17. package/dist/__tests__/negotiation.test.js +667 -0
  18. package/dist/__tests__/seller.test.d.ts +1 -0
  19. package/dist/__tests__/seller.test.js +767 -0
  20. package/dist/__tests__/server.test.d.ts +1 -0
  21. package/dist/__tests__/server.test.js +239 -0
  22. package/dist/__tests__/signing.test.d.ts +1 -0
  23. package/dist/__tests__/signing.test.js +713 -0
  24. package/dist/__tests__/sla.test.d.ts +1 -0
  25. package/dist/__tests__/sla.test.js +342 -0
  26. package/dist/__tests__/transport.test.d.ts +1 -0
  27. package/dist/__tests__/transport.test.js +197 -0
  28. package/dist/__tests__/x402.test.d.ts +1 -0
  29. package/dist/__tests__/x402.test.js +141 -0
  30. package/dist/buyer.d.ts +190 -0
  31. package/dist/buyer.js +555 -0
  32. package/dist/discovery.d.ts +47 -0
  33. package/dist/discovery.js +51 -0
  34. package/dist/escrow.d.ts +177 -0
  35. package/dist/escrow.js +434 -0
  36. package/dist/identity.d.ts +60 -0
  37. package/dist/identity.js +108 -0
  38. package/dist/index.d.ts +122 -0
  39. package/dist/index.js +43 -0
  40. package/dist/lockstep.d.ts +94 -0
  41. package/dist/lockstep.js +127 -0
  42. package/dist/messages.d.ts +172 -0
  43. package/dist/messages.js +262 -0
  44. package/dist/negotiation.d.ts +113 -0
  45. package/dist/negotiation.js +214 -0
  46. package/dist/seller.d.ts +127 -0
  47. package/dist/seller.js +395 -0
  48. package/dist/server.d.ts +52 -0
  49. package/dist/server.js +149 -0
  50. package/dist/signing.d.ts +98 -0
  51. package/dist/signing.js +165 -0
  52. package/dist/sla.d.ts +95 -0
  53. package/dist/sla.js +187 -0
  54. package/dist/transport.d.ts +41 -0
  55. package/dist/transport.js +127 -0
  56. package/dist/types.d.ts +86 -0
  57. package/dist/types.js +1 -0
  58. package/dist/x402.d.ts +25 -0
  59. package/dist/x402.js +54 -0
  60. package/package.json +40 -0
@@ -0,0 +1,681 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { BuyerAgent } from '../buyer.js';
3
+ import { SellerAgent } from '../seller.js';
4
+ import { generateKeyPair, didToPublicKey } from '../identity.js';
5
+ import { signMessage, verifyMessage, agreementHash } from '../signing.js';
6
+ import { buildQuote } from '../messages.js';
7
+ describe('Integration: Full negotiation flows', () => {
8
+ describe('Test 1: Happy path — RFQ → Quote → Accept', () => {
9
+ let seller;
10
+ let buyer;
11
+ beforeAll(async () => {
12
+ seller = new SellerAgent({
13
+ endpoint: 'http://localhost:0',
14
+ services: [
15
+ {
16
+ category: 'inference',
17
+ description: 'LLM inference service',
18
+ base_price: '0.005',
19
+ currency: 'USDC',
20
+ unit: 'request',
21
+ },
22
+ ],
23
+ });
24
+ await seller.listen(0);
25
+ buyer = new BuyerAgent({
26
+ endpoint: 'http://localhost:0',
27
+ });
28
+ await buyer.listen(0);
29
+ // Auto-generate quote on RFQ
30
+ seller.onRFQ(async (rfq) => seller.generateQuote(rfq));
31
+ });
32
+ afterAll(async () => {
33
+ await buyer.close();
34
+ await seller.close();
35
+ });
36
+ it('completes full negotiation flow', async () => {
37
+ const session = await buyer.requestQuotes({
38
+ sellers: [seller.getEndpoint()],
39
+ service: { category: 'inference' },
40
+ budget: {
41
+ max_price_per_unit: '0.01',
42
+ currency: 'USDC',
43
+ unit: 'request',
44
+ },
45
+ });
46
+ // Wait for quote with generous timeout
47
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
48
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
49
+ // Verify quote has correct pricing from seller's base_price
50
+ expect(quotes[0].pricing.price_per_unit).toBe('0.0050');
51
+ expect(quotes[0].pricing.currency).toBe('USDC');
52
+ // Verify quote signature is valid
53
+ const { signature, ...unsigned } = quotes[0];
54
+ const sellerPubKey = didToPublicKey(seller.getAgentId());
55
+ expect(verifyMessage(unsigned, signature, sellerPubKey)).toBe(true);
56
+ // Rank and accept
57
+ const ranked = buyer.rankQuotes(quotes, 'cheapest');
58
+ expect(ranked.length).toBeGreaterThanOrEqual(1);
59
+ const bestQuote = ranked[0];
60
+ const agreement = await buyer.acceptQuote(bestQuote);
61
+ // Verify agreement_hash is valid SHA-256 hex
62
+ expect(agreement.agreement_hash).toMatch(/^[a-f0-9]{64}$/);
63
+ // Recompute agreement_hash and compare
64
+ const recomputed = agreementHash(agreement.final_terms);
65
+ expect(agreement.agreement_hash).toBe(recomputed);
66
+ // Verify buyer_signature is valid
67
+ expect(agreement.buyer_signature).toBeDefined();
68
+ const buyerPubKey = didToPublicKey(buyer.getAgentId());
69
+ const unsignedAccept = {
70
+ agreement_id: agreement.agreement_id,
71
+ rfq_id: agreement.rfq_id,
72
+ accepting_message_id: bestQuote.quote_id,
73
+ final_terms: agreement.final_terms,
74
+ agreement_hash: agreement.agreement_hash,
75
+ };
76
+ expect(verifyMessage(unsignedAccept, agreement.buyer_signature, buyerPubKey)).toBe(true);
77
+ });
78
+ });
79
+ describe('Test 2: Counter-offer flow', () => {
80
+ let seller;
81
+ let buyer;
82
+ let sellerKp;
83
+ beforeAll(async () => {
84
+ sellerKp = generateKeyPair();
85
+ seller = new SellerAgent({
86
+ keypair: sellerKp,
87
+ endpoint: 'http://localhost:0',
88
+ services: [
89
+ {
90
+ category: 'inference',
91
+ description: 'LLM inference service',
92
+ base_price: '0.005',
93
+ currency: 'USDC',
94
+ unit: 'request',
95
+ },
96
+ ],
97
+ });
98
+ await seller.listen(0);
99
+ buyer = new BuyerAgent({
100
+ endpoint: 'http://localhost:0',
101
+ });
102
+ await buyer.listen(0);
103
+ // Counter handler: accept at $0.004 (split the difference)
104
+ seller.onCounter(async (counter, _session) => {
105
+ const quoteMsg = buildQuote({
106
+ rfqId: counter.rfq_id,
107
+ seller: {
108
+ agent_id: seller.getAgentId(),
109
+ endpoint: seller.getEndpoint(),
110
+ },
111
+ pricing: {
112
+ price_per_unit: '0.004',
113
+ currency: 'USDC',
114
+ unit: 'request',
115
+ pricing_model: 'fixed',
116
+ },
117
+ sla: {
118
+ metrics: [
119
+ { name: 'uptime_pct', target: 99.9, comparison: 'gte' },
120
+ ],
121
+ },
122
+ secretKey: sellerKp.secretKey,
123
+ });
124
+ return quoteMsg.params;
125
+ });
126
+ });
127
+ afterAll(async () => {
128
+ await buyer.close();
129
+ await seller.close();
130
+ });
131
+ it('negotiates via counter-offer to $0.004', async () => {
132
+ const session = await buyer.requestQuotes({
133
+ sellers: [seller.getEndpoint()],
134
+ service: { category: 'inference' },
135
+ budget: {
136
+ max_price_per_unit: '0.01',
137
+ currency: 'USDC',
138
+ unit: 'request',
139
+ },
140
+ });
141
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
142
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
143
+ // Original quote at $0.005
144
+ const originalQuote = quotes[0];
145
+ expect(originalQuote.pricing.price_per_unit).toBe('0.0050');
146
+ // Buyer counters at $0.003
147
+ await buyer.counter(originalQuote, { price_per_unit: '0.003' });
148
+ // Verify session round tracking
149
+ expect(session.counters.length).toBeGreaterThanOrEqual(1);
150
+ expect(session.currentRound).toBeGreaterThanOrEqual(1);
151
+ // Wait for seller's response quote
152
+ const updatedQuotes = await buyer.waitForQuotes(session, {
153
+ minQuotes: 2,
154
+ timeout: 10_000,
155
+ });
156
+ expect(updatedQuotes.length).toBeGreaterThanOrEqual(2);
157
+ // Seller accepts at $0.004
158
+ const newQuote = updatedQuotes[updatedQuotes.length - 1];
159
+ expect(newQuote.pricing.price_per_unit).toBe('0.004');
160
+ // Verify round 2
161
+ expect(session.currentRound).toBeGreaterThanOrEqual(1);
162
+ // Accept the counter
163
+ const agreement = await buyer.acceptQuote(newQuote);
164
+ expect(agreement.final_terms.price_per_unit).toBe('0.004');
165
+ expect(agreement.agreement_hash).toMatch(/^[a-f0-9]{64}$/);
166
+ });
167
+ });
168
+ describe('Test 3: Rejection flow', () => {
169
+ let seller;
170
+ let buyer;
171
+ beforeAll(async () => {
172
+ seller = new SellerAgent({
173
+ endpoint: 'http://localhost:0',
174
+ services: [
175
+ {
176
+ category: 'inference',
177
+ description: 'LLM inference — expensive',
178
+ base_price: '1.000',
179
+ currency: 'USDC',
180
+ unit: 'request',
181
+ },
182
+ ],
183
+ });
184
+ await seller.listen(0);
185
+ buyer = new BuyerAgent({
186
+ endpoint: 'http://localhost:0',
187
+ });
188
+ await buyer.listen(0);
189
+ });
190
+ afterAll(async () => {
191
+ await buyer.close();
192
+ await seller.close();
193
+ });
194
+ it('rejects quote above budget with reason', async () => {
195
+ const session = await buyer.requestQuotes({
196
+ sellers: [seller.getEndpoint()],
197
+ service: { category: 'inference' },
198
+ budget: {
199
+ max_price_per_unit: '0.001',
200
+ currency: 'USDC',
201
+ unit: 'request',
202
+ },
203
+ });
204
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
205
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
206
+ // Quote price ($1.00) far exceeds budget ($0.001)
207
+ expect(parseFloat(quotes[0].pricing.price_per_unit)).toBeGreaterThan(0.001);
208
+ await buyer.reject(session, 'price_too_high');
209
+ expect(session.state).toBe('REJECTED');
210
+ expect(session.rejectionReason).toBe('price_too_high');
211
+ });
212
+ });
213
+ describe('Test 4: Multiple sellers', () => {
214
+ let cheapSeller;
215
+ let expensiveSeller;
216
+ let buyer;
217
+ beforeAll(async () => {
218
+ cheapSeller = new SellerAgent({
219
+ endpoint: 'http://localhost:0',
220
+ services: [
221
+ {
222
+ category: 'inference',
223
+ description: 'Cheap inference',
224
+ base_price: '0.005',
225
+ currency: 'USDC',
226
+ unit: 'request',
227
+ },
228
+ ],
229
+ });
230
+ await cheapSeller.listen(0);
231
+ expensiveSeller = new SellerAgent({
232
+ endpoint: 'http://localhost:0',
233
+ services: [
234
+ {
235
+ category: 'inference',
236
+ description: 'Premium inference',
237
+ base_price: '0.008',
238
+ currency: 'USDC',
239
+ unit: 'request',
240
+ },
241
+ ],
242
+ });
243
+ await expensiveSeller.listen(0);
244
+ buyer = new BuyerAgent({
245
+ endpoint: 'http://localhost:0',
246
+ });
247
+ await buyer.listen(0);
248
+ });
249
+ afterAll(async () => {
250
+ await buyer.close();
251
+ await cheapSeller.close();
252
+ await expensiveSeller.close();
253
+ });
254
+ it('ranks 2 sellers and accepts cheapest ($0.005)', async () => {
255
+ const session = await buyer.requestQuotes({
256
+ sellers: [cheapSeller.getEndpoint(), expensiveSeller.getEndpoint()],
257
+ service: { category: 'inference' },
258
+ budget: {
259
+ max_price_per_unit: '1.00',
260
+ currency: 'USDC',
261
+ unit: 'request',
262
+ },
263
+ });
264
+ const quotes = await buyer.waitForQuotes(session, {
265
+ minQuotes: 2,
266
+ timeout: 10_000,
267
+ });
268
+ expect(quotes).toHaveLength(2);
269
+ const ranked = buyer.rankQuotes(quotes, 'cheapest');
270
+ // $0.005 should be first
271
+ const cheapPrice = parseFloat(ranked[0].pricing.price_per_unit);
272
+ const expensivePrice = parseFloat(ranked[1].pricing.price_per_unit);
273
+ expect(cheapPrice).toBeLessThan(expensivePrice);
274
+ expect(cheapPrice).toBeCloseTo(0.005, 3);
275
+ expect(expensivePrice).toBeCloseTo(0.008, 3);
276
+ // Accept cheapest
277
+ const agreement = await buyer.acceptQuote(ranked[0]);
278
+ expect(agreement.final_terms.price_per_unit).toBe(ranked[0].pricing.price_per_unit);
279
+ });
280
+ });
281
+ describe('Test 5: Signature verification end-to-end', () => {
282
+ let seller;
283
+ let buyer;
284
+ let sellerKp;
285
+ let buyerKp;
286
+ beforeAll(async () => {
287
+ sellerKp = generateKeyPair();
288
+ buyerKp = generateKeyPair();
289
+ seller = new SellerAgent({
290
+ keypair: sellerKp,
291
+ endpoint: 'http://localhost:0',
292
+ services: [
293
+ {
294
+ category: 'inference',
295
+ description: 'Inference service',
296
+ base_price: '0.005',
297
+ currency: 'USDC',
298
+ unit: 'request',
299
+ },
300
+ ],
301
+ });
302
+ await seller.listen(0);
303
+ buyer = new BuyerAgent({
304
+ keypair: buyerKp,
305
+ endpoint: 'http://localhost:0',
306
+ });
307
+ await buyer.listen(0);
308
+ });
309
+ afterAll(async () => {
310
+ await buyer.close();
311
+ await seller.close();
312
+ });
313
+ it('verifies seller quote signature with their public key', async () => {
314
+ const session = await buyer.requestQuotes({
315
+ sellers: [seller.getEndpoint()],
316
+ service: { category: 'inference' },
317
+ budget: {
318
+ max_price_per_unit: '0.01',
319
+ currency: 'USDC',
320
+ unit: 'request',
321
+ },
322
+ });
323
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
324
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
325
+ const quote = quotes[0];
326
+ // Verify seller's signature on the quote
327
+ const { signature, ...unsigned } = quote;
328
+ const sellerPubKey = didToPublicKey(seller.getAgentId());
329
+ expect(verifyMessage(unsigned, signature, sellerPubKey)).toBe(true);
330
+ // Tampered params should fail
331
+ const tampered = {
332
+ ...unsigned,
333
+ pricing: { ...unsigned.pricing, price_per_unit: '999.000' },
334
+ };
335
+ expect(verifyMessage(tampered, signature, sellerPubKey)).toBe(false);
336
+ // Accept and verify buyer's signature
337
+ const agreement = await buyer.acceptQuote(quote);
338
+ const buyerPubKey = didToPublicKey(buyer.getAgentId());
339
+ // Verify agreement_hash matches recomputed hash
340
+ const recomputed = agreementHash(agreement.final_terms);
341
+ expect(agreement.agreement_hash).toBe(recomputed);
342
+ // Verify buyer_signature is valid
343
+ const unsignedAccept = {
344
+ agreement_id: agreement.agreement_id,
345
+ rfq_id: agreement.rfq_id,
346
+ accepting_message_id: quote.quote_id,
347
+ final_terms: agreement.final_terms,
348
+ agreement_hash: agreement.agreement_hash,
349
+ };
350
+ expect(verifyMessage(unsignedAccept, agreement.buyer_signature, buyerPubKey)).toBe(true);
351
+ // Verify with wrong key fails
352
+ const wrongKey = generateKeyPair().publicKey;
353
+ expect(verifyMessage(unsignedAccept, agreement.buyer_signature, wrongKey)).toBe(false);
354
+ });
355
+ });
356
+ describe('Test 6: Expired quote handling', () => {
357
+ let seller;
358
+ let buyer;
359
+ let sellerKp;
360
+ beforeAll(async () => {
361
+ sellerKp = generateKeyPair();
362
+ seller = new SellerAgent({
363
+ keypair: sellerKp,
364
+ endpoint: 'http://localhost:0',
365
+ services: [
366
+ {
367
+ category: 'inference',
368
+ description: 'Inference service',
369
+ base_price: '0.005',
370
+ currency: 'USDC',
371
+ unit: 'request',
372
+ },
373
+ ],
374
+ });
375
+ await seller.listen(0);
376
+ buyer = new BuyerAgent({
377
+ endpoint: 'http://localhost:0',
378
+ });
379
+ await buyer.listen(0);
380
+ // Custom handler that generates a quote with very short TTL (100ms)
381
+ seller.onRFQ(async (rfq) => {
382
+ const service = { base_price: '0.005', currency: 'USDC', unit: 'request' };
383
+ const { v4: uuidv4 } = await import('uuid');
384
+ const { signMessage } = await import('../signing.js');
385
+ const unsigned = {
386
+ quote_id: uuidv4(),
387
+ rfq_id: rfq.rfq_id,
388
+ seller: {
389
+ agent_id: seller.getAgentId(),
390
+ endpoint: seller.getEndpoint(),
391
+ },
392
+ pricing: {
393
+ price_per_unit: service.base_price,
394
+ currency: service.currency,
395
+ unit: service.unit,
396
+ pricing_model: 'fixed',
397
+ },
398
+ sla_offered: {
399
+ metrics: [
400
+ { name: 'uptime_pct', target: 99.9, comparison: 'gte' },
401
+ ],
402
+ },
403
+ // Expires in 100ms
404
+ expires_at: new Date(Date.now() + 100).toISOString(),
405
+ };
406
+ const signature = signMessage(unsigned, sellerKp.secretKey);
407
+ return { ...unsigned, signature };
408
+ });
409
+ });
410
+ afterAll(async () => {
411
+ await buyer.close();
412
+ await seller.close();
413
+ });
414
+ it('detects expired quote by checking expires_at', async () => {
415
+ const session = await buyer.requestQuotes({
416
+ sellers: [seller.getEndpoint()],
417
+ service: { category: 'inference' },
418
+ budget: {
419
+ max_price_per_unit: '0.01',
420
+ currency: 'USDC',
421
+ unit: 'request',
422
+ },
423
+ });
424
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
425
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
426
+ const quote = quotes[0];
427
+ expect(quote.expires_at).toBeDefined();
428
+ // Wait for the quote to expire
429
+ await new Promise((resolve) => setTimeout(resolve, 200));
430
+ // Verify the quote is now expired
431
+ const expiresAt = new Date(quote.expires_at).getTime();
432
+ expect(Date.now()).toBeGreaterThan(expiresAt);
433
+ });
434
+ });
435
+ describe('Test 7: Max rounds exceeded', () => {
436
+ let seller;
437
+ let buyer;
438
+ let sellerKp;
439
+ beforeAll(async () => {
440
+ sellerKp = generateKeyPair();
441
+ seller = new SellerAgent({
442
+ keypair: sellerKp,
443
+ endpoint: 'http://localhost:0',
444
+ services: [
445
+ {
446
+ category: 'inference',
447
+ description: 'Inference service',
448
+ base_price: '0.010',
449
+ currency: 'USDC',
450
+ unit: 'request',
451
+ },
452
+ ],
453
+ });
454
+ await seller.listen(0);
455
+ buyer = new BuyerAgent({
456
+ endpoint: 'http://localhost:0',
457
+ });
458
+ await buyer.listen(0);
459
+ // Counter handler: always respond with a new quote at a slightly lower price
460
+ seller.onCounter(async (counter, _session) => {
461
+ const quoteMsg = buildQuote({
462
+ rfqId: counter.rfq_id,
463
+ seller: {
464
+ agent_id: seller.getAgentId(),
465
+ endpoint: seller.getEndpoint(),
466
+ },
467
+ pricing: {
468
+ price_per_unit: '0.009',
469
+ currency: 'USDC',
470
+ unit: 'request',
471
+ pricing_model: 'fixed',
472
+ },
473
+ sla: {
474
+ metrics: [
475
+ { name: 'uptime_pct', target: 99.9, comparison: 'gte' },
476
+ ],
477
+ },
478
+ secretKey: sellerKp.secretKey,
479
+ });
480
+ return quoteMsg.params;
481
+ });
482
+ });
483
+ afterAll(async () => {
484
+ await buyer.close();
485
+ await seller.close();
486
+ });
487
+ it('throws MAX_ROUNDS_EXCEEDED after exceeding limit', async () => {
488
+ // Request with maxRounds = 2
489
+ const session = await buyer.requestQuotes({
490
+ sellers: [seller.getEndpoint()],
491
+ service: { category: 'inference' },
492
+ budget: {
493
+ max_price_per_unit: '0.02',
494
+ currency: 'USDC',
495
+ unit: 'request',
496
+ },
497
+ maxRounds: 2,
498
+ });
499
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
500
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
501
+ const quote = quotes[0];
502
+ // Round 1: buyer counters
503
+ await buyer.counter(quote, { price_per_unit: '0.007' });
504
+ expect(session.currentRound).toBe(1);
505
+ // Wait for seller's response
506
+ const quotes2 = await buyer.waitForQuotes(session, {
507
+ minQuotes: 2,
508
+ timeout: 10_000,
509
+ });
510
+ const newQuote = quotes2[quotes2.length - 1];
511
+ // Round 2: buyer counters again
512
+ await buyer.counter(newQuote, { price_per_unit: '0.006' });
513
+ expect(session.currentRound).toBe(2);
514
+ // Wait for seller's response
515
+ const quotes3 = await buyer.waitForQuotes(session, {
516
+ minQuotes: 3,
517
+ timeout: 10_000,
518
+ });
519
+ const newestQuote = quotes3[quotes3.length - 1];
520
+ // Round 3: should throw MAX_ROUNDS_EXCEEDED
521
+ await expect(buyer.counter(newestQuote, { price_per_unit: '0.005' })).rejects.toThrow('exceeds max');
522
+ });
523
+ });
524
+ describe('Test 8: Full flow with signature verification at every step', () => {
525
+ let seller;
526
+ let buyer;
527
+ let sellerKp;
528
+ let buyerKp;
529
+ beforeAll(async () => {
530
+ sellerKp = generateKeyPair();
531
+ buyerKp = generateKeyPair();
532
+ seller = new SellerAgent({
533
+ keypair: sellerKp,
534
+ endpoint: 'http://localhost:0',
535
+ services: [
536
+ {
537
+ category: 'inference',
538
+ description: 'LLM inference service',
539
+ base_price: '0.005',
540
+ currency: 'USDC',
541
+ unit: 'request',
542
+ },
543
+ ],
544
+ });
545
+ await seller.listen(0);
546
+ buyer = new BuyerAgent({
547
+ keypair: buyerKp,
548
+ endpoint: 'http://localhost:0',
549
+ });
550
+ await buyer.listen(0);
551
+ });
552
+ afterAll(async () => {
553
+ await buyer.close();
554
+ await seller.close();
555
+ });
556
+ it('buyer signs RFQ → seller verifies → seller signs quote → buyer verifies → buyer signs accept → seller verifies', async () => {
557
+ // Step 1: Buyer sends RFQ (signed by buyer) → seller receives and verifies
558
+ const session = await buyer.requestQuotes({
559
+ sellers: [seller.getEndpoint()],
560
+ service: { category: 'inference' },
561
+ budget: {
562
+ max_price_per_unit: '0.01',
563
+ currency: 'USDC',
564
+ unit: 'request',
565
+ },
566
+ });
567
+ // Step 2: Wait for seller's signed quote
568
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
569
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
570
+ const quote = quotes[0];
571
+ // Step 3: Verify quote signature with seller's public key
572
+ const { signature: quoteSig, ...unsignedQuote } = quote;
573
+ const sellerPubKey = didToPublicKey(seller.getAgentId());
574
+ expect(verifyMessage(unsignedQuote, quoteSig, sellerPubKey)).toBe(true);
575
+ // Step 4: Buyer accepts the quote (buyer signs the accept)
576
+ const agreement = await buyer.acceptQuote(quote);
577
+ // Step 5: Verify buyer_signature with buyer's public key
578
+ const buyerPubKey = didToPublicKey(buyer.getAgentId());
579
+ const unsignedAccept = {
580
+ agreement_id: agreement.agreement_id,
581
+ rfq_id: agreement.rfq_id,
582
+ accepting_message_id: quote.quote_id,
583
+ final_terms: agreement.final_terms,
584
+ agreement_hash: agreement.agreement_hash,
585
+ };
586
+ expect(verifyMessage(unsignedAccept, agreement.buyer_signature, buyerPubKey)).toBe(true);
587
+ // Step 6: The accept was sent to the seller via the transport inside acceptQuote.
588
+ // Verify that the seller returned a seller_signature (counter-signature).
589
+ expect(agreement.seller_signature).toBeDefined();
590
+ // Step 7: Verify seller_signature with seller's public key
591
+ expect(verifyMessage(unsignedAccept, agreement.seller_signature, sellerPubKey)).toBe(true);
592
+ });
593
+ });
594
+ describe('Test 9: Forged quote rejected by buyer', () => {
595
+ let seller;
596
+ let buyer;
597
+ let sellerKp;
598
+ beforeAll(async () => {
599
+ sellerKp = generateKeyPair();
600
+ seller = new SellerAgent({
601
+ keypair: sellerKp,
602
+ endpoint: 'http://localhost:0',
603
+ services: [
604
+ {
605
+ category: 'inference',
606
+ description: 'LLM inference service',
607
+ base_price: '0.005',
608
+ currency: 'USDC',
609
+ unit: 'request',
610
+ },
611
+ ],
612
+ });
613
+ await seller.listen(0);
614
+ buyer = new BuyerAgent({
615
+ endpoint: 'http://localhost:0',
616
+ });
617
+ await buyer.listen(0);
618
+ });
619
+ afterAll(async () => {
620
+ await buyer.close();
621
+ await seller.close();
622
+ });
623
+ it('buyer rejects quote signed by wrong seller key', async () => {
624
+ // Get a real quote from the seller
625
+ const session = await buyer.requestQuotes({
626
+ sellers: [seller.getEndpoint()],
627
+ service: { category: 'inference' },
628
+ budget: {
629
+ max_price_per_unit: '0.01',
630
+ currency: 'USDC',
631
+ unit: 'request',
632
+ },
633
+ });
634
+ const quotes = await buyer.waitForQuotes(session, { timeout: 10_000 });
635
+ expect(quotes.length).toBeGreaterThanOrEqual(1);
636
+ const realQuote = quotes[0];
637
+ // Create a forged quote: same pricing data but from a different (forged) seller
638
+ const forgerKp = generateKeyPair();
639
+ const { publicKeyToDid } = await import('../identity.js');
640
+ const forgerDid = publicKeyToDid(forgerKp.publicKey);
641
+ const { signature: _realSig, ...unsignedReal } = realQuote;
642
+ const forgedUnsigned = {
643
+ ...unsignedReal,
644
+ seller: {
645
+ agent_id: forgerDid,
646
+ endpoint: 'http://localhost:9999',
647
+ },
648
+ };
649
+ const forgedSignature = signMessage(forgedUnsigned, forgerKp.secretKey);
650
+ const forgedQuote = { ...forgedUnsigned, signature: forgedSignature };
651
+ // Add the forged quote directly to the session
652
+ session.addQuote(forgedQuote);
653
+ // acceptQuote should throw because the forged quote's seller DID
654
+ // does not match any legitimate seller key known to the negotiation,
655
+ // and the buyer verifies the signature against the quote's seller.agent_id.
656
+ // The signature IS valid for the forger's key, but the buyer still calls
657
+ // acceptQuote which sends to the forger's endpoint — that will fail.
658
+ // However, the critical check is that acceptQuote verifies the seller
659
+ // signature against the DID embedded in the quote. Since the forger
660
+ // signed with their own key matching their own DID, the signature check
661
+ // passes, but the accept send to the fake endpoint will fail.
662
+ //
663
+ // The real protection: if we tamper with the seller DID but keep the
664
+ // original seller's signature, verification fails.
665
+ const tamperedUnsigned = {
666
+ ...unsignedReal,
667
+ seller: {
668
+ agent_id: forgerDid,
669
+ endpoint: seller.getEndpoint(),
670
+ },
671
+ };
672
+ // Sign with the REAL seller's key but claim to be the forger's DID
673
+ const tamperedSignature = signMessage(tamperedUnsigned, sellerKp.secretKey);
674
+ const tamperedQuote = { ...tamperedUnsigned, signature: tamperedSignature };
675
+ session.addQuote(tamperedQuote);
676
+ // acceptQuote verifies signature against seller.agent_id (forgerDid),
677
+ // but the signature was made with sellerKp — mismatch → rejection
678
+ await expect(buyer.acceptQuote(tamperedQuote)).rejects.toThrow('seller signature is invalid');
679
+ });
680
+ });
681
+ });
@@ -0,0 +1 @@
1
+ export {};