@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,713 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import nacl from 'tweetnacl';
3
+ import bs58 from 'bs58';
4
+ import { canonicalize, sign, verify, agreementHash, signMessage, verifyMessage, } from '../signing.js';
5
+ import { generateKeyPair, publicKeyToDid, didToPublicKey, generateAgentIdentity, } from '../identity.js';
6
+ import { buildRFQ, buildReject } from '../messages.js';
7
+ describe('canonicalize', () => {
8
+ it('produces deterministic output regardless of key order', () => {
9
+ const a = { z: 1, a: 2, m: 3 };
10
+ const b = { a: 2, m: 3, z: 1 };
11
+ expect(canonicalize(a)).toBe(canonicalize(b));
12
+ });
13
+ it('handles nested objects deterministically', () => {
14
+ const a = { outer: { z: 1, a: 2 }, list: [3, 1, 2] };
15
+ const b = { list: [3, 1, 2], outer: { a: 2, z: 1 } };
16
+ expect(canonicalize(a)).toBe(canonicalize(b));
17
+ });
18
+ it('handles arrays preserving element order', () => {
19
+ const obj = { items: [3, 1, 2] };
20
+ const result = canonicalize(obj);
21
+ expect(result).toBe('{"items":[3,1,2]}');
22
+ });
23
+ it('handles null values', () => {
24
+ const obj = { a: null, b: 1 };
25
+ const result = canonicalize(obj);
26
+ expect(result).toContain('"a":null');
27
+ });
28
+ it('excludes undefined values', () => {
29
+ const obj = { a: 1, b: undefined, c: 3 };
30
+ const result = canonicalize(obj);
31
+ expect(result).not.toContain('"b"');
32
+ expect(result).toContain('"a":1');
33
+ expect(result).toContain('"c":3');
34
+ });
35
+ it('produces identical output for equivalent objects with different construction', () => {
36
+ const a = {};
37
+ a['x'] = 10;
38
+ a['y'] = 20;
39
+ const b = {};
40
+ b['y'] = 20;
41
+ b['x'] = 10;
42
+ expect(canonicalize(a)).toBe(canonicalize(b));
43
+ });
44
+ it('handles booleans and numbers correctly', () => {
45
+ const obj = { flag: true, count: 42, ratio: 3.14, off: false };
46
+ const result = canonicalize(obj);
47
+ expect(result).toContain('"flag":true');
48
+ expect(result).toContain('"count":42');
49
+ expect(result).toContain('"ratio":3.14');
50
+ expect(result).toContain('"off":false');
51
+ });
52
+ });
53
+ describe('sign and verify', () => {
54
+ const keypair = nacl.sign.keyPair();
55
+ it('roundtrip: sign then verify succeeds', () => {
56
+ const data = new TextEncoder().encode('hello ophir');
57
+ const signature = sign(data, keypair.secretKey);
58
+ expect(verify(data, signature, keypair.publicKey)).toBe(true);
59
+ });
60
+ it('rejects verification with wrong public key', () => {
61
+ const data = new TextEncoder().encode('hello ophir');
62
+ const signature = sign(data, keypair.secretKey);
63
+ const wrongKey = nacl.sign.keyPair().publicKey;
64
+ expect(verify(data, signature, wrongKey)).toBe(false);
65
+ });
66
+ it('rejects verification with tampered data (flip one byte)', () => {
67
+ const data = new TextEncoder().encode('hello ophir');
68
+ const signature = sign(data, keypair.secretKey);
69
+ const tampered = new Uint8Array(data);
70
+ tampered[0] ^= 0xff;
71
+ expect(verify(tampered, signature, keypair.publicKey)).toBe(false);
72
+ });
73
+ it('rejects truncated signature', () => {
74
+ const data = new TextEncoder().encode('hello ophir');
75
+ const signature = sign(data, keypair.secretKey);
76
+ const truncated = signature.slice(0, 10);
77
+ expect(verify(data, truncated, keypair.publicKey)).toBe(false);
78
+ });
79
+ it('rejects empty signature', () => {
80
+ const data = new TextEncoder().encode('hello ophir');
81
+ expect(verify(data, '', keypair.publicKey)).toBe(false);
82
+ });
83
+ it('throws on invalid secret key length', () => {
84
+ const data = new TextEncoder().encode('hello ophir');
85
+ const badKey = new Uint8Array(32); // should be 64
86
+ expect(() => sign(data, badKey)).toThrow('Invalid secret key length');
87
+ });
88
+ it('returns false for garbage signature string', () => {
89
+ const data = new TextEncoder().encode('hello ophir');
90
+ expect(verify(data, '!!!not-base64-at-all!!!', keypair.publicKey)).toBe(false);
91
+ });
92
+ it('returns false when publicKey is wrong length', () => {
93
+ const data = new TextEncoder().encode('hello ophir');
94
+ const signature = sign(data, keypair.secretKey);
95
+ const shortKey = new Uint8Array(16);
96
+ expect(verify(data, signature, shortKey)).toBe(false);
97
+ });
98
+ it('returns false for all-zero signature', () => {
99
+ const data = new TextEncoder().encode('hello ophir');
100
+ const zeroSig = Buffer.from(new Uint8Array(64)).toString('base64');
101
+ expect(verify(data, zeroSig, keypair.publicKey)).toBe(false);
102
+ });
103
+ it('verify with empty Uint8Array data still works correctly', () => {
104
+ const emptyData = new Uint8Array(0);
105
+ const signature = sign(emptyData, keypair.secretKey);
106
+ expect(verify(emptyData, signature, keypair.publicKey)).toBe(true);
107
+ });
108
+ it('signMessage with empty object produces valid signature', () => {
109
+ const sig = signMessage({}, keypair.secretKey);
110
+ expect(typeof sig).toBe('string');
111
+ expect(sig.length).toBeGreaterThan(0);
112
+ expect(verifyMessage({}, sig, keypair.publicKey)).toBe(true);
113
+ });
114
+ it('verifyMessage with deeply nested objects is deterministic', () => {
115
+ const nested1 = { a: { b: { c: { d: 1 } } }, x: [1, { y: 2 }] };
116
+ const nested2 = { x: [1, { y: 2 }], a: { b: { c: { d: 1 } } } };
117
+ const sig = signMessage(nested1, keypair.secretKey);
118
+ expect(verifyMessage(nested2, sig, keypair.publicKey)).toBe(true);
119
+ });
120
+ it('agreementHash with identical nested structures', () => {
121
+ const terms1 = { price_per_unit: '0.01', metadata: { region: 'us', tier: 'premium' }, currency: 'USDC', unit: 'request' };
122
+ const terms2 = { currency: 'USDC', unit: 'request', metadata: { tier: 'premium', region: 'us' }, price_per_unit: '0.01' };
123
+ expect(agreementHash(terms1)).toBe(agreementHash(terms2));
124
+ });
125
+ });
126
+ describe('agreementHash', () => {
127
+ it('is deterministic for same final terms', () => {
128
+ const terms = {
129
+ price_per_unit: '0.01',
130
+ currency: 'USDC',
131
+ unit: 'request',
132
+ };
133
+ const hash1 = agreementHash(terms);
134
+ const hash2 = agreementHash(terms);
135
+ expect(hash1).toBe(hash2);
136
+ });
137
+ it('returns a 64-char hex string (SHA-256)', () => {
138
+ const terms = {
139
+ price_per_unit: '0.05',
140
+ currency: 'USDC',
141
+ unit: 'token',
142
+ };
143
+ const hash = agreementHash(terms);
144
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
145
+ });
146
+ it('changes when terms differ', () => {
147
+ const terms1 = { price_per_unit: '0.01', currency: 'USDC', unit: 'req' };
148
+ const terms2 = { price_per_unit: '0.02', currency: 'USDC', unit: 'req' };
149
+ expect(agreementHash(terms1)).not.toBe(agreementHash(terms2));
150
+ });
151
+ });
152
+ describe('signMessage and verifyMessage', () => {
153
+ const keypair = nacl.sign.keyPair();
154
+ it('roundtrip with arbitrary params object', () => {
155
+ const params = { rfq_id: 'rfq-001', price: '10.00', currency: 'USDC' };
156
+ const sig = signMessage(params, keypair.secretKey);
157
+ expect(verifyMessage(params, sig, keypair.publicKey)).toBe(true);
158
+ });
159
+ it('rejects when params differ', () => {
160
+ const params = { rfq_id: 'rfq-001', price: '10.00' };
161
+ const sig = signMessage(params, keypair.secretKey);
162
+ const altered = { rfq_id: 'rfq-001', price: '20.00' };
163
+ expect(verifyMessage(altered, sig, keypair.publicKey)).toBe(false);
164
+ });
165
+ it('signature is key-order independent', () => {
166
+ const params1 = { a: 1, b: 2 };
167
+ const params2 = { b: 2, a: 1 };
168
+ const sig = signMessage(params1, keypair.secretKey);
169
+ expect(verifyMessage(params2, sig, keypair.publicKey)).toBe(true);
170
+ });
171
+ });
172
+ describe('DID:key identity', () => {
173
+ it('generates a valid keypair with correct lengths', () => {
174
+ const kp = generateKeyPair();
175
+ expect(kp.publicKey).toHaveLength(32);
176
+ expect(kp.secretKey).toHaveLength(64);
177
+ });
178
+ it('two generateKeyPair calls produce different keys', () => {
179
+ const kp1 = generateKeyPair();
180
+ const kp2 = generateKeyPair();
181
+ expect(Buffer.from(kp1.publicKey)).not.toEqual(Buffer.from(kp2.publicKey));
182
+ expect(Buffer.from(kp1.secretKey)).not.toEqual(Buffer.from(kp2.secretKey));
183
+ });
184
+ it('roundtrips publicKey through DID conversion', () => {
185
+ const kp = generateKeyPair();
186
+ const did = publicKeyToDid(kp.publicKey);
187
+ expect(did).toMatch(/^did:key:z6Mk/);
188
+ const recovered = didToPublicKey(did);
189
+ expect(Buffer.from(recovered)).toEqual(Buffer.from(kp.publicKey));
190
+ });
191
+ it('DID starts with did:key:z6Mk', () => {
192
+ const kp = generateKeyPair();
193
+ const did = publicKeyToDid(kp.publicKey);
194
+ expect(did.startsWith('did:key:z6Mk')).toBe(true);
195
+ });
196
+ it('throws on invalid DID prefix', () => {
197
+ expect(() => didToPublicKey('did:web:example.com')).toThrow('Invalid did:key format');
198
+ });
199
+ it('throws on wrong multicodec prefix', () => {
200
+ // Construct a did:key with wrong multicodec bytes (0x00, 0x00 instead of 0xed, 0x01)
201
+ const fakeKey = new Uint8Array(34);
202
+ fakeKey[0] = 0x00;
203
+ fakeKey[1] = 0x00;
204
+ // Import bs58 to encode
205
+ const fakeDid = `did:key:z${bs58.encode(fakeKey)}`;
206
+ expect(() => didToPublicKey(fakeDid)).toThrow('Invalid multicodec prefix');
207
+ });
208
+ it('generateAgentIdentity returns complete bundle', () => {
209
+ const identity = generateAgentIdentity('https://agent.example.com');
210
+ expect(identity.agentId).toMatch(/^did:key:z6Mk/);
211
+ expect(identity.endpoint).toBe('https://agent.example.com');
212
+ expect(identity.keypair.publicKey).toHaveLength(32);
213
+ expect(identity.keypair.secretKey).toHaveLength(64);
214
+ const did = publicKeyToDid(identity.keypair.publicKey);
215
+ expect(identity.agentId).toBe(did);
216
+ });
217
+ it('throws on publicKey shorter than 32 bytes', () => {
218
+ const shortKey = new Uint8Array(16);
219
+ expect(() => publicKeyToDid(shortKey)).toThrow('Invalid public key length: expected 32, got 16');
220
+ });
221
+ it('throws on publicKey longer than 32 bytes', () => {
222
+ const longKey = new Uint8Array(48);
223
+ expect(() => publicKeyToDid(longKey)).toThrow('Invalid public key length: expected 32, got 48');
224
+ });
225
+ it('throws on empty publicKey', () => {
226
+ const emptyKey = new Uint8Array(0);
227
+ expect(() => publicKeyToDid(emptyKey)).toThrow('Invalid public key length: expected 32, got 0');
228
+ });
229
+ it('throws on DID with truncated key (decoded < 34 bytes)', () => {
230
+ // A valid DID encodes 2-byte prefix + 32-byte key = 34 bytes.
231
+ // Here we encode only 2-byte prefix + 10-byte key = 12 bytes.
232
+ const truncated = new Uint8Array(12);
233
+ truncated[0] = 0xed;
234
+ truncated[1] = 0x01;
235
+ const fakeDid = `did:key:z${bs58.encode(truncated)}`;
236
+ expect(() => didToPublicKey(fakeDid)).toThrow('Invalid public key length after DID decoding: expected 32, got 10');
237
+ });
238
+ it('throws on DID with extra bytes (decoded > 34 bytes)', () => {
239
+ const oversized = new Uint8Array(40); // 2 prefix + 38 key bytes
240
+ oversized[0] = 0xed;
241
+ oversized[1] = 0x01;
242
+ const fakeDid = `did:key:z${bs58.encode(oversized)}`;
243
+ expect(() => didToPublicKey(fakeDid)).toThrow('Invalid public key length after DID decoding: expected 32, got 38');
244
+ });
245
+ });
246
+ describe('cross-verification', () => {
247
+ it('sign with one keypair, verify fails with another', () => {
248
+ const kp1 = nacl.sign.keyPair();
249
+ const kp2 = nacl.sign.keyPair();
250
+ const data = new TextEncoder().encode('cross-verify test');
251
+ const signature = sign(data, kp1.secretKey);
252
+ expect(verify(data, signature, kp1.publicKey)).toBe(true);
253
+ expect(verify(data, signature, kp2.publicKey)).toBe(false);
254
+ });
255
+ it('signMessage with one keypair, verifyMessage fails with another', () => {
256
+ const kp1 = nacl.sign.keyPair();
257
+ const kp2 = nacl.sign.keyPair();
258
+ const params = { action: 'transfer', amount: '100' };
259
+ const sig = signMessage(params, kp1.secretKey);
260
+ expect(verifyMessage(params, sig, kp1.publicKey)).toBe(true);
261
+ expect(verifyMessage(params, sig, kp2.publicKey)).toBe(false);
262
+ });
263
+ });
264
+ describe('input validation edge cases', () => {
265
+ it('canonicalize throws on undefined input', () => {
266
+ expect(() => canonicalize(undefined)).toThrow('Cannot canonicalize undefined');
267
+ });
268
+ it('canonicalize throws on function input', () => {
269
+ expect(() => canonicalize(() => { })).toThrow('Cannot canonicalize function');
270
+ });
271
+ it('canonicalize throws on symbol input', () => {
272
+ expect(() => canonicalize(Symbol('test'))).toThrow('Cannot canonicalize symbol');
273
+ });
274
+ it('canonicalize handles empty object', () => {
275
+ const result = canonicalize({});
276
+ expect(result).toBe('{}');
277
+ });
278
+ it('canonicalize handles empty array', () => {
279
+ const result = canonicalize([]);
280
+ expect(result).toBe('[]');
281
+ });
282
+ it('sign with valid 64-byte key and empty data works', () => {
283
+ const kp = nacl.sign.keyPair();
284
+ const emptyData = new Uint8Array(0);
285
+ const signature = sign(emptyData, kp.secretKey);
286
+ expect(typeof signature).toBe('string');
287
+ expect(signature.length).toBeGreaterThan(0);
288
+ expect(verify(emptyData, signature, kp.publicKey)).toBe(true);
289
+ });
290
+ it('verify returns false for very long signature string', () => {
291
+ const kp = nacl.sign.keyPair();
292
+ const data = new TextEncoder().encode('test');
293
+ const longSig = Buffer.from(new Uint8Array(256)).toString('base64');
294
+ expect(verify(data, longSig, kp.publicKey)).toBe(false);
295
+ });
296
+ });
297
+ describe('edge cases and error handling', () => {
298
+ const keypair = nacl.sign.keyPair();
299
+ // 1. sign() throws for non-Uint8Array input
300
+ it('sign() throws for non-Uint8Array input', () => {
301
+ expect(() => sign('not a uint8array', keypair.secretKey)).toThrow('canonicalBytes must be a Uint8Array');
302
+ });
303
+ // 2. sign() throws for wrong-length secret key
304
+ it('sign() throws for wrong-length secret key', () => {
305
+ const data = new TextEncoder().encode('test');
306
+ const shortKey = new Uint8Array(32);
307
+ expect(() => sign(data, shortKey)).toThrow('Invalid secret key length');
308
+ });
309
+ // 3. verify() returns false for non-Uint8Array canonicalBytes
310
+ it('verify() returns false for non-Uint8Array canonicalBytes', () => {
311
+ const sig = sign(new TextEncoder().encode('test'), keypair.secretKey);
312
+ expect(verify('not a uint8array', sig, keypair.publicKey)).toBe(false);
313
+ });
314
+ // 4. verify() returns false for wrong-length public key
315
+ it('verify() returns false for wrong-length public key', () => {
316
+ const data = new TextEncoder().encode('test');
317
+ const sig = sign(data, keypair.secretKey);
318
+ const badKey = new Uint8Array(10);
319
+ expect(verify(data, sig, badKey)).toBe(false);
320
+ });
321
+ // 5. verify() returns false for wrong-length signature
322
+ it('verify() returns false for wrong-length signature', () => {
323
+ const data = new TextEncoder().encode('test');
324
+ // 32 bytes encodes to base64, but Ed25519 signatures are 64 bytes
325
+ const shortSig = Buffer.from(new Uint8Array(32)).toString('base64');
326
+ expect(verify(data, shortSig, keypair.publicKey)).toBe(false);
327
+ });
328
+ // 6. signMessage() throws for null params
329
+ it('signMessage() throws for null params', () => {
330
+ expect(() => signMessage(null, keypair.secretKey)).toThrow('params must not be null or undefined');
331
+ });
332
+ // 7. signMessage() throws for undefined params
333
+ it('signMessage() throws for undefined params', () => {
334
+ expect(() => signMessage(undefined, keypair.secretKey)).toThrow('params must not be null or undefined');
335
+ });
336
+ // 8. verifyMessage() returns false for empty signature
337
+ it('verifyMessage() returns false for empty signature', () => {
338
+ const params = { action: 'test' };
339
+ expect(verifyMessage(params, '', keypair.publicKey)).toBe(false);
340
+ });
341
+ // 9. verifyMessage() returns false for non-string signature
342
+ it('verifyMessage() returns false for non-string signature', () => {
343
+ const params = { action: 'test' };
344
+ expect(verifyMessage(params, 12345, keypair.publicKey)).toBe(false);
345
+ expect(verifyMessage(params, null, keypair.publicKey)).toBe(false);
346
+ expect(verifyMessage(params, undefined, keypair.publicKey)).toBe(false);
347
+ });
348
+ // 10. agreementHash() throws for null finalTerms
349
+ it('agreementHash() throws for null finalTerms', () => {
350
+ expect(() => agreementHash(null)).toThrow('finalTerms must be a non-null object');
351
+ });
352
+ // 11. agreementHash() throws for missing required fields
353
+ it('agreementHash() throws for missing required fields', () => {
354
+ expect(() => agreementHash({ price_per_unit: '0.01' })).toThrow('finalTerms must contain price_per_unit, currency, and unit');
355
+ expect(() => agreementHash({ currency: 'USDC' })).toThrow('finalTerms must contain price_per_unit, currency, and unit');
356
+ expect(() => agreementHash({})).toThrow('finalTerms must contain price_per_unit, currency, and unit');
357
+ });
358
+ // 12. agreementHash() produces consistent output for same input
359
+ it('agreementHash() produces consistent output for same input', () => {
360
+ const terms = { price_per_unit: '0.10', currency: 'USDC', unit: 'request' };
361
+ const hash1 = agreementHash(terms);
362
+ const hash2 = agreementHash(terms);
363
+ const hash3 = agreementHash({ ...terms });
364
+ expect(hash1).toBe(hash2);
365
+ expect(hash1).toBe(hash3);
366
+ expect(hash1).toMatch(/^[0-9a-f]{64}$/);
367
+ });
368
+ // 13. agreementHash() produces different output for different inputs
369
+ it('agreementHash() produces different output for different inputs', () => {
370
+ const termsA = { price_per_unit: '0.01', currency: 'USDC', unit: 'request' };
371
+ const termsB = { price_per_unit: '0.01', currency: 'USDC', unit: 'token' };
372
+ const termsC = { price_per_unit: '0.02', currency: 'USDC', unit: 'request' };
373
+ const hashA = agreementHash(termsA);
374
+ const hashB = agreementHash(termsB);
375
+ const hashC = agreementHash(termsC);
376
+ expect(hashA).not.toBe(hashB);
377
+ expect(hashA).not.toBe(hashC);
378
+ expect(hashB).not.toBe(hashC);
379
+ });
380
+ });
381
+ describe('crypto edge cases', () => {
382
+ const keypairA = nacl.sign.keyPair();
383
+ const keypairB = nacl.sign.keyPair();
384
+ it('agreementHash includes SLA in commitment', () => {
385
+ const base = { price_per_unit: '0.05', currency: 'USDC', unit: 'request' };
386
+ const withSla = {
387
+ ...base,
388
+ sla: { metric: 'latency_p99', threshold: '200ms', penalty: '0.01' },
389
+ };
390
+ const withoutSla = { ...base };
391
+ expect(agreementHash(withSla)).not.toBe(agreementHash(withoutSla));
392
+ });
393
+ it('agreementHash includes escrow in commitment', () => {
394
+ const base = { price_per_unit: '0.05', currency: 'USDC', unit: 'request' };
395
+ const withEscrow = {
396
+ ...base,
397
+ escrow: { network: 'solana', deposit_amount: '100.00', release_condition: 'sla_met' },
398
+ };
399
+ const withoutEscrow = { ...base };
400
+ expect(agreementHash(withEscrow)).not.toBe(agreementHash(withoutEscrow));
401
+ });
402
+ it('signMessage with array top-level', () => {
403
+ const arr = [1, 2, 3];
404
+ const sig = signMessage(arr, keypairA.secretKey);
405
+ expect(typeof sig).toBe('string');
406
+ expect(sig.length).toBeGreaterThan(0);
407
+ expect(verifyMessage(arr, sig, keypairA.publicKey)).toBe(true);
408
+ });
409
+ it('signMessage with deeply nested object', () => {
410
+ const deep = {
411
+ level1: {
412
+ level2: {
413
+ level3: {
414
+ level4: { value: 'deep', nums: [1, 2, { nested: true }] },
415
+ },
416
+ },
417
+ },
418
+ sibling: [{ a: 1 }, { b: 2 }],
419
+ };
420
+ const sig = signMessage(deep, keypairA.secretKey);
421
+ // Reconstruct in different key order
422
+ const deep2 = {
423
+ sibling: [{ a: 1 }, { b: 2 }],
424
+ level1: {
425
+ level2: {
426
+ level3: {
427
+ level4: { nums: [1, 2, { nested: true }], value: 'deep' },
428
+ },
429
+ },
430
+ },
431
+ };
432
+ expect(verifyMessage(deep2, sig, keypairA.publicKey)).toBe(true);
433
+ });
434
+ it('canonicalize handles Unicode/multibyte strings', () => {
435
+ const obj1 = { greeting: '\u{1F600}', name: '\u00E9\u00E8\u00EA', kanji: '\u6F22\u5B57' };
436
+ const obj2 = { kanji: '\u6F22\u5B57', greeting: '\u{1F600}', name: '\u00E9\u00E8\u00EA' };
437
+ expect(canonicalize(obj1)).toBe(canonicalize(obj2));
438
+ // Verify it produces consistent output across multiple calls
439
+ expect(canonicalize(obj1)).toBe(canonicalize(obj1));
440
+ });
441
+ it('canonicalize with special JSON values', () => {
442
+ const obj = { zero: 0, negZero: -0, empty: '', long: 'x'.repeat(10000) };
443
+ const result = canonicalize(obj);
444
+ expect(result).toContain('"zero":0');
445
+ expect(result).toContain('"empty":""');
446
+ expect(result).toContain('"long":"' + 'x'.repeat(10000) + '"');
447
+ // Determinism check
448
+ expect(canonicalize(obj)).toBe(result);
449
+ });
450
+ it('sign output is base64 encoded and 88 chars', () => {
451
+ const data = new TextEncoder().encode('test data for length check');
452
+ const sig = sign(data, keypairA.secretKey);
453
+ // Ed25519 signature is 64 bytes, base64 of 64 bytes = 88 chars (with padding)
454
+ expect(sig).toHaveLength(88);
455
+ expect(sig).toMatch(/^[A-Za-z0-9+/]+=*$/);
456
+ });
457
+ it('verify rejects signature with wrong length (63 bytes, 65 bytes)', () => {
458
+ const data = new TextEncoder().encode('length test');
459
+ const short63 = Buffer.from(new Uint8Array(63)).toString('base64');
460
+ const long65 = Buffer.from(new Uint8Array(65)).toString('base64');
461
+ expect(verify(data, short63, keypairA.publicKey)).toBe(false);
462
+ expect(verify(data, long65, keypairA.publicKey)).toBe(false);
463
+ });
464
+ it('signMessage with empty object', () => {
465
+ const sig = signMessage({}, keypairA.secretKey);
466
+ expect(typeof sig).toBe('string');
467
+ expect(sig.length).toBeGreaterThan(0);
468
+ expect(verifyMessage({}, sig, keypairA.publicKey)).toBe(true);
469
+ });
470
+ it('verifyMessage with tampered single character', () => {
471
+ const params = { action: 'transfer', amount: '500' };
472
+ const sig = signMessage(params, keypairA.secretKey);
473
+ expect(verifyMessage(params, sig, keypairA.publicKey)).toBe(true);
474
+ // Tamper with one character in the middle of the signature
475
+ const chars = sig.split('');
476
+ const midIdx = Math.floor(chars.length / 2);
477
+ chars[midIdx] = chars[midIdx] === 'A' ? 'B' : 'A';
478
+ const tampered = chars.join('');
479
+ expect(verifyMessage(params, tampered, keypairA.publicKey)).toBe(false);
480
+ });
481
+ it('agreementHash is lowercase hex', () => {
482
+ const terms = { price_per_unit: '1.00', currency: 'USDC', unit: 'call' };
483
+ const hash = agreementHash(terms);
484
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
485
+ // Ensure no uppercase hex chars
486
+ expect(hash).toBe(hash.toLowerCase());
487
+ });
488
+ it('cross-keypair verification fails', () => {
489
+ const params = { contract: 'abc-123', value: '42' };
490
+ const sig = signMessage(params, keypairA.secretKey);
491
+ // Correct keypair succeeds
492
+ expect(verifyMessage(params, sig, keypairA.publicKey)).toBe(true);
493
+ // Wrong keypair fails
494
+ expect(verifyMessage(params, sig, keypairB.publicKey)).toBe(false);
495
+ });
496
+ });
497
+ describe('known test vectors', () => {
498
+ // Use a fixed seed to get deterministic keys
499
+ const seed = new Uint8Array(32).fill(42);
500
+ const keypair = nacl.sign.keyPair.fromSeed(seed);
501
+ it('produces deterministic signature for known input', () => {
502
+ const msg = { hello: 'world' };
503
+ const sig1 = signMessage(msg, keypair.secretKey);
504
+ const sig2 = signMessage(msg, keypair.secretKey);
505
+ expect(sig1).toBe(sig2);
506
+ expect(sig1).toHaveLength(88); // base64 of 64 bytes
507
+ });
508
+ it('known canonical form is correct', () => {
509
+ const canonical = canonicalize({ z: 1, a: 2, m: { x: true } });
510
+ expect(canonical).toBe('{"a":2,"m":{"x":true},"z":1}');
511
+ });
512
+ it('agreementHash is deterministic for same terms', () => {
513
+ const terms = { price_per_unit: '0.01', currency: 'USDC', unit: 'request' };
514
+ const hash1 = agreementHash(terms);
515
+ const hash2 = agreementHash(terms);
516
+ expect(hash1).toBe(hash2);
517
+ expect(hash1).toHaveLength(64);
518
+ expect(hash1).toMatch(/^[0-9a-f]{64}$/);
519
+ });
520
+ });
521
+ describe('cross-keypair isolation', () => {
522
+ it('signature from keypair A fails verification with keypair B', () => {
523
+ const kpA = nacl.sign.keyPair();
524
+ const kpB = nacl.sign.keyPair();
525
+ const msg = { test: 'data' };
526
+ const sig = signMessage(msg, kpA.secretKey);
527
+ expect(verifyMessage(msg, sig, kpA.publicKey)).toBe(true);
528
+ expect(verifyMessage(msg, sig, kpB.publicKey)).toBe(false);
529
+ });
530
+ it('10 random keypairs all produce unique signatures for same message', () => {
531
+ const msg = { same: 'message' };
532
+ const sigs = new Set();
533
+ for (let i = 0; i < 10; i++) {
534
+ const kp = nacl.sign.keyPair();
535
+ sigs.add(signMessage(msg, kp.secretKey));
536
+ }
537
+ expect(sigs.size).toBe(10);
538
+ });
539
+ });
540
+ describe('canonicalize advanced edge cases', () => {
541
+ it('handles deeply nested objects', () => {
542
+ const deep = { a: { b: { c: { d: { e: 'value' } } } } };
543
+ const result = canonicalize(deep);
544
+ expect(result).toBe('{"a":{"b":{"c":{"d":{"e":"value"}}}}}');
545
+ });
546
+ it('handles arrays with mixed types', () => {
547
+ const mixed = [1, 'two', true, null, { three: 3 }];
548
+ const result = canonicalize(mixed);
549
+ expect(result).toBe('[1,"two",true,null,{"three":3}]');
550
+ });
551
+ it('handles empty object', () => {
552
+ expect(canonicalize({})).toBe('{}');
553
+ });
554
+ it('handles empty array', () => {
555
+ expect(canonicalize([])).toBe('[]');
556
+ });
557
+ it('handles empty string', () => {
558
+ expect(canonicalize('')).toBe('""');
559
+ });
560
+ it('handles zero', () => {
561
+ expect(canonicalize(0)).toBe('0');
562
+ });
563
+ it('handles boolean false', () => {
564
+ expect(canonicalize(false)).toBe('false');
565
+ });
566
+ it('handles null', () => {
567
+ expect(canonicalize(null)).toBe('null');
568
+ });
569
+ it('strips undefined values from objects', () => {
570
+ const obj = { a: 1, b: undefined, c: 3 };
571
+ const result = canonicalize(obj);
572
+ expect(result).toBe('{"a":1,"c":3}');
573
+ });
574
+ });
575
+ describe('signature tamper detection', () => {
576
+ it('detects single-bit change in signature', () => {
577
+ const kp = nacl.sign.keyPair();
578
+ const msg = { amount: '100.00', currency: 'USDC' };
579
+ const sig = signMessage(msg, kp.secretKey);
580
+ // Flip one bit in the signature
581
+ const sigBytes = Buffer.from(sig, 'base64');
582
+ sigBytes[0] ^= 1;
583
+ const tamperedSig = sigBytes.toString('base64');
584
+ expect(verifyMessage(msg, sig, kp.publicKey)).toBe(true);
585
+ expect(verifyMessage(msg, tamperedSig, kp.publicKey)).toBe(false);
586
+ });
587
+ it('detects single-character change in signed data', () => {
588
+ const kp = nacl.sign.keyPair();
589
+ const msg = { price: '0.01' };
590
+ const sig = signMessage(msg, kp.secretKey);
591
+ expect(verifyMessage(msg, sig, kp.publicKey)).toBe(true);
592
+ expect(verifyMessage({ price: '0.02' }, sig, kp.publicKey)).toBe(false);
593
+ expect(verifyMessage({ price: '0.011' }, sig, kp.publicKey)).toBe(false);
594
+ });
595
+ it('detects added field in signed data', () => {
596
+ const kp = nacl.sign.keyPair();
597
+ const msg = { a: 1 };
598
+ const sig = signMessage(msg, kp.secretKey);
599
+ expect(verifyMessage(msg, sig, kp.publicKey)).toBe(true);
600
+ expect(verifyMessage({ a: 1, b: 2 }, sig, kp.publicKey)).toBe(false);
601
+ });
602
+ it('detects removed field from signed data', () => {
603
+ const kp = nacl.sign.keyPair();
604
+ const msg = { a: 1, b: 2 };
605
+ const sig = signMessage(msg, kp.secretKey);
606
+ expect(verifyMessage(msg, sig, kp.publicKey)).toBe(true);
607
+ expect(verifyMessage({ a: 1 }, sig, kp.publicKey)).toBe(false);
608
+ });
609
+ });
610
+ describe('agreementHash security properties', () => {
611
+ it('different prices produce different hashes', () => {
612
+ const h1 = agreementHash({ price_per_unit: '0.01', currency: 'USDC', unit: 'request' });
613
+ const h2 = agreementHash({ price_per_unit: '0.02', currency: 'USDC', unit: 'request' });
614
+ expect(h1).not.toBe(h2);
615
+ });
616
+ it('different currencies produce different hashes', () => {
617
+ const h1 = agreementHash({ price_per_unit: '0.01', currency: 'USDC', unit: 'request' });
618
+ const h2 = agreementHash({ price_per_unit: '0.01', currency: 'SOL', unit: 'request' });
619
+ expect(h1).not.toBe(h2);
620
+ });
621
+ it('different units produce different hashes', () => {
622
+ const h1 = agreementHash({ price_per_unit: '0.01', currency: 'USDC', unit: 'request' });
623
+ const h2 = agreementHash({ price_per_unit: '0.01', currency: 'USDC', unit: 'token' });
624
+ expect(h1).not.toBe(h2);
625
+ });
626
+ it('with and without optional SLA produce different hashes', () => {
627
+ const base = { price_per_unit: '0.01', currency: 'USDC', unit: 'request' };
628
+ const withSla = { ...base, sla: { metrics: [{ name: 'uptime_pct', target: 99.9, comparison: 'gte' }] } };
629
+ const h1 = agreementHash(base);
630
+ const h2 = agreementHash(withSla);
631
+ expect(h1).not.toBe(h2);
632
+ });
633
+ it('hash is exactly 64 hex characters', () => {
634
+ const hash = agreementHash({ price_per_unit: '1', currency: 'USD', unit: 'call' });
635
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
636
+ });
637
+ });
638
+ describe('buildRFQ signing integration', () => {
639
+ it('buildRFQ produces a signed RFQ verifiable with buyer key', () => {
640
+ const kp = generateKeyPair();
641
+ const buyerDid = publicKeyToDid(kp.publicKey);
642
+ const rfq = buildRFQ({
643
+ buyer: { agent_id: buyerDid, endpoint: 'https://buyer.example.com' },
644
+ service: { category: 'inference' },
645
+ budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
646
+ secretKey: kp.secretKey,
647
+ });
648
+ const { signature, ...unsigned } = rfq.params;
649
+ expect(typeof signature).toBe('string');
650
+ expect(signature.length).toBeGreaterThan(0);
651
+ expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
652
+ });
653
+ it('buildReject produces a signed Reject verifiable with signer key', () => {
654
+ const kp = generateKeyPair();
655
+ const agentDid = publicKeyToDid(kp.publicKey);
656
+ const reject = buildReject({
657
+ rfqId: '00000000-0000-0000-0000-000000000001',
658
+ rejectingMessageId: '00000000-0000-0000-0000-000000000002',
659
+ reason: 'Price too high',
660
+ agentId: agentDid,
661
+ secretKey: kp.secretKey,
662
+ });
663
+ const { signature, ...unsigned } = reject.params;
664
+ expect(typeof signature).toBe('string');
665
+ expect(signature.length).toBeGreaterThan(0);
666
+ expect(verifyMessage(unsigned, signature, kp.publicKey)).toBe(true);
667
+ });
668
+ it('forged RFQ: signed with key A, verified with key B fails', () => {
669
+ const kpA = generateKeyPair();
670
+ const kpB = generateKeyPair();
671
+ const buyerDid = publicKeyToDid(kpA.publicKey);
672
+ const rfq = buildRFQ({
673
+ buyer: { agent_id: buyerDid, endpoint: 'https://buyer.example.com' },
674
+ service: { category: 'inference' },
675
+ budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
676
+ secretKey: kpA.secretKey,
677
+ });
678
+ const { signature, ...unsigned } = rfq.params;
679
+ expect(verifyMessage(unsigned, signature, kpB.publicKey)).toBe(false);
680
+ });
681
+ it('RFQ with tampered buyer.agent_id after signing has invalid signature', () => {
682
+ const kp = generateKeyPair();
683
+ const buyerDid = publicKeyToDid(kp.publicKey);
684
+ const rfq = buildRFQ({
685
+ buyer: { agent_id: buyerDid, endpoint: 'https://buyer.example.com' },
686
+ service: { category: 'inference' },
687
+ budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
688
+ secretKey: kp.secretKey,
689
+ });
690
+ const { signature, ...unsigned } = rfq.params;
691
+ // Tamper with buyer.agent_id
692
+ const tampered = { ...unsigned, buyer: { ...unsigned.buyer, agent_id: 'did:key:z6MkFAKE' } };
693
+ expect(verifyMessage(tampered, signature, kp.publicKey)).toBe(false);
694
+ });
695
+ });
696
+ describe('canonicalize special characters', () => {
697
+ it('canonicalize with newlines and tabs in strings', () => {
698
+ const obj = { a: 'line1\nline2', b: 'tab\there' };
699
+ const result1 = canonicalize(obj);
700
+ const result2 = canonicalize(obj);
701
+ expect(result1).toBe(result2);
702
+ expect(typeof result1).toBe('string');
703
+ expect(result1.length).toBeGreaterThan(0);
704
+ });
705
+ it('canonicalize with emoji in strings', () => {
706
+ const obj = { smile: '\u{1F604}', rocket: '\u{1F680}', key: 'value' };
707
+ const result1 = canonicalize(obj);
708
+ const result2 = canonicalize({ key: 'value', rocket: '\u{1F680}', smile: '\u{1F604}' });
709
+ expect(result1).toBe(result2);
710
+ expect(typeof result1).toBe('string');
711
+ expect(result1.length).toBeGreaterThan(0);
712
+ });
713
+ });