@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { agreementToX402Headers, parseX402Response } from '../x402.js';
|
|
3
|
+
function makeAgreement(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
agreement_id: 'agr_x402_001',
|
|
6
|
+
rfq_id: 'rfq_001',
|
|
7
|
+
accepting_message_id: 'quote_001',
|
|
8
|
+
final_terms: {
|
|
9
|
+
price_per_unit: '0.005',
|
|
10
|
+
currency: 'USDC',
|
|
11
|
+
unit: 'request',
|
|
12
|
+
},
|
|
13
|
+
agreement_hash: 'abcdef1234567890',
|
|
14
|
+
buyer_signature: 'buyer_sig',
|
|
15
|
+
seller_signature: 'seller_sig',
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('agreementToX402Headers', () => {
|
|
20
|
+
it('includes agreement ID in headers', () => {
|
|
21
|
+
const agreement = makeAgreement();
|
|
22
|
+
const headers = agreementToX402Headers(agreement);
|
|
23
|
+
expect(headers['X-Payment-Agreement-Id']).toBe('agr_x402_001');
|
|
24
|
+
});
|
|
25
|
+
it('includes price, currency, unit, and agreement hash', () => {
|
|
26
|
+
const agreement = makeAgreement();
|
|
27
|
+
const headers = agreementToX402Headers(agreement);
|
|
28
|
+
expect(headers['X-Payment-Amount']).toBe('0.005');
|
|
29
|
+
expect(headers['X-Payment-Currency']).toBe('USDC');
|
|
30
|
+
expect(headers['X-Payment-Unit']).toBe('request');
|
|
31
|
+
expect(headers['X-Payment-Agreement-Hash']).toBe('abcdef1234567890');
|
|
32
|
+
});
|
|
33
|
+
it('includes escrow network and deposit when final_terms has escrow', () => {
|
|
34
|
+
const agreement = makeAgreement({
|
|
35
|
+
final_terms: {
|
|
36
|
+
price_per_unit: '0.01',
|
|
37
|
+
currency: 'USDC',
|
|
38
|
+
unit: 'request',
|
|
39
|
+
escrow: {
|
|
40
|
+
network: 'solana',
|
|
41
|
+
deposit_amount: '100.0',
|
|
42
|
+
release_condition: 'sla_met',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const headers = agreementToX402Headers(agreement);
|
|
47
|
+
expect(headers['X-Payment-Network']).toBe('solana');
|
|
48
|
+
expect(headers['X-Payment-Escrow-Deposit']).toBe('100.0');
|
|
49
|
+
});
|
|
50
|
+
it('includes escrow address when agreement has escrow PDA', () => {
|
|
51
|
+
const agreement = makeAgreement({
|
|
52
|
+
escrow: {
|
|
53
|
+
address: 'EscrowPDA_base58address',
|
|
54
|
+
txSignature: 'tx_sig_123',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const headers = agreementToX402Headers(agreement);
|
|
58
|
+
expect(headers['X-Payment-Escrow-Address']).toBe('EscrowPDA_base58address');
|
|
59
|
+
});
|
|
60
|
+
it('omits escrow headers when no escrow configured', () => {
|
|
61
|
+
const agreement = makeAgreement();
|
|
62
|
+
const headers = agreementToX402Headers(agreement);
|
|
63
|
+
expect(headers['X-Payment-Network']).toBeUndefined();
|
|
64
|
+
expect(headers['X-Payment-Escrow-Deposit']).toBeUndefined();
|
|
65
|
+
expect(headers['X-Payment-Escrow-Address']).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
it('returns all expected header keys', () => {
|
|
68
|
+
const agreement = makeAgreement({
|
|
69
|
+
final_terms: {
|
|
70
|
+
price_per_unit: '0.01',
|
|
71
|
+
currency: 'USDC',
|
|
72
|
+
unit: 'token',
|
|
73
|
+
escrow: {
|
|
74
|
+
network: 'solana',
|
|
75
|
+
deposit_amount: '50',
|
|
76
|
+
release_condition: 'job_complete',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
escrow: {
|
|
80
|
+
address: 'PDA_addr',
|
|
81
|
+
txSignature: 'tx123',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const headers = agreementToX402Headers(agreement);
|
|
85
|
+
const keys = Object.keys(headers);
|
|
86
|
+
expect(keys).toContain('X-Payment-Amount');
|
|
87
|
+
expect(keys).toContain('X-Payment-Currency');
|
|
88
|
+
expect(keys).toContain('X-Payment-Agreement-Id');
|
|
89
|
+
expect(keys).toContain('X-Payment-Agreement-Hash');
|
|
90
|
+
expect(keys).toContain('X-Payment-Unit');
|
|
91
|
+
expect(keys).toContain('X-Payment-Network');
|
|
92
|
+
expect(keys).toContain('X-Payment-Escrow-Deposit');
|
|
93
|
+
expect(keys).toContain('X-Payment-Escrow-Address');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('parseX402Response', () => {
|
|
97
|
+
it('parses standard cased headers', () => {
|
|
98
|
+
const result = parseX402Response({
|
|
99
|
+
'X-Payment-Amount': '0.005',
|
|
100
|
+
'X-Payment-Currency': 'USDC',
|
|
101
|
+
'X-Payment-Address': 'addr_123',
|
|
102
|
+
});
|
|
103
|
+
expect(result.price).toBe('0.005');
|
|
104
|
+
expect(result.currency).toBe('USDC');
|
|
105
|
+
expect(result.paymentAddress).toBe('addr_123');
|
|
106
|
+
});
|
|
107
|
+
it('parses lowercase headers', () => {
|
|
108
|
+
const result = parseX402Response({
|
|
109
|
+
'x-payment-amount': '0.01',
|
|
110
|
+
'x-payment-currency': 'SOL',
|
|
111
|
+
'x-payment-address': 'sol_addr',
|
|
112
|
+
});
|
|
113
|
+
expect(result.price).toBe('0.01');
|
|
114
|
+
expect(result.currency).toBe('SOL');
|
|
115
|
+
expect(result.paymentAddress).toBe('sol_addr');
|
|
116
|
+
});
|
|
117
|
+
it('parses mixed-case headers', () => {
|
|
118
|
+
const result = parseX402Response({
|
|
119
|
+
'X-PAYMENT-AMOUNT': '1.5',
|
|
120
|
+
'X-Payment-currency': 'ETH',
|
|
121
|
+
'x-Payment-Address': 'eth_addr',
|
|
122
|
+
});
|
|
123
|
+
expect(result.price).toBe('1.5');
|
|
124
|
+
expect(result.currency).toBe('ETH');
|
|
125
|
+
expect(result.paymentAddress).toBe('eth_addr');
|
|
126
|
+
});
|
|
127
|
+
it('returns defaults when headers are missing', () => {
|
|
128
|
+
const result = parseX402Response({});
|
|
129
|
+
expect(result.price).toBe('0');
|
|
130
|
+
expect(result.currency).toBe('USDC');
|
|
131
|
+
expect(result.paymentAddress).toBe('');
|
|
132
|
+
});
|
|
133
|
+
it('returns partial defaults for partially present headers', () => {
|
|
134
|
+
const result = parseX402Response({
|
|
135
|
+
'X-Payment-Amount': '5.0',
|
|
136
|
+
});
|
|
137
|
+
expect(result.price).toBe('5.0');
|
|
138
|
+
expect(result.currency).toBe('USDC');
|
|
139
|
+
expect(result.paymentAddress).toBe('');
|
|
140
|
+
});
|
|
141
|
+
});
|
package/dist/buyer.d.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { QuoteParams, ServiceRequirement, BudgetConstraint, SLARequirement, ViolationEvidence } from '@ophirai/protocol';
|
|
2
|
+
import { NegotiationSession } from './negotiation.js';
|
|
3
|
+
import type { EscrowConfig, RankingFunction, SellerInfo, Agreement, DisputeResult } from './types.js';
|
|
4
|
+
/** Configuration for creating a BuyerAgent. */
|
|
5
|
+
export interface BuyerAgentConfig {
|
|
6
|
+
/** Optional Ed25519 keypair; auto-generated if omitted. */
|
|
7
|
+
keypair?: {
|
|
8
|
+
publicKey: Uint8Array;
|
|
9
|
+
secretKey: Uint8Array;
|
|
10
|
+
};
|
|
11
|
+
/** HTTP endpoint URL where this buyer listens for incoming quotes and counter-offers. */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
/** Optional Solana escrow configuration for payment enforcement. */
|
|
14
|
+
escrowConfig?: EscrowConfig;
|
|
15
|
+
/** Optional Lockstep verification endpoint for SLA compliance monitoring. */
|
|
16
|
+
lockstepEndpoint?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Buy-side negotiation agent. Sends RFQs, collects quotes, ranks them,
|
|
20
|
+
* and accepts/counters/rejects offers. Verifies seller signatures on all
|
|
21
|
+
* incoming messages.
|
|
22
|
+
*/
|
|
23
|
+
export declare class BuyerAgent {
|
|
24
|
+
private keypair;
|
|
25
|
+
private agentId;
|
|
26
|
+
private endpoint;
|
|
27
|
+
private transport;
|
|
28
|
+
private server;
|
|
29
|
+
private sessions;
|
|
30
|
+
private quoteListeners;
|
|
31
|
+
/** Tracks processed message IDs within the replay window to reject duplicate/replayed messages. */
|
|
32
|
+
private seenMessageIds;
|
|
33
|
+
constructor(config: BuyerAgentConfig);
|
|
34
|
+
/** Check if a message ID has already been processed (replay protection).
|
|
35
|
+
* Records the ID if new; throws DUPLICATE_MESSAGE if already seen.
|
|
36
|
+
* Periodically evicts entries older than the replay protection window. */
|
|
37
|
+
private enforceNoDuplicate;
|
|
38
|
+
/** Register JSON-RPC handlers for Quote, Counter, and Accept methods.
|
|
39
|
+
* Each handler validates the message schema, verifies the sender's Ed25519
|
|
40
|
+
* signature, checks expiration, enforces replay protection, and updates the session state. */
|
|
41
|
+
private registerHandlers;
|
|
42
|
+
/** Resolve all pending waitForQuotes() promises for the given RFQ and clear the listener queue. */
|
|
43
|
+
private notifyQuoteListeners;
|
|
44
|
+
/** Discover seller agents matching a service category. Currently returns empty; use direct endpoints.
|
|
45
|
+
* @param _query - Search criteria containing a service category and optional requirements
|
|
46
|
+
* @returns An empty array (placeholder for future discovery integration)
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const sellers = await buyer.discover({ category: 'llm-inference' });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
discover(_query: {
|
|
53
|
+
category: string;
|
|
54
|
+
requirements?: Record<string, unknown>;
|
|
55
|
+
}): Promise<SellerInfo[]>;
|
|
56
|
+
/** Send an RFQ to one or more sellers and return the negotiation session.
|
|
57
|
+
* @param params - RFQ parameters including seller targets, service requirements, budget, and SLA
|
|
58
|
+
* @param params.sellers - Seller endpoints (strings) or SellerInfo objects to receive the RFQ
|
|
59
|
+
* @param params.service - The service requirement describing what the buyer needs
|
|
60
|
+
* @param params.budget - Budget constraint with maximum price and currency
|
|
61
|
+
* @param params.sla - Optional SLA requirements for the service
|
|
62
|
+
* @param params.maxRounds - Optional maximum number of negotiation rounds
|
|
63
|
+
* @param params.timeout - Optional TTL in milliseconds for the RFQ
|
|
64
|
+
* @returns The newly created NegotiationSession tracking this RFQ
|
|
65
|
+
* @throws {OphirError} When a non-network error occurs sending to a seller
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const session = await buyer.requestQuotes({
|
|
69
|
+
* sellers: ['http://seller:3000'],
|
|
70
|
+
* service: { category: 'llm-inference', params: {} },
|
|
71
|
+
* budget: { max_price_per_unit: '0.01', currency: 'USDC' },
|
|
72
|
+
* });
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
requestQuotes(params: {
|
|
76
|
+
sellers: string[] | SellerInfo[];
|
|
77
|
+
service: ServiceRequirement;
|
|
78
|
+
budget: BudgetConstraint;
|
|
79
|
+
sla?: SLARequirement;
|
|
80
|
+
maxRounds?: number;
|
|
81
|
+
timeout?: number;
|
|
82
|
+
}): Promise<NegotiationSession>;
|
|
83
|
+
/** Wait for quotes to arrive, resolving when minQuotes are received or timeout elapses.
|
|
84
|
+
* @param session - The negotiation session to wait on
|
|
85
|
+
* @param options - Optional wait configuration
|
|
86
|
+
* @param options.minQuotes - Minimum number of quotes before resolving (default: 1)
|
|
87
|
+
* @param options.timeout - Maximum time to wait in milliseconds (default: 30000)
|
|
88
|
+
* @returns Array of quotes received so far when the condition is met or timeout elapses
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* const quotes = await buyer.waitForQuotes(session, { minQuotes: 2, timeout: 10000 });
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
waitForQuotes(session: NegotiationSession, options?: {
|
|
95
|
+
minQuotes?: number;
|
|
96
|
+
timeout?: number;
|
|
97
|
+
}): Promise<QuoteParams[]>;
|
|
98
|
+
/** Sort quotes by a ranking strategy (cheapest, fastest, best_sla, or custom function).
|
|
99
|
+
* @param quotes - Array of quotes to rank
|
|
100
|
+
* @param strategy - Ranking strategy: 'cheapest', 'fastest', 'best_sla', or a custom comparator (default: 'cheapest')
|
|
101
|
+
* @returns A new sorted array of quotes (best first)
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* const ranked = buyer.rankQuotes(quotes, 'fastest');
|
|
105
|
+
* const best = ranked[0];
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
rankQuotes(quotes: QuoteParams[], strategy?: 'cheapest' | 'fastest' | 'best_sla' | RankingFunction): QuoteParams[];
|
|
109
|
+
/** 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. */
|
|
110
|
+
private scoreSLA;
|
|
111
|
+
/** Accept a quote, creating a signed agreement with the seller. Verifies the seller's signature first.
|
|
112
|
+
* @param quote - The quote to accept, as received from a seller
|
|
113
|
+
* @returns A dual-signed Agreement containing final terms and both party signatures
|
|
114
|
+
* @throws {OphirError} When the seller's signature on the quote is invalid
|
|
115
|
+
* @throws {OphirError} When the seller's counter-signature on the accept is invalid
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* const agreement = await buyer.acceptQuote(quotes[0]);
|
|
119
|
+
* console.log(agreement.agreement_id);
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
acceptQuote(quote: QuoteParams): Promise<Agreement>;
|
|
123
|
+
/** Send a counter-offer proposing modified terms for a quote.
|
|
124
|
+
* @param quote - The original quote to counter
|
|
125
|
+
* @param modifications - Key-value map of proposed term changes (e.g., price, SLA targets)
|
|
126
|
+
* @param justification - Optional human-readable reason for the counter-offer
|
|
127
|
+
* @returns The updated NegotiationSession reflecting the new counter round
|
|
128
|
+
* @throws {OphirError} When no active session exists for the quote's RFQ ID
|
|
129
|
+
* @example
|
|
130
|
+
* ```typescript
|
|
131
|
+
* const session = await buyer.counter(quote, { price_per_unit: '0.008' }, 'Volume discount');
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
counter(quote: QuoteParams, modifications: Record<string, unknown>, justification?: string): Promise<NegotiationSession>;
|
|
135
|
+
/** Reject all quotes in a session and notify sellers.
|
|
136
|
+
* @param session - The negotiation session whose quotes should be rejected
|
|
137
|
+
* @param reason - Optional rejection reason sent to all sellers (default: 'Rejected by buyer')
|
|
138
|
+
* @returns Resolves when all rejection messages have been sent
|
|
139
|
+
* @throws {OphirError} When a non-network error occurs notifying a seller
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* await buyer.reject(session, 'Budget exceeded');
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
reject(session: NegotiationSession, reason?: string): Promise<void>;
|
|
146
|
+
/** File an SLA violation dispute against a seller for a given agreement.
|
|
147
|
+
* @param agreement - The agreement under which the violation occurred
|
|
148
|
+
* @param violation - Evidence of the SLA violation including metric name and observed value
|
|
149
|
+
* @returns A DisputeResult with the dispute ID and initial 'pending' outcome
|
|
150
|
+
* @throws {OphirError} When a non-network error occurs notifying the seller
|
|
151
|
+
* @example
|
|
152
|
+
* ```typescript
|
|
153
|
+
* const result = await buyer.dispute(agreement, {
|
|
154
|
+
* metric: 'uptime_pct', observed: 95.0, threshold: 99.9,
|
|
155
|
+
* });
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
dispute(agreement: Agreement, violation: ViolationEvidence): Promise<DisputeResult>;
|
|
159
|
+
/** Get a negotiation session by its RFQ ID.
|
|
160
|
+
* @param rfqId - The RFQ identifier to look up
|
|
161
|
+
* @returns The matching NegotiationSession, or undefined if not found
|
|
162
|
+
*/
|
|
163
|
+
getSession(rfqId: string): NegotiationSession | undefined;
|
|
164
|
+
/** Get all active negotiation sessions.
|
|
165
|
+
* @returns Array of all NegotiationSession instances tracked by this agent
|
|
166
|
+
*/
|
|
167
|
+
getSessions(): NegotiationSession[];
|
|
168
|
+
/** Start the HTTP server to receive quotes and counter-offers.
|
|
169
|
+
* @param port - Port number to listen on (default: 3001). Pass 0 for a random available port.
|
|
170
|
+
* @returns Resolves when the server is listening
|
|
171
|
+
* @example
|
|
172
|
+
* ```typescript
|
|
173
|
+
* await buyer.listen(0);
|
|
174
|
+
* console.log(buyer.getEndpoint());
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
listen(port?: number): Promise<void>;
|
|
178
|
+
/** Stop the HTTP server and close all connections.
|
|
179
|
+
* @returns Resolves when the server has been shut down
|
|
180
|
+
*/
|
|
181
|
+
close(): Promise<void>;
|
|
182
|
+
/** Get this agent's did:key identifier.
|
|
183
|
+
* @returns The agent's decentralized identifier (did:key)
|
|
184
|
+
*/
|
|
185
|
+
getAgentId(): string;
|
|
186
|
+
/** Get this agent's HTTP endpoint URL.
|
|
187
|
+
* @returns The endpoint URL string (updated after listen() binds a port)
|
|
188
|
+
*/
|
|
189
|
+
getEndpoint(): string;
|
|
190
|
+
}
|