@ophirai/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +139 -0
  2. package/dist/__tests__/buyer.test.d.ts +1 -0
  3. package/dist/__tests__/buyer.test.js +664 -0
  4. package/dist/__tests__/discovery.test.d.ts +1 -0
  5. package/dist/__tests__/discovery.test.js +188 -0
  6. package/dist/__tests__/escrow.test.d.ts +1 -0
  7. package/dist/__tests__/escrow.test.js +385 -0
  8. package/dist/__tests__/identity.test.d.ts +1 -0
  9. package/dist/__tests__/identity.test.js +222 -0
  10. package/dist/__tests__/integration.test.d.ts +1 -0
  11. package/dist/__tests__/integration.test.js +681 -0
  12. package/dist/__tests__/lockstep.test.d.ts +1 -0
  13. package/dist/__tests__/lockstep.test.js +320 -0
  14. package/dist/__tests__/messages.test.d.ts +1 -0
  15. package/dist/__tests__/messages.test.js +976 -0
  16. package/dist/__tests__/negotiation.test.d.ts +1 -0
  17. package/dist/__tests__/negotiation.test.js +667 -0
  18. package/dist/__tests__/seller.test.d.ts +1 -0
  19. package/dist/__tests__/seller.test.js +767 -0
  20. package/dist/__tests__/server.test.d.ts +1 -0
  21. package/dist/__tests__/server.test.js +239 -0
  22. package/dist/__tests__/signing.test.d.ts +1 -0
  23. package/dist/__tests__/signing.test.js +713 -0
  24. package/dist/__tests__/sla.test.d.ts +1 -0
  25. package/dist/__tests__/sla.test.js +342 -0
  26. package/dist/__tests__/transport.test.d.ts +1 -0
  27. package/dist/__tests__/transport.test.js +197 -0
  28. package/dist/__tests__/x402.test.d.ts +1 -0
  29. package/dist/__tests__/x402.test.js +141 -0
  30. package/dist/buyer.d.ts +190 -0
  31. package/dist/buyer.js +555 -0
  32. package/dist/discovery.d.ts +47 -0
  33. package/dist/discovery.js +51 -0
  34. package/dist/escrow.d.ts +177 -0
  35. package/dist/escrow.js +434 -0
  36. package/dist/identity.d.ts +60 -0
  37. package/dist/identity.js +108 -0
  38. package/dist/index.d.ts +122 -0
  39. package/dist/index.js +43 -0
  40. package/dist/lockstep.d.ts +94 -0
  41. package/dist/lockstep.js +127 -0
  42. package/dist/messages.d.ts +172 -0
  43. package/dist/messages.js +262 -0
  44. package/dist/negotiation.d.ts +113 -0
  45. package/dist/negotiation.js +214 -0
  46. package/dist/seller.d.ts +127 -0
  47. package/dist/seller.js +395 -0
  48. package/dist/server.d.ts +52 -0
  49. package/dist/server.js +149 -0
  50. package/dist/signing.d.ts +98 -0
  51. package/dist/signing.js +165 -0
  52. package/dist/sla.d.ts +95 -0
  53. package/dist/sla.js +187 -0
  54. package/dist/transport.d.ts +41 -0
  55. package/dist/transport.js +127 -0
  56. package/dist/types.d.ts +86 -0
  57. package/dist/types.js +1 -0
  58. package/dist/x402.d.ts +25 -0
  59. package/dist/x402.js +54 -0
  60. package/package.json +40 -0
