@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
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
|
+
}
|