@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.
- package/README.md +139 -0
- package/dist/__tests__/buyer.test.d.ts +1 -0
- package/dist/__tests__/buyer.test.js +664 -0
- package/dist/__tests__/discovery.test.d.ts +1 -0
- package/dist/__tests__/discovery.test.js +188 -0
- package/dist/__tests__/escrow.test.d.ts +1 -0
- package/dist/__tests__/escrow.test.js +385 -0
- package/dist/__tests__/identity.test.d.ts +1 -0
- package/dist/__tests__/identity.test.js +222 -0
- package/dist/__tests__/integration.test.d.ts +1 -0
- package/dist/__tests__/integration.test.js +681 -0
- package/dist/__tests__/lockstep.test.d.ts +1 -0
- package/dist/__tests__/lockstep.test.js +320 -0
- package/dist/__tests__/messages.test.d.ts +1 -0
- package/dist/__tests__/messages.test.js +976 -0
- package/dist/__tests__/negotiation.test.d.ts +1 -0
- package/dist/__tests__/negotiation.test.js +667 -0
- package/dist/__tests__/seller.test.d.ts +1 -0
- package/dist/__tests__/seller.test.js +767 -0
- package/dist/__tests__/server.test.d.ts +1 -0
- package/dist/__tests__/server.test.js +239 -0
- package/dist/__tests__/signing.test.d.ts +1 -0
- package/dist/__tests__/signing.test.js +713 -0
- package/dist/__tests__/sla.test.d.ts +1 -0
- package/dist/__tests__/sla.test.js +342 -0
- package/dist/__tests__/transport.test.d.ts +1 -0
- package/dist/__tests__/transport.test.js +197 -0
- package/dist/__tests__/x402.test.d.ts +1 -0
- package/dist/__tests__/x402.test.js +141 -0
- package/dist/buyer.d.ts +190 -0
- package/dist/buyer.js +555 -0
- package/dist/discovery.d.ts +47 -0
- package/dist/discovery.js +51 -0
- package/dist/escrow.d.ts +177 -0
- package/dist/escrow.js +434 -0
- package/dist/identity.d.ts +60 -0
- package/dist/identity.js +108 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +43 -0
- package/dist/lockstep.d.ts +94 -0
- package/dist/lockstep.js +127 -0
- package/dist/messages.d.ts +172 -0
- package/dist/messages.js +262 -0
- package/dist/negotiation.d.ts +113 -0
- package/dist/negotiation.js +214 -0
- package/dist/seller.d.ts +127 -0
- package/dist/seller.js +395 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +149 -0
- package/dist/signing.d.ts +98 -0
- package/dist/signing.js +165 -0
- package/dist/sla.d.ts +95 -0
- package/dist/sla.js +187 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.js +127 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +1 -0
- package/dist/x402.d.ts +25 -0
- package/dist/x402.js +54 -0
- 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 {};
|