@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,976 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
import { buildRFQ, buildQuote, buildCounter, buildAccept, buildReject, buildDispute, } from '../messages.js';
|
|
4
|
+
import { verifyMessage, agreementHash, signMessage } from '../signing.js';
|
|
5
|
+
import { publicKeyToDid } from '../identity.js';
|
|
6
|
+
function makeIdentity(kp) {
|
|
7
|
+
return {
|
|
8
|
+
agent_id: publicKeyToDid(kp.publicKey),
|
|
9
|
+
endpoint: 'https://agent.example.com',
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('buildRFQ', () => {
|
|
13
|
+
it('produces a valid JSON-RPC envelope', () => {
|
|
14
|
+
const kp = nacl.sign.keyPair();
|
|
15
|
+
const msg = buildRFQ({
|
|
16
|
+
buyer: makeIdentity(kp),
|
|
17
|
+
service: { category: 'inference' },
|
|
18
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
19
|
+
secretKey: kp.secretKey,
|
|
20
|
+
});
|
|
21
|
+
expect(msg.jsonrpc).toBe('2.0');
|
|
22
|
+
expect(msg.method).toBe('negotiate/rfq');
|
|
23
|
+
expect(msg.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
24
|
+
expect(msg.params).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
it('auto-generates rfq_id as UUID v4', () => {
|
|
27
|
+
const kp = nacl.sign.keyPair();
|
|
28
|
+
const msg = buildRFQ({
|
|
29
|
+
buyer: makeIdentity(kp),
|
|
30
|
+
service: { category: 'inference' },
|
|
31
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
32
|
+
secretKey: kp.secretKey,
|
|
33
|
+
});
|
|
34
|
+
expect(msg.params.rfq_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
35
|
+
});
|
|
36
|
+
it('signs correctly and verifiable with buyer public key', () => {
|
|
37
|
+
const kp = nacl.sign.keyPair();
|
|
38
|
+
const msg = buildRFQ({
|
|
39
|
+
buyer: makeIdentity(kp),
|
|
40
|
+
service: { category: 'inference' },
|
|
41
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
42
|
+
secretKey: kp.secretKey,
|
|
43
|
+
});
|
|
44
|
+
const { signature, ...unsigned } = msg.params;
|
|
45
|
+
expect(signature).toBeTruthy();
|
|
46
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('rejects verification with wrong key', () => {
|
|
49
|
+
const kp = nacl.sign.keyPair();
|
|
50
|
+
const wrongKp = nacl.sign.keyPair();
|
|
51
|
+
const msg = buildRFQ({
|
|
52
|
+
buyer: makeIdentity(kp),
|
|
53
|
+
service: { category: 'inference' },
|
|
54
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
55
|
+
secretKey: kp.secretKey,
|
|
56
|
+
});
|
|
57
|
+
const { signature, ...unsigned } = msg.params;
|
|
58
|
+
expect(verifyMessage(unsigned, signature, wrongKp.publicKey)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('sets expires_at in the future', () => {
|
|
61
|
+
const kp = nacl.sign.keyPair();
|
|
62
|
+
const before = Date.now();
|
|
63
|
+
const msg = buildRFQ({
|
|
64
|
+
buyer: makeIdentity(kp),
|
|
65
|
+
service: { category: 'inference' },
|
|
66
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
67
|
+
secretKey: kp.secretKey,
|
|
68
|
+
});
|
|
69
|
+
const expiresAt = new Date(msg.params.expires_at).getTime();
|
|
70
|
+
expect(expiresAt).toBeGreaterThan(before);
|
|
71
|
+
// Default 5 min TTL
|
|
72
|
+
expect(expiresAt - before).toBeGreaterThanOrEqual(4 * 60 * 1000);
|
|
73
|
+
expect(expiresAt - before).toBeLessThanOrEqual(6 * 60 * 1000);
|
|
74
|
+
});
|
|
75
|
+
it('uses custom ttlMs', () => {
|
|
76
|
+
const kp = nacl.sign.keyPair();
|
|
77
|
+
const before = Date.now();
|
|
78
|
+
const msg = buildRFQ({
|
|
79
|
+
buyer: makeIdentity(kp),
|
|
80
|
+
service: { category: 'inference' },
|
|
81
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
82
|
+
ttlMs: 60_000,
|
|
83
|
+
secretKey: kp.secretKey,
|
|
84
|
+
});
|
|
85
|
+
const expiresAt = new Date(msg.params.expires_at).getTime();
|
|
86
|
+
expect(expiresAt - before).toBeLessThanOrEqual(62_000);
|
|
87
|
+
expect(expiresAt - before).toBeGreaterThanOrEqual(58_000);
|
|
88
|
+
});
|
|
89
|
+
it('generates unique rfq_ids', () => {
|
|
90
|
+
const kp = nacl.sign.keyPair();
|
|
91
|
+
const args = {
|
|
92
|
+
buyer: makeIdentity(kp),
|
|
93
|
+
service: { category: 'inference' },
|
|
94
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
95
|
+
secretKey: kp.secretKey,
|
|
96
|
+
};
|
|
97
|
+
const a = buildRFQ(args);
|
|
98
|
+
const b = buildRFQ(args);
|
|
99
|
+
expect(a.params.rfq_id).not.toBe(b.params.rfq_id);
|
|
100
|
+
expect(a.id).not.toBe(b.id);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('buildQuote', () => {
|
|
104
|
+
it('produces a valid JSON-RPC envelope', () => {
|
|
105
|
+
const kp = nacl.sign.keyPair();
|
|
106
|
+
const msg = buildQuote({
|
|
107
|
+
rfqId: 'rfq-123',
|
|
108
|
+
seller: makeIdentity(kp),
|
|
109
|
+
pricing: {
|
|
110
|
+
price_per_unit: '0.005',
|
|
111
|
+
currency: 'USDC',
|
|
112
|
+
unit: 'request',
|
|
113
|
+
pricing_model: 'fixed',
|
|
114
|
+
},
|
|
115
|
+
secretKey: kp.secretKey,
|
|
116
|
+
});
|
|
117
|
+
expect(msg.jsonrpc).toBe('2.0');
|
|
118
|
+
expect(msg.method).toBe('negotiate/quote');
|
|
119
|
+
expect(msg.params.quote_id).toBeTruthy();
|
|
120
|
+
expect(msg.params.rfq_id).toBe('rfq-123');
|
|
121
|
+
});
|
|
122
|
+
it('signs correctly and verifiable with public key', () => {
|
|
123
|
+
const kp = nacl.sign.keyPair();
|
|
124
|
+
const msg = buildQuote({
|
|
125
|
+
rfqId: 'rfq-456',
|
|
126
|
+
seller: makeIdentity(kp),
|
|
127
|
+
pricing: {
|
|
128
|
+
price_per_unit: '0.01',
|
|
129
|
+
currency: 'USDC',
|
|
130
|
+
unit: 'request',
|
|
131
|
+
pricing_model: 'fixed',
|
|
132
|
+
},
|
|
133
|
+
secretKey: kp.secretKey,
|
|
134
|
+
});
|
|
135
|
+
// Reconstruct the unsigned params to verify
|
|
136
|
+
const { signature, ...unsigned } = msg.params;
|
|
137
|
+
expect(signature).toBeTruthy();
|
|
138
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it('rejects verification with wrong key', () => {
|
|
141
|
+
const kp = nacl.sign.keyPair();
|
|
142
|
+
const wrongKp = nacl.sign.keyPair();
|
|
143
|
+
const msg = buildQuote({
|
|
144
|
+
rfqId: 'rfq-789',
|
|
145
|
+
seller: makeIdentity(kp),
|
|
146
|
+
pricing: {
|
|
147
|
+
price_per_unit: '0.01',
|
|
148
|
+
currency: 'USDC',
|
|
149
|
+
unit: 'request',
|
|
150
|
+
pricing_model: 'fixed',
|
|
151
|
+
},
|
|
152
|
+
secretKey: kp.secretKey,
|
|
153
|
+
});
|
|
154
|
+
const { signature, ...unsigned } = msg.params;
|
|
155
|
+
expect(verifyMessage(unsigned, signature, wrongKp.publicKey)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
it('signature changes when params change', () => {
|
|
158
|
+
const kp = nacl.sign.keyPair();
|
|
159
|
+
const base = {
|
|
160
|
+
rfqId: 'rfq-sig-test',
|
|
161
|
+
seller: makeIdentity(kp),
|
|
162
|
+
pricing: {
|
|
163
|
+
price_per_unit: '0.01',
|
|
164
|
+
currency: 'USDC',
|
|
165
|
+
unit: 'request',
|
|
166
|
+
pricing_model: 'fixed',
|
|
167
|
+
},
|
|
168
|
+
secretKey: kp.secretKey,
|
|
169
|
+
};
|
|
170
|
+
const msg1 = buildQuote(base);
|
|
171
|
+
const msg2 = buildQuote({ ...base, pricing: { ...base.pricing, price_per_unit: '0.02' } });
|
|
172
|
+
expect(msg1.params.signature).not.toBe(msg2.params.signature);
|
|
173
|
+
});
|
|
174
|
+
it('same params produce same signature (deterministic)', () => {
|
|
175
|
+
const kp = nacl.sign.keyPair();
|
|
176
|
+
const pricing = {
|
|
177
|
+
price_per_unit: '0.01',
|
|
178
|
+
currency: 'USDC',
|
|
179
|
+
unit: 'request',
|
|
180
|
+
pricing_model: 'fixed',
|
|
181
|
+
};
|
|
182
|
+
// Sign the same canonical content twice manually to confirm determinism
|
|
183
|
+
// (buildQuote generates new quote_id each time, so we test signMessage directly)
|
|
184
|
+
const params = { rfq_id: 'rfq-det', seller: makeIdentity(kp), pricing };
|
|
185
|
+
const sig1 = signMessage(params, kp.secretKey);
|
|
186
|
+
const sig2 = signMessage(params, kp.secretKey);
|
|
187
|
+
expect(sig1).toBe(sig2);
|
|
188
|
+
});
|
|
189
|
+
it('generates unique quote_ids', () => {
|
|
190
|
+
const kp = nacl.sign.keyPair();
|
|
191
|
+
const args = {
|
|
192
|
+
rfqId: 'rfq-uniq',
|
|
193
|
+
seller: makeIdentity(kp),
|
|
194
|
+
pricing: {
|
|
195
|
+
price_per_unit: '0.01',
|
|
196
|
+
currency: 'USDC',
|
|
197
|
+
unit: 'request',
|
|
198
|
+
pricing_model: 'fixed',
|
|
199
|
+
},
|
|
200
|
+
secretKey: kp.secretKey,
|
|
201
|
+
};
|
|
202
|
+
const a = buildQuote(args);
|
|
203
|
+
const b = buildQuote(args);
|
|
204
|
+
expect(a.params.quote_id).not.toBe(b.params.quote_id);
|
|
205
|
+
expect(a.id).not.toBe(b.id);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('buildCounter', () => {
|
|
209
|
+
it('produces valid envelope with signature', () => {
|
|
210
|
+
const kp = nacl.sign.keyPair();
|
|
211
|
+
const msg = buildCounter({
|
|
212
|
+
rfqId: 'rfq-100',
|
|
213
|
+
inResponseTo: 'quote-100',
|
|
214
|
+
round: 1,
|
|
215
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
216
|
+
modifications: { price_per_unit: '0.008' },
|
|
217
|
+
secretKey: kp.secretKey,
|
|
218
|
+
});
|
|
219
|
+
expect(msg.jsonrpc).toBe('2.0');
|
|
220
|
+
expect(msg.method).toBe('negotiate/counter');
|
|
221
|
+
expect(msg.params.counter_id).toBeTruthy();
|
|
222
|
+
expect(msg.params.round).toBe(1);
|
|
223
|
+
const { signature, ...unsigned } = msg.params;
|
|
224
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
it('preserves the round number', () => {
|
|
227
|
+
const kp = nacl.sign.keyPair();
|
|
228
|
+
const msg = buildCounter({
|
|
229
|
+
rfqId: 'rfq-round',
|
|
230
|
+
inResponseTo: 'quote-round',
|
|
231
|
+
round: 3,
|
|
232
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'seller' },
|
|
233
|
+
modifications: { price_per_unit: '0.005' },
|
|
234
|
+
secretKey: kp.secretKey,
|
|
235
|
+
});
|
|
236
|
+
expect(msg.params.round).toBe(3);
|
|
237
|
+
});
|
|
238
|
+
it('generates unique counter_ids', () => {
|
|
239
|
+
const kp = nacl.sign.keyPair();
|
|
240
|
+
const args = {
|
|
241
|
+
rfqId: 'rfq-cuniq',
|
|
242
|
+
inResponseTo: 'quote-cuniq',
|
|
243
|
+
round: 1,
|
|
244
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
245
|
+
modifications: { price_per_unit: '0.008' },
|
|
246
|
+
secretKey: kp.secretKey,
|
|
247
|
+
};
|
|
248
|
+
const a = buildCounter(args);
|
|
249
|
+
const b = buildCounter(args);
|
|
250
|
+
expect(a.params.counter_id).not.toBe(b.params.counter_id);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('buildAccept', () => {
|
|
254
|
+
const finalTerms = {
|
|
255
|
+
price_per_unit: '0.01',
|
|
256
|
+
currency: 'USDC',
|
|
257
|
+
unit: 'request',
|
|
258
|
+
sla: {
|
|
259
|
+
metrics: [{ name: 'uptime_pct', target: 99.9, comparison: 'gte' }],
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
it('computes agreement_hash deterministically', () => {
|
|
263
|
+
const kp = nacl.sign.keyPair();
|
|
264
|
+
const a = buildAccept({
|
|
265
|
+
rfqId: 'rfq-200',
|
|
266
|
+
acceptingMessageId: 'quote-200',
|
|
267
|
+
finalTerms,
|
|
268
|
+
buyerSecretKey: kp.secretKey,
|
|
269
|
+
});
|
|
270
|
+
const b = buildAccept({
|
|
271
|
+
rfqId: 'rfq-200',
|
|
272
|
+
acceptingMessageId: 'quote-200',
|
|
273
|
+
finalTerms,
|
|
274
|
+
buyerSecretKey: kp.secretKey,
|
|
275
|
+
});
|
|
276
|
+
expect(a.params.agreement_hash).toBe(b.params.agreement_hash);
|
|
277
|
+
expect(a.params.agreement_hash).toBe(agreementHash(finalTerms));
|
|
278
|
+
expect(a.params.agreement_hash).toMatch(/^[0-9a-f]{64}$/);
|
|
279
|
+
});
|
|
280
|
+
it('signs with buyer key', () => {
|
|
281
|
+
const kp = nacl.sign.keyPair();
|
|
282
|
+
const msg = buildAccept({
|
|
283
|
+
rfqId: 'rfq-300',
|
|
284
|
+
acceptingMessageId: 'quote-300',
|
|
285
|
+
finalTerms,
|
|
286
|
+
buyerSecretKey: kp.secretKey,
|
|
287
|
+
});
|
|
288
|
+
expect(msg.params.buyer_signature).toBeTruthy();
|
|
289
|
+
expect(msg.method).toBe('negotiate/accept');
|
|
290
|
+
});
|
|
291
|
+
it('agreement_hash changes with different terms', () => {
|
|
292
|
+
const kp = nacl.sign.keyPair();
|
|
293
|
+
const altTerms = {
|
|
294
|
+
price_per_unit: '0.05',
|
|
295
|
+
currency: 'USDC',
|
|
296
|
+
unit: 'request',
|
|
297
|
+
sla: {
|
|
298
|
+
metrics: [{ name: 'uptime_pct', target: 99, comparison: 'gte' }],
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const a = buildAccept({
|
|
302
|
+
rfqId: 'rfq-hash1',
|
|
303
|
+
acceptingMessageId: 'quote-hash1',
|
|
304
|
+
finalTerms,
|
|
305
|
+
buyerSecretKey: kp.secretKey,
|
|
306
|
+
});
|
|
307
|
+
const b = buildAccept({
|
|
308
|
+
rfqId: 'rfq-hash2',
|
|
309
|
+
acceptingMessageId: 'quote-hash2',
|
|
310
|
+
finalTerms: altTerms,
|
|
311
|
+
buyerSecretKey: kp.secretKey,
|
|
312
|
+
});
|
|
313
|
+
expect(a.params.agreement_hash).not.toBe(b.params.agreement_hash);
|
|
314
|
+
});
|
|
315
|
+
it('generates unique agreement_ids', () => {
|
|
316
|
+
const kp = nacl.sign.keyPair();
|
|
317
|
+
const args = {
|
|
318
|
+
rfqId: 'rfq-auniq',
|
|
319
|
+
acceptingMessageId: 'quote-auniq',
|
|
320
|
+
finalTerms,
|
|
321
|
+
buyerSecretKey: kp.secretKey,
|
|
322
|
+
};
|
|
323
|
+
const a = buildAccept(args);
|
|
324
|
+
const b = buildAccept(args);
|
|
325
|
+
expect(a.params.agreement_id).not.toBe(b.params.agreement_id);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
describe('buildReject', () => {
|
|
329
|
+
it('produces valid envelope with signature', () => {
|
|
330
|
+
const kp = nacl.sign.keyPair();
|
|
331
|
+
const msg = buildReject({
|
|
332
|
+
rfqId: 'rfq-400',
|
|
333
|
+
rejectingMessageId: 'quote-400',
|
|
334
|
+
reason: 'Price too high',
|
|
335
|
+
agentId: publicKeyToDid(kp.publicKey),
|
|
336
|
+
secretKey: kp.secretKey,
|
|
337
|
+
});
|
|
338
|
+
expect(msg.jsonrpc).toBe('2.0');
|
|
339
|
+
expect(msg.method).toBe('negotiate/reject');
|
|
340
|
+
expect(msg.params.reason).toBe('Price too high');
|
|
341
|
+
expect(msg.params.from.agent_id).toBe(publicKeyToDid(kp.publicKey));
|
|
342
|
+
const { signature, ...unsigned } = msg.params;
|
|
343
|
+
expect(signature).toBeTruthy();
|
|
344
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
it('rejects verification with wrong key', () => {
|
|
347
|
+
const kp = nacl.sign.keyPair();
|
|
348
|
+
const wrongKp = nacl.sign.keyPair();
|
|
349
|
+
const msg = buildReject({
|
|
350
|
+
rfqId: 'rfq-nosig',
|
|
351
|
+
rejectingMessageId: 'quote-nosig',
|
|
352
|
+
reason: 'Not interested',
|
|
353
|
+
agentId: publicKeyToDid(kp.publicKey),
|
|
354
|
+
secretKey: kp.secretKey,
|
|
355
|
+
});
|
|
356
|
+
const { signature, ...unsigned } = msg.params;
|
|
357
|
+
expect(verifyMessage(unsigned, signature, wrongKp.publicKey)).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
describe('buildDispute', () => {
|
|
361
|
+
it('produces valid envelope with signature', () => {
|
|
362
|
+
const kp = nacl.sign.keyPair();
|
|
363
|
+
const msg = buildDispute({
|
|
364
|
+
agreementId: 'agr-500',
|
|
365
|
+
filedBy: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
366
|
+
violation: {
|
|
367
|
+
sla_metric: 'uptime_pct',
|
|
368
|
+
agreed_value: 99.9,
|
|
369
|
+
observed_value: 95.0,
|
|
370
|
+
measurement_window: '24h',
|
|
371
|
+
evidence_hash: 'abc123',
|
|
372
|
+
},
|
|
373
|
+
requestedRemedy: 'Full refund',
|
|
374
|
+
escrowAction: 'release_to_buyer',
|
|
375
|
+
secretKey: kp.secretKey,
|
|
376
|
+
});
|
|
377
|
+
expect(msg.jsonrpc).toBe('2.0');
|
|
378
|
+
expect(msg.method).toBe('negotiate/dispute');
|
|
379
|
+
expect(msg.params.dispute_id).toBeTruthy();
|
|
380
|
+
const { signature, ...unsigned } = msg.params;
|
|
381
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
it('generates unique dispute_ids', () => {
|
|
384
|
+
const kp = nacl.sign.keyPair();
|
|
385
|
+
const args = {
|
|
386
|
+
agreementId: 'agr-duniq',
|
|
387
|
+
filedBy: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
388
|
+
violation: {
|
|
389
|
+
sla_metric: 'uptime_pct',
|
|
390
|
+
agreed_value: 99.9,
|
|
391
|
+
observed_value: 95.0,
|
|
392
|
+
measurement_window: '24h',
|
|
393
|
+
evidence_hash: 'abc123',
|
|
394
|
+
},
|
|
395
|
+
requestedRemedy: 'Full refund',
|
|
396
|
+
escrowAction: 'release_to_buyer',
|
|
397
|
+
secretKey: kp.secretKey,
|
|
398
|
+
};
|
|
399
|
+
const a = buildDispute(args);
|
|
400
|
+
const b = buildDispute(args);
|
|
401
|
+
expect(a.params.dispute_id).not.toBe(b.params.dispute_id);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
describe('cross-builder uniqueness', () => {
|
|
405
|
+
it('all builders produce unique JSON-RPC ids on each call', () => {
|
|
406
|
+
const kp = nacl.sign.keyPair();
|
|
407
|
+
const ids = new Set();
|
|
408
|
+
const rfq = buildRFQ({
|
|
409
|
+
buyer: makeIdentity(kp),
|
|
410
|
+
service: { category: 'inference' },
|
|
411
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
412
|
+
secretKey: kp.secretKey,
|
|
413
|
+
});
|
|
414
|
+
ids.add(rfq.id);
|
|
415
|
+
const quote = buildQuote({
|
|
416
|
+
rfqId: 'rfq-1',
|
|
417
|
+
seller: makeIdentity(kp),
|
|
418
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
419
|
+
secretKey: kp.secretKey,
|
|
420
|
+
});
|
|
421
|
+
ids.add(quote.id);
|
|
422
|
+
const counter = buildCounter({
|
|
423
|
+
rfqId: 'rfq-1',
|
|
424
|
+
inResponseTo: 'quote-1',
|
|
425
|
+
round: 1,
|
|
426
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
427
|
+
modifications: {},
|
|
428
|
+
secretKey: kp.secretKey,
|
|
429
|
+
});
|
|
430
|
+
ids.add(counter.id);
|
|
431
|
+
const accept = buildAccept({
|
|
432
|
+
rfqId: 'rfq-1',
|
|
433
|
+
acceptingMessageId: 'quote-1',
|
|
434
|
+
finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', sla: { metrics: [] } },
|
|
435
|
+
buyerSecretKey: kp.secretKey,
|
|
436
|
+
});
|
|
437
|
+
ids.add(accept.id);
|
|
438
|
+
const reject = buildReject({
|
|
439
|
+
rfqId: 'rfq-1',
|
|
440
|
+
rejectingMessageId: 'quote-1',
|
|
441
|
+
reason: 'no',
|
|
442
|
+
agentId: publicKeyToDid(kp.publicKey),
|
|
443
|
+
secretKey: kp.secretKey,
|
|
444
|
+
});
|
|
445
|
+
ids.add(reject.id);
|
|
446
|
+
const dispute = buildDispute({
|
|
447
|
+
agreementId: 'agr-1',
|
|
448
|
+
filedBy: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
449
|
+
violation: { sla_metric: 'uptime_pct', agreed_value: 99.9, observed_value: 90, measurement_window: '1h', evidence_hash: 'x' },
|
|
450
|
+
requestedRemedy: 'refund',
|
|
451
|
+
escrowAction: 'release_to_buyer',
|
|
452
|
+
secretKey: kp.secretKey,
|
|
453
|
+
});
|
|
454
|
+
ids.add(dispute.id);
|
|
455
|
+
// All 6 JSON-RPC ids should be unique
|
|
456
|
+
expect(ids.size).toBe(6);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
describe('message builder edge cases', () => {
|
|
460
|
+
const baseFinalTerms = {
|
|
461
|
+
price_per_unit: '0.01',
|
|
462
|
+
currency: 'USDC',
|
|
463
|
+
unit: 'request',
|
|
464
|
+
sla: {
|
|
465
|
+
metrics: [{ name: 'uptime_pct', target: 99.9, comparison: 'gte' }],
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
it('buildAccept buyer_signature covers agreement_hash', () => {
|
|
469
|
+
const kp = nacl.sign.keyPair();
|
|
470
|
+
const termsA = {
|
|
471
|
+
price_per_unit: '0.01',
|
|
472
|
+
currency: 'USDC',
|
|
473
|
+
unit: 'request',
|
|
474
|
+
sla: { metrics: [] },
|
|
475
|
+
};
|
|
476
|
+
const termsB = {
|
|
477
|
+
price_per_unit: '0.02',
|
|
478
|
+
currency: 'USDC',
|
|
479
|
+
unit: 'request',
|
|
480
|
+
sla: { metrics: [] },
|
|
481
|
+
};
|
|
482
|
+
const acceptA = buildAccept({
|
|
483
|
+
rfqId: 'rfq-edge-1',
|
|
484
|
+
acceptingMessageId: 'quote-edge-1',
|
|
485
|
+
finalTerms: termsA,
|
|
486
|
+
buyerSecretKey: kp.secretKey,
|
|
487
|
+
});
|
|
488
|
+
const acceptB = buildAccept({
|
|
489
|
+
rfqId: 'rfq-edge-1',
|
|
490
|
+
acceptingMessageId: 'quote-edge-1',
|
|
491
|
+
finalTerms: termsB,
|
|
492
|
+
buyerSecretKey: kp.secretKey,
|
|
493
|
+
});
|
|
494
|
+
// Different agreement_hash values must produce different buyer_signatures
|
|
495
|
+
expect(acceptA.params.agreement_hash).not.toBe(acceptB.params.agreement_hash);
|
|
496
|
+
expect(acceptA.params.buyer_signature).not.toBe(acceptB.params.buyer_signature);
|
|
497
|
+
});
|
|
498
|
+
it('buildCounter signature covers round number', () => {
|
|
499
|
+
const kp = nacl.sign.keyPair();
|
|
500
|
+
const base = {
|
|
501
|
+
rfqId: 'rfq-round-edge',
|
|
502
|
+
inResponseTo: 'quote-round-edge',
|
|
503
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
504
|
+
modifications: { price_per_unit: '0.008' },
|
|
505
|
+
secretKey: kp.secretKey,
|
|
506
|
+
};
|
|
507
|
+
const counterR1 = buildCounter({ ...base, round: 1 });
|
|
508
|
+
const counterR2 = buildCounter({ ...base, round: 2 });
|
|
509
|
+
// Same params except round -- signatures must differ because round is
|
|
510
|
+
// included in the signed payload. We compare the raw signature values
|
|
511
|
+
// noting that counter_id also differs (UUID), but the key point is that
|
|
512
|
+
// round is canonicalized into the signed content.
|
|
513
|
+
expect(counterR1.params.round).toBe(1);
|
|
514
|
+
expect(counterR2.params.round).toBe(2);
|
|
515
|
+
expect(counterR1.params.signature).not.toBe(counterR2.params.signature);
|
|
516
|
+
});
|
|
517
|
+
it('buildDispute signature covers evidence', () => {
|
|
518
|
+
const kp = nacl.sign.keyPair();
|
|
519
|
+
const base = {
|
|
520
|
+
agreementId: 'agr-ev-edge',
|
|
521
|
+
filedBy: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
522
|
+
requestedRemedy: 'Full refund',
|
|
523
|
+
escrowAction: 'release_to_buyer',
|
|
524
|
+
secretKey: kp.secretKey,
|
|
525
|
+
};
|
|
526
|
+
const disputeA = buildDispute({
|
|
527
|
+
...base,
|
|
528
|
+
violation: {
|
|
529
|
+
sla_metric: 'uptime_pct',
|
|
530
|
+
agreed_value: 99.9,
|
|
531
|
+
observed_value: 95.0,
|
|
532
|
+
measurement_window: '24h',
|
|
533
|
+
evidence_hash: 'evidence_aaa',
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
const disputeB = buildDispute({
|
|
537
|
+
...base,
|
|
538
|
+
violation: {
|
|
539
|
+
sla_metric: 'uptime_pct',
|
|
540
|
+
agreed_value: 99.9,
|
|
541
|
+
observed_value: 80.0,
|
|
542
|
+
measurement_window: '24h',
|
|
543
|
+
evidence_hash: 'evidence_bbb',
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
expect(disputeA.params.signature).not.toBe(disputeB.params.signature);
|
|
547
|
+
});
|
|
548
|
+
it('buildRFQ with ttlMs of 0 produces expires_at near now', () => {
|
|
549
|
+
const kp = nacl.sign.keyPair();
|
|
550
|
+
const before = Date.now();
|
|
551
|
+
const msg = buildRFQ({
|
|
552
|
+
buyer: makeIdentity(kp),
|
|
553
|
+
service: { category: 'inference' },
|
|
554
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
555
|
+
ttlMs: 0,
|
|
556
|
+
secretKey: kp.secretKey,
|
|
557
|
+
});
|
|
558
|
+
// ttlMs=0 means expires_at = Date.now() + 0, so it should be essentially now
|
|
559
|
+
const expiresAt = new Date(msg.params.expires_at).getTime();
|
|
560
|
+
expect(expiresAt).toBeGreaterThanOrEqual(before);
|
|
561
|
+
expect(expiresAt).toBeLessThanOrEqual(before + 1000);
|
|
562
|
+
});
|
|
563
|
+
it('buildRFQ with negative ttlMs produces expires_at in the past', () => {
|
|
564
|
+
const kp = nacl.sign.keyPair();
|
|
565
|
+
const before = Date.now();
|
|
566
|
+
const msg = buildRFQ({
|
|
567
|
+
buyer: makeIdentity(kp),
|
|
568
|
+
service: { category: 'inference' },
|
|
569
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
570
|
+
ttlMs: -60_000,
|
|
571
|
+
secretKey: kp.secretKey,
|
|
572
|
+
});
|
|
573
|
+
// Negative TTL: expires_at = Date.now() - 60s, so it is in the past
|
|
574
|
+
const expiresAt = new Date(msg.params.expires_at).getTime();
|
|
575
|
+
expect(expiresAt).toBeLessThan(before);
|
|
576
|
+
});
|
|
577
|
+
it('buildCounter with round = 0', () => {
|
|
578
|
+
const kp = nacl.sign.keyPair();
|
|
579
|
+
const msg = buildCounter({
|
|
580
|
+
rfqId: 'rfq-r0',
|
|
581
|
+
inResponseTo: 'quote-r0',
|
|
582
|
+
round: 0,
|
|
583
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
584
|
+
modifications: { price_per_unit: '0.007' },
|
|
585
|
+
secretKey: kp.secretKey,
|
|
586
|
+
});
|
|
587
|
+
// round=0 is accepted (no validation rejects it)
|
|
588
|
+
expect(msg.params.round).toBe(0);
|
|
589
|
+
expect(msg.params.counter_id).toBeTruthy();
|
|
590
|
+
const { signature, ...unsigned } = msg.params;
|
|
591
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
592
|
+
});
|
|
593
|
+
it('buildAccept without sellerSignature omits seller_signature', () => {
|
|
594
|
+
const kp = nacl.sign.keyPair();
|
|
595
|
+
const msg = buildAccept({
|
|
596
|
+
rfqId: 'rfq-noseller',
|
|
597
|
+
acceptingMessageId: 'quote-noseller',
|
|
598
|
+
finalTerms: baseFinalTerms,
|
|
599
|
+
buyerSecretKey: kp.secretKey,
|
|
600
|
+
});
|
|
601
|
+
expect(msg.params.buyer_signature).toBeTruthy();
|
|
602
|
+
expect(msg.params['seller_signature']).toBeUndefined();
|
|
603
|
+
});
|
|
604
|
+
it('buildAccept with sellerSignature includes seller_signature', () => {
|
|
605
|
+
const buyerKp = nacl.sign.keyPair();
|
|
606
|
+
const sellerKp = nacl.sign.keyPair();
|
|
607
|
+
const sellerSig = signMessage({ test: 'seller-ack' }, sellerKp.secretKey);
|
|
608
|
+
const msg = buildAccept({
|
|
609
|
+
rfqId: 'rfq-withseller',
|
|
610
|
+
acceptingMessageId: 'quote-withseller',
|
|
611
|
+
finalTerms: baseFinalTerms,
|
|
612
|
+
buyerSecretKey: buyerKp.secretKey,
|
|
613
|
+
sellerSignature: sellerSig,
|
|
614
|
+
});
|
|
615
|
+
expect(msg.params.buyer_signature).toBeTruthy();
|
|
616
|
+
expect(msg.params.seller_signature).toBe(sellerSig);
|
|
617
|
+
});
|
|
618
|
+
it('buildRFQ includes SLA in params', () => {
|
|
619
|
+
const kp = nacl.sign.keyPair();
|
|
620
|
+
const sla = {
|
|
621
|
+
metrics: [
|
|
622
|
+
{ name: 'uptime_pct', target: 99.9, comparison: 'gte' },
|
|
623
|
+
{ name: 'p99_latency_ms', target: 200, comparison: 'lte' },
|
|
624
|
+
],
|
|
625
|
+
};
|
|
626
|
+
const msg = buildRFQ({
|
|
627
|
+
buyer: makeIdentity(kp),
|
|
628
|
+
service: { category: 'inference' },
|
|
629
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
630
|
+
sla,
|
|
631
|
+
secretKey: kp.secretKey,
|
|
632
|
+
});
|
|
633
|
+
expect(msg.params.sla_requirements).toBeDefined();
|
|
634
|
+
expect(msg.params.sla_requirements?.metrics).toHaveLength(2);
|
|
635
|
+
expect(msg.params.sla_requirements?.metrics[0].name).toBe('uptime_pct');
|
|
636
|
+
expect(msg.params.sla_requirements?.metrics[1].name).toBe('p99_latency_ms');
|
|
637
|
+
});
|
|
638
|
+
it('buildReject with special characters in reason', () => {
|
|
639
|
+
const kp = nacl.sign.keyPair();
|
|
640
|
+
const specialReason = 'Price "too high"\nnot acceptable\t\u2603 \u00e9';
|
|
641
|
+
const msg = buildReject({
|
|
642
|
+
rfqId: 'rfq-special',
|
|
643
|
+
rejectingMessageId: 'quote-special',
|
|
644
|
+
reason: specialReason,
|
|
645
|
+
agentId: publicKeyToDid(kp.publicKey),
|
|
646
|
+
secretKey: kp.secretKey,
|
|
647
|
+
});
|
|
648
|
+
expect(msg.params.reason).toBe(specialReason);
|
|
649
|
+
expect(msg.method).toBe('negotiate/reject');
|
|
650
|
+
});
|
|
651
|
+
it('buildRFQ with empty string service category does not throw', () => {
|
|
652
|
+
const kp = nacl.sign.keyPair();
|
|
653
|
+
// buildRFQ does not validate service.category -- it only validates
|
|
654
|
+
// buyer.agent_id and buyer.endpoint. An empty category passes through.
|
|
655
|
+
const msg = buildRFQ({
|
|
656
|
+
buyer: makeIdentity(kp),
|
|
657
|
+
service: { category: '' },
|
|
658
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
659
|
+
secretKey: kp.secretKey,
|
|
660
|
+
});
|
|
661
|
+
expect(msg.params.service.category).toBe('');
|
|
662
|
+
expect(msg.method).toBe('negotiate/rfq');
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
describe('message builder input validation', () => {
|
|
666
|
+
const kp = nacl.sign.keyPair();
|
|
667
|
+
it('buildRFQ throws for empty buyer.agent_id', () => {
|
|
668
|
+
expect(() => buildRFQ({
|
|
669
|
+
buyer: { agent_id: '', endpoint: 'http://localhost:3001' },
|
|
670
|
+
service: { category: 'inference' },
|
|
671
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
672
|
+
secretKey: kp.secretKey,
|
|
673
|
+
})).toThrow('agent_id');
|
|
674
|
+
});
|
|
675
|
+
it('buildRFQ throws for empty buyer.endpoint', () => {
|
|
676
|
+
expect(() => buildRFQ({
|
|
677
|
+
buyer: { agent_id: 'did:key:z6MkTest', endpoint: '' },
|
|
678
|
+
service: { category: 'inference' },
|
|
679
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
680
|
+
secretKey: kp.secretKey,
|
|
681
|
+
})).toThrow('endpoint');
|
|
682
|
+
});
|
|
683
|
+
it('buildQuote throws for empty rfqId', () => {
|
|
684
|
+
expect(() => buildQuote({
|
|
685
|
+
rfqId: '',
|
|
686
|
+
seller: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3000' },
|
|
687
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
688
|
+
secretKey: kp.secretKey,
|
|
689
|
+
})).toThrow('rfqId');
|
|
690
|
+
});
|
|
691
|
+
it('buildQuote throws for empty seller.agent_id', () => {
|
|
692
|
+
expect(() => buildQuote({
|
|
693
|
+
rfqId: 'test-rfq',
|
|
694
|
+
seller: { agent_id: '', endpoint: 'http://localhost:3000' },
|
|
695
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
696
|
+
secretKey: kp.secretKey,
|
|
697
|
+
})).toThrow('agent_id');
|
|
698
|
+
});
|
|
699
|
+
it('buildCounter throws for empty rfqId', () => {
|
|
700
|
+
expect(() => buildCounter({
|
|
701
|
+
rfqId: '',
|
|
702
|
+
inResponseTo: 'test',
|
|
703
|
+
round: 1,
|
|
704
|
+
from: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
|
|
705
|
+
modifications: {},
|
|
706
|
+
secretKey: kp.secretKey,
|
|
707
|
+
})).toThrow('rfqId');
|
|
708
|
+
});
|
|
709
|
+
it('buildCounter throws for empty from.agent_id', () => {
|
|
710
|
+
expect(() => buildCounter({
|
|
711
|
+
rfqId: 'test',
|
|
712
|
+
inResponseTo: 'test',
|
|
713
|
+
round: 1,
|
|
714
|
+
from: { agent_id: '', role: 'buyer' },
|
|
715
|
+
modifications: {},
|
|
716
|
+
secretKey: kp.secretKey,
|
|
717
|
+
})).toThrow('agent_id');
|
|
718
|
+
});
|
|
719
|
+
it('buildAccept throws for empty rfqId', () => {
|
|
720
|
+
expect(() => buildAccept({
|
|
721
|
+
rfqId: '',
|
|
722
|
+
acceptingMessageId: 'test',
|
|
723
|
+
finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
724
|
+
buyerSecretKey: kp.secretKey,
|
|
725
|
+
})).toThrow('rfqId');
|
|
726
|
+
});
|
|
727
|
+
it('buildAccept throws for missing finalTerms fields', () => {
|
|
728
|
+
expect(() => buildAccept({
|
|
729
|
+
rfqId: 'test',
|
|
730
|
+
acceptingMessageId: 'test',
|
|
731
|
+
finalTerms: { price_per_unit: '', currency: 'USDC', unit: 'request' },
|
|
732
|
+
buyerSecretKey: kp.secretKey,
|
|
733
|
+
})).toThrow('price_per_unit');
|
|
734
|
+
});
|
|
735
|
+
it('buildReject throws for empty reason', () => {
|
|
736
|
+
expect(() => buildReject({
|
|
737
|
+
rfqId: 'test',
|
|
738
|
+
rejectingMessageId: 'test',
|
|
739
|
+
reason: '',
|
|
740
|
+
agentId: 'did:key:z6MkTest',
|
|
741
|
+
secretKey: kp.secretKey,
|
|
742
|
+
})).toThrow('reason');
|
|
743
|
+
});
|
|
744
|
+
it('buildDispute throws for empty agreementId', () => {
|
|
745
|
+
expect(() => buildDispute({
|
|
746
|
+
agreementId: '',
|
|
747
|
+
filedBy: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
|
|
748
|
+
violation: { sla_metric: 'uptime_pct', agreed_value: 99.9, observed_value: 95, measurement_window: '24h', evidence_hash: 'abc' },
|
|
749
|
+
requestedRemedy: 'refund',
|
|
750
|
+
escrowAction: 'release',
|
|
751
|
+
secretKey: kp.secretKey,
|
|
752
|
+
})).toThrow('agreementId');
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
describe('message builder crypto verification', () => {
|
|
756
|
+
const kp = nacl.sign.keyPair();
|
|
757
|
+
it('buildQuote signature is verifiable with seller public key', () => {
|
|
758
|
+
const quote = buildQuote({
|
|
759
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
760
|
+
seller: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3000' },
|
|
761
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
762
|
+
secretKey: kp.secretKey,
|
|
763
|
+
});
|
|
764
|
+
const { signature, ...unsigned } = quote.params;
|
|
765
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
766
|
+
});
|
|
767
|
+
it('buildQuote signature fails with wrong public key', () => {
|
|
768
|
+
const wrongKp = nacl.sign.keyPair();
|
|
769
|
+
const quote = buildQuote({
|
|
770
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
771
|
+
seller: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3000' },
|
|
772
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
773
|
+
secretKey: kp.secretKey,
|
|
774
|
+
});
|
|
775
|
+
const { signature, ...unsigned } = quote.params;
|
|
776
|
+
expect(verifyMessage(unsigned, signature, wrongKp.publicKey)).toBe(false);
|
|
777
|
+
});
|
|
778
|
+
it('buildCounter signature is verifiable', () => {
|
|
779
|
+
const counter = buildCounter({
|
|
780
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
781
|
+
inResponseTo: '12345678-1234-1234-1234-123456789def',
|
|
782
|
+
round: 1,
|
|
783
|
+
from: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
|
|
784
|
+
modifications: { price: '0.005' },
|
|
785
|
+
secretKey: kp.secretKey,
|
|
786
|
+
});
|
|
787
|
+
const { signature, ...unsigned } = counter.params;
|
|
788
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
789
|
+
});
|
|
790
|
+
it('buildAccept buyer_signature is verifiable', () => {
|
|
791
|
+
const accept = buildAccept({
|
|
792
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
793
|
+
acceptingMessageId: '12345678-1234-1234-1234-123456789def',
|
|
794
|
+
finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
795
|
+
buyerSecretKey: kp.secretKey,
|
|
796
|
+
});
|
|
797
|
+
const { buyer_signature, seller_signature: _ss, ...unsigned } = accept.params;
|
|
798
|
+
expect(verifyMessage(unsigned, buyer_signature, kp.publicKey)).toBe(true);
|
|
799
|
+
});
|
|
800
|
+
it('buildDispute signature is verifiable', () => {
|
|
801
|
+
const dispute = buildDispute({
|
|
802
|
+
agreementId: '12345678-1234-1234-1234-123456789abc',
|
|
803
|
+
filedBy: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
|
|
804
|
+
violation: { sla_metric: 'uptime_pct', agreed_value: 99.9, observed_value: 95, measurement_window: '24h', evidence_hash: 'abc123' },
|
|
805
|
+
requestedRemedy: 'escrow_release',
|
|
806
|
+
escrowAction: 'freeze',
|
|
807
|
+
secretKey: kp.secretKey,
|
|
808
|
+
});
|
|
809
|
+
const { signature, ...unsigned } = dispute.params;
|
|
810
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
811
|
+
});
|
|
812
|
+
it('buildAccept agreement_hash is SHA-256 hex', () => {
|
|
813
|
+
const accept = buildAccept({
|
|
814
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
815
|
+
acceptingMessageId: '12345678-1234-1234-1234-123456789def',
|
|
816
|
+
finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
817
|
+
buyerSecretKey: kp.secretKey,
|
|
818
|
+
});
|
|
819
|
+
expect(accept.params.agreement_hash).toMatch(/^[0-9a-f]{64}$/);
|
|
820
|
+
});
|
|
821
|
+
it('buildQuote with optional SLA includes it in signature', () => {
|
|
822
|
+
const withSla = buildQuote({
|
|
823
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
824
|
+
seller: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3000' },
|
|
825
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
826
|
+
sla: { metrics: [{ name: 'uptime_pct', target: 99.9, comparison: 'gte' }] },
|
|
827
|
+
secretKey: kp.secretKey,
|
|
828
|
+
});
|
|
829
|
+
const withoutSla = buildQuote({
|
|
830
|
+
rfqId: '12345678-1234-1234-1234-123456789abc',
|
|
831
|
+
seller: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3000' },
|
|
832
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
833
|
+
secretKey: kp.secretKey,
|
|
834
|
+
});
|
|
835
|
+
expect(withSla.params.signature).not.toBe(withoutSla.params.signature);
|
|
836
|
+
});
|
|
837
|
+
it('buildDispute with lockstep_report includes it in signature', () => {
|
|
838
|
+
const withReport = buildDispute({
|
|
839
|
+
agreementId: '12345678-1234-1234-1234-123456789abc',
|
|
840
|
+
filedBy: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
|
|
841
|
+
violation: { sla_metric: 'uptime_pct', agreed_value: 99.9, observed_value: 95, measurement_window: '24h', evidence_hash: 'abc' },
|
|
842
|
+
requestedRemedy: 'refund',
|
|
843
|
+
escrowAction: 'freeze',
|
|
844
|
+
lockstepReport: { verification_id: 'v1', result: 'FAIL', deviations: ['latency exceeded'] },
|
|
845
|
+
secretKey: kp.secretKey,
|
|
846
|
+
});
|
|
847
|
+
const withoutReport = buildDispute({
|
|
848
|
+
agreementId: '12345678-1234-1234-1234-123456789abc',
|
|
849
|
+
filedBy: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
|
|
850
|
+
violation: { sla_metric: 'uptime_pct', agreed_value: 99.9, observed_value: 95, measurement_window: '24h', evidence_hash: 'abc' },
|
|
851
|
+
requestedRemedy: 'refund',
|
|
852
|
+
escrowAction: 'freeze',
|
|
853
|
+
secretKey: kp.secretKey,
|
|
854
|
+
});
|
|
855
|
+
expect(withReport.params.signature).not.toBe(withoutReport.params.signature);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
describe('builder method names', () => {
|
|
859
|
+
it('buildRFQ sets method to negotiate/rfq', () => {
|
|
860
|
+
const kp = nacl.sign.keyPair();
|
|
861
|
+
const msg = buildRFQ({
|
|
862
|
+
buyer: makeIdentity(kp),
|
|
863
|
+
service: { category: 'inference' },
|
|
864
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
865
|
+
secretKey: kp.secretKey,
|
|
866
|
+
});
|
|
867
|
+
expect(msg.method).toBe('negotiate/rfq');
|
|
868
|
+
});
|
|
869
|
+
it('buildQuote sets method to negotiate/quote', () => {
|
|
870
|
+
const kp = nacl.sign.keyPair();
|
|
871
|
+
const msg = buildQuote({
|
|
872
|
+
rfqId: 'rfq-method-test',
|
|
873
|
+
seller: makeIdentity(kp),
|
|
874
|
+
pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
875
|
+
secretKey: kp.secretKey,
|
|
876
|
+
});
|
|
877
|
+
expect(msg.method).toBe('negotiate/quote');
|
|
878
|
+
});
|
|
879
|
+
it('buildCounter sets method to negotiate/counter', () => {
|
|
880
|
+
const kp = nacl.sign.keyPair();
|
|
881
|
+
const msg = buildCounter({
|
|
882
|
+
rfqId: 'rfq-method-test',
|
|
883
|
+
inResponseTo: 'quote-method-test',
|
|
884
|
+
round: 1,
|
|
885
|
+
from: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
886
|
+
modifications: { price_per_unit: '0.008' },
|
|
887
|
+
secretKey: kp.secretKey,
|
|
888
|
+
});
|
|
889
|
+
expect(msg.method).toBe('negotiate/counter');
|
|
890
|
+
});
|
|
891
|
+
it('buildAccept sets method to negotiate/accept', () => {
|
|
892
|
+
const kp = nacl.sign.keyPair();
|
|
893
|
+
const msg = buildAccept({
|
|
894
|
+
rfqId: 'rfq-method-test',
|
|
895
|
+
acceptingMessageId: 'quote-method-test',
|
|
896
|
+
finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
897
|
+
buyerSecretKey: kp.secretKey,
|
|
898
|
+
});
|
|
899
|
+
expect(msg.method).toBe('negotiate/accept');
|
|
900
|
+
});
|
|
901
|
+
it('buildReject sets method to negotiate/reject', () => {
|
|
902
|
+
const kp = nacl.sign.keyPair();
|
|
903
|
+
const msg = buildReject({
|
|
904
|
+
rfqId: 'rfq-method-test',
|
|
905
|
+
rejectingMessageId: 'quote-method-test',
|
|
906
|
+
reason: 'Too expensive',
|
|
907
|
+
agentId: publicKeyToDid(kp.publicKey),
|
|
908
|
+
secretKey: kp.secretKey,
|
|
909
|
+
});
|
|
910
|
+
expect(msg.method).toBe('negotiate/reject');
|
|
911
|
+
});
|
|
912
|
+
it('buildDispute sets method to negotiate/dispute', () => {
|
|
913
|
+
const kp = nacl.sign.keyPair();
|
|
914
|
+
const msg = buildDispute({
|
|
915
|
+
agreementId: 'agr-method-test',
|
|
916
|
+
filedBy: { agent_id: publicKeyToDid(kp.publicKey), role: 'buyer' },
|
|
917
|
+
violation: { sla_metric: 'uptime_pct', agreed_value: 99.9, observed_value: 95.0, measurement_window: '24h', evidence_hash: 'abc123' },
|
|
918
|
+
requestedRemedy: 'Full refund',
|
|
919
|
+
escrowAction: 'release_to_buyer',
|
|
920
|
+
secretKey: kp.secretKey,
|
|
921
|
+
});
|
|
922
|
+
expect(msg.method).toBe('negotiate/dispute');
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
describe('buildRFQ signing requirement', () => {
|
|
926
|
+
it('buildRFQ produces a signed RFQ verifiable with buyer public key', () => {
|
|
927
|
+
const kp = nacl.sign.keyPair();
|
|
928
|
+
const msg = buildRFQ({
|
|
929
|
+
buyer: makeIdentity(kp),
|
|
930
|
+
service: { category: 'inference' },
|
|
931
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
932
|
+
secretKey: kp.secretKey,
|
|
933
|
+
});
|
|
934
|
+
expect(msg.params.signature).toBeDefined();
|
|
935
|
+
const { signature, ...unsigned } = msg.params;
|
|
936
|
+
expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
|
|
937
|
+
});
|
|
938
|
+
it('buildRFQ signature changes when buyer identity changes', () => {
|
|
939
|
+
const kp1 = nacl.sign.keyPair();
|
|
940
|
+
const kp2 = nacl.sign.keyPair();
|
|
941
|
+
const msg1 = buildRFQ({
|
|
942
|
+
buyer: makeIdentity(kp1),
|
|
943
|
+
service: { category: 'inference' },
|
|
944
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
945
|
+
secretKey: kp1.secretKey,
|
|
946
|
+
});
|
|
947
|
+
const msg2 = buildRFQ({
|
|
948
|
+
buyer: makeIdentity(kp2),
|
|
949
|
+
service: { category: 'inference' },
|
|
950
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
951
|
+
secretKey: kp2.secretKey,
|
|
952
|
+
});
|
|
953
|
+
expect(msg1.params.signature).not.toBe(msg2.params.signature);
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
describe('secretKey requirement', () => {
|
|
957
|
+
it('buildRFQ throws when secretKey is undefined', () => {
|
|
958
|
+
const kp = nacl.sign.keyPair();
|
|
959
|
+
expect(() => buildRFQ({
|
|
960
|
+
buyer: makeIdentity(kp),
|
|
961
|
+
service: { category: 'inference' },
|
|
962
|
+
budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
963
|
+
secretKey: undefined,
|
|
964
|
+
})).toThrow();
|
|
965
|
+
});
|
|
966
|
+
it('buildReject throws when secretKey is undefined', () => {
|
|
967
|
+
const kp = nacl.sign.keyPair();
|
|
968
|
+
expect(() => buildReject({
|
|
969
|
+
rfqId: 'rfq-nokey',
|
|
970
|
+
rejectingMessageId: 'quote-nokey',
|
|
971
|
+
reason: 'Not interested',
|
|
972
|
+
agentId: publicKeyToDid(kp.publicKey),
|
|
973
|
+
secretKey: undefined,
|
|
974
|
+
})).toThrow();
|
|
975
|
+
});
|
|
976
|
+
});
|