@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,664 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { BuyerAgent } from '../buyer.js';
3
+ import { generateKeyPair, publicKeyToDid, didToPublicKey } from '../identity.js';
4
+ import { signMessage, verifyMessage, agreementHash } from '../signing.js';
5
+ import { buildCounter } from '../messages.js';
6
+ /** Create a valid signed RFQ for constructing NegotiationSession directly. */
7
+ function makeRFQ(overrides) {
8
+ const buyerKp = generateKeyPair();
9
+ const buyerDid = publicKeyToDid(buyerKp.publicKey);
10
+ const unsigned = {
11
+ rfq_id: crypto.randomUUID(),
12
+ buyer: { agent_id: buyerDid, endpoint: 'http://localhost:3001' },
13
+ service: { category: 'inference' },
14
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
15
+ negotiation_style: 'rfq',
16
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
17
+ ...overrides,
18
+ };
19
+ const { signature: _existingSig, ...toSign } = unsigned;
20
+ const signature = signMessage(toSign, buyerKp.secretKey);
21
+ return { ...toSign, signature };
22
+ }
23
+ /** Create a QuoteParams with sensible defaults, optionally signed with a real key. */
24
+ function makeQuote(overrides) {
25
+ return {
26
+ quote_id: `quote-${Math.random().toString(36).slice(2, 8)}`,
27
+ rfq_id: overrides.rfq_id,
28
+ seller: overrides.seller ?? {
29
+ agent_id: 'did:key:zSeller1',
30
+ endpoint: 'http://localhost:9000',
31
+ },
32
+ pricing: overrides.pricing ?? {
33
+ price_per_unit: '0.01',
34
+ currency: 'USDC',
35
+ unit: 'request',
36
+ pricing_model: 'fixed',
37
+ },
38
+ sla_offered: overrides.sla_offered,
39
+ expires_at: overrides.expires_at ?? new Date(Date.now() + 120_000).toISOString(),
40
+ signature: overrides.signature ?? 'fake-sig',
41
+ };
42
+ }
43
+ /** Create a properly signed quote from a real keypair. */
44
+ function makeSignedQuote(rfqId, sellerKp, overrides) {
45
+ const sellerDid = publicKeyToDid(sellerKp.publicKey);
46
+ const unsigned = {
47
+ quote_id: crypto.randomUUID(),
48
+ rfq_id: rfqId,
49
+ seller: {
50
+ agent_id: sellerDid,
51
+ endpoint: 'http://localhost:9000',
52
+ },
53
+ pricing: overrides?.pricing ?? {
54
+ price_per_unit: '0.01',
55
+ currency: 'USDC',
56
+ unit: 'request',
57
+ pricing_model: 'fixed',
58
+ },
59
+ sla_offered: overrides?.sla_offered,
60
+ expires_at: overrides?.expires_at ?? new Date(Date.now() + 120_000).toISOString(),
61
+ };
62
+ const signature = signMessage(unsigned, sellerKp.secretKey);
63
+ return { ...unsigned, signature };
64
+ }
65
+ describe('BuyerAgent', () => {
66
+ let agent;
67
+ afterEach(async () => {
68
+ if (agent) {
69
+ await agent.close();
70
+ agent = undefined;
71
+ }
72
+ });
73
+ // ── Constructor ──────────────────────────────────────────────────
74
+ describe('constructor', () => {
75
+ it('auto-generates keypair and DID when no keypair provided', () => {
76
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
77
+ const agentId = agent.getAgentId();
78
+ expect(agentId).toMatch(/^did:key:z/);
79
+ const pubKey = didToPublicKey(agentId);
80
+ expect(pubKey).toBeInstanceOf(Uint8Array);
81
+ expect(pubKey.length).toBe(32);
82
+ });
83
+ it('uses provided keypair and derives correct DID', () => {
84
+ const kp = generateKeyPair();
85
+ agent = new BuyerAgent({ keypair: kp, endpoint: 'http://localhost:3001' });
86
+ const pubKey = didToPublicKey(agent.getAgentId());
87
+ expect(Buffer.from(pubKey)).toEqual(Buffer.from(kp.publicKey));
88
+ });
89
+ it('stores endpoint correctly', () => {
90
+ agent = new BuyerAgent({ endpoint: 'http://localhost:5555' });
91
+ expect(agent.getEndpoint()).toBe('http://localhost:5555');
92
+ });
93
+ });
94
+ // ── rankQuotes ───────────────────────────────────────────────────
95
+ describe('rankQuotes', () => {
96
+ const rfqId = 'rfq-rank-test';
97
+ const cheapQuote = makeQuote({
98
+ rfq_id: rfqId,
99
+ pricing: { price_per_unit: '0.005', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
100
+ sla_offered: {
101
+ metrics: [
102
+ { name: 'p99_latency_ms', target: 1000, comparison: 'lte' },
103
+ { name: 'uptime_pct', target: 99.0, comparison: 'gte' },
104
+ ],
105
+ },
106
+ });
107
+ const midQuote = makeQuote({
108
+ rfq_id: rfqId,
109
+ pricing: { price_per_unit: '0.010', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
110
+ sla_offered: {
111
+ metrics: [
112
+ { name: 'p99_latency_ms', target: 200, comparison: 'lte' },
113
+ { name: 'uptime_pct', target: 99.9, comparison: 'gte' },
114
+ ],
115
+ },
116
+ });
117
+ const expensiveQuote = makeQuote({
118
+ rfq_id: rfqId,
119
+ pricing: { price_per_unit: '0.050', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
120
+ sla_offered: {
121
+ metrics: [
122
+ { name: 'p99_latency_ms', target: 50, comparison: 'lte' },
123
+ { name: 'uptime_pct', target: 99.99, comparison: 'gte' },
124
+ { name: 'accuracy_pct', target: 99.5, comparison: 'gte' },
125
+ ],
126
+ },
127
+ });
128
+ it('sorts by cheapest by default', () => {
129
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
130
+ const ranked = agent.rankQuotes([expensiveQuote, cheapQuote, midQuote]);
131
+ expect(ranked[0].pricing.price_per_unit).toBe('0.005');
132
+ expect(ranked[1].pricing.price_per_unit).toBe('0.010');
133
+ expect(ranked[2].pricing.price_per_unit).toBe('0.050');
134
+ });
135
+ it('sorts by cheapest handles string prices correctly', () => {
136
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
137
+ const q1 = makeQuote({
138
+ rfq_id: rfqId,
139
+ pricing: { price_per_unit: '10.00', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
140
+ });
141
+ const q2 = makeQuote({
142
+ rfq_id: rfqId,
143
+ pricing: { price_per_unit: '2.50', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
144
+ });
145
+ const q3 = makeQuote({
146
+ rfq_id: rfqId,
147
+ pricing: { price_per_unit: '0.0001', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
148
+ });
149
+ // Ensure numeric comparison, not lexicographic ("10.00" < "2.50" lexicographically)
150
+ const ranked = agent.rankQuotes([q1, q2, q3], 'cheapest');
151
+ expect(ranked[0].pricing.price_per_unit).toBe('0.0001');
152
+ expect(ranked[1].pricing.price_per_unit).toBe('2.50');
153
+ expect(ranked[2].pricing.price_per_unit).toBe('10.00');
154
+ });
155
+ it('sorts by fastest (p99 latency)', () => {
156
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
157
+ const ranked = agent.rankQuotes([cheapQuote, midQuote, expensiveQuote], 'fastest');
158
+ // p99: expensive=50, mid=200, cheap=1000
159
+ expect(ranked[0].pricing.price_per_unit).toBe('0.050');
160
+ expect(ranked[1].pricing.price_per_unit).toBe('0.010');
161
+ expect(ranked[2].pricing.price_per_unit).toBe('0.005');
162
+ });
163
+ it('sorts by best_sla (more metrics = higher score)', () => {
164
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
165
+ const ranked = agent.rankQuotes([cheapQuote, midQuote, expensiveQuote], 'best_sla');
166
+ // expensiveQuote has best metrics: lowest latency, highest uptime, plus accuracy
167
+ expect(ranked[0].pricing.price_per_unit).toBe('0.050');
168
+ });
169
+ it('sorts with custom ranking function', () => {
170
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
171
+ // Sort by price descending
172
+ const ranked = agent.rankQuotes([cheapQuote, midQuote, expensiveQuote], (a, b) => parseFloat(b.pricing.price_per_unit) - parseFloat(a.pricing.price_per_unit));
173
+ expect(ranked[0].pricing.price_per_unit).toBe('0.050');
174
+ expect(ranked[2].pricing.price_per_unit).toBe('0.005');
175
+ });
176
+ it('returns empty array for empty input', () => {
177
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
178
+ const ranked = agent.rankQuotes([]);
179
+ expect(ranked).toEqual([]);
180
+ });
181
+ it('handles quotes with no SLA in fastest ranking', () => {
182
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
183
+ const noSlaQuote = makeQuote({
184
+ rfq_id: rfqId,
185
+ pricing: { price_per_unit: '0.001', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
186
+ });
187
+ const ranked = agent.rankQuotes([noSlaQuote, expensiveQuote], 'fastest');
188
+ // expensiveQuote has p99=50, noSlaQuote has no SLA so Infinity
189
+ expect(ranked[0].pricing.price_per_unit).toBe('0.050');
190
+ expect(ranked[1].pricing.price_per_unit).toBe('0.001');
191
+ });
192
+ it('does not modify original array', () => {
193
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
194
+ const original = [expensiveQuote, cheapQuote, midQuote];
195
+ const originalCopy = [...original];
196
+ const ranked = agent.rankQuotes(original);
197
+ // The returned array should be sorted (cheapest first)
198
+ expect(ranked[0].pricing.price_per_unit).toBe('0.005');
199
+ // The original array must be untouched
200
+ expect(original).toEqual(originalCopy);
201
+ expect(original[0]).toBe(expensiveQuote);
202
+ expect(ranked).not.toBe(original);
203
+ });
204
+ it('single quote returns array with that quote', () => {
205
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
206
+ const ranked = agent.rankQuotes([midQuote]);
207
+ expect(ranked).toHaveLength(1);
208
+ expect(ranked[0]).toBe(midQuote);
209
+ });
210
+ });
211
+ // ── requestQuotes ────────────────────────────────────────────────
212
+ describe('requestQuotes', () => {
213
+ it('creates a session with correct state RFQ_SENT', async () => {
214
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
215
+ const session = await agent.requestQuotes({
216
+ sellers: ['http://localhost:19999'],
217
+ service: { category: 'inference', description: 'LLM inference' },
218
+ budget: { max_price_per_unit: '0.10', currency: 'USDC', unit: 'request' },
219
+ });
220
+ expect(session).toBeDefined();
221
+ expect(session.rfqId).toBeDefined();
222
+ expect(session.state).toBe('RFQ_SENT');
223
+ expect(session.rfq.buyer.agent_id).toBe(agent.getAgentId());
224
+ expect(session.rfq.service.category).toBe('inference');
225
+ });
226
+ it('sets session state to RFQ_SENT even if seller is unreachable', async () => {
227
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
228
+ const session = await agent.requestQuotes({
229
+ sellers: ['http://localhost:19999', 'http://localhost:19998'],
230
+ service: { category: 'inference' },
231
+ budget: { max_price_per_unit: '0.10', currency: 'USDC', unit: 'request' },
232
+ });
233
+ expect(session.state).toBe('RFQ_SENT');
234
+ expect(agent.getSession(session.rfqId)).toBe(session);
235
+ });
236
+ it('accepts SellerInfo objects as sellers', async () => {
237
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
238
+ const session = await agent.requestQuotes({
239
+ sellers: [
240
+ { agentId: 'did:key:zSeller1', endpoint: 'http://localhost:19998', services: [] },
241
+ ],
242
+ service: { category: 'translation' },
243
+ budget: { max_price_per_unit: '0.05', currency: 'USDC', unit: 'request' },
244
+ });
245
+ expect(session.state).toBe('RFQ_SENT');
246
+ expect(session.rfq.service.category).toBe('translation');
247
+ });
248
+ it('includes SLA requirements in RFQ', async () => {
249
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
250
+ const session = await agent.requestQuotes({
251
+ sellers: ['http://localhost:19999'],
252
+ service: { category: 'inference' },
253
+ budget: { max_price_per_unit: '0.10', currency: 'USDC', unit: 'request' },
254
+ sla: {
255
+ metrics: [
256
+ { name: 'uptime_pct', target: 99.9, comparison: 'gte' },
257
+ { name: 'p99_latency_ms', target: 500, comparison: 'lte' },
258
+ ],
259
+ dispute_resolution: { method: 'lockstep_verification', timeout_hours: 24 },
260
+ },
261
+ });
262
+ expect(session.rfq.sla_requirements).toBeDefined();
263
+ expect(session.rfq.sla_requirements.metrics).toHaveLength(2);
264
+ });
265
+ });
266
+ // ── acceptQuote ──────────────────────────────────────────────────
267
+ describe('acceptQuote', () => {
268
+ it('computes valid agreement_hash from final terms', async () => {
269
+ const buyerKp = generateKeyPair();
270
+ const sellerKp = generateKeyPair();
271
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
272
+ const session = await agent.requestQuotes({
273
+ sellers: ['http://localhost:19999'],
274
+ service: { category: 'inference' },
275
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
276
+ });
277
+ const quote = makeSignedQuote(session.rfqId, sellerKp, {
278
+ pricing: { price_per_unit: '0.05', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
279
+ sla_offered: { metrics: [{ name: 'uptime_pct', target: 99.9, comparison: 'gte' }] },
280
+ });
281
+ session.addQuote(quote);
282
+ const agreement = await agent.acceptQuote(quote);
283
+ const expectedHash = agreementHash(agreement.final_terms);
284
+ expect(agreement.agreement_hash).toBe(expectedHash);
285
+ expect(agreement.agreement_hash).toMatch(/^[0-9a-f]{64}$/);
286
+ });
287
+ it('signs with buyer key and signature is verifiable', async () => {
288
+ const buyerKp = generateKeyPair();
289
+ const sellerKp = generateKeyPair();
290
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
291
+ const session = await agent.requestQuotes({
292
+ sellers: ['http://localhost:19999'],
293
+ service: { category: 'inference' },
294
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
295
+ });
296
+ const quote = makeSignedQuote(session.rfqId, sellerKp, {
297
+ pricing: { price_per_unit: '0.05', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
298
+ });
299
+ session.addQuote(quote);
300
+ const agreement = await agent.acceptQuote(quote);
301
+ expect(agreement.buyer_signature).toBeDefined();
302
+ expect(agreement.buyer_signature.length).toBeGreaterThan(0);
303
+ expect(agreement.agreement_id).toBeDefined();
304
+ expect(agreement.rfq_id).toBe(session.rfqId);
305
+ expect(agreement.final_terms.price_per_unit).toBe('0.05');
306
+ expect(agreement.final_terms.currency).toBe('USDC');
307
+ });
308
+ it('transitions session to ACCEPTED', async () => {
309
+ const buyerKp = generateKeyPair();
310
+ const sellerKp = generateKeyPair();
311
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
312
+ const session = await agent.requestQuotes({
313
+ sellers: ['http://localhost:19999'],
314
+ service: { category: 'inference' },
315
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
316
+ });
317
+ const quote = makeSignedQuote(session.rfqId, sellerKp);
318
+ session.addQuote(quote);
319
+ await agent.acceptQuote(quote);
320
+ expect(session.state).toBe('ACCEPTED');
321
+ expect(session.agreement).toBeDefined();
322
+ });
323
+ it('rejects quote with invalid seller signature', async () => {
324
+ const buyerKp = generateKeyPair();
325
+ const sellerKp = generateKeyPair();
326
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
327
+ const session = await agent.requestQuotes({
328
+ sellers: ['http://localhost:19999'],
329
+ service: { category: 'inference' },
330
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
331
+ });
332
+ const quote = makeSignedQuote(session.rfqId, sellerKp);
333
+ // Tamper with quote data after signing — signature won't match
334
+ const tamperedQuote = { ...quote, pricing: { ...quote.pricing, price_per_unit: '999.00' } };
335
+ session.addQuote(tamperedQuote);
336
+ await expect(agent.acceptQuote(tamperedQuote)).rejects.toThrow('seller signature is invalid');
337
+ });
338
+ it('rejects quote signed by wrong keypair', async () => {
339
+ const buyerKp = generateKeyPair();
340
+ const signerKp = generateKeyPair();
341
+ const imposterKp = generateKeyPair();
342
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
343
+ const session = await agent.requestQuotes({
344
+ sellers: ['http://localhost:19999'],
345
+ service: { category: 'inference' },
346
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
347
+ });
348
+ // Sign with signerKp but claim the seller DID belongs to imposterKp
349
+ const quote = makeSignedQuote(session.rfqId, signerKp);
350
+ const wrongDidQuote = {
351
+ ...quote,
352
+ seller: {
353
+ agent_id: publicKeyToDid(imposterKp.publicKey),
354
+ endpoint: 'http://localhost:9000',
355
+ },
356
+ };
357
+ session.addQuote(wrongDidQuote);
358
+ await expect(agent.acceptQuote(wrongDidQuote)).rejects.toThrow('seller signature is invalid');
359
+ });
360
+ });
361
+ // ── signature verification ─────────────────────────────────────
362
+ describe('signature verification', () => {
363
+ it('verifies seller signature on incoming quotes via handler', async () => {
364
+ const buyerKp = generateKeyPair();
365
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:0' });
366
+ const session = await agent.requestQuotes({
367
+ sellers: ['http://localhost:19999'],
368
+ service: { category: 'inference' },
369
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
370
+ });
371
+ await agent.listen(0);
372
+ const port = new URL(agent.getEndpoint()).port;
373
+ // Build a quote with a bad signature
374
+ const sellerKp = generateKeyPair();
375
+ const sellerDid = publicKeyToDid(sellerKp.publicKey);
376
+ const badQuote = {
377
+ quote_id: crypto.randomUUID(),
378
+ rfq_id: session.rfqId,
379
+ seller: { agent_id: sellerDid, endpoint: 'http://localhost:9000' },
380
+ pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
381
+ expires_at: new Date(Date.now() + 120_000).toISOString(),
382
+ signature: Buffer.alloc(64, 0xff).toString('base64'), // valid format, wrong key
383
+ };
384
+ const response = await fetch(`http://localhost:${port}`, {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({
388
+ jsonrpc: '2.0',
389
+ method: 'negotiate/quote',
390
+ params: badQuote,
391
+ id: 'sig-test-1',
392
+ }),
393
+ });
394
+ const json = await response.json();
395
+ expect(json.error).toBeDefined();
396
+ expect(json.error.message).toContain('Invalid signature');
397
+ // The quote should NOT have been added to the session
398
+ expect(session.quotes).toHaveLength(0);
399
+ });
400
+ it('verifies counter-party signature on incoming counters', async () => {
401
+ const buyerKp = generateKeyPair();
402
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:0' });
403
+ const session = await agent.requestQuotes({
404
+ sellers: ['http://localhost:19999'],
405
+ service: { category: 'inference' },
406
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
407
+ });
408
+ // Add a quote so session is in a state that can receive counters
409
+ const sellerKp = generateKeyPair();
410
+ const quote = makeSignedQuote(session.rfqId, sellerKp);
411
+ session.addQuote(quote);
412
+ await agent.listen(0);
413
+ const port = new URL(agent.getEndpoint()).port;
414
+ // Build a counter claiming to be from the known seller but signed by the wrong key.
415
+ // This gets past the "known seller" check but fails signature verification.
416
+ const wrongSignerKp = generateKeyPair();
417
+ const counterMsg = buildCounter({
418
+ rfqId: session.rfqId,
419
+ inResponseTo: quote.quote_id,
420
+ round: 1,
421
+ from: { agent_id: publicKeyToDid(sellerKp.publicKey), role: 'seller' },
422
+ modifications: { price_per_unit: '0.02' },
423
+ secretKey: wrongSignerKp.secretKey, // signed by the wrong key
424
+ });
425
+ const response = await fetch(`http://localhost:${port}`, {
426
+ method: 'POST',
427
+ headers: { 'Content-Type': 'application/json' },
428
+ body: JSON.stringify({
429
+ jsonrpc: '2.0',
430
+ method: 'negotiate/counter',
431
+ params: counterMsg.params,
432
+ id: 'sig-test-2',
433
+ }),
434
+ });
435
+ const json = await response.json();
436
+ expect(json.error).toBeDefined();
437
+ expect(json.error.message).toContain('Invalid signature');
438
+ });
439
+ });
440
+ // ── counter ──────────────────────────────────────────────────────
441
+ describe('counter', () => {
442
+ it('creates counter with correct round number', async () => {
443
+ const buyerKp = generateKeyPair();
444
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
445
+ const session = await agent.requestQuotes({
446
+ sellers: ['http://localhost:19999'],
447
+ service: { category: 'inference' },
448
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
449
+ });
450
+ const quote = makeQuote({
451
+ rfq_id: session.rfqId,
452
+ pricing: { price_per_unit: '0.10', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
453
+ });
454
+ session.addQuote(quote);
455
+ const updatedSession = await agent.counter(quote, { price_per_unit: '0.07' }, 'Price too high');
456
+ expect(updatedSession.state).toBe('COUNTERING');
457
+ expect(updatedSession.currentRound).toBe(1);
458
+ expect(updatedSession.counters).toHaveLength(1);
459
+ expect(updatedSession.counters[0].round).toBe(1);
460
+ expect(updatedSession.counters[0].from.role).toBe('buyer');
461
+ });
462
+ it('throws for unknown session', async () => {
463
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
464
+ const quote = makeQuote({ rfq_id: 'unknown-rfq' });
465
+ await expect(agent.counter(quote, { price: '0.01' })).rejects.toThrow(/No active session/);
466
+ });
467
+ });
468
+ // ── reject ───────────────────────────────────────────────────────
469
+ describe('reject', () => {
470
+ it('transitions session to REJECTED', async () => {
471
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
472
+ const session = await agent.requestQuotes({
473
+ sellers: ['http://localhost:19999'],
474
+ service: { category: 'inference' },
475
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
476
+ });
477
+ await agent.reject(session, 'Too expensive');
478
+ expect(session.state).toBe('REJECTED');
479
+ expect(session.rejectionReason).toBe('Too expensive');
480
+ });
481
+ it('uses default reason when none provided', async () => {
482
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
483
+ const session = await agent.requestQuotes({
484
+ sellers: ['http://localhost:19999'],
485
+ service: { category: 'inference' },
486
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
487
+ });
488
+ await agent.reject(session);
489
+ expect(session.state).toBe('REJECTED');
490
+ expect(session.rejectionReason).toBe('Rejected by buyer');
491
+ });
492
+ });
493
+ // ── listen / close ───────────────────────────────────────────────
494
+ describe('listen and close', () => {
495
+ it('starts server and close stops it', async () => {
496
+ agent = new BuyerAgent({ endpoint: 'http://localhost:0' });
497
+ await agent.listen(0);
498
+ const endpoint = agent.getEndpoint();
499
+ const port = new URL(endpoint).port;
500
+ // Verify server is listening by sending a request
501
+ const response = await fetch(`http://localhost:${port}`, {
502
+ method: 'POST',
503
+ headers: { 'Content-Type': 'application/json' },
504
+ body: JSON.stringify({
505
+ jsonrpc: '2.0',
506
+ method: 'negotiate/unknown',
507
+ params: {},
508
+ id: 'test-1',
509
+ }),
510
+ });
511
+ expect(response.ok).toBe(true);
512
+ const json = await response.json();
513
+ expect(json.error.message).toContain('Method not found');
514
+ await agent.close();
515
+ agent = undefined;
516
+ // After close, server should not respond
517
+ await expect(fetch(`http://localhost:${port}`, {
518
+ method: 'POST',
519
+ headers: { 'Content-Type': 'application/json' },
520
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'test', params: {}, id: 'test-2' }),
521
+ })).rejects.toThrow();
522
+ });
523
+ });
524
+ // ── Session management ───────────────────────────────────────────
525
+ describe('session management', () => {
526
+ it('getSession returns undefined for unknown rfqId', () => {
527
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
528
+ expect(agent.getSession('nonexistent')).toBeUndefined();
529
+ });
530
+ it('getSessions returns empty array initially', () => {
531
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
532
+ expect(agent.getSessions()).toHaveLength(0);
533
+ });
534
+ it('multiple sessions can exist simultaneously', async () => {
535
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
536
+ const session1 = await agent.requestQuotes({
537
+ sellers: ['http://localhost:19999'],
538
+ service: { category: 'inference' },
539
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
540
+ });
541
+ const session2 = await agent.requestQuotes({
542
+ sellers: ['http://localhost:19998'],
543
+ service: { category: 'translation' },
544
+ budget: { max_price_per_unit: '0.50', currency: 'USDC', unit: 'request' },
545
+ });
546
+ const session3 = await agent.requestQuotes({
547
+ sellers: ['http://localhost:19997'],
548
+ service: { category: 'data_processing' },
549
+ budget: { max_price_per_unit: '0.25', currency: 'USDC', unit: 'request' },
550
+ });
551
+ expect(agent.getSessions()).toHaveLength(3);
552
+ expect(agent.getSession(session1.rfqId)).toBe(session1);
553
+ expect(agent.getSession(session2.rfqId)).toBe(session2);
554
+ expect(agent.getSession(session3.rfqId)).toBe(session3);
555
+ // Each session is independent
556
+ expect(session1.rfq.service.category).toBe('inference');
557
+ expect(session2.rfq.service.category).toBe('translation');
558
+ expect(session3.rfq.service.category).toBe('data_processing');
559
+ });
560
+ });
561
+ // ── discover ─────────────────────────────────────────────────────
562
+ describe('discover', () => {
563
+ it('returns empty array (stub)', async () => {
564
+ agent = new BuyerAgent({ endpoint: 'http://localhost:3001' });
565
+ const sellers = await agent.discover({ category: 'inference' });
566
+ expect(sellers).toEqual([]);
567
+ });
568
+ });
569
+ // ── buyer signing verification ──────────────────────────────────
570
+ describe('buyer signing verification', () => {
571
+ it('buyer signs RFQ with its own key', async () => {
572
+ const buyerKp = generateKeyPair();
573
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
574
+ const session = await agent.requestQuotes({
575
+ sellers: ['http://localhost:19999'],
576
+ service: { category: 'inference' },
577
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
578
+ });
579
+ expect(session.rfq.signature).toBeDefined();
580
+ // Strip the signature field to get the unsigned params
581
+ const { signature, ...unsigned } = session.rfq;
582
+ const isValid = verifyMessage(unsigned, signature, buyerKp.publicKey);
583
+ expect(isValid).toBe(true);
584
+ });
585
+ it('buyer signs reject with its own key', async () => {
586
+ const buyerKp = generateKeyPair();
587
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
588
+ const session = await agent.requestQuotes({
589
+ sellers: ['http://localhost:19999'],
590
+ service: { category: 'inference' },
591
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
592
+ });
593
+ expect(session.state).toBe('RFQ_SENT');
594
+ await agent.reject(session, 'too expensive');
595
+ expect(session.state).toBe('REJECTED');
596
+ expect(session.rejectionReason).toBe('too expensive');
597
+ });
598
+ it('buyer verifies seller quote signature before accepting', async () => {
599
+ const buyerKp = generateKeyPair();
600
+ const sellerKp = generateKeyPair();
601
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
602
+ const session = await agent.requestQuotes({
603
+ sellers: ['http://localhost:19999'],
604
+ service: { category: 'inference' },
605
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
606
+ });
607
+ const quote = makeSignedQuote(session.rfqId, sellerKp, {
608
+ pricing: { price_per_unit: '0.05', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
609
+ });
610
+ session.addQuote(quote);
611
+ const agreement = await agent.acceptQuote(quote);
612
+ expect(agreement).toBeDefined();
613
+ expect(agreement.rfq_id).toBe(session.rfqId);
614
+ expect(session.state).toBe('ACCEPTED');
615
+ });
616
+ it('buyer rejects quote with invalid signature from imposter key', async () => {
617
+ const buyerKp = generateKeyPair();
618
+ const realSellerKp = generateKeyPair();
619
+ const imposterKp = generateKeyPair();
620
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
621
+ const session = await agent.requestQuotes({
622
+ sellers: ['http://localhost:19999'],
623
+ service: { category: 'inference' },
624
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
625
+ });
626
+ // Create a quote claiming to be from realSeller but signed by imposter
627
+ const sellerDid = publicKeyToDid(realSellerKp.publicKey);
628
+ const unsigned = {
629
+ quote_id: crypto.randomUUID(),
630
+ rfq_id: session.rfqId,
631
+ seller: { agent_id: sellerDid, endpoint: 'http://localhost:9000' },
632
+ pricing: { price_per_unit: '0.05', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
633
+ expires_at: new Date(Date.now() + 120_000).toISOString(),
634
+ };
635
+ const badSig = signMessage(unsigned, imposterKp.secretKey);
636
+ const badQuote = { ...unsigned, signature: badSig };
637
+ session.addQuote(badQuote);
638
+ // This quote has an invalid signature — signed by wrong key
639
+ await expect(agent.acceptQuote(badQuote)).rejects.toThrow('seller signature is invalid');
640
+ });
641
+ it('buyer rejects quote with forged seller DID', async () => {
642
+ const buyerKp = generateKeyPair();
643
+ const signerKp = generateKeyPair();
644
+ const forgedKp = generateKeyPair();
645
+ agent = new BuyerAgent({ keypair: buyerKp, endpoint: 'http://localhost:3001' });
646
+ const session = await agent.requestQuotes({
647
+ sellers: ['http://localhost:19999'],
648
+ service: { category: 'inference' },
649
+ budget: { max_price_per_unit: '1.00', currency: 'USDC', unit: 'request' },
650
+ });
651
+ // Sign with signerKp but set seller.agent_id to forgedKp's DID
652
+ const quote = makeSignedQuote(session.rfqId, signerKp);
653
+ const forgedQuote = {
654
+ ...quote,
655
+ seller: {
656
+ agent_id: publicKeyToDid(forgedKp.publicKey),
657
+ endpoint: 'http://localhost:9000',
658
+ },
659
+ };
660
+ session.addQuote(forgedQuote);
661
+ await expect(agent.acceptQuote(forgedQuote)).rejects.toThrow('seller signature is invalid');
662
+ });
663
+ });
664
+ });
@@ -0,0 +1 @@
1
+ export {};