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