@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
package/dist/buyer.js ADDED
@@ -0,0 +1,555 @@
1
+ import { QuoteParamsSchema, CounterParamsSchema, AcceptParamsSchema, METHODS, DEFAULT_CONFIG, OphirError, OphirErrorCode, } from '@ophirai/protocol';
2
+ import { generateKeyPair, publicKeyToDid, didToPublicKey } from './identity.js';
3
+ import { verifyMessage, agreementHash } from './signing.js';
4
+ import { NegotiationServer } from './server.js';
5
+ import { NegotiationSession } from './negotiation.js';
6
+ import { JsonRpcClient } from './transport.js';
7
+ import { buildRFQ, buildCounter, buildAccept, buildReject, buildDispute } from './messages.js';
8
+ /**
9
+ * Buy-side negotiation agent. Sends RFQs, collects quotes, ranks them,
10
+ * and accepts/counters/rejects offers. Verifies seller signatures on all
11
+ * incoming messages.
12
+ */
13
+ export class BuyerAgent {
14
+ keypair;
15
+ agentId;
16
+ endpoint;
17
+ transport;
18
+ server;
19
+ sessions = new Map();
20
+ quoteListeners = new Map();
21
+ /** Tracks processed message IDs within the replay window to reject duplicate/replayed messages. */
22
+ seenMessageIds = new Map();
23
+ constructor(config) {
24
+ this.keypair = config.keypair ?? generateKeyPair();
25
+ this.agentId = publicKeyToDid(this.keypair.publicKey);
26
+ this.endpoint = config.endpoint;
27
+ this.transport = new JsonRpcClient();
28
+ this.server = new NegotiationServer();
29
+ this.registerHandlers();
30
+ }
31
+ /** Check if a message ID has already been processed (replay protection).
32
+ * Records the ID if new; throws DUPLICATE_MESSAGE if already seen.
33
+ * Periodically evicts entries older than the replay protection window. */
34
+ enforceNoDuplicate(messageId) {
35
+ const now = Date.now();
36
+ // Evict expired entries (older than the replay window)
37
+ const windowMs = DEFAULT_CONFIG.replay_protection_window_ms;
38
+ if (this.seenMessageIds.size > 1000) {
39
+ for (const [id, ts] of this.seenMessageIds) {
40
+ if (now - ts > windowMs)
41
+ this.seenMessageIds.delete(id);
42
+ }
43
+ }
44
+ if (this.seenMessageIds.has(messageId)) {
45
+ throw new OphirError(OphirErrorCode.DUPLICATE_MESSAGE, `Duplicate message ID ${messageId} rejected (potential replay attack)`, { messageId });
46
+ }
47
+ this.seenMessageIds.set(messageId, now);
48
+ }
49
+ /** Register JSON-RPC handlers for Quote, Counter, and Accept methods.
50
+ * Each handler validates the message schema, verifies the sender's Ed25519
51
+ * signature, checks expiration, enforces replay protection, and updates the session state. */
52
+ registerHandlers() {
53
+ // Handle incoming quotes from sellers
54
+ this.server.handle(METHODS.QUOTE, async (params) => {
55
+ const quote = QuoteParamsSchema.parse(params);
56
+ this.enforceNoDuplicate(quote.quote_id);
57
+ const session = this.sessions.get(quote.rfq_id);
58
+ if (!session) {
59
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Received quote for unknown RFQ ${quote.rfq_id}. Was this RFQ sent by this buyer?`);
60
+ }
61
+ // Reject expired quotes
62
+ if (quote.expires_at && new Date(quote.expires_at).getTime() < Date.now()) {
63
+ throw new OphirError(OphirErrorCode.EXPIRED_MESSAGE, `Quote ${quote.quote_id} from ${quote.seller.agent_id} has expired at ${quote.expires_at}`);
64
+ }
65
+ // Verify seller's signature before trusting the quote
66
+ const { signature, ...unsigned } = quote;
67
+ const sellerPubKey = didToPublicKey(quote.seller.agent_id);
68
+ if (!verifyMessage(unsigned, signature, sellerPubKey)) {
69
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid signature on quote ${quote.quote_id} from ${quote.seller.agent_id}`);
70
+ }
71
+ session.addQuote(quote);
72
+ this.notifyQuoteListeners(quote.rfq_id);
73
+ return { status: 'received', quote_id: quote.quote_id };
74
+ });
75
+ // Handle incoming counter-offers from sellers
76
+ this.server.handle(METHODS.COUNTER, async (params) => {
77
+ const counter = CounterParamsSchema.parse(params);
78
+ this.enforceNoDuplicate(counter.counter_id);
79
+ const session = this.sessions.get(counter.rfq_id);
80
+ if (!session) {
81
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Received counter for unknown RFQ ${counter.rfq_id}`);
82
+ }
83
+ // Verify the sender is a known seller who submitted a quote in this session
84
+ const knownSeller = session.quotes.some((q) => q.seller.agent_id === counter.from.agent_id);
85
+ if (!knownSeller) {
86
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Counter from ${counter.from.agent_id} rejected: sender is not a known seller in this negotiation`);
87
+ }
88
+ // Reject expired counter-offers
89
+ if (counter.expires_at && new Date(counter.expires_at).getTime() < Date.now()) {
90
+ throw new OphirError(OphirErrorCode.EXPIRED_MESSAGE, `Counter ${counter.counter_id} from ${counter.from.agent_id} has expired at ${counter.expires_at}`);
91
+ }
92
+ // Verify counter-party's signature
93
+ const { signature, ...unsigned } = counter;
94
+ const counterPubKey = didToPublicKey(counter.from.agent_id);
95
+ if (!verifyMessage(unsigned, signature, counterPubKey)) {
96
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid signature on counter ${counter.counter_id} from ${counter.from.agent_id}`);
97
+ }
98
+ session.addCounter(counter);
99
+ return { status: 'received', counter_id: counter.counter_id };
100
+ });
101
+ // Handle accept acknowledgment from sellers
102
+ this.server.handle(METHODS.ACCEPT, async (params) => {
103
+ const accept = AcceptParamsSchema.parse(params);
104
+ this.enforceNoDuplicate(accept.agreement_id);
105
+ const session = this.sessions.get(accept.rfq_id);
106
+ if (!session) {
107
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Received accept for unknown RFQ ${accept.rfq_id}`);
108
+ }
109
+ // Verify the agreement hash matches the final terms
110
+ const expectedHash = agreementHash(accept.final_terms);
111
+ if (expectedHash !== accept.agreement_hash) {
112
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Agreement hash mismatch on incoming accept: expected ${expectedHash}, got ${accept.agreement_hash}`);
113
+ }
114
+ // Verify the seller's signature on the accept message.
115
+ // Match the seller by the accepting_message_id (quote_id),
116
+ // falling back to the first quote if no match is found.
117
+ const { buyer_signature: _bs, seller_signature, ...unsigned } = accept;
118
+ const sellerQuote = session.quotes.find(q => q.quote_id === accept.accepting_message_id) ??
119
+ session.quotes[0];
120
+ if (!sellerQuote) {
121
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Cannot verify accept for agreement ${accept.agreement_id}: no matching quote found in session`);
122
+ }
123
+ if (seller_signature) {
124
+ const sellerPubKey = didToPublicKey(sellerQuote.seller.agent_id);
125
+ if (!verifyMessage(unsigned, seller_signature, sellerPubKey)) {
126
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid seller signature on incoming accept for agreement ${accept.agreement_id}`);
127
+ }
128
+ }
129
+ return { status: 'acknowledged', agreement_id: accept.agreement_id };
130
+ });
131
+ }
132
+ /** Resolve all pending waitForQuotes() promises for the given RFQ and clear the listener queue. */
133
+ notifyQuoteListeners(rfqId) {
134
+ const listeners = this.quoteListeners.get(rfqId);
135
+ if (listeners) {
136
+ for (const resolve of listeners)
137
+ resolve();
138
+ this.quoteListeners.set(rfqId, []);
139
+ }
140
+ }
141
+ /** Discover seller agents matching a service category. Currently returns empty; use direct endpoints.
142
+ * @param _query - Search criteria containing a service category and optional requirements
143
+ * @returns An empty array (placeholder for future discovery integration)
144
+ * @example
145
+ * ```typescript
146
+ * const sellers = await buyer.discover({ category: 'llm-inference' });
147
+ * ```
148
+ */
149
+ async discover(_query) {
150
+ return [];
151
+ }
152
+ /** Send an RFQ to one or more sellers and return the negotiation session.
153
+ * @param params - RFQ parameters including seller targets, service requirements, budget, and SLA
154
+ * @param params.sellers - Seller endpoints (strings) or SellerInfo objects to receive the RFQ
155
+ * @param params.service - The service requirement describing what the buyer needs
156
+ * @param params.budget - Budget constraint with maximum price and currency
157
+ * @param params.sla - Optional SLA requirements for the service
158
+ * @param params.maxRounds - Optional maximum number of negotiation rounds
159
+ * @param params.timeout - Optional TTL in milliseconds for the RFQ
160
+ * @returns The newly created NegotiationSession tracking this RFQ
161
+ * @throws {OphirError} When a non-network error occurs sending to a seller
162
+ * @example
163
+ * ```typescript
164
+ * const session = await buyer.requestQuotes({
165
+ * sellers: ['http://seller:3000'],
166
+ * service: { category: 'llm-inference', params: {} },
167
+ * budget: { max_price_per_unit: '0.01', currency: 'USDC' },
168
+ * });
169
+ * ```
170
+ */
171
+ async requestQuotes(params) {
172
+ const rfqMessage = buildRFQ({
173
+ buyer: { agent_id: this.agentId, endpoint: this.endpoint },
174
+ service: params.service,
175
+ budget: params.budget,
176
+ sla: params.sla,
177
+ maxRounds: params.maxRounds,
178
+ ttlMs: params.timeout,
179
+ secretKey: this.keypair.secretKey,
180
+ });
181
+ const rfq = rfqMessage.params;
182
+ const session = new NegotiationSession(rfq, params.maxRounds);
183
+ this.sessions.set(rfq.rfq_id, session);
184
+ // Resolve seller endpoints
185
+ const endpoints = params.sellers.map((s) => typeof s === 'string' ? s : s.endpoint);
186
+ // Send RFQ to all sellers concurrently.
187
+ // Network errors are caught so one unreachable seller doesn't block others.
188
+ // Non-network errors (e.g. crypto, programming) are re-thrown.
189
+ const sends = endpoints.map(async (endpoint) => {
190
+ try {
191
+ await this.transport.send(endpoint, METHODS.RFQ, rfq);
192
+ }
193
+ catch (err) {
194
+ if (err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE) {
195
+ return; // expected — seller is unreachable, continue with others
196
+ }
197
+ throw err; // unexpected error — don't silently swallow
198
+ }
199
+ });
200
+ await Promise.allSettled(sends);
201
+ return session;
202
+ }
203
+ /** Wait for quotes to arrive, resolving when minQuotes are received or timeout elapses.
204
+ * @param session - The negotiation session to wait on
205
+ * @param options - Optional wait configuration
206
+ * @param options.minQuotes - Minimum number of quotes before resolving (default: 1)
207
+ * @param options.timeout - Maximum time to wait in milliseconds (default: 30000)
208
+ * @returns Array of quotes received so far when the condition is met or timeout elapses
209
+ * @example
210
+ * ```typescript
211
+ * const quotes = await buyer.waitForQuotes(session, { minQuotes: 2, timeout: 10000 });
212
+ * ```
213
+ */
214
+ async waitForQuotes(session, options) {
215
+ const minQuotes = options?.minQuotes ?? 1;
216
+ const timeout = options?.timeout ?? 30_000;
217
+ if (session.quotes.length >= minQuotes) {
218
+ return session.quotes;
219
+ }
220
+ return new Promise((resolve) => {
221
+ const timer = setTimeout(() => {
222
+ cleanup();
223
+ resolve(session.quotes);
224
+ }, timeout);
225
+ const check = () => {
226
+ if (session.quotes.length >= minQuotes) {
227
+ cleanup();
228
+ resolve(session.quotes);
229
+ }
230
+ };
231
+ const cleanup = () => {
232
+ clearTimeout(timer);
233
+ const listeners = this.quoteListeners.get(session.rfqId);
234
+ if (listeners) {
235
+ const idx = listeners.indexOf(check);
236
+ if (idx !== -1)
237
+ listeners.splice(idx, 1);
238
+ }
239
+ };
240
+ if (!this.quoteListeners.has(session.rfqId)) {
241
+ this.quoteListeners.set(session.rfqId, []);
242
+ }
243
+ this.quoteListeners.get(session.rfqId).push(check);
244
+ });
245
+ }
246
+ /** Sort quotes by a ranking strategy (cheapest, fastest, best_sla, or custom function).
247
+ * @param quotes - Array of quotes to rank
248
+ * @param strategy - Ranking strategy: 'cheapest', 'fastest', 'best_sla', or a custom comparator (default: 'cheapest')
249
+ * @returns A new sorted array of quotes (best first)
250
+ * @example
251
+ * ```typescript
252
+ * const ranked = buyer.rankQuotes(quotes, 'fastest');
253
+ * const best = ranked[0];
254
+ * ```
255
+ */
256
+ rankQuotes(quotes, strategy) {
257
+ const sorted = [...quotes];
258
+ const resolvedStrategy = strategy ?? 'cheapest';
259
+ if (typeof resolvedStrategy === 'function') {
260
+ return sorted.sort(resolvedStrategy);
261
+ }
262
+ switch (resolvedStrategy) {
263
+ case 'cheapest':
264
+ return sorted.sort((a, b) => parseFloat(a.pricing.price_per_unit) - parseFloat(b.pricing.price_per_unit));
265
+ case 'fastest':
266
+ return sorted.sort((a, b) => {
267
+ const aLatency = a.sla_offered?.metrics.find((m) => m.name === 'p99_latency_ms')?.target ?? Infinity;
268
+ const bLatency = b.sla_offered?.metrics.find((m) => m.name === 'p99_latency_ms')?.target ?? Infinity;
269
+ return aLatency - bLatency;
270
+ });
271
+ case 'best_sla':
272
+ return sorted.sort((a, b) => {
273
+ const scoreA = this.scoreSLA(a);
274
+ const scoreB = this.scoreSLA(b);
275
+ return scoreB - scoreA; // higher score = better
276
+ });
277
+ }
278
+ }
279
+ /** Compute a composite SLA quality score for ranking. Higher-is-better metrics (uptime, accuracy) add directly; lower-is-better metrics (latency, error rate) are inverted. */
280
+ scoreSLA(quote) {
281
+ if (!quote.sla_offered)
282
+ return 0;
283
+ let score = 0;
284
+ for (const metric of quote.sla_offered.metrics) {
285
+ switch (metric.name) {
286
+ case 'uptime_pct':
287
+ score += metric.target;
288
+ break;
289
+ case 'accuracy_pct':
290
+ score += metric.target;
291
+ break;
292
+ case 'p99_latency_ms':
293
+ case 'p50_latency_ms':
294
+ case 'time_to_first_byte_ms':
295
+ // Lower is better — invert contribution
296
+ score += 1000 / metric.target;
297
+ break;
298
+ case 'throughput_rpm':
299
+ score += metric.target / 100;
300
+ break;
301
+ case 'error_rate_pct':
302
+ score += (100 - metric.target);
303
+ break;
304
+ default:
305
+ score += metric.target;
306
+ }
307
+ }
308
+ return score;
309
+ }
310
+ /** Accept a quote, creating a signed agreement with the seller. Verifies the seller's signature first.
311
+ * @param quote - The quote to accept, as received from a seller
312
+ * @returns A dual-signed Agreement containing final terms and both party signatures
313
+ * @throws {OphirError} When the seller's signature on the quote is invalid
314
+ * @throws {OphirError} When the seller's counter-signature on the accept is invalid
315
+ * @example
316
+ * ```typescript
317
+ * const agreement = await buyer.acceptQuote(quotes[0]);
318
+ * console.log(agreement.agreement_id);
319
+ * ```
320
+ */
321
+ async acceptQuote(quote) {
322
+ // Verify seller's signature before trusting the quote
323
+ const { signature, ...unsigned } = quote;
324
+ const sellerPubKey = didToPublicKey(quote.seller.agent_id);
325
+ if (!verifyMessage(unsigned, signature, sellerPubKey)) {
326
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Cannot accept quote ${quote.quote_id}: seller signature is invalid`);
327
+ }
328
+ const finalTerms = {
329
+ price_per_unit: quote.pricing.price_per_unit,
330
+ currency: quote.pricing.currency,
331
+ unit: quote.pricing.unit,
332
+ sla: quote.sla_offered,
333
+ escrow: quote.escrow_requirement
334
+ ? {
335
+ network: quote.escrow_requirement.type === 'solana_pda' ? 'solana' : quote.escrow_requirement.type,
336
+ deposit_amount: quote.escrow_requirement.deposit_amount,
337
+ release_condition: quote.escrow_requirement.release_condition,
338
+ }
339
+ : undefined,
340
+ };
341
+ // Build the accept without a seller_signature — the seller will
342
+ // counter-sign the same unsigned payload to produce a proper dual-sig.
343
+ const acceptMessage = buildAccept({
344
+ rfqId: quote.rfq_id,
345
+ acceptingMessageId: quote.quote_id,
346
+ finalTerms,
347
+ buyerSecretKey: this.keypair.secretKey,
348
+ });
349
+ const accept = acceptMessage.params;
350
+ // Send accept to seller and capture their counter-signature.
351
+ // The seller counter-signs the same unsigned accept data, producing a
352
+ // proper dual-signature agreement where both parties sign the identical
353
+ // canonical payload: {agreement_id, rfq_id, accepting_message_id,
354
+ // final_terms, agreement_hash}.
355
+ let sellerCounterSignature;
356
+ try {
357
+ const response = await this.transport.send(quote.seller.endpoint, METHODS.ACCEPT, accept);
358
+ if (response.seller_signature) {
359
+ // Verify the seller's counter-signature before trusting it
360
+ const { buyer_signature: _bs, seller_signature: _ss, ...unsignedAccept } = accept;
361
+ if (!verifyMessage(unsignedAccept, response.seller_signature, sellerPubKey)) {
362
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Seller counter-signature on accept for ${accept.agreement_id} is invalid`);
363
+ }
364
+ sellerCounterSignature = response.seller_signature;
365
+ }
366
+ }
367
+ catch (err) {
368
+ if (err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE) {
369
+ // Seller unreachable — agreement proceeds without seller counter-signature.
370
+ // The buyer_signature alone commits the buyer; the seller must counter-sign
371
+ // before escrow is funded.
372
+ }
373
+ else {
374
+ throw err;
375
+ }
376
+ }
377
+ const agreement = {
378
+ agreement_id: accept.agreement_id,
379
+ rfq_id: accept.rfq_id,
380
+ accepting_message_id: accept.accepting_message_id,
381
+ final_terms: accept.final_terms,
382
+ agreement_hash: accept.agreement_hash,
383
+ buyer_signature: accept.buyer_signature,
384
+ seller_signature: sellerCounterSignature,
385
+ };
386
+ // Update session state
387
+ const session = this.sessions.get(quote.rfq_id);
388
+ if (session) {
389
+ session.accept(agreement);
390
+ }
391
+ return agreement;
392
+ }
393
+ /** Send a counter-offer proposing modified terms for a quote.
394
+ * @param quote - The original quote to counter
395
+ * @param modifications - Key-value map of proposed term changes (e.g., price, SLA targets)
396
+ * @param justification - Optional human-readable reason for the counter-offer
397
+ * @returns The updated NegotiationSession reflecting the new counter round
398
+ * @throws {OphirError} When no active session exists for the quote's RFQ ID
399
+ * @example
400
+ * ```typescript
401
+ * const session = await buyer.counter(quote, { price_per_unit: '0.008' }, 'Volume discount');
402
+ * ```
403
+ */
404
+ async counter(quote, modifications, justification) {
405
+ const session = this.sessions.get(quote.rfq_id);
406
+ if (!session) {
407
+ throw new OphirError(OphirErrorCode.INVALID_STATE_TRANSITION, `No active session for RFQ ${quote.rfq_id}. Call requestQuotes() first.`);
408
+ }
409
+ const counterMessage = buildCounter({
410
+ rfqId: quote.rfq_id,
411
+ inResponseTo: quote.quote_id,
412
+ round: session.currentRound + 1,
413
+ from: { agent_id: this.agentId, role: 'buyer' },
414
+ modifications,
415
+ justification,
416
+ secretKey: this.keypair.secretKey,
417
+ });
418
+ const counterParams = counterMessage.params;
419
+ session.addCounter(counterParams);
420
+ try {
421
+ await this.transport.send(quote.seller.endpoint, METHODS.COUNTER, counterParams);
422
+ }
423
+ catch (err) {
424
+ if (!(err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE)) {
425
+ throw err;
426
+ }
427
+ }
428
+ return session;
429
+ }
430
+ /** Reject all quotes in a session and notify sellers.
431
+ * @param session - The negotiation session whose quotes should be rejected
432
+ * @param reason - Optional rejection reason sent to all sellers (default: 'Rejected by buyer')
433
+ * @returns Resolves when all rejection messages have been sent
434
+ * @throws {OphirError} When a non-network error occurs notifying a seller
435
+ * @example
436
+ * ```typescript
437
+ * await buyer.reject(session, 'Budget exceeded');
438
+ * ```
439
+ */
440
+ async reject(session, reason) {
441
+ const rejectReason = reason ?? 'Rejected by buyer';
442
+ // Collect all seller endpoints from received quotes
443
+ const sellerEndpoints = new Set(session.quotes.map((q) => q.seller.endpoint));
444
+ const rejectMessage = buildReject({
445
+ rfqId: session.rfqId,
446
+ rejectingMessageId: session.rfqId,
447
+ reason: rejectReason,
448
+ agentId: this.agentId,
449
+ secretKey: this.keypair.secretKey,
450
+ });
451
+ const sends = [...sellerEndpoints].map(async (endpoint) => {
452
+ try {
453
+ await this.transport.send(endpoint, METHODS.REJECT, rejectMessage.params);
454
+ }
455
+ catch (err) {
456
+ if (!(err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE)) {
457
+ throw err;
458
+ }
459
+ }
460
+ });
461
+ await Promise.allSettled(sends);
462
+ session.reject(rejectReason);
463
+ }
464
+ /** File an SLA violation dispute against a seller for a given agreement.
465
+ * @param agreement - The agreement under which the violation occurred
466
+ * @param violation - Evidence of the SLA violation including metric name and observed value
467
+ * @returns A DisputeResult with the dispute ID and initial 'pending' outcome
468
+ * @throws {OphirError} When a non-network error occurs notifying the seller
469
+ * @example
470
+ * ```typescript
471
+ * const result = await buyer.dispute(agreement, {
472
+ * metric: 'uptime_pct', observed: 95.0, threshold: 99.9,
473
+ * });
474
+ * ```
475
+ */
476
+ async dispute(agreement, violation) {
477
+ const disputeMessage = buildDispute({
478
+ agreementId: agreement.agreement_id,
479
+ filedBy: { agent_id: this.agentId, role: 'buyer' },
480
+ violation,
481
+ requestedRemedy: 'escrow_release',
482
+ escrowAction: 'freeze',
483
+ secretKey: this.keypair.secretKey,
484
+ });
485
+ // Find the session and seller endpoint from the agreement
486
+ const session = this.sessions.get(agreement.rfq_id);
487
+ const sellerEndpoint = session?.quotes.find((q) => q.seller.agent_id !== this.agentId)?.seller.endpoint;
488
+ if (sellerEndpoint) {
489
+ try {
490
+ await this.transport.send(sellerEndpoint, METHODS.DISPUTE, disputeMessage.params);
491
+ }
492
+ catch (err) {
493
+ if (!(err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE)) {
494
+ throw err;
495
+ }
496
+ }
497
+ }
498
+ if (session) {
499
+ session.dispute();
500
+ }
501
+ return {
502
+ dispute_id: disputeMessage.params.dispute_id,
503
+ outcome: 'pending',
504
+ };
505
+ }
506
+ /** Get a negotiation session by its RFQ ID.
507
+ * @param rfqId - The RFQ identifier to look up
508
+ * @returns The matching NegotiationSession, or undefined if not found
509
+ */
510
+ getSession(rfqId) {
511
+ return this.sessions.get(rfqId);
512
+ }
513
+ /** Get all active negotiation sessions.
514
+ * @returns Array of all NegotiationSession instances tracked by this agent
515
+ */
516
+ getSessions() {
517
+ return [...this.sessions.values()];
518
+ }
519
+ /** Start the HTTP server to receive quotes and counter-offers.
520
+ * @param port - Port number to listen on (default: 3001). Pass 0 for a random available port.
521
+ * @returns Resolves when the server is listening
522
+ * @example
523
+ * ```typescript
524
+ * await buyer.listen(0);
525
+ * console.log(buyer.getEndpoint());
526
+ * ```
527
+ */
528
+ async listen(port) {
529
+ await this.server.listen(port ?? 3001);
530
+ const boundPort = this.server.getPort();
531
+ if (boundPort !== undefined) {
532
+ const url = new URL(this.endpoint);
533
+ url.port = String(boundPort);
534
+ this.endpoint = url.toString().replace(/\/$/, '');
535
+ }
536
+ }
537
+ /** Stop the HTTP server and close all connections.
538
+ * @returns Resolves when the server has been shut down
539
+ */
540
+ async close() {
541
+ await this.server.close();
542
+ }
543
+ /** Get this agent's did:key identifier.
544
+ * @returns The agent's decentralized identifier (did:key)
545
+ */
546
+ getAgentId() {
547
+ return this.agentId;
548
+ }
549
+ /** Get this agent's HTTP endpoint URL.
550
+ * @returns The endpoint URL string (updated after listen() binds a port)
551
+ */
552
+ getEndpoint() {
553
+ return this.endpoint;
554
+ }
555
+ }
@@ -0,0 +1,47 @@
1
+ import type { PaymentMethod } from '@ophirai/protocol';
2
+ import type { SellerInfo } from './types.js';
3
+ /** Negotiation capability advertised in an A2A Agent Card. */
4
+ export interface NegotiationCapability {
5
+ supported: boolean;
6
+ endpoint: string;
7
+ protocols: string[];
8
+ acceptedPayments: PaymentMethod[];
9
+ negotiationStyles: string[];
10
+ maxNegotiationRounds: number;
11
+ services: {
12
+ category: string;
13
+ description: string;
14
+ base_price: string;
15
+ currency: string;
16
+ unit: string;
17
+ }[];
18
+ }
19
+ /** A2A-compatible Agent Card describing an agent's identity and capabilities. */
20
+ export interface AgentCard {
21
+ name: string;
22
+ description: string;
23
+ url: string;
24
+ capabilities: {
25
+ negotiation?: NegotiationCapability;
26
+ [key: string]: unknown;
27
+ };
28
+ }
29
+ /** Fetch /.well-known/agent.json from each endpoint and filter to agents with negotiation capability.
30
+ * @param endpoints - Base URLs of agents to probe (e.g. `['https://agent.example.com']`)
31
+ * @returns Agent cards for all reachable agents that support negotiation; unreachable endpoints are silently skipped
32
+ * @example
33
+ * ```typescript
34
+ * const agents = await discoverAgents(['https://agent1.io', 'https://agent2.io']);
35
+ * ```
36
+ */
37
+ export declare function discoverAgents(endpoints: string[]): Promise<AgentCard[]>;
38
+ /** Extract SellerInfo from an agent card's negotiation capability.
39
+ * @param card - An A2A Agent Card to extract seller information from
40
+ * @returns Parsed seller info with endpoint and service offerings, or null if the card has no negotiation capability or no services
41
+ * @example
42
+ * ```typescript
43
+ * const seller = parseAgentCard(card);
44
+ * if (seller) console.log(seller.services);
45
+ * ```
46
+ */
47
+ export declare function parseAgentCard(card: AgentCard): SellerInfo | null;
@@ -0,0 +1,51 @@
1
+ /** Fetch /.well-known/agent.json from each endpoint and filter to agents with negotiation capability.
2
+ * @param endpoints - Base URLs of agents to probe (e.g. `['https://agent.example.com']`)
3
+ * @returns Agent cards for all reachable agents that support negotiation; unreachable endpoints are silently skipped
4
+ * @example
5
+ * ```typescript
6
+ * const agents = await discoverAgents(['https://agent1.io', 'https://agent2.io']);
7
+ * ```
8
+ */
9
+ export async function discoverAgents(endpoints) {
10
+ const results = await Promise.allSettled(endpoints.map(async (endpoint) => {
11
+ const url = endpoint.replace(/\/$/, '') + '/.well-known/agent.json';
12
+ const res = await fetch(url);
13
+ if (!res.ok)
14
+ return null;
15
+ const card = await res.json();
16
+ if (!card.capabilities?.negotiation?.supported)
17
+ return null;
18
+ return card;
19
+ }));
20
+ return results
21
+ .filter((r) => r.status === 'fulfilled' && r.value !== null)
22
+ .map((r) => r.value);
23
+ }
24
+ /** Extract SellerInfo from an agent card's negotiation capability.
25
+ * @param card - An A2A Agent Card to extract seller information from
26
+ * @returns Parsed seller info with endpoint and service offerings, or null if the card has no negotiation capability or no services
27
+ * @example
28
+ * ```typescript
29
+ * const seller = parseAgentCard(card);
30
+ * if (seller) console.log(seller.services);
31
+ * ```
32
+ */
33
+ export function parseAgentCard(card) {
34
+ const neg = card.capabilities?.negotiation;
35
+ if (!neg?.supported)
36
+ return null;
37
+ const services = (neg.services ?? []).map((s) => ({
38
+ category: s.category,
39
+ description: s.description,
40
+ base_price: s.base_price,
41
+ currency: s.currency,
42
+ unit: s.unit,
43
+ }));
44
+ if (services.length === 0)
45
+ return null;
46
+ return {
47
+ agentId: card.url,
48
+ endpoint: neg.endpoint,
49
+ services,
50
+ };
51
+ }