@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/messages.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { METHODS, DEFAULT_CONFIG, OphirError, OphirErrorCode } from '@ophirai/protocol';
|
|
3
|
+
import { signMessage, agreementHash } from './signing.js';
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
/** Validate that a string is non-empty. */
|
|
6
|
+
function requireNonEmpty(value, name) {
|
|
7
|
+
if (!value || typeof value !== 'string' || value.trim().length === 0) {
|
|
8
|
+
throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `${name} must be a non-empty string`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Validate that a string looks like a UUID. */
|
|
12
|
+
function requireUUID(value, name) {
|
|
13
|
+
requireNonEmpty(value, name);
|
|
14
|
+
if (!UUID_RE.test(value)) {
|
|
15
|
+
throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `${name} must be a valid UUID, got: ${value}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Compute an ISO 8601 expiration timestamp from a TTL in milliseconds. */
|
|
19
|
+
function expiresAt(ttlMs) {
|
|
20
|
+
return new Date(Date.now() + ttlMs).toISOString();
|
|
21
|
+
}
|
|
22
|
+
/** Wrap params in a JSON-RPC 2.0 request envelope with a UUID id. */
|
|
23
|
+
function rpcEnvelope(method, params) {
|
|
24
|
+
return { jsonrpc: '2.0', method, id: uuidv4(), params };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a signed RFQ (Request for Quote) message. Signs the RFQ with the buyer's Ed25519 key
|
|
28
|
+
* so that sellers can verify the buyer authorized this request.
|
|
29
|
+
*
|
|
30
|
+
* @throws {OphirError} if buyer identity fields are missing.
|
|
31
|
+
* @throws {OphirError} if secretKey is not 64 bytes.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const rfq = buildRFQ({
|
|
36
|
+
* buyer: { agent_id: 'did:key:z6Mk...', endpoint: 'https://buyer.example.com' },
|
|
37
|
+
* service: { category: 'inference' },
|
|
38
|
+
* budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
39
|
+
* secretKey: keypair.secretKey,
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function buildRFQ(params) {
|
|
44
|
+
requireNonEmpty(params.buyer.agent_id, 'buyer.agent_id');
|
|
45
|
+
requireNonEmpty(params.buyer.endpoint, 'buyer.endpoint');
|
|
46
|
+
if (params.secretKey.length !== 64) {
|
|
47
|
+
throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
|
|
48
|
+
}
|
|
49
|
+
const unsigned = {
|
|
50
|
+
rfq_id: uuidv4(),
|
|
51
|
+
buyer: params.buyer,
|
|
52
|
+
service: params.service,
|
|
53
|
+
budget: params.budget,
|
|
54
|
+
sla_requirements: params.sla,
|
|
55
|
+
negotiation_style: 'rfq',
|
|
56
|
+
max_rounds: params.maxRounds ?? DEFAULT_CONFIG.max_negotiation_rounds,
|
|
57
|
+
expires_at: expiresAt(params.ttlMs ?? DEFAULT_CONFIG.rfq_timeout_ms),
|
|
58
|
+
accepted_payments: params.acceptedPayments,
|
|
59
|
+
};
|
|
60
|
+
const signature = signMessage(unsigned, params.secretKey);
|
|
61
|
+
const rfqParams = { ...unsigned, signature };
|
|
62
|
+
return rpcEnvelope(METHODS.RFQ, rfqParams);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a signed Quote response message. Signs the quote with the seller's Ed25519 key.
|
|
66
|
+
*
|
|
67
|
+
* @throws {OphirError} if rfqId or seller identity fields are missing.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const quote = buildQuote({
|
|
72
|
+
* rfqId: 'a1b2c3d4-...',
|
|
73
|
+
* seller: { agent_id: 'did:key:z6Mk...', endpoint: 'https://seller.example.com' },
|
|
74
|
+
* pricing: { price_per_unit: '0.005', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
|
|
75
|
+
* secretKey: keypair.secretKey,
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function buildQuote(params) {
|
|
80
|
+
requireNonEmpty(params.rfqId, 'rfqId');
|
|
81
|
+
requireNonEmpty(params.seller.agent_id, 'seller.agent_id');
|
|
82
|
+
requireNonEmpty(params.seller.endpoint, 'seller.endpoint');
|
|
83
|
+
if (params.secretKey.length !== 64) {
|
|
84
|
+
throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
|
|
85
|
+
}
|
|
86
|
+
const unsigned = {
|
|
87
|
+
quote_id: uuidv4(),
|
|
88
|
+
rfq_id: params.rfqId,
|
|
89
|
+
seller: params.seller,
|
|
90
|
+
pricing: params.pricing,
|
|
91
|
+
sla_offered: params.sla,
|
|
92
|
+
execution: params.execution,
|
|
93
|
+
escrow_requirement: params.escrow,
|
|
94
|
+
expires_at: expiresAt(params.ttlMs ?? DEFAULT_CONFIG.quote_timeout_ms),
|
|
95
|
+
};
|
|
96
|
+
const signature = signMessage(unsigned, params.secretKey);
|
|
97
|
+
const quoteParams = { ...unsigned, signature };
|
|
98
|
+
return rpcEnvelope(METHODS.QUOTE, quoteParams);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build a signed counter-offer message. Signs with the sender's Ed25519 key.
|
|
102
|
+
*
|
|
103
|
+
* @throws {OphirError} if rfqId, inResponseTo, or from.agent_id are missing.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const counter = buildCounter({
|
|
108
|
+
* rfqId: 'a1b2c3d4-...',
|
|
109
|
+
* inResponseTo: 'e5f6g7h8-...',
|
|
110
|
+
* round: 1,
|
|
111
|
+
* from: { agent_id: 'did:key:z6Mk...', role: 'buyer' },
|
|
112
|
+
* modifications: { price_per_unit: '0.008' },
|
|
113
|
+
* secretKey: keypair.secretKey,
|
|
114
|
+
* });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export function buildCounter(params) {
|
|
118
|
+
requireNonEmpty(params.rfqId, 'rfqId');
|
|
119
|
+
requireNonEmpty(params.inResponseTo, 'inResponseTo');
|
|
120
|
+
requireNonEmpty(params.from.agent_id, 'from.agent_id');
|
|
121
|
+
if (params.secretKey.length !== 64) {
|
|
122
|
+
throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
|
|
123
|
+
}
|
|
124
|
+
const unsigned = {
|
|
125
|
+
counter_id: uuidv4(),
|
|
126
|
+
rfq_id: params.rfqId,
|
|
127
|
+
in_response_to: params.inResponseTo,
|
|
128
|
+
round: params.round,
|
|
129
|
+
from: params.from,
|
|
130
|
+
modifications: params.modifications,
|
|
131
|
+
justification: params.justification,
|
|
132
|
+
expires_at: expiresAt(params.ttlMs ?? DEFAULT_CONFIG.counter_timeout_ms),
|
|
133
|
+
};
|
|
134
|
+
const signature = signMessage(unsigned, params.secretKey);
|
|
135
|
+
const counterParams = { ...unsigned, signature };
|
|
136
|
+
return rpcEnvelope(METHODS.COUNTER, counterParams);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build an Accept message with agreement hash and buyer signature.
|
|
140
|
+
*
|
|
141
|
+
* Validates that finalTerms contains the required fields: price_per_unit, currency, and unit.
|
|
142
|
+
*
|
|
143
|
+
* @throws {OphirError} if rfqId, acceptingMessageId, or required finalTerms fields are missing.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const accept = buildAccept({
|
|
148
|
+
* rfqId: 'a1b2c3d4-...',
|
|
149
|
+
* acceptingMessageId: 'e5f6g7h8-...',
|
|
150
|
+
* finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
|
|
151
|
+
* buyerSecretKey: keypair.secretKey,
|
|
152
|
+
* });
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export function buildAccept(params) {
|
|
156
|
+
requireNonEmpty(params.rfqId, 'rfqId');
|
|
157
|
+
requireNonEmpty(params.acceptingMessageId, 'acceptingMessageId');
|
|
158
|
+
if (!params.finalTerms || typeof params.finalTerms !== 'object') {
|
|
159
|
+
throw new OphirError(OphirErrorCode.INVALID_MESSAGE, 'finalTerms must be a non-null object');
|
|
160
|
+
}
|
|
161
|
+
if (!params.finalTerms.price_per_unit || !params.finalTerms.currency || !params.finalTerms.unit) {
|
|
162
|
+
throw new OphirError(OphirErrorCode.INVALID_MESSAGE, 'finalTerms must contain price_per_unit, currency, and unit');
|
|
163
|
+
}
|
|
164
|
+
if (params.buyerSecretKey.length !== 64) {
|
|
165
|
+
throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid buyer secret key length: expected 64, got ${params.buyerSecretKey.length}`);
|
|
166
|
+
}
|
|
167
|
+
const hash = agreementHash(params.finalTerms);
|
|
168
|
+
const unsigned = {
|
|
169
|
+
agreement_id: uuidv4(),
|
|
170
|
+
rfq_id: params.rfqId,
|
|
171
|
+
accepting_message_id: params.acceptingMessageId,
|
|
172
|
+
final_terms: params.finalTerms,
|
|
173
|
+
agreement_hash: hash,
|
|
174
|
+
};
|
|
175
|
+
const buyerSig = signMessage(unsigned, params.buyerSecretKey);
|
|
176
|
+
const acceptParams = {
|
|
177
|
+
...unsigned,
|
|
178
|
+
buyer_signature: buyerSig,
|
|
179
|
+
...(params.sellerSignature ? { seller_signature: params.sellerSignature } : {}),
|
|
180
|
+
};
|
|
181
|
+
return rpcEnvelope(METHODS.ACCEPT, acceptParams);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Build a signed Reject message to decline a negotiation. Signs with the rejecting
|
|
185
|
+
* agent's Ed25519 key so that receivers can verify the rejection is authorized.
|
|
186
|
+
*
|
|
187
|
+
* @throws {OphirError} if rfqId, rejectingMessageId, reason, or agentId are empty.
|
|
188
|
+
* @throws {OphirError} if secretKey is not 64 bytes.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* const reject = buildReject({
|
|
193
|
+
* rfqId: 'a1b2c3d4-...',
|
|
194
|
+
* rejectingMessageId: 'e5f6g7h8-...',
|
|
195
|
+
* reason: 'Price too high',
|
|
196
|
+
* agentId: 'did:key:z6Mk...',
|
|
197
|
+
* secretKey: keypair.secretKey,
|
|
198
|
+
* });
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export function buildReject(params) {
|
|
202
|
+
requireNonEmpty(params.rfqId, 'rfqId');
|
|
203
|
+
requireNonEmpty(params.rejectingMessageId, 'rejectingMessageId');
|
|
204
|
+
requireNonEmpty(params.reason, 'reason');
|
|
205
|
+
requireNonEmpty(params.agentId, 'agentId');
|
|
206
|
+
if (params.secretKey.length !== 64) {
|
|
207
|
+
throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
|
|
208
|
+
}
|
|
209
|
+
const unsigned = {
|
|
210
|
+
rfq_id: params.rfqId,
|
|
211
|
+
rejecting_message_id: params.rejectingMessageId,
|
|
212
|
+
reason: params.reason,
|
|
213
|
+
from: { agent_id: params.agentId },
|
|
214
|
+
};
|
|
215
|
+
const signature = signMessage(unsigned, params.secretKey);
|
|
216
|
+
const rejectParams = { ...unsigned, signature };
|
|
217
|
+
return rpcEnvelope(METHODS.REJECT, rejectParams);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Build a signed Dispute message with violation evidence.
|
|
221
|
+
*
|
|
222
|
+
* @throws {OphirError} if agreementId, filedBy.agent_id, requestedRemedy, or escrowAction are empty.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```typescript
|
|
226
|
+
* const dispute = buildDispute({
|
|
227
|
+
* agreementId: 'a1b2c3d4-...',
|
|
228
|
+
* filedBy: { agent_id: 'did:key:z6Mk...', role: 'buyer' },
|
|
229
|
+
* violation: {
|
|
230
|
+
* sla_metric: 'uptime_pct',
|
|
231
|
+
* agreed_value: 99.9,
|
|
232
|
+
* observed_value: 95.0,
|
|
233
|
+
* measurement_window: '24h',
|
|
234
|
+
* evidence_hash: 'abc123',
|
|
235
|
+
* },
|
|
236
|
+
* requestedRemedy: 'Full refund',
|
|
237
|
+
* escrowAction: 'release_to_buyer',
|
|
238
|
+
* secretKey: keypair.secretKey,
|
|
239
|
+
* });
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
export function buildDispute(params) {
|
|
243
|
+
requireNonEmpty(params.agreementId, 'agreementId');
|
|
244
|
+
requireNonEmpty(params.filedBy.agent_id, 'filedBy.agent_id');
|
|
245
|
+
requireNonEmpty(params.requestedRemedy, 'requestedRemedy');
|
|
246
|
+
requireNonEmpty(params.escrowAction, 'escrowAction');
|
|
247
|
+
if (params.secretKey.length !== 64) {
|
|
248
|
+
throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
|
|
249
|
+
}
|
|
250
|
+
const unsigned = {
|
|
251
|
+
dispute_id: uuidv4(),
|
|
252
|
+
agreement_id: params.agreementId,
|
|
253
|
+
filed_by: params.filedBy,
|
|
254
|
+
violation: params.violation,
|
|
255
|
+
requested_remedy: params.requestedRemedy,
|
|
256
|
+
escrow_action: params.escrowAction,
|
|
257
|
+
lockstep_report: params.lockstepReport,
|
|
258
|
+
};
|
|
259
|
+
const signature = signMessage(unsigned, params.secretKey);
|
|
260
|
+
const disputeParams = { ...unsigned, signature };
|
|
261
|
+
return rpcEnvelope(METHODS.DISPUTE, disputeParams);
|
|
262
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { NegotiationState, RFQParams, QuoteParams, CounterParams } from '@ophirai/protocol';
|
|
2
|
+
import type { Agreement } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tracks the state of a single negotiation from RFQ through completion or rejection.
|
|
5
|
+
*
|
|
6
|
+
* All state transitions are validated against the protocol's canonical state machine
|
|
7
|
+
* defined in `@ophirai/protocol` using `isValidTransition()`. Invalid transitions
|
|
8
|
+
* throw `OphirError` with code `INVALID_STATE_TRANSITION`.
|
|
9
|
+
*/
|
|
10
|
+
export declare class NegotiationSession {
|
|
11
|
+
readonly rfqId: string;
|
|
12
|
+
state: NegotiationState;
|
|
13
|
+
readonly rfq: RFQParams;
|
|
14
|
+
quotes: QuoteParams[];
|
|
15
|
+
counters: CounterParams[];
|
|
16
|
+
agreement?: Agreement;
|
|
17
|
+
rejectionReason?: string;
|
|
18
|
+
currentRound: number;
|
|
19
|
+
maxRounds: number;
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
updatedAt: Date;
|
|
22
|
+
timeouts: Map<NegotiationState, number>;
|
|
23
|
+
private escrowAddress?;
|
|
24
|
+
constructor(rfq: RFQParams, maxRounds?: number);
|
|
25
|
+
/** Add a received quote to this session and transition state to QUOTES_RECEIVED.
|
|
26
|
+
*
|
|
27
|
+
* Can be called from RFQ_SENT (first quote triggers transition),
|
|
28
|
+
* QUOTES_RECEIVED (additional quotes accumulate without re-transitioning),
|
|
29
|
+
* or COUNTERING (seller's response quote during counter-offer flow).
|
|
30
|
+
*
|
|
31
|
+
* @param quote - The quote parameters received from a seller agent
|
|
32
|
+
* @throws {OphirError} When the session is not in a state that accepts quotes
|
|
33
|
+
*/
|
|
34
|
+
addQuote(quote: QuoteParams): void;
|
|
35
|
+
/** Add a counter-offer and increment the negotiation round.
|
|
36
|
+
* @param counter - The counter-offer parameters
|
|
37
|
+
* @throws {OphirError} When the transition from the current state to COUNTERING is not valid
|
|
38
|
+
* @throws {OphirError} When the current round exceeds the maximum allowed rounds
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* session.addCounter({ rfq_id: 'rfq_1', counter_id: 'ctr_1', ...terms });
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
addCounter(counter: CounterParams): void;
|
|
45
|
+
/** Accept the negotiation with a finalized agreement and transition to ACCEPTED.
|
|
46
|
+
* @param agreement - The finalized agreement terms both parties have agreed upon
|
|
47
|
+
* @throws {OphirError} When the transition from the current state to ACCEPTED is not valid
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* session.accept({ agreement_id: 'agr_1', terms, sla, payment });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
accept(agreement: Agreement): void;
|
|
54
|
+
/** Reject the negotiation with a human-readable reason and transition to REJECTED.
|
|
55
|
+
*
|
|
56
|
+
* Valid from: RFQ_SENT, QUOTES_RECEIVED, COUNTERING, ACCEPTED (per protocol spec).
|
|
57
|
+
* @param reason - Human-readable explanation for why the negotiation was rejected
|
|
58
|
+
* @throws {OphirError} When the transition from the current state to REJECTED is not valid
|
|
59
|
+
*/
|
|
60
|
+
reject(reason: string): void;
|
|
61
|
+
/** Record that escrow has been funded on-chain and transition to ESCROWED.
|
|
62
|
+
* @param escrowAddress - The on-chain address where escrow funds are held
|
|
63
|
+
* @throws {OphirError} When the transition from the current state to ESCROWED is not valid
|
|
64
|
+
*/
|
|
65
|
+
escrowFunded(escrowAddress: string): void;
|
|
66
|
+
/** Activate the agreement so the seller begins service delivery.
|
|
67
|
+
* @throws {OphirError} When the transition from the current state to ACTIVE is not valid
|
|
68
|
+
*/
|
|
69
|
+
activate(): void;
|
|
70
|
+
/** Mark the agreement as successfully completed and transition to COMPLETED.
|
|
71
|
+
* @throws {OphirError} When the transition from the current state to COMPLETED is not valid
|
|
72
|
+
*/
|
|
73
|
+
complete(): void;
|
|
74
|
+
/** Transition to DISPUTED state to claim an SLA violation.
|
|
75
|
+
* @throws {OphirError} When the transition from the current state to DISPUTED is not valid
|
|
76
|
+
*/
|
|
77
|
+
dispute(): void;
|
|
78
|
+
/** Mark a dispute as resolved and transition to RESOLVED.
|
|
79
|
+
* @throws {OphirError} When the transition from the current state to RESOLVED is not valid
|
|
80
|
+
*/
|
|
81
|
+
resolve(): void;
|
|
82
|
+
/** Get the on-chain escrow address, if set.
|
|
83
|
+
* @returns The escrow address, or undefined if escrow has not been funded
|
|
84
|
+
*/
|
|
85
|
+
getEscrowAddress(): string | undefined;
|
|
86
|
+
/** Check if the current state has exceeded its configured timeout.
|
|
87
|
+
* @returns True if the time elapsed since the last transition exceeds the state timeout
|
|
88
|
+
*/
|
|
89
|
+
isExpired(): boolean;
|
|
90
|
+
/** Check if the session is in a terminal state (COMPLETED, REJECTED, or RESOLVED).
|
|
91
|
+
* @returns True if the session has reached a terminal state
|
|
92
|
+
*/
|
|
93
|
+
isTerminal(): boolean;
|
|
94
|
+
/** Get the set of valid next states from the current state.
|
|
95
|
+
* @returns Read-only array of states that can be transitioned to
|
|
96
|
+
*/
|
|
97
|
+
getValidNextStates(): readonly NegotiationState[];
|
|
98
|
+
/** Serialize session state to a plain object for logging or persistence.
|
|
99
|
+
* @returns A plain object containing all session fields with dates as ISO strings
|
|
100
|
+
*/
|
|
101
|
+
toJSON(): Record<string, unknown>;
|
|
102
|
+
/**
|
|
103
|
+
* Validate a state transition against the protocol's canonical state machine.
|
|
104
|
+
* Uses `isValidTransition()` from `@ophirai/protocol` as the single source of truth.
|
|
105
|
+
*
|
|
106
|
+
* @param targetState - The state to transition to
|
|
107
|
+
* @param action - The action name for error messages
|
|
108
|
+
* @throws {OphirError} INVALID_STATE_TRANSITION if the transition is not allowed
|
|
109
|
+
*/
|
|
110
|
+
private enforceTransition;
|
|
111
|
+
/** Apply a validated state transition. */
|
|
112
|
+
private applyTransition;
|
|
113
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { OphirError, OphirErrorCode, DEFAULT_CONFIG, isValidTransition, isTerminalState, getValidNextStates } from '@ophirai/protocol';
|
|
2
|
+
const DEFAULT_TIMEOUTS = {
|
|
3
|
+
RFQ_SENT: DEFAULT_CONFIG.rfq_timeout_ms,
|
|
4
|
+
QUOTES_RECEIVED: DEFAULT_CONFIG.quote_timeout_ms,
|
|
5
|
+
COUNTERING: DEFAULT_CONFIG.counter_timeout_ms,
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Tracks the state of a single negotiation from RFQ through completion or rejection.
|
|
9
|
+
*
|
|
10
|
+
* All state transitions are validated against the protocol's canonical state machine
|
|
11
|
+
* defined in `@ophirai/protocol` using `isValidTransition()`. Invalid transitions
|
|
12
|
+
* throw `OphirError` with code `INVALID_STATE_TRANSITION`.
|
|
13
|
+
*/
|
|
14
|
+
export class NegotiationSession {
|
|
15
|
+
rfqId;
|
|
16
|
+
state;
|
|
17
|
+
rfq;
|
|
18
|
+
quotes = [];
|
|
19
|
+
counters = [];
|
|
20
|
+
agreement;
|
|
21
|
+
rejectionReason;
|
|
22
|
+
currentRound = 0;
|
|
23
|
+
maxRounds;
|
|
24
|
+
createdAt;
|
|
25
|
+
updatedAt;
|
|
26
|
+
timeouts;
|
|
27
|
+
escrowAddress;
|
|
28
|
+
constructor(rfq, maxRounds) {
|
|
29
|
+
this.rfqId = rfq.rfq_id;
|
|
30
|
+
this.rfq = rfq;
|
|
31
|
+
this.state = 'RFQ_SENT';
|
|
32
|
+
this.maxRounds = maxRounds ?? rfq.max_rounds ?? DEFAULT_CONFIG.max_negotiation_rounds;
|
|
33
|
+
this.createdAt = new Date();
|
|
34
|
+
this.updatedAt = new Date();
|
|
35
|
+
this.timeouts = new Map();
|
|
36
|
+
for (const [state, ms] of Object.entries(DEFAULT_TIMEOUTS)) {
|
|
37
|
+
this.timeouts.set(state, ms);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Add a received quote to this session and transition state to QUOTES_RECEIVED.
|
|
41
|
+
*
|
|
42
|
+
* Can be called from RFQ_SENT (first quote triggers transition),
|
|
43
|
+
* QUOTES_RECEIVED (additional quotes accumulate without re-transitioning),
|
|
44
|
+
* or COUNTERING (seller's response quote during counter-offer flow).
|
|
45
|
+
*
|
|
46
|
+
* @param quote - The quote parameters received from a seller agent
|
|
47
|
+
* @throws {OphirError} When the session is not in a state that accepts quotes
|
|
48
|
+
*/
|
|
49
|
+
addQuote(quote) {
|
|
50
|
+
// Allow accumulating quotes while already in QUOTES_RECEIVED (same-state is a no-op transition)
|
|
51
|
+
if (this.state === 'QUOTES_RECEIVED') {
|
|
52
|
+
this.quotes.push(quote);
|
|
53
|
+
this.updatedAt = new Date();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// During countering, a seller's response quote is part of the counter-offer flow.
|
|
57
|
+
// Accumulate it without changing state (COUNTERING → COUNTERING is valid).
|
|
58
|
+
if (this.state === 'COUNTERING') {
|
|
59
|
+
this.quotes.push(quote);
|
|
60
|
+
this.updatedAt = new Date();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.enforceTransition('QUOTES_RECEIVED', 'addQuote');
|
|
64
|
+
this.quotes.push(quote);
|
|
65
|
+
this.applyTransition('QUOTES_RECEIVED');
|
|
66
|
+
}
|
|
67
|
+
/** Add a counter-offer and increment the negotiation round.
|
|
68
|
+
* @param counter - The counter-offer parameters
|
|
69
|
+
* @throws {OphirError} When the transition from the current state to COUNTERING is not valid
|
|
70
|
+
* @throws {OphirError} When the current round exceeds the maximum allowed rounds
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* session.addCounter({ rfq_id: 'rfq_1', counter_id: 'ctr_1', ...terms });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
addCounter(counter) {
|
|
77
|
+
this.enforceTransition('COUNTERING', 'addCounter');
|
|
78
|
+
this.currentRound++;
|
|
79
|
+
if (this.currentRound > this.maxRounds) {
|
|
80
|
+
throw new OphirError(OphirErrorCode.MAX_ROUNDS_EXCEEDED, `Round ${this.currentRound} exceeds max ${this.maxRounds}`);
|
|
81
|
+
}
|
|
82
|
+
this.counters.push(counter);
|
|
83
|
+
this.applyTransition('COUNTERING');
|
|
84
|
+
}
|
|
85
|
+
/** Accept the negotiation with a finalized agreement and transition to ACCEPTED.
|
|
86
|
+
* @param agreement - The finalized agreement terms both parties have agreed upon
|
|
87
|
+
* @throws {OphirError} When the transition from the current state to ACCEPTED is not valid
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* session.accept({ agreement_id: 'agr_1', terms, sla, payment });
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
accept(agreement) {
|
|
94
|
+
this.enforceTransition('ACCEPTED', 'accept');
|
|
95
|
+
this.agreement = agreement;
|
|
96
|
+
this.applyTransition('ACCEPTED');
|
|
97
|
+
}
|
|
98
|
+
/** Reject the negotiation with a human-readable reason and transition to REJECTED.
|
|
99
|
+
*
|
|
100
|
+
* Valid from: RFQ_SENT, QUOTES_RECEIVED, COUNTERING, ACCEPTED (per protocol spec).
|
|
101
|
+
* @param reason - Human-readable explanation for why the negotiation was rejected
|
|
102
|
+
* @throws {OphirError} When the transition from the current state to REJECTED is not valid
|
|
103
|
+
*/
|
|
104
|
+
reject(reason) {
|
|
105
|
+
this.enforceTransition('REJECTED', 'reject');
|
|
106
|
+
this.rejectionReason = reason;
|
|
107
|
+
this.applyTransition('REJECTED');
|
|
108
|
+
}
|
|
109
|
+
/** Record that escrow has been funded on-chain and transition to ESCROWED.
|
|
110
|
+
* @param escrowAddress - The on-chain address where escrow funds are held
|
|
111
|
+
* @throws {OphirError} When the transition from the current state to ESCROWED is not valid
|
|
112
|
+
*/
|
|
113
|
+
escrowFunded(escrowAddress) {
|
|
114
|
+
this.enforceTransition('ESCROWED', 'escrowFunded');
|
|
115
|
+
this.escrowAddress = escrowAddress;
|
|
116
|
+
this.applyTransition('ESCROWED');
|
|
117
|
+
}
|
|
118
|
+
/** Activate the agreement so the seller begins service delivery.
|
|
119
|
+
* @throws {OphirError} When the transition from the current state to ACTIVE is not valid
|
|
120
|
+
*/
|
|
121
|
+
activate() {
|
|
122
|
+
this.enforceTransition('ACTIVE', 'activate');
|
|
123
|
+
this.applyTransition('ACTIVE');
|
|
124
|
+
}
|
|
125
|
+
/** Mark the agreement as successfully completed and transition to COMPLETED.
|
|
126
|
+
* @throws {OphirError} When the transition from the current state to COMPLETED is not valid
|
|
127
|
+
*/
|
|
128
|
+
complete() {
|
|
129
|
+
this.enforceTransition('COMPLETED', 'complete');
|
|
130
|
+
this.applyTransition('COMPLETED');
|
|
131
|
+
}
|
|
132
|
+
/** Transition to DISPUTED state to claim an SLA violation.
|
|
133
|
+
* @throws {OphirError} When the transition from the current state to DISPUTED is not valid
|
|
134
|
+
*/
|
|
135
|
+
dispute() {
|
|
136
|
+
this.enforceTransition('DISPUTED', 'dispute');
|
|
137
|
+
this.applyTransition('DISPUTED');
|
|
138
|
+
}
|
|
139
|
+
/** Mark a dispute as resolved and transition to RESOLVED.
|
|
140
|
+
* @throws {OphirError} When the transition from the current state to RESOLVED is not valid
|
|
141
|
+
*/
|
|
142
|
+
resolve() {
|
|
143
|
+
this.enforceTransition('RESOLVED', 'resolve');
|
|
144
|
+
this.applyTransition('RESOLVED');
|
|
145
|
+
}
|
|
146
|
+
/** Get the on-chain escrow address, if set.
|
|
147
|
+
* @returns The escrow address, or undefined if escrow has not been funded
|
|
148
|
+
*/
|
|
149
|
+
getEscrowAddress() {
|
|
150
|
+
return this.escrowAddress;
|
|
151
|
+
}
|
|
152
|
+
/** Check if the current state has exceeded its configured timeout.
|
|
153
|
+
* @returns True if the time elapsed since the last transition exceeds the state timeout
|
|
154
|
+
*/
|
|
155
|
+
isExpired() {
|
|
156
|
+
const timeout = this.timeouts.get(this.state);
|
|
157
|
+
if (timeout === undefined)
|
|
158
|
+
return false;
|
|
159
|
+
return Date.now() - this.updatedAt.getTime() > timeout;
|
|
160
|
+
}
|
|
161
|
+
/** Check if the session is in a terminal state (COMPLETED, REJECTED, or RESOLVED).
|
|
162
|
+
* @returns True if the session has reached a terminal state
|
|
163
|
+
*/
|
|
164
|
+
isTerminal() {
|
|
165
|
+
return isTerminalState(this.state);
|
|
166
|
+
}
|
|
167
|
+
/** Get the set of valid next states from the current state.
|
|
168
|
+
* @returns Read-only array of states that can be transitioned to
|
|
169
|
+
*/
|
|
170
|
+
getValidNextStates() {
|
|
171
|
+
return getValidNextStates(this.state);
|
|
172
|
+
}
|
|
173
|
+
/** Serialize session state to a plain object for logging or persistence.
|
|
174
|
+
* @returns A plain object containing all session fields with dates as ISO strings
|
|
175
|
+
*/
|
|
176
|
+
toJSON() {
|
|
177
|
+
return {
|
|
178
|
+
rfqId: this.rfqId,
|
|
179
|
+
state: this.state,
|
|
180
|
+
rfq: this.rfq,
|
|
181
|
+
quotes: this.quotes,
|
|
182
|
+
counters: this.counters,
|
|
183
|
+
agreement: this.agreement,
|
|
184
|
+
rejectionReason: this.rejectionReason,
|
|
185
|
+
currentRound: this.currentRound,
|
|
186
|
+
maxRounds: this.maxRounds,
|
|
187
|
+
escrowAddress: this.escrowAddress,
|
|
188
|
+
createdAt: this.createdAt.toISOString(),
|
|
189
|
+
updatedAt: this.updatedAt.toISOString(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Validate a state transition against the protocol's canonical state machine.
|
|
194
|
+
* Uses `isValidTransition()` from `@ophirai/protocol` as the single source of truth.
|
|
195
|
+
*
|
|
196
|
+
* @param targetState - The state to transition to
|
|
197
|
+
* @param action - The action name for error messages
|
|
198
|
+
* @throws {OphirError} INVALID_STATE_TRANSITION if the transition is not allowed
|
|
199
|
+
*/
|
|
200
|
+
enforceTransition(targetState, action) {
|
|
201
|
+
if (isTerminalState(this.state)) {
|
|
202
|
+
throw new OphirError(OphirErrorCode.INVALID_STATE_TRANSITION, `Cannot ${action}: session is in terminal state ${this.state}`, { currentState: this.state, targetState });
|
|
203
|
+
}
|
|
204
|
+
if (!isValidTransition(this.state, targetState)) {
|
|
205
|
+
const validNext = getValidNextStates(this.state);
|
|
206
|
+
throw new OphirError(OphirErrorCode.INVALID_STATE_TRANSITION, `Cannot ${action} from state ${this.state}. Valid transitions: ${validNext.join(', ')}`, { currentState: this.state, targetState, validTransitions: [...validNext] });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Apply a validated state transition. */
|
|
210
|
+
applyTransition(to) {
|
|
211
|
+
this.state = to;
|
|
212
|
+
this.updatedAt = new Date();
|
|
213
|
+
}
|
|
214
|
+
}
|