@@ -0,0 +1,127 @@
1
+ import type { RFQParams, QuoteParams, CounterParams } from '@ophirai/protocol';
2
+ import { NegotiationSession } from './negotiation.js';
3
+ import type { ServiceOffering, PricingStrategy } from './types.js';
4
+ import type { AgentCard } from './discovery.js';
5
+ /** Configuration for creating a SellerAgent. */
6
+ export interface SellerAgentConfig {
7
+ /** Optional Ed25519 keypair; auto-generated if omitted. */
8
+ keypair?: {
9
+ publicKey: Uint8Array;
10
+ secretKey: Uint8Array;
11
+ };
12
+ /** HTTP endpoint URL where this seller listens for incoming JSON-RPC messages. */
13
+ endpoint: string;
14
+ /** Service offerings this seller advertises (category, price, SLA). */
15
+ services: ServiceOffering[];
16
+ /** Pricing strategy for quote generation (default: fixed). */
17
+ pricingStrategy?: PricingStrategy;
18
+ }
19
+ /**
20
+ * Sell-side negotiation agent. Receives RFQs, generates quotes, handles
21
+ * counters, and manages agreements. Verifies buyer signatures on incoming
22
+ * counter-offers.
23
+ */
24
+ export declare class SellerAgent {
25
+ private keypair;
26
+ private agentId;
27
+ private endpoint;
28
+ private services;
29
+ private pricingStrategy;
30
+ private server;
31
+ private sessions;
32
+ private transport;
33
+ /** Tracks processed message IDs within the replay window to reject duplicate/replayed messages. */
34
+ private seenMessageIds;
35
+ private rfqHandler?;
36
+ private counterHandler?;
37
+ constructor(config: SellerAgentConfig);
38
+ /** Check if a message ID has already been processed (replay protection).
39
+ * Records the ID if new; throws DUPLICATE_MESSAGE if already seen.
40
+ * Periodically evicts entries older than the replay protection window. */
41
+ private enforceNoDuplicate;
42
+ /** Register JSON-RPC handlers for RFQ, Counter, Accept, and Reject methods.
43
+ * Each handler validates the incoming message schema, verifies the sender's
44
+ * Ed25519 signature, enforces replay protection, updates the session state,
45
+ * and dispatches to user-provided callbacks (onRFQ, onCounter) when configured. */
46
+ private registerHandlers;
47
+ /** Register an additional service offering.
48
+ * @param service - The service offering to add to this agent's catalog
49
+ * @example
50
+ * ```typescript
51
+ * seller.registerService({ category: 'embedding', description: 'Text embeddings', base_price: '0.001', currency: 'USDC', unit: 'request' });
52
+ * ```
53
+ */
54
+ registerService(service: ServiceOffering): void;
55
+ /** Generate an A2A-compatible Agent Card describing this seller's capabilities.
56
+ * @returns An AgentCard object with this agent's services, endpoint, and negotiation metadata
57
+ * @example
58
+ * ```typescript
59
+ * const card = seller.generateAgentCard();
60
+ * console.log(card.capabilities.negotiation.services);
61
+ * ```
62
+ */
63
+ generateAgentCard(): AgentCard;
64
+ /** Set a custom handler for incoming RFQs. Return a QuoteParams to respond, or null to ignore.
65
+ * @param handler - Async callback invoked for each incoming RFQ. Return a QuoteParams to send a quote, or null to ignore the RFQ.
66
+ * @example
67
+ * ```typescript
68
+ * seller.onRFQ(async (rfq) => {
69
+ * if (rfq.budget.max_price_per_unit < '0.005') return null;
70
+ * return seller.generateQuote(rfq);
71
+ * });
72
+ * ```
73
+ */
74
+ onRFQ(handler: (rfq: RFQParams) => Promise<QuoteParams | null>): void;
75
+ /** Generate a signed quote for an RFQ based on matching services and pricing strategy.
76
+ * @param rfq - The incoming RFQ to generate a quote for
77
+ * @returns A signed QuoteParams if a matching service is found, or null if no service matches
78
+ * @example
79
+ * ```typescript
80
+ * const quote = seller.generateQuote(rfq);
81
+ * if (quote) console.log(quote.pricing.price_per_unit);
82
+ * ```
83
+ */
84
+ generateQuote(rfq: RFQParams): QuoteParams | null;
85
+ /** Set a custom handler for incoming counter-offers. Return a new QuoteParams, 'accept', or 'reject'.
86
+ * @param handler - Async callback invoked for each counter-offer. Return a new QuoteParams to continue negotiation, 'accept' to agree, or 'reject' to end.
87
+ * @example
88
+ * ```typescript
89
+ * seller.onCounter(async (counter, session) => {
90
+ * if (session.currentRound >= 3) return 'accept';
91
+ * return 'reject';
92
+ * });
93
+ * ```
94
+ */
95
+ onCounter(handler: (counter: CounterParams, session: NegotiationSession) => Promise<QuoteParams | 'accept' | 'reject'>): void;
96
+ /** Start the HTTP server and begin accepting RFQs.
97
+ * @param port - Port number to listen on (default: 3000). Pass 0 for a random available port.
98
+ * @returns Resolves when the server is listening
99
+ * @example
100
+ * ```typescript
101
+ * await seller.listen(0);
102
+ * console.log(seller.getEndpoint());
103
+ * ```
104
+ */
105
+ listen(port?: number): Promise<void>;
106
+ /** Stop the HTTP server and close all connections.
107
+ * @returns Resolves when the server has been shut down
108
+ */
109
+ close(): Promise<void>;
110
+ /** Get a negotiation session by its RFQ ID.
111
+ * @param rfqId - The RFQ identifier to look up
112
+ * @returns The matching NegotiationSession, or undefined if not found
113
+ */
114
+ getSession(rfqId: string): NegotiationSession | undefined;
115
+ /** Get all active negotiation sessions.
116
+ * @returns Array of all NegotiationSession instances tracked by this agent
117
+ */
118
+ getSessions(): NegotiationSession[];
119
+ /** Get this agent's did:key identifier.
120
+ * @returns The agent's decentralized identifier (did:key)
121
+ */
122
+ getAgentId(): string;
123
+ /** Get this agent's HTTP endpoint URL.
124
+ * @returns The endpoint URL string (updated after listen() binds a port)
125
+ */
126
+ getEndpoint(): string;
127
+ }
package/dist/seller.js ADDED
@@ -0,0 +1,395 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { RFQParamsSchema, CounterParamsSchema, AcceptParamsSchema, RejectParamsSchema, METHODS, DEFAULT_CONFIG, OphirError, OphirErrorCode, } from '@ophirai/protocol';
3
+ import { generateKeyPair, publicKeyToDid, didToPublicKey } from './identity.js';
4
+ import { signMessage, verifyMessage, agreementHash } from './signing.js';
5
+ import { NegotiationServer } from './server.js';
6
+ import { NegotiationSession } from './negotiation.js';
7
+ import { JsonRpcClient } from './transport.js';
8
+ /**
9
+ * Sell-side negotiation agent. Receives RFQs, generates quotes, handles
10
+ * counters, and manages agreements. Verifies buyer signatures on incoming
11
+ * counter-offers.
12
+ */
13
+ export class SellerAgent {
14
+ keypair;
15
+ agentId;
16
+ endpoint;
17
+ services;
18
+ pricingStrategy;
19
+ server;
20
+ sessions = new Map();
21
+ transport = new JsonRpcClient();
22
+ /** Tracks processed message IDs within the replay window to reject duplicate/replayed messages. */
23
+ seenMessageIds = new Map();
24
+ rfqHandler;
25
+ counterHandler;
26
+ constructor(config) {
27
+ this.keypair = config.keypair ?? generateKeyPair();
28
+ this.agentId = publicKeyToDid(this.keypair.publicKey);
29
+ this.endpoint = config.endpoint;
30
+ this.services = [...config.services];
31
+ this.pricingStrategy = config.pricingStrategy ?? { type: 'fixed' };
32
+ this.server = new NegotiationServer();
33
+ this.registerHandlers();
34
+ }
35
+ /** Check if a message ID has already been processed (replay protection).
36
+ * Records the ID if new; throws DUPLICATE_MESSAGE if already seen.
37
+ * Periodically evicts entries older than the replay protection window. */
38
+ enforceNoDuplicate(messageId) {
39
+ const now = Date.now();
40
+ const windowMs = DEFAULT_CONFIG.replay_protection_window_ms;
41
+ if (this.seenMessageIds.size > 1000) {
42
+ for (const [id, ts] of this.seenMessageIds) {
43
+ if (now - ts > windowMs)
44
+ this.seenMessageIds.delete(id);
45
+ }
46
+ }
47
+ if (this.seenMessageIds.has(messageId)) {
48
+ throw new OphirError(OphirErrorCode.DUPLICATE_MESSAGE, `Duplicate message ID ${messageId} rejected (potential replay attack)`, { messageId });
49
+ }
50
+ this.seenMessageIds.set(messageId, now);
51
+ }
52
+ /** Register JSON-RPC handlers for RFQ, Counter, Accept, and Reject methods.
53
+ * Each handler validates the incoming message schema, verifies the sender's
54
+ * Ed25519 signature, enforces replay protection, updates the session state,
55
+ * and dispatches to user-provided callbacks (onRFQ, onCounter) when configured. */
56
+ registerHandlers() {
57
+ this.server.handle(METHODS.RFQ, async (params) => {
58
+ const rfq = RFQParamsSchema.parse(params);
59
+ this.enforceNoDuplicate(rfq.rfq_id);
60
+ // Reject expired RFQs
61
+ if (rfq.expires_at && new Date(rfq.expires_at).getTime() < Date.now()) {
62
+ throw new OphirError(OphirErrorCode.EXPIRED_MESSAGE, `RFQ ${rfq.rfq_id} from ${rfq.buyer.agent_id} has expired at ${rfq.expires_at}`);
63
+ }
64
+ // Verify buyer's signature on the RFQ to prevent forgery.
65
+ // Extract the buyer's public key from their DID and verify the signature
66
+ // over the unsigned (signature-excluded) RFQ params.
67
+ const { signature: rfqSignature, ...unsignedRfq } = rfq;
68
+ const buyerPubKey = didToPublicKey(rfq.buyer.agent_id);
69
+ if (!verifyMessage(unsignedRfq, rfqSignature, buyerPubKey)) {
70
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid signature on RFQ ${rfq.rfq_id} from ${rfq.buyer.agent_id}`);
71
+ }
72
+ const session = new NegotiationSession(rfq);
73
+ this.sessions.set(rfq.rfq_id, session);
74
+ let quote;
75
+ if (this.rfqHandler) {
76
+ quote = await this.rfqHandler(rfq);
77
+ // Re-sign quotes from custom handlers to ensure cryptographic integrity.
78
+ // Custom handlers may return quotes with placeholder signatures.
79
+ if (quote) {
80
+ const { signature: _sig, ...unsigned } = quote;
81
+ const freshSignature = signMessage(unsigned, this.keypair.secretKey);
82
+ quote = { ...unsigned, signature: freshSignature };
83
+ }
84
+ }
85
+ else {
86
+ quote = this.generateQuote(rfq);
87
+ }
88
+ if (!quote)
89
+ return { status: 'ignored' };
90
+ session.addQuote(quote);
91
+ // Send quote back to buyer endpoint
92
+ try {
93
+ await this.transport.send(rfq.buyer.endpoint, METHODS.QUOTE, quote);
94
+ }
95
+ catch (err) {
96
+ if (!(err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE)) {
97
+ throw err;
98
+ }
99
+ // Buyer unreachable — quote is still stored in session
100
+ }
101
+ return quote;
102
+ });
103
+ this.server.handle(METHODS.COUNTER, async (params) => {
104
+ const counter = CounterParamsSchema.parse(params);
105
+ this.enforceNoDuplicate(counter.counter_id);
106
+ const session = this.sessions.get(counter.rfq_id);
107
+ if (!session) {
108
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Received counter for unknown RFQ ${counter.rfq_id}`);
109
+ }
110
+ // Verify the counter sender is the buyer from the original RFQ
111
+ if (counter.from.agent_id !== session.rfq.buyer.agent_id) {
112
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Counter from ${counter.from.agent_id} rejected: sender does not match RFQ buyer ${session.rfq.buyer.agent_id}`);
113
+ }
114
+ // Reject expired counter-offers
115
+ if (counter.expires_at && new Date(counter.expires_at).getTime() < Date.now()) {
116
+ throw new OphirError(OphirErrorCode.EXPIRED_MESSAGE, `Counter ${counter.counter_id} from ${counter.from.agent_id} has expired at ${counter.expires_at}`);
117
+ }
118
+ // Verify counter-party's signature
119
+ const { signature, ...unsigned } = counter;
120
+ const counterPubKey = didToPublicKey(counter.from.agent_id);
121
+ if (!verifyMessage(unsigned, signature, counterPubKey)) {
122
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid signature on counter ${counter.counter_id} from ${counter.from.agent_id}`);
123
+ }
124
+ session.addCounter(counter);
125
+ if (this.counterHandler) {
126
+ const result = await this.counterHandler(counter, session);
127
+ if (result === 'accept') {
128
+ return { status: 'accepted' };
129
+ }
130
+ else if (result === 'reject') {
131
+ session.reject('Counter rejected by seller');
132
+ return { status: 'rejected' };
133
+ }
134
+ else {
135
+ // It's a new quote — re-sign to ensure cryptographic integrity
136
+ const { signature: _sig, ...unsigned } = result;
137
+ const freshSignature = signMessage(unsigned, this.keypair.secretKey);
138
+ const signedQuote = { ...unsigned, signature: freshSignature };
139
+ session.addQuote(signedQuote);
140
+ try {
141
+ await this.transport.send(session.rfq.buyer.endpoint, METHODS.QUOTE, signedQuote);
142
+ }
143
+ catch (err) {
144
+ if (!(err instanceof OphirError && err.code === OphirErrorCode.SELLER_UNREACHABLE)) {
145
+ throw err;
146
+ }
147
+ }
148
+ return signedQuote;
149
+ }
150
+ }
151
+ return { status: 'received' };
152
+ });
153
+ this.server.handle(METHODS.ACCEPT, async (params) => {
154
+ const accept = AcceptParamsSchema.parse(params);
155
+ this.enforceNoDuplicate(accept.agreement_id);
156
+ const session = this.sessions.get(accept.rfq_id);
157
+ if (!session) {
158
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Received accept for unknown RFQ ${accept.rfq_id}`);
159
+ }
160
+ // Verify the accepting_message_id refers to a quote this seller sent
161
+ const matchingQuote = session.quotes.find((q) => q.quote_id === accept.accepting_message_id);
162
+ if (!matchingQuote) {
163
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Accept references message ${accept.accepting_message_id} which does not match any quote sent in this session`);
164
+ }
165
+ // Verify the agreement hash matches the final terms
166
+ const expectedHash = agreementHash(accept.final_terms);
167
+ if (expectedHash !== accept.agreement_hash) {
168
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Agreement hash mismatch: expected ${expectedHash}, got ${accept.agreement_hash}`);
169
+ }
170
+ // Verify buyer's signature on the accept message
171
+ const buyerDid = session.rfq.buyer.agent_id;
172
+ const buyerPubKey = didToPublicKey(buyerDid);
173
+ const { buyer_signature, seller_signature: _sellerSig, ...unsigned } = accept;
174
+ if (!verifyMessage(unsigned, buyer_signature, buyerPubKey)) {
175
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid buyer signature on accept for agreement ${accept.agreement_id}`);
176
+ }
177
+ // Seller counter-signs the same unsigned accept data, creating a proper
178
+ // dual-signature agreement. Both buyer and seller sign the identical
179
+ // canonical payload: {agreement_id, rfq_id, accepting_message_id,
180
+ // final_terms, agreement_hash}.
181
+ const sellerCounterSignature = signMessage(unsigned, this.keypair.secretKey);
182
+ const agreement = {
183
+ agreement_id: accept.agreement_id,
184
+ rfq_id: accept.rfq_id,
185
+ accepting_message_id: accept.accepting_message_id,
186
+ final_terms: accept.final_terms,
187
+ agreement_hash: accept.agreement_hash,
188
+ buyer_signature: accept.buyer_signature,
189
+ seller_signature: sellerCounterSignature,
190
+ };
191
+ session.accept(agreement);
192
+ return {
193
+ status: 'accepted',
194
+ agreement_id: accept.agreement_id,
195
+ seller_signature: sellerCounterSignature,
196
+ };
197
+ });
198
+ this.server.handle(METHODS.REJECT, async (params) => {
199
+ const reject = RejectParamsSchema.parse(params);
200
+ this.enforceNoDuplicate(`reject_${reject.rfq_id}_${reject.rejecting_message_id}`);
201
+ const session = this.sessions.get(reject.rfq_id);
202
+ if (!session) {
203
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `Received reject for unknown RFQ ${reject.rfq_id}`);
204
+ }
205
+ // Verify the rejecting agent's signature to prevent unauthorized rejections.
206
+ const { signature: rejectSig, ...unsignedReject } = reject;
207
+ const rejectPubKey = didToPublicKey(reject.from.agent_id);
208
+ if (!verifyMessage(unsignedReject, rejectSig, rejectPubKey)) {
209
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid signature on reject for RFQ ${reject.rfq_id} from ${reject.from.agent_id}`);
210
+ }
211
+ session.reject(reject.reason);
212
+ return { status: 'rejected' };
213
+ });
214
+ }
215
+ /** Register an additional service offering.
216
+ * @param service - The service offering to add to this agent's catalog
217
+ * @example
218
+ * ```typescript
219
+ * seller.registerService({ category: 'embedding', description: 'Text embeddings', base_price: '0.001', currency: 'USDC', unit: 'request' });
220
+ * ```
221
+ */
222
+ registerService(service) {
223
+ this.services.push(service);
224
+ }
225
+ /** Generate an A2A-compatible Agent Card describing this seller's capabilities.
226
+ * @returns An AgentCard object with this agent's services, endpoint, and negotiation metadata
227
+ * @example
228
+ * ```typescript
229
+ * const card = seller.generateAgentCard();
230
+ * console.log(card.capabilities.negotiation.services);
231
+ * ```
232
+ */
233
+ generateAgentCard() {
234
+ return {
235
+ name: `Seller ${this.agentId.slice(-8)}`,
236
+ description: 'Ophir-compatible seller agent',
237
+ url: this.endpoint,
238
+ capabilities: {
239
+ negotiation: {
240
+ supported: true,
241
+ endpoint: this.endpoint,
242
+ protocols: ['ophir/1.0'],
243
+ acceptedPayments: [
244
+ { network: DEFAULT_CONFIG.payment_network, token: DEFAULT_CONFIG.payment_token },
245
+ ],
246
+ negotiationStyles: ['rfq'],
247
+ maxNegotiationRounds: DEFAULT_CONFIG.max_negotiation_rounds,
248
+ services: this.services.map((s) => ({
249
+ category: s.category,
250
+ description: s.description,
251
+ base_price: s.base_price,
252
+ currency: s.currency,
253
+ unit: s.unit,
254
+ })),
255
+ },
256
+ },
257
+ };
258
+ }
259
+ /** Set a custom handler for incoming RFQs. Return a QuoteParams to respond, or null to ignore.
260
+ * @param handler - Async callback invoked for each incoming RFQ. Return a QuoteParams to send a quote, or null to ignore the RFQ.
261
+ * @example
262
+ * ```typescript
263
+ * seller.onRFQ(async (rfq) => {
264
+ * if (rfq.budget.max_price_per_unit < '0.005') return null;
265
+ * return seller.generateQuote(rfq);
266
+ * });
267
+ * ```
268
+ */
269
+ onRFQ(handler) {
270
+ this.rfqHandler = handler;
271
+ }
272
+ /** Generate a signed quote for an RFQ based on matching services and pricing strategy.
273
+ * @param rfq - The incoming RFQ to generate a quote for
274
+ * @returns A signed QuoteParams if a matching service is found, or null if no service matches
275
+ * @example
276
+ * ```typescript
277
+ * const quote = seller.generateQuote(rfq);
278
+ * if (quote) console.log(quote.pricing.price_per_unit);
279
+ * ```
280
+ */
281
+ generateQuote(rfq) {
282
+ const service = this.services.find((s) => s.category === rfq.service.category);
283
+ if (!service)
284
+ return null;
285
+ let pricePerUnit;
286
+ const basePrice = parseFloat(service.base_price);
287
+ if (Number.isNaN(basePrice)) {
288
+ return null; // Invalid base price — cannot generate a quote
289
+ }
290
+ switch (this.pricingStrategy.type) {
291
+ case 'competitive':
292
+ pricePerUnit = basePrice * 0.9;
293
+ break;
294
+ case 'dynamic':
295
+ pricePerUnit = basePrice;
296
+ break;
297
+ case 'fixed':
298
+ default:
299
+ pricePerUnit = basePrice;
300
+ break;
301
+ }
302
+ const sla = {
303
+ metrics: [
304
+ { name: 'uptime_pct', target: 99.9, comparison: 'gte' },
305
+ { name: 'p99_latency_ms', target: 500, comparison: 'lte' },
306
+ { name: 'accuracy_pct', target: 95, comparison: 'gte' },
307
+ ],
308
+ dispute_resolution: { method: 'lockstep_verification', timeout_hours: 24 },
309
+ };
310
+ const unsigned = {
311
+ quote_id: uuidv4(),
312
+ rfq_id: rfq.rfq_id,
313
+ seller: {
314
+ agent_id: this.agentId,
315
+ endpoint: this.endpoint,
316
+ },
317
+ pricing: {
318
+ price_per_unit: pricePerUnit.toFixed(4),
319
+ currency: service.currency,
320
+ unit: service.unit,
321
+ pricing_model: 'fixed',
322
+ volume_discounts: [
323
+ { min_units: 1000, price_per_unit: (pricePerUnit * 0.9).toFixed(4) },
324
+ { min_units: 10000, price_per_unit: (pricePerUnit * 0.8).toFixed(4) },
325
+ ],
326
+ },
327
+ sla_offered: sla,
328
+ expires_at: new Date(Date.now() + DEFAULT_CONFIG.quote_timeout_ms).toISOString(),
329
+ };
330
+ const signature = signMessage(unsigned, this.keypair.secretKey);
331
+ return { ...unsigned, signature };
332
+ }
333
+ /** Set a custom handler for incoming counter-offers. Return a new QuoteParams, 'accept', or 'reject'.
334
+ * @param handler - Async callback invoked for each counter-offer. Return a new QuoteParams to continue negotiation, 'accept' to agree, or 'reject' to end.
335
+ * @example
336
+ * ```typescript
337
+ * seller.onCounter(async (counter, session) => {
338
+ * if (session.currentRound >= 3) return 'accept';
339
+ * return 'reject';
340
+ * });
341
+ * ```
342
+ */
343
+ onCounter(handler) {
344
+ this.counterHandler = handler;
345
+ }
346
+ /** Start the HTTP server and begin accepting RFQs.
347
+ * @param port - Port number to listen on (default: 3000). Pass 0 for a random available port.
348
+ * @returns Resolves when the server is listening
349
+ * @example
350
+ * ```typescript
351
+ * await seller.listen(0);
352
+ * console.log(seller.getEndpoint());
353
+ * ```
354
+ */
355
+ async listen(port) {
356
+ await this.server.listen(port ?? 3000);
357
+ const boundPort = this.server.getPort();
358
+ if (boundPort !== undefined) {
359
+ const url = new URL(this.endpoint);
360
+ url.port = String(boundPort);
361
+ this.endpoint = url.toString().replace(/\/$/, '');
362
+ }
363
+ }
364
+ /** Stop the HTTP server and close all connections.
365
+ * @returns Resolves when the server has been shut down
366
+ */
367
+ async close() {
368
+ await this.server.close();
369
+ }
370
+ /** Get a negotiation session by its RFQ ID.
371
+ * @param rfqId - The RFQ identifier to look up
372
+ * @returns The matching NegotiationSession, or undefined if not found
373
+ */
374
+ getSession(rfqId) {
375
+ return this.sessions.get(rfqId);
376
+ }
377
+ /** Get all active negotiation sessions.
378
+ * @returns Array of all NegotiationSession instances tracked by this agent
379
+ */
380
+ getSessions() {
381
+ return [...this.sessions.values()];
382
+ }
383
+ /** Get this agent's did:key identifier.
384
+ * @returns The agent's decentralized identifier (did:key)
385
+ */
386
+ getAgentId() {
387
+ return this.agentId;
388
+ }
389
+ /** Get this agent's HTTP endpoint URL.
390
+ * @returns The endpoint URL string (updated after listen() binds a port)
391
+ */
392
+ getEndpoint() {
393
+ return this.endpoint;
394
+ }
395
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Express-based JSON-RPC 2.0 server for receiving Ophir negotiation messages.
3
+ * Dispatches incoming requests to registered method handlers.
4
+ */
5
+ export declare class NegotiationServer {
6
+ private app;
7
+ private handlers;
8
+ private server?;
9
+ private boundPort?;
10
+ constructor();
11
+ /** Register an async handler for a JSON-RPC method name.
12
+ * @param method - The JSON-RPC method name to handle (e.g. "ophir.propose")
13
+ * @param handler - Async function that receives params and returns the result
14
+ * @returns void
15
+ * @example
16
+ * ```typescript
17
+ * server.handle('ophir.propose', async (params) => {
18
+ * return { accepted: true };
19
+ * });
20
+ * ```
21
+ */
22
+ handle(method: string, handler: (params: unknown) => Promise<unknown>): void;
23
+ /** Start listening for JSON-RPC requests on the given port.
24
+ * @param port - The TCP port to bind to; pass 0 for an OS-assigned port
25
+ * @returns Resolves when the server is ready to accept connections
26
+ * @example
27
+ * ```typescript
28
+ * const server = new NegotiationServer();
29
+ * await server.listen(3000);
30
+ * console.log('Listening on port', server.getPort());
31
+ * ```
32
+ */
33
+ listen(port: number): Promise<void>;
34
+ /** Get the actual bound port (useful when port 0 was passed to listen).
35
+ * @returns The bound port number, or undefined if the server is not listening
36
+ * @example
37
+ * ```typescript
38
+ * await server.listen(0);
39
+ * const port = server.getPort(); // e.g. 49152
40
+ * ```
41
+ */
42
+ getPort(): number | undefined;
43
+ /** Stop the server and close all connections.
44
+ * @returns Resolves when the server has fully shut down
45
+ * @throws {Error} When the underlying HTTP server fails to close
46
+ * @example
47
+ * ```typescript
48
+ * await server.close();
49
+ * ```
50
+ */
51
+ close(): Promise<void>;
52
+ }