@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,767 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { SellerAgent } from '../seller.js';
3
+ import { verifyMessage, signMessage, agreementHash } from '../signing.js';
4
+ import { generateKeyPair, publicKeyToDid, didToPublicKey } from '../identity.js';
5
+ const TEST_SERVICES = [
6
+ {
7
+ category: 'inference',
8
+ description: 'LLM inference service',
9
+ base_price: '0.01',
10
+ currency: 'USDC',
11
+ unit: 'request',
12
+ },
13
+ {
14
+ category: 'translation',
15
+ description: 'Multi-language translation',
16
+ base_price: '0.005',
17
+ currency: 'USDC',
18
+ unit: 'request',
19
+ },
20
+ ];
21
+ /** Create a valid signed RFQ that passes Zod schema validation and signature verification. */
22
+ function makeValidRFQ(overrides, keypair) {
23
+ const buyerKp = keypair ?? generateKeyPair();
24
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
25
+ const unsigned = {
26
+ rfq_id: crypto.randomUUID(),
27
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
28
+ service: { category: 'inference' },
29
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
30
+ negotiation_style: 'rfq',
31
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
32
+ ...overrides,
33
+ };
34
+ // Remove any existing signature from overrides before signing
35
+ const { signature: _existingSig, ...toSign } = unsigned;
36
+ const signature = signMessage(toSign, buyerKp.secretKey);
37
+ return { ...toSign, signature };
38
+ }
39
+ /** Send a JSON-RPC request to a local server and return the parsed response. */
40
+ async function jsonRpc(port, method, params) {
41
+ const res = await fetch(`http://localhost:${port}`, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ jsonrpc: '2.0', method, params, id: crypto.randomUUID() }),
45
+ });
46
+ return res.json();
47
+ }
48
+ describe('SellerAgent', () => {
49
+ let agent;
50
+ afterEach(async () => {
51
+ if (agent) {
52
+ await agent.close();
53
+ agent = undefined;
54
+ }
55
+ });
56
+ // ── Constructor ──────────────────────────────────────────────────
57
+ describe('constructor', () => {
58
+ it('auto-generates keypair and DID when no keypair provided', () => {
59
+ agent = new SellerAgent({
60
+ endpoint: 'http://localhost:3000',
61
+ services: TEST_SERVICES,
62
+ });
63
+ const agentId = agent.getAgentId();
64
+ expect(agentId).toMatch(/^did:key:z/);
65
+ const pubKey = didToPublicKey(agentId);
66
+ expect(pubKey).toBeInstanceOf(Uint8Array);
67
+ expect(pubKey.length).toBe(32);
68
+ });
69
+ it('DID starts with "did:key:z"', () => {
70
+ agent = new SellerAgent({
71
+ endpoint: 'http://localhost:3000',
72
+ services: TEST_SERVICES,
73
+ });
74
+ expect(agent.getAgentId()).toMatch(/^did:key:z/);
75
+ });
76
+ it('uses provided keypair', () => {
77
+ const kp = generateKeyPair();
78
+ agent = new SellerAgent({
79
+ keypair: kp,
80
+ endpoint: 'http://localhost:3000',
81
+ services: TEST_SERVICES,
82
+ });
83
+ const pubKey = didToPublicKey(agent.getAgentId());
84
+ expect(Buffer.from(pubKey)).toEqual(Buffer.from(kp.publicKey));
85
+ });
86
+ it('defaults pricing strategy to fixed', () => {
87
+ agent = new SellerAgent({
88
+ endpoint: 'http://localhost:3000',
89
+ services: TEST_SERVICES,
90
+ });
91
+ const quote = agent.generateQuote(makeValidRFQ());
92
+ expect(quote).not.toBeNull();
93
+ // Fixed strategy uses base_price directly: 0.01 → '0.0100'
94
+ expect(quote.pricing.price_per_unit).toBe('0.0100');
95
+ });
96
+ });
97
+ // ── registerService ──────────────────────────────────────────────
98
+ describe('registerService', () => {
99
+ it('adds service to the offerings', () => {
100
+ agent = new SellerAgent({
101
+ endpoint: 'http://localhost:4000',
102
+ services: TEST_SERVICES,
103
+ });
104
+ agent.registerService({
105
+ category: 'code_review',
106
+ description: 'AI code review',
107
+ base_price: '0.05',
108
+ currency: 'USDC',
109
+ unit: 'review',
110
+ });
111
+ const card = agent.generateAgentCard();
112
+ const neg = card.capabilities.negotiation;
113
+ expect(neg.services).toHaveLength(3);
114
+ expect(neg.services[2].category).toBe('code_review');
115
+ });
116
+ it('supports multiple services and quotes work for each', () => {
117
+ agent = new SellerAgent({
118
+ endpoint: 'http://localhost:4000',
119
+ services: [TEST_SERVICES[0]],
120
+ });
121
+ agent.registerService({
122
+ category: 'data_processing',
123
+ description: 'Data processing',
124
+ base_price: '0.02',
125
+ currency: 'USDC',
126
+ unit: 'MB',
127
+ });
128
+ // Quote for original service
129
+ const q1 = agent.generateQuote(makeValidRFQ({ service: { category: 'inference' } }));
130
+ expect(q1).not.toBeNull();
131
+ expect(q1.pricing.price_per_unit).toBe('0.0100');
132
+ // Quote for newly added service
133
+ const q2 = agent.generateQuote(makeValidRFQ({ service: { category: 'data_processing' } }));
134
+ expect(q2).not.toBeNull();
135
+ expect(q2.pricing.price_per_unit).toBe('0.0200');
136
+ expect(q2.pricing.unit).toBe('MB');
137
+ });
138
+ });
139
+ // ── generateAgentCard ────────────────────────────────────────────
140
+ describe('generateAgentCard', () => {
141
+ it('returns valid A2A-compatible agent card with negotiation capability', () => {
142
+ agent = new SellerAgent({
143
+ endpoint: 'http://localhost:3000',
144
+ services: TEST_SERVICES,
145
+ });
146
+ const card = agent.generateAgentCard();
147
+ const neg = card.capabilities.negotiation;
148
+ expect(card.name).toBeDefined();
149
+ expect(card.url).toBe('http://localhost:3000');
150
+ expect(neg.supported).toBe(true);
151
+ expect(neg.endpoint).toBe('http://localhost:3000');
152
+ expect(neg.protocols).toEqual(['ophir/1.0']);
153
+ expect(neg.acceptedPayments).toEqual([
154
+ { network: 'solana', token: 'USDC' },
155
+ ]);
156
+ expect(neg.negotiationStyles).toEqual(['rfq']);
157
+ expect(neg.maxNegotiationRounds).toBe(5);
158
+ });
159
+ it('lists all registered services', () => {
160
+ agent = new SellerAgent({
161
+ endpoint: 'http://localhost:3000',
162
+ services: TEST_SERVICES,
163
+ });
164
+ const card = agent.generateAgentCard();
165
+ const neg = card.capabilities.negotiation;
166
+ expect(neg.services).toHaveLength(2);
167
+ expect(neg.services[0].category).toBe('inference');
168
+ expect(neg.services[0].base_price).toBe('0.01');
169
+ expect(neg.services[1].category).toBe('translation');
170
+ });
171
+ });
172
+ // ── generateQuote ────────────────────────────────────────────────
173
+ describe('generateQuote', () => {
174
+ it('produces signed quote for matching service', () => {
175
+ agent = new SellerAgent({
176
+ endpoint: 'http://localhost:4000',
177
+ services: TEST_SERVICES,
178
+ });
179
+ const rfq = makeValidRFQ();
180
+ const quote = agent.generateQuote(rfq);
181
+ expect(quote).not.toBeNull();
182
+ expect(quote.rfq_id).toBe(rfq.rfq_id);
183
+ expect(quote.seller.agent_id).toBe(agent.getAgentId());
184
+ expect(quote.seller.endpoint).toBe('http://localhost:4000');
185
+ expect(quote.pricing.currency).toBe('USDC');
186
+ expect(quote.pricing.unit).toBe('request');
187
+ expect(quote.pricing.volume_discounts).toHaveLength(2);
188
+ expect(quote.sla_offered).toBeDefined();
189
+ expect(quote.signature).toBeDefined();
190
+ });
191
+ it('signature is verifiable with seller public key', () => {
192
+ agent = new SellerAgent({
193
+ endpoint: 'http://localhost:4000',
194
+ services: TEST_SERVICES,
195
+ });
196
+ const quote = agent.generateQuote(makeValidRFQ());
197
+ const { signature, ...unsigned } = quote;
198
+ const pubKey = didToPublicKey(agent.getAgentId());
199
+ expect(verifyMessage(unsigned, signature, pubKey)).toBe(true);
200
+ });
201
+ it('returns null for non-matching service category', () => {
202
+ agent = new SellerAgent({
203
+ endpoint: 'http://localhost:4000',
204
+ services: TEST_SERVICES,
205
+ });
206
+ const quote = agent.generateQuote(makeValidRFQ({ service: { category: 'unknown-service' } }));
207
+ expect(quote).toBeNull();
208
+ });
209
+ it('applies competitive pricing strategy (lower than base)', () => {
210
+ agent = new SellerAgent({
211
+ endpoint: 'http://localhost:4000',
212
+ services: TEST_SERVICES,
213
+ pricingStrategy: { type: 'competitive' },
214
+ });
215
+ const quote = agent.generateQuote(makeValidRFQ());
216
+ // 0.01 * 0.9 = 0.009
217
+ expect(quote.pricing.price_per_unit).toBe('0.0090');
218
+ // Competitive should be strictly less than fixed base price
219
+ expect(parseFloat(quote.pricing.price_per_unit)).toBeLessThan(0.01);
220
+ });
221
+ it('includes volume discounts at 1000+ and 10000+ units', () => {
222
+ agent = new SellerAgent({
223
+ endpoint: 'http://localhost:4000',
224
+ services: TEST_SERVICES,
225
+ });
226
+ const quote = agent.generateQuote(makeValidRFQ());
227
+ const discounts = quote.pricing.volume_discounts;
228
+ expect(discounts).toHaveLength(2);
229
+ expect(discounts[0].min_units).toBe(1000);
230
+ expect(discounts[1].min_units).toBe(10000);
231
+ // 10% off at 1000+, 20% off at 10000+
232
+ const basePrice = parseFloat(quote.pricing.price_per_unit);
233
+ expect(parseFloat(discounts[0].price_per_unit)).toBeCloseTo(basePrice * 0.9, 4);
234
+ expect(parseFloat(discounts[1].price_per_unit)).toBeCloseTo(basePrice * 0.8, 4);
235
+ });
236
+ });
237
+ // ── listen / close ───────────────────────────────────────────────
238
+ describe('listen and close', () => {
239
+ it('starts server and close stops it', async () => {
240
+ agent = new SellerAgent({
241
+ endpoint: 'http://localhost:0',
242
+ services: TEST_SERVICES,
243
+ });
244
+ await agent.listen(0);
245
+ // Extract the actual port from the updated endpoint
246
+ const endpoint = agent.getEndpoint();
247
+ const port = new URL(endpoint).port;
248
+ // Verify server is listening
249
+ const response = await fetch(`http://localhost:${port}`, {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json' },
252
+ body: JSON.stringify({
253
+ jsonrpc: '2.0',
254
+ method: 'negotiate/unknown',
255
+ params: {},
256
+ id: 'test-1',
257
+ }),
258
+ });
259
+ expect(response.ok).toBe(true);
260
+ const json = await response.json();
261
+ expect(json.error.message).toContain('Method not found');
262
+ await agent.close();
263
+ agent = undefined;
264
+ // After close, server should not respond
265
+ await expect(fetch(`http://localhost:${port}`, {
266
+ method: 'POST',
267
+ headers: { 'Content-Type': 'application/json' },
268
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-2' }),
269
+ })).rejects.toThrow();
270
+ });
271
+ });
272
+ // ── Server handlers ──────────────────────────────────────────────
273
+ describe('server handlers', () => {
274
+ it('responds to negotiate/rfq with a quote', async () => {
275
+ agent = new SellerAgent({
276
+ endpoint: 'http://localhost:0',
277
+ services: TEST_SERVICES,
278
+ });
279
+ await agent.listen(0);
280
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
281
+ const rfq = makeValidRFQ();
282
+ const res = await jsonRpc(port, 'negotiate/rfq', rfq);
283
+ expect(res.error).toBeUndefined();
284
+ const quote = res.result;
285
+ expect(quote.rfq_id).toBe(rfq.rfq_id);
286
+ expect(quote.seller.agent_id).toBe(agent.getAgentId());
287
+ expect(quote.signature).toBeDefined();
288
+ // Session should be created
289
+ const session = agent.getSession(rfq.rfq_id);
290
+ expect(session).toBeDefined();
291
+ expect(session.state).toBe('QUOTES_RECEIVED');
292
+ });
293
+ it('handles negotiate/accept with valid buyer signature', async () => {
294
+ agent = new SellerAgent({
295
+ endpoint: 'http://localhost:0',
296
+ services: TEST_SERVICES,
297
+ });
298
+ await agent.listen(0);
299
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
300
+ // First send an RFQ to create a session — use the buyer DID from the RFQ
301
+ const buyerKp = generateKeyPair();
302
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
303
+ const rfq = makeValidRFQ({
304
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
305
+ }, buyerKp);
306
+ await jsonRpc(port, 'negotiate/rfq', rfq);
307
+ // Get the quote_id from the session so accepting_message_id is valid
308
+ const session = agent.getSession(rfq.rfq_id);
309
+ const quoteId = session.quotes[0].quote_id;
310
+ // Build a proper accept with valid agreement_hash and buyer signature
311
+ const finalTerms = {
312
+ price_per_unit: '0.01',
313
+ currency: 'USDC',
314
+ unit: 'request',
315
+ };
316
+ const hash = agreementHash(finalTerms);
317
+ const unsigned = {
318
+ agreement_id: crypto.randomUUID(),
319
+ rfq_id: rfq.rfq_id,
320
+ accepting_message_id: quoteId,
321
+ final_terms: finalTerms,
322
+ agreement_hash: hash,
323
+ };
324
+ const buyerSig = signMessage(unsigned, buyerKp.secretKey);
325
+ const acceptParams = {
326
+ ...unsigned,
327
+ buyer_signature: buyerSig,
328
+ seller_signature: Buffer.alloc(64).toString('base64'),
329
+ };
330
+ const res = await jsonRpc(port, 'negotiate/accept', acceptParams);
331
+ expect(res.error).toBeUndefined();
332
+ const result = res.result;
333
+ expect(result.status).toBe('accepted');
334
+ expect(result.seller_signature).toBeDefined();
335
+ expect(result.seller_signature.length).toBeGreaterThan(0);
336
+ // Verify the seller's counter-signature is valid over the same unsigned data
337
+ const sellerPubKey = didToPublicKey(agent.getAgentId());
338
+ const counterSigValid = verifyMessage(unsigned, result.seller_signature, sellerPubKey);
339
+ expect(counterSigValid).toBe(true);
340
+ // Session should transition to ACCEPTED
341
+ const updatedSession = agent.getSession(rfq.rfq_id);
342
+ expect(updatedSession.state).toBe('ACCEPTED');
343
+ // The agreement stored in the session should have the seller's counter-signature
344
+ expect(updatedSession.agreement).toBeDefined();
345
+ expect(updatedSession.agreement.seller_signature).toBe(result.seller_signature);
346
+ });
347
+ it('seller counter-signature is verifiable by third parties', async () => {
348
+ agent = new SellerAgent({
349
+ endpoint: 'http://localhost:0',
350
+ services: TEST_SERVICES,
351
+ });
352
+ await agent.listen(0);
353
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
354
+ const buyerKp = generateKeyPair();
355
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
356
+ const rfq = makeValidRFQ({
357
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
358
+ }, buyerKp);
359
+ await jsonRpc(port, 'negotiate/rfq', rfq);
360
+ // Get the quote_id from the session
361
+ const session = agent.getSession(rfq.rfq_id);
362
+ const quoteId = session.quotes[0].quote_id;
363
+ const finalTerms = {
364
+ price_per_unit: '0.02',
365
+ currency: 'USDC',
366
+ unit: 'token',
367
+ };
368
+ const hash = agreementHash(finalTerms);
369
+ const unsigned = {
370
+ agreement_id: crypto.randomUUID(),
371
+ rfq_id: rfq.rfq_id,
372
+ accepting_message_id: quoteId,
373
+ final_terms: finalTerms,
374
+ agreement_hash: hash,
375
+ };
376
+ const buyerSig = signMessage(unsigned, buyerKp.secretKey);
377
+ const acceptParams = {
378
+ ...unsigned,
379
+ buyer_signature: buyerSig,
380
+ seller_signature: Buffer.alloc(64).toString('base64'),
381
+ };
382
+ const res = await jsonRpc(port, 'negotiate/accept', acceptParams);
383
+ const result = res.result;
384
+ // A third party can verify both signatures with just the public DIDs
385
+ const buyerPubKey = didToPublicKey(buyerDid);
386
+ const sellerPubKey = didToPublicKey(agent.getAgentId());
387
+ expect(verifyMessage(unsigned, buyerSig, buyerPubKey)).toBe(true);
388
+ expect(verifyMessage(unsigned, result.seller_signature, sellerPubKey)).toBe(true);
389
+ // Cross-verification: buyer sig fails with seller key and vice versa
390
+ expect(verifyMessage(unsigned, buyerSig, sellerPubKey)).toBe(false);
391
+ expect(verifyMessage(unsigned, result.seller_signature, buyerPubKey)).toBe(false);
392
+ });
393
+ it('handles negotiate/reject', async () => {
394
+ agent = new SellerAgent({
395
+ endpoint: 'http://localhost:0',
396
+ services: TEST_SERVICES,
397
+ });
398
+ await agent.listen(0);
399
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
400
+ // First send an RFQ to create a session — use the same buyer keypair for rejection
401
+ const buyerKp = generateKeyPair();
402
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
403
+ const rfq = makeValidRFQ({
404
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
405
+ }, buyerKp);
406
+ await jsonRpc(port, 'negotiate/rfq', rfq);
407
+ // Now send a signed reject from the same buyer
408
+ const unsignedReject = {
409
+ rfq_id: rfq.rfq_id,
410
+ rejecting_message_id: crypto.randomUUID(),
411
+ reason: 'Too expensive',
412
+ from: { agent_id: buyerDid },
413
+ };
414
+ const rejectSig = signMessage(unsignedReject, buyerKp.secretKey);
415
+ const rejectParams = { ...unsignedReject, signature: rejectSig };
416
+ const res = await jsonRpc(port, 'negotiate/reject', rejectParams);
417
+ expect(res.error).toBeUndefined();
418
+ expect(res.result.status).toBe('rejected');
419
+ // Session should transition to REJECTED
420
+ const session = agent.getSession(rfq.rfq_id);
421
+ expect(session.state).toBe('REJECTED');
422
+ });
423
+ it('returns error for accept on unknown RFQ', async () => {
424
+ agent = new SellerAgent({
425
+ endpoint: 'http://localhost:0',
426
+ services: TEST_SERVICES,
427
+ });
428
+ await agent.listen(0);
429
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
430
+ const fakeSig = Buffer.alloc(64).toString('base64');
431
+ const fakeHash = '0'.repeat(64);
432
+ const res = await jsonRpc(port, 'negotiate/accept', {
433
+ agreement_id: crypto.randomUUID(),
434
+ rfq_id: crypto.randomUUID(),
435
+ accepting_message_id: crypto.randomUUID(),
436
+ final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
437
+ agreement_hash: fakeHash,
438
+ buyer_signature: fakeSig,
439
+ seller_signature: fakeSig,
440
+ });
441
+ expect(res.error).toBeDefined();
442
+ expect(res.error.message).toContain('unknown RFQ');
443
+ });
444
+ it('rejects accept with mismatched agreement_hash', async () => {
445
+ agent = new SellerAgent({
446
+ endpoint: 'http://localhost:0',
447
+ services: TEST_SERVICES,
448
+ });
449
+ await agent.listen(0);
450
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
451
+ // Send an RFQ to create a session with a real buyer DID
452
+ const buyerKp = generateKeyPair();
453
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
454
+ const rfq = makeValidRFQ({
455
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
456
+ }, buyerKp);
457
+ await jsonRpc(port, 'negotiate/rfq', rfq);
458
+ // Get the quote_id from the session
459
+ const session = agent.getSession(rfq.rfq_id);
460
+ const quoteId = session.quotes[0].quote_id;
461
+ // Build an accept with a wrong agreement_hash
462
+ const finalTerms = {
463
+ price_per_unit: '0.01',
464
+ currency: 'USDC',
465
+ unit: 'request',
466
+ };
467
+ const wrongHash = 'deadbeef0000000000000000000000000000000000000000000000000000abcd';
468
+ const unsigned = {
469
+ agreement_id: crypto.randomUUID(),
470
+ rfq_id: rfq.rfq_id,
471
+ accepting_message_id: quoteId,
472
+ final_terms: finalTerms,
473
+ agreement_hash: wrongHash,
474
+ };
475
+ const buyerSig = signMessage(unsigned, buyerKp.secretKey);
476
+ const acceptParams = {
477
+ ...unsigned,
478
+ buyer_signature: buyerSig,
479
+ seller_signature: Buffer.alloc(64).toString('base64'),
480
+ };
481
+ const res = await jsonRpc(port, 'negotiate/accept', acceptParams);
482
+ expect(res.error).toBeDefined();
483
+ expect(res.error.message).toContain('Agreement hash mismatch');
484
+ });
485
+ it('rejects accept with invalid buyer signature', async () => {
486
+ agent = new SellerAgent({
487
+ endpoint: 'http://localhost:0',
488
+ services: TEST_SERVICES,
489
+ });
490
+ await agent.listen(0);
491
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
492
+ // Send an RFQ to create a session with a real buyer DID
493
+ const buyerKp = generateKeyPair();
494
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
495
+ const rfq = makeValidRFQ({
496
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
497
+ }, buyerKp);
498
+ await jsonRpc(port, 'negotiate/rfq', rfq);
499
+ // Get the quote_id from the session
500
+ const session = agent.getSession(rfq.rfq_id);
501
+ const quoteId = session.quotes[0].quote_id;
502
+ // Build an accept with correct agreement_hash but sign with a different key
503
+ const finalTerms = {
504
+ price_per_unit: '0.01',
505
+ currency: 'USDC',
506
+ unit: 'request',
507
+ };
508
+ const hash = agreementHash(finalTerms);
509
+ const unsigned = {
510
+ agreement_id: crypto.randomUUID(),
511
+ rfq_id: rfq.rfq_id,
512
+ accepting_message_id: quoteId,
513
+ final_terms: finalTerms,
514
+ agreement_hash: hash,
515
+ };
516
+ // Sign with a completely different keypair (not the buyer)
517
+ const imposterKp = generateKeyPair();
518
+ const badSig = signMessage(unsigned, imposterKp.secretKey);
519
+ const acceptParams = {
520
+ ...unsigned,
521
+ buyer_signature: badSig,
522
+ seller_signature: Buffer.alloc(64).toString('base64'),
523
+ };
524
+ const res = await jsonRpc(port, 'negotiate/accept', acceptParams);
525
+ expect(res.error).toBeDefined();
526
+ expect(res.error.message).toContain('Invalid buyer signature');
527
+ });
528
+ it('rejects accept with tampered final_terms', async () => {
529
+ agent = new SellerAgent({
530
+ endpoint: 'http://localhost:0',
531
+ services: TEST_SERVICES,
532
+ });
533
+ await agent.listen(0);
534
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
535
+ // Send an RFQ to create a session with a real buyer DID
536
+ const buyerKp = generateKeyPair();
537
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
538
+ const rfq = makeValidRFQ({
539
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
540
+ }, buyerKp);
541
+ await jsonRpc(port, 'negotiate/rfq', rfq);
542
+ // Get the quote_id from the session
543
+ const session = agent.getSession(rfq.rfq_id);
544
+ const quoteId = session.quotes[0].quote_id;
545
+ // Compute agreement_hash from the original terms
546
+ const originalTerms = {
547
+ price_per_unit: '0.01',
548
+ currency: 'USDC',
549
+ unit: 'request',
550
+ };
551
+ const hash = agreementHash(originalTerms);
552
+ // Tamper with final_terms so they no longer match the hash
553
+ const tamperedTerms = {
554
+ price_per_unit: '0.001',
555
+ currency: 'USDC',
556
+ unit: 'request',
557
+ };
558
+ const unsigned = {
559
+ agreement_id: crypto.randomUUID(),
560
+ rfq_id: rfq.rfq_id,
561
+ accepting_message_id: quoteId,
562
+ final_terms: tamperedTerms,
563
+ agreement_hash: hash,
564
+ };
565
+ const buyerSig = signMessage(unsigned, buyerKp.secretKey);
566
+ const acceptParams = {
567
+ ...unsigned,
568
+ buyer_signature: buyerSig,
569
+ seller_signature: Buffer.alloc(64).toString('base64'),
570
+ };
571
+ const res = await jsonRpc(port, 'negotiate/accept', acceptParams);
572
+ expect(res.error).toBeDefined();
573
+ expect(res.error.message).toContain('Agreement hash mismatch');
574
+ });
575
+ it('rejects accept with unknown accepting_message_id', async () => {
576
+ agent = new SellerAgent({
577
+ endpoint: 'http://localhost:0',
578
+ services: TEST_SERVICES,
579
+ });
580
+ await agent.listen(0);
581
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
582
+ const buyerKp = generateKeyPair();
583
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
584
+ const rfq = makeValidRFQ({
585
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
586
+ }, buyerKp);
587
+ await jsonRpc(port, 'negotiate/rfq', rfq);
588
+ const finalTerms = {
589
+ price_per_unit: '0.01',
590
+ currency: 'USDC',
591
+ unit: 'request',
592
+ };
593
+ const hash = agreementHash(finalTerms);
594
+ // Use a random UUID that does NOT match any quote
595
+ const unsigned = {
596
+ agreement_id: crypto.randomUUID(),
597
+ rfq_id: rfq.rfq_id,
598
+ accepting_message_id: crypto.randomUUID(),
599
+ final_terms: finalTerms,
600
+ agreement_hash: hash,
601
+ };
602
+ const buyerSig = signMessage(unsigned, buyerKp.secretKey);
603
+ const acceptParams = {
604
+ ...unsigned,
605
+ buyer_signature: buyerSig,
606
+ seller_signature: Buffer.alloc(64).toString('base64'),
607
+ };
608
+ const res = await jsonRpc(port, 'negotiate/accept', acceptParams);
609
+ expect(res.error).toBeDefined();
610
+ expect(res.error.message).toContain('does not match any quote');
611
+ });
612
+ });
613
+ // ── Custom handlers ──────────────────────────────────────────────
614
+ describe('onRFQ', () => {
615
+ it('calls custom RFQ handler instead of generateQuote', async () => {
616
+ agent = new SellerAgent({
617
+ endpoint: 'http://localhost:0',
618
+ services: TEST_SERVICES,
619
+ });
620
+ let handlerCalled = false;
621
+ agent.onRFQ(async (rfq) => {
622
+ handlerCalled = true;
623
+ return null; // ignore the RFQ
624
+ });
625
+ await agent.listen(0);
626
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
627
+ const rfq = makeValidRFQ();
628
+ const res = await jsonRpc(port, 'negotiate/rfq', rfq);
629
+ expect(handlerCalled).toBe(true);
630
+ expect(res.result.status).toBe('ignored');
631
+ });
632
+ });
633
+ // ── Session management ───────────────────────────────────────────
634
+ describe('session management', () => {
635
+ it('getSession and getSessions work', () => {
636
+ agent = new SellerAgent({
637
+ endpoint: 'http://localhost:4000',
638
+ services: TEST_SERVICES,
639
+ });
640
+ expect(agent.getSessions()).toHaveLength(0);
641
+ expect(agent.getSession('nonexistent')).toBeUndefined();
642
+ });
643
+ });
644
+ // ── Signature verification adversarial tests ────────────────────
645
+ describe('signature verification adversarial tests', () => {
646
+ it('rejects RFQ with invalid signature (signed by wrong key)', async () => {
647
+ agent = new SellerAgent({
648
+ endpoint: 'http://localhost:0',
649
+ services: TEST_SERVICES,
650
+ });
651
+ await agent.listen(0);
652
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
653
+ // Buyer DID is from keypair A, but signature is made with keypair B
654
+ const keypairA = generateKeyPair();
655
+ const keypairB = generateKeyPair();
656
+ const buyerDid = publicKeyToDid(keypairA.publicKey);
657
+ const unsigned = {
658
+ rfq_id: crypto.randomUUID(),
659
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
660
+ service: { category: 'inference' },
661
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
662
+ negotiation_style: 'rfq',
663
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
664
+ };
665
+ // Sign with the wrong key (keypairB instead of keypairA)
666
+ const signature = signMessage(unsigned, keypairB.secretKey);
667
+ const rfq = { ...unsigned, signature };
668
+ const res = await jsonRpc(port, 'negotiate/rfq', rfq);
669
+ expect(res.error).toBeDefined();
670
+ expect(res.error.message).toContain('Invalid signature');
671
+ });
672
+ it('rejects RFQ with missing signature', async () => {
673
+ agent = new SellerAgent({
674
+ endpoint: 'http://localhost:0',
675
+ services: TEST_SERVICES,
676
+ });
677
+ await agent.listen(0);
678
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
679
+ const buyerKp = generateKeyPair();
680
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
681
+ // Send an RFQ with no signature field at all
682
+ const rfq = {
683
+ rfq_id: crypto.randomUUID(),
684
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
685
+ service: { category: 'inference' },
686
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
687
+ negotiation_style: 'rfq',
688
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
689
+ };
690
+ const res = await jsonRpc(port, 'negotiate/rfq', rfq);
691
+ expect(res.error).toBeDefined();
692
+ });
693
+ it('verifies buyer counter-offer signature', async () => {
694
+ agent = new SellerAgent({
695
+ endpoint: 'http://localhost:0',
696
+ services: TEST_SERVICES,
697
+ });
698
+ agent.onCounter(async (_counter, _session) => {
699
+ return 'accept';
700
+ });
701
+ await agent.listen(0);
702
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
703
+ // Set up a session by sending a valid RFQ
704
+ const buyerKp = generateKeyPair();
705
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
706
+ const rfq = makeValidRFQ({
707
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
708
+ }, buyerKp);
709
+ const rfqRes = await jsonRpc(port, 'negotiate/rfq', rfq);
710
+ expect(rfqRes.error).toBeUndefined();
711
+ const quote = rfqRes.result;
712
+ // Send a properly signed counter from the buyer
713
+ const unsignedCounter = {
714
+ counter_id: crypto.randomUUID(),
715
+ rfq_id: rfq.rfq_id,
716
+ in_response_to: quote.quote_id ?? crypto.randomUUID(),
717
+ round: 1,
718
+ from: { agent_id: buyerDid, role: 'buyer' },
719
+ modifications: { price_per_unit: '0.008' },
720
+ justification: 'Requesting lower price',
721
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
722
+ };
723
+ const counterSig = signMessage(unsignedCounter, buyerKp.secretKey);
724
+ const counter = { ...unsignedCounter, signature: counterSig };
725
+ const res = await jsonRpc(port, 'negotiate/counter', counter);
726
+ expect(res.error).toBeUndefined();
727
+ });
728
+ it('rejects counter with invalid signature', async () => {
729
+ agent = new SellerAgent({
730
+ endpoint: 'http://localhost:0',
731
+ services: TEST_SERVICES,
732
+ });
733
+ agent.onCounter(async (_counter, _session) => {
734
+ return 'accept';
735
+ });
736
+ await agent.listen(0);
737
+ const port = parseInt(new URL(agent.getEndpoint()).port, 10);
738
+ // Set up a session by sending a valid RFQ
739
+ const buyerKp = generateKeyPair();
740
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
741
+ const rfq = makeValidRFQ({
742
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:9999' },
743
+ }, buyerKp);
744
+ const rfqRes = await jsonRpc(port, 'negotiate/rfq', rfq);
745
+ expect(rfqRes.error).toBeUndefined();
746
+ const quote = rfqRes.result;
747
+ // Send a counter where from.agent_id is the buyer DID but signature is from a different key
748
+ const imposterKp = generateKeyPair();
749
+ const unsignedCounter = {
750
+ counter_id: crypto.randomUUID(),
751
+ rfq_id: rfq.rfq_id,
752
+ in_response_to: quote.quote_id ?? crypto.randomUUID(),
753
+ round: 1,
754
+ from: { agent_id: buyerDid, role: 'buyer' },
755
+ modifications: { price_per_unit: '0.008' },
756
+ justification: 'Requesting lower price',
757
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
758
+ };
759
+ // Sign with the imposter's key, not the buyer's
760
+ const counterSig = signMessage(unsignedCounter, imposterKp.secretKey);
761
+ const counter = { ...unsignedCounter, signature: counterSig };
762
+ const res = await jsonRpc(port, 'negotiate/counter', counter);
763
+ expect(res.error).toBeDefined();
764
+ expect(res.error.message).toContain('Invalid signature');
765
+ });
766
+ });
767
+ });