@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/seller.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|