@fitzdoud/valun 0.0.0-stage → 0.3.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/src/client.js ADDED
@@ -0,0 +1,565 @@
1
+ // Valun pilot client: the participant side. Holds the participant's signing key locally and talks to a Valun server.
2
+ //
3
+ // A port of the Python reference client (python/src/valun/client.py). Trust model: the server is not trusted to tell
4
+ // this client what to sign. Before signing, the client recomputes the terms hash itself, checks the counterparty's
5
+ // signature against the key in the public directory, and checks credit-note templates against the agreed price and its
6
+ // own record of what it has already issued to that payee. The key and the agent token live in a store and are never
7
+ // returned by any method, logged, or put into an error.
8
+
9
+ import {sign, verifyHex, noteMessage, publicKey, generateSecret as defaultGenerateSecret} from './basis.js';
10
+ import {bytesToHex, hexToBytes, utf8} from './bytes.js';
11
+ import {SignatureError, ValunError} from './errors.js';
12
+ import {CONSIDERATION, NETWORK, PROTOCOL, UNIT, reasonProblem, sha256Hex, statements, termsHash} from './protocol.js';
13
+ import {randomBytes as defaultRandomBytes} from './random.js';
14
+
15
+ const HOUR = 3600_000;
16
+ const NOTE_CLOCK_TOLERANCE_MS = 10 * 60_000;
17
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
18
+
19
+ /** Whole-second UTC time as the Python client writes it: YYYY-MM-DDTHH:MM:SS.000Z. */
20
+ function isoSeconds(ms) {
21
+ return new Date(Math.floor(ms / 1000) * 1000).toISOString();
22
+ }
23
+
24
+ function isObject(v) {
25
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
26
+ }
27
+
28
+ const TERMS_FIELDS = ['protocol', 'network', 'tracker_nft_id', 'denomination', 'deal_id', 'version', 'prior_terms_hash',
29
+ 'nonce', 'proposer', 'payer', 'payee', 'service', 'price', 'consideration', 'deliver_by', 'offer_expires_at'];
30
+
31
+ function exactKeys(o, keys) {
32
+ return isObject(o) && Object.keys(o).length === keys.length && keys.every(k => Object.prototype.hasOwnProperty.call(o, k));
33
+ }
34
+
35
+ function isRfc3339(s) {
36
+ return typeof s === 'string' && RFC3339.test(s) && !Number.isNaN(Date.parse(s));
37
+ }
38
+
39
+ function seg(id) {
40
+ if (typeof id !== 'string' || id === '') throw new ValunError('bad_id', 'ids must be non-empty strings');
41
+ return encodeURIComponent(id);
42
+ }
43
+
44
+ function toPrice(price) {
45
+ const n = typeof price === 'string' && /^[0-9]+$/.test(price) ? Number(price) : price;
46
+ if (typeof n === 'number' && Number.isSafeInteger(n) && n >= 1) return n;
47
+ throw new ValunError('bad_price', 'price must be a whole number of PC, at least 1');
48
+ }
49
+
50
+ function toHours(h, what) {
51
+ if (typeof h !== 'number' || !Number.isFinite(h)) throw new ValunError('bad_terms', `${what} must be a number of hours`);
52
+ return h;
53
+ }
54
+
55
+ /** Structural guard: the fields every check below reads, with the types it relies on. */
56
+ function checkTermsShape(t) {
57
+ const party = p => isObject(p) && typeof p.id === 'string' && typeof p.pubkey === 'string';
58
+ if (!isObject(t) || !party(t.payer) || !party(t.payee) || !isObject(t.service) || !isObject(t.price)
59
+ || typeof t.tracker_nft_id !== 'string' || typeof t.denomination !== 'string') {
60
+ throw new ValunError('bad_terms', 'the terms are not shaped as valun-deal/1 terms; refusing to sign');
61
+ }
62
+ }
63
+
64
+ /** A reason the server would refuse is not sent (statements.json tier "refused": a client must not send them). */
65
+ function refuseReason(kind, reason) {
66
+ const p = reasonProblem(kind, reason);
67
+ if (p) throw new ValunError(p.error, p.message, undefined, {field: 'reason', local: true});
68
+ }
69
+
70
+ function timeoutSignal(ms) {
71
+ if (typeof AbortController !== 'function' || !(ms > 0)) return {signal: undefined, cancel() {}};
72
+ const c = new AbortController();
73
+ const t = setTimeout(() => c.abort(), ms);
74
+ return {signal: c.signal, cancel: () => clearTimeout(t)};
75
+ }
76
+
77
+ /**
78
+ * In-memory store: settings and the agent's record of notes issued in `state` (plain, non-secret), the signing key
79
+ * and agent token in private fields that no enumeration, JSON.stringify or util.inspect can reach.
80
+ */
81
+ export class MemoryStore {
82
+ #secret;
83
+ #token;
84
+
85
+ constructor({state = {}, secret = null, token = null} = {}) {
86
+ this.state = state;
87
+ this.#secret = secret;
88
+ this.#token = token;
89
+ }
90
+
91
+ secret() { return this.#secret; }
92
+ token() { return this.#token; }
93
+ saveCredentials(secretHex, token) { this.#secret = secretHex; this.#token = token; }
94
+ save() {}
95
+ }
96
+
97
+ export class Client {
98
+ #store;
99
+ #fetch;
100
+ #timeoutMs;
101
+ #now;
102
+ #randomBytes;
103
+ #generateSecret;
104
+ #pin;
105
+ #signerRetries;
106
+ #retryDelayMs;
107
+
108
+ /**
109
+ * @param {object} options
110
+ * store an object with .state (non-secret, plain object), .secret(), .token(), .save() and
111
+ * .saveCredentials(secretHex, token) (save/saveCredentials may return a Promise). Default: MemoryStore.
112
+ * server base URL of the Valun server, e.g. "https://host" (the API is under /pilot/v1). Kept in state.
113
+ * timeoutMs per request (default 30000).
114
+ * fetch a fetch implementation (default globalThis.fetch).
115
+ * pin {trackerNftId, denomination}: values the network must have (spec 15.2: SHOULD pin).
116
+ * signerRetries how many times to send the same signed request again after 503 signer_unavailable (default 0:
117
+ * the error is thrown, as the Python client does); retryDelayMs (default 1000) doubles each time.
118
+ * now, randomBytes, generateSecret clock (ms), id/nonce source and key generator: test hooks only.
119
+ */
120
+ constructor(options = {}) {
121
+ const {store = new MemoryStore(), server, timeoutMs = 30_000, fetch, pin = {}, signerRetries = 0, retryDelayMs = 1000,
122
+ now, randomBytes, generateSecret} = options;
123
+ if (!Number.isInteger(signerRetries) || signerRetries < 0) throw new TypeError('signerRetries must be a whole number');
124
+ this.#signerRetries = signerRetries;
125
+ this.#retryDelayMs = retryDelayMs;
126
+ for (const m of ['secret', 'token', 'save', 'saveCredentials']) {
127
+ if (typeof store?.[m] !== 'function') throw new TypeError(`store must have a ${m}() method`);
128
+ }
129
+ if (!isObject(store.state)) throw new TypeError('store.state must be a plain object');
130
+ this.#store = store;
131
+ this.#fetch = fetch;
132
+ this.#timeoutMs = timeoutMs;
133
+ this.#pin = {trackerNftId: pin.trackerNftId ?? null, denomination: pin.denomination ?? null};
134
+ this.#now = now || (() => Date.now());
135
+ this.#randomBytes = randomBytes || defaultRandomBytes;
136
+ this.#generateSecret = generateSecret || (() => defaultGenerateSecret());
137
+ if (server) this.state.server = String(server).replace(/\/+$/, '');
138
+ }
139
+
140
+ /** Non-secret state: server, participant_id, pubkey, network, enrolled_at, issued (notes issued per key pair). */
141
+ get state() { return this.#store.state; }
142
+
143
+ /** True when the store holds both a signing key and an agent token. */
144
+ get connected() { return Boolean(this.#store.token() && this.#store.secret()); }
145
+
146
+ /** This agent's public key (66 hex), or null before enrollment. */
147
+ get pubkey() {
148
+ if (!this.state.pubkey && this.#store.secret()) this.state.pubkey = bytesToHex(publicKey(hexToBytes(this.#store.secret())));
149
+ return this.state.pubkey ?? null;
150
+ }
151
+
152
+ get participantId() { return this.state.participant_id ?? null; }
153
+
154
+ #secretBytes() {
155
+ const s = this.#store.secret();
156
+ if (!s) throw new ValunError('not_connected', 'this agent has no Valun signing key; the operator must enroll it');
157
+ return hexToBytes(s);
158
+ }
159
+
160
+ #signBytes(message) {
161
+ const secret = this.#secretBytes();
162
+ try {
163
+ return bytesToHex(sign(message, secret, hexToBytes(this.pubkey)));
164
+ } catch (e) {
165
+ if (e instanceof SignatureError) throw new ValunError('bad_key', `the stored signing key is unusable: ${e.message}`);
166
+ throw e;
167
+ } finally {
168
+ secret.fill(0);
169
+ }
170
+ }
171
+
172
+ #sign(text) {
173
+ return this.#signBytes(utf8(text));
174
+ }
175
+
176
+ async #save() {
177
+ await this.#store.save();
178
+ }
179
+
180
+ // ---------- HTTP ----------
181
+ /** One API call. Returns the parsed JSON body; throws ValunError(code = the server's `error`) for HTTP errors. */
182
+ async request(method, path, body = undefined, {auth = true, token = undefined} = {}) {
183
+ const server = this.state.server;
184
+ if (!server) throw new ValunError('not_configured', 'no Valun server configured');
185
+ const f = this.#fetch || globalThis.fetch;
186
+ if (typeof f !== 'function') throw new ValunError('not_configured', 'no fetch implementation: pass options.fetch');
187
+ const headers = {Accept: 'application/json'};
188
+ let payload;
189
+ if (body !== undefined && body !== null) {
190
+ payload = JSON.stringify(body);
191
+ headers['Content-Type'] = 'application/json';
192
+ }
193
+ const tok = token || (auth ? this.#store.token() : null);
194
+ if (tok) headers.Authorization = 'Bearer ' + tok;
195
+ for (let attempt = 0; ; attempt++) {
196
+ const {signal, cancel} = timeoutSignal(this.#timeoutMs);
197
+ let res, text;
198
+ try {
199
+ // redirect: 'error' so the bearer token is never forwarded to wherever a redirect points.
200
+ res = await f.call(globalThis, server + path, {method, headers, body: payload, signal, redirect: 'error'});
201
+ text = await res.text();
202
+ } catch (e) {
203
+ const why = e?.name === 'AbortError' || e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e));
204
+ throw new ValunError('unreachable', `cannot reach ${server}: ${why}`);
205
+ } finally {
206
+ cancel();
207
+ }
208
+ if (res.ok) {
209
+ if (!text) return null;
210
+ try {
211
+ return JSON.parse(text);
212
+ } catch {
213
+ throw new ValunError('bad_response', `the server sent a response that is not JSON (HTTP ${res.status})`, res.status);
214
+ }
215
+ }
216
+ let j = {};
217
+ try {
218
+ const parsed = JSON.parse(text);
219
+ if (isObject(parsed)) j = parsed;
220
+ } catch { /* not JSON */ }
221
+ const {error, message, ...extra} = j;
222
+ const code = typeof error === 'string' && error ? error : 'http_error';
223
+ // spec 15.2: on 503 signer_unavailable nothing was recorded; send the same signed request again, with back-off
224
+ if (res.status === 503 && code === 'signer_unavailable' && attempt < this.#signerRetries) {
225
+ await new Promise(resolve => setTimeout(resolve, this.#retryDelayMs * 2 ** attempt));
226
+ continue;
227
+ }
228
+ const msg = (typeof message === 'string' && message) || (typeof error === 'string' && error) || `HTTP ${res.status}`;
229
+ throw new ValunError(code, msg, res.status, extra);
230
+ }
231
+ }
232
+
233
+ // ---------- joining ----------
234
+ /**
235
+ * Create a participant (owner side). The response holds the owner token and the enrollment code: both are for the
236
+ * HUMAN operator, not for the agent.
237
+ */
238
+ async join({invite, handle, operator, displayName = '', about = '', services = ''}) {
239
+ return this.request('POST', '/pilot/v1/join',
240
+ {invite, handle, display_name: displayName, operator, about, services}, {auth: false});
241
+ }
242
+
243
+ /** Join without an invite as a sandbox-only participant (deals only with the sandbox agents, small limits, 7 days),
244
+ * where the server offers it (server release v14+, PILOT_OPEN_SANDBOX=1); otherwise ValunError('not_found'). */
245
+ async joinSandbox({handle, operator, displayName = '', about = ''}) {
246
+ return this.request('POST', '/pilot/v1/join-sandbox', {handle, display_name: displayName, operator, about}, {auth: false});
247
+ }
248
+
249
+ /** Generate this agent's key locally and register its public key with a proof-of-possession signature. */
250
+ async enroll(participantId, code) {
251
+ if (this.connected) throw new ValunError('already_connected', 'this client already holds an enrolled key');
252
+ if (typeof participantId !== 'string' || typeof code !== 'string') {
253
+ throw new ValunError('bad_enrollment', 'participant id and enrollment code must be strings');
254
+ }
255
+ const secret = this.#generateSecret();
256
+ try {
257
+ const pk = bytesToHex(publicKey(secret));
258
+ const sig = bytesToHex(sign(utf8(statements.enroll(participantId, pk, code)), secret, hexToBytes(pk)));
259
+ const r = await this.request('POST', '/pilot/v1/enroll',
260
+ {participant_id: participantId, code, pubkey: pk, signature: sig}, {auth: false});
261
+ if (!isObject(r) || typeof r.agent_token !== 'string' || !r.agent_token) {
262
+ throw new ValunError('bad_response', 'the enroll response carries no agent token');
263
+ }
264
+ Object.assign(this.state, {participant_id: participantId, pubkey: pk, network: r.network,
265
+ enrolled_at: isoSeconds(this.#now()), issued: this.state.issued || {}});
266
+ await this.#store.saveCredentials(bytesToHex(secret), r.agent_token);
267
+ await this.#save();
268
+ return {participant_id: participantId, pubkey: pk, network: r.network};
269
+ } finally {
270
+ secret.fill(0);
271
+ }
272
+ }
273
+
274
+ // ---------- reads ----------
275
+ info() { return this.request('GET', '/pilot/v1/info', undefined, {auth: false}); }
276
+ me() { return this.request('GET', '/pilot/v1/me'); }
277
+ inbox() { return this.request('GET', '/pilot/v1/inbox'); }
278
+ deals() { return this.request('GET', '/pilot/v1/deals'); }
279
+ deal(dealId) { return this.request('GET', `/pilot/v1/deals/${seg(dealId)}`); }
280
+ directory() { return this.request('GET', '/pilot/v1/participants', undefined, {auth: false}); }
281
+ participant(pid) { return this.request('GET', `/pilot/v1/participants/${seg(pid)}`, undefined, {auth: false}); }
282
+ ledger() { return this.request('GET', '/pilot/v1/ledger', undefined, {auth: false}); }
283
+
284
+ /** Record limits and goals the owner gave this agent; the server keeps them within the owner's mandate. */
285
+ setLimits(body) { return this.request('PUT', '/pilot/v1/limits', body); }
286
+
287
+ // ---------- independent checks ----------
288
+ async #registeredKey(pid) {
289
+ const p = await this.participant(pid);
290
+ if (!isObject(p) || p.status !== 'active' || !p.pubkey) {
291
+ throw new ValunError('counterparty_inactive', `${pid} has no active registered key`);
292
+ }
293
+ return p.pubkey;
294
+ }
295
+
296
+ /** GET /info, checked against the protocol constants and the pins given to the constructor. */
297
+ async #networkInfo() {
298
+ const info = await this.info();
299
+ if (!isObject(info)) throw new ValunError('bad_response', 'GET /info did not return an object');
300
+ if (info.network !== NETWORK) throw new ValunError('wrong_network', `the server is on network ${JSON.stringify(info.network)}, not ${NETWORK}`);
301
+ if (this.#pin.trackerNftId && info.tracker_nft_id !== this.#pin.trackerNftId) {
302
+ throw new ValunError('wrong_tracker', 'the server names a different tracker than the one pinned for this client');
303
+ }
304
+ if (this.#pin.denomination && info.denomination !== this.#pin.denomination) {
305
+ throw new ValunError('wrong_tracker', 'the server names a different unit than the one pinned for this client');
306
+ }
307
+ return info;
308
+ }
309
+
310
+ /**
311
+ * Check the current offer of a deal view before accepting or countering it (spec 15.2 items 1-5 as the Python
312
+ * client does them, then item 6): recompute the terms hash, check network/tracker/unit, that this agent is a party
313
+ * named with its own key, the proposer's key against the directory and its signature, and expiry.
314
+ * Returns {terms_hash, proposer_signature_valid: true, terms}.
315
+ */
316
+ async verifyOffer(d) {
317
+ const cur = isObject(d) ? d.current : null;
318
+ if (!isObject(cur)) throw new ValunError('no_offer', 'this deal has no visible offer');
319
+ const t = cur.terms;
320
+ checkTermsShape(t);
321
+ const h = termsHash(t);
322
+ if (h !== cur.terms_hash) throw new ValunError('hash_mismatch', 'the terms do not hash to the stated terms_hash; refusing to sign');
323
+ if (t.network !== NETWORK || t.protocol !== PROTOCOL) throw new ValunError('wrong_network', 'terms are for a different network or protocol');
324
+ const info = await this.#networkInfo();
325
+ if (t.tracker_nft_id !== info.tracker_nft_id || t.denomination !== info.denomination) {
326
+ throw new ValunError('wrong_tracker', 'terms name a different tracker or unit than this network');
327
+ }
328
+ const me = this.participantId;
329
+ if (me !== t.payer.id && me !== t.payee.id) throw new ValunError('not_a_party', 'you are not a party to these terms');
330
+ for (const role of ['payer', 'payee']) {
331
+ if (t[role].id === me && t[role].pubkey !== this.pubkey) throw new ValunError('wrong_key', `the terms name a different key for you as ${role}`);
332
+ }
333
+ if (cur.by !== t.payer.id && cur.by !== t.payee.id) throw new ValunError('not_a_party', 'the offer was not made by a party to its terms');
334
+ const proposerKey = await this.#registeredKey(cur.by);
335
+ if (proposerKey !== (t.payer.id === cur.by ? t.payer : t.payee).pubkey) {
336
+ throw new ValunError('wrong_key', 'the proposer key in the terms is not the registered key');
337
+ }
338
+ if (!verifyHex(cur.signature, utf8(statements.propose(h)), proposerKey)) {
339
+ throw new ValunError('bad_signature', "the proposer's signature does not verify");
340
+ }
341
+ if (!isRfc3339(t.offer_expires_at) || !isRfc3339(t.deliver_by)) {
342
+ throw new ValunError('bad_terms', 'the offer times are not RFC 3339 date-times; refusing to sign');
343
+ }
344
+ if (Date.parse(t.offer_expires_at) < this.#now()) throw new ValunError('expired', 'the offer has expired');
345
+ // spec 15.2 item 6 (SHOULD): the Python client does not make these checks.
346
+ if (d.id !== undefined && t.deal_id !== d.id) throw new ValunError('wrong_deal', 'the terms are for a different deal id');
347
+ if (t.proposer !== cur.by) throw new ValunError('wrong_proposer', 'the terms name a different proposer than the signer');
348
+ if (!Number.isSafeInteger(t.version) || (cur.version !== undefined && t.version !== cur.version)) {
349
+ throw new ValunError('bad_terms', 'the terms version is not the offer version');
350
+ }
351
+ if (t.price.unit !== UNIT || !Number.isSafeInteger(t.price.amount)) throw new ValunError('bad_terms', 'the price is not a whole number of PC');
352
+ if (t.consideration !== CONSIDERATION) throw new ValunError('bad_terms', `the consideration is not ${CONSIDERATION}`);
353
+ if (!exactKeys(t, TERMS_FIELDS) || !exactKeys(t.payer, ['id', 'pubkey']) || !exactKeys(t.payee, ['id', 'pubkey'])
354
+ || !exactKeys(t.price, ['amount', 'unit']) || !exactKeys(t.service, ['title', 'description', 'deliverable'])
355
+ || !['title', 'description', 'deliverable'].every(k => typeof t.service[k] === 'string')) {
356
+ throw new ValunError('bad_terms', 'the terms have missing or unknown fields; refusing to sign what may not be shown');
357
+ }
358
+ if (t.version === 1 ? t.prior_terms_hash !== null : !/^[0-9a-f]{64}$/.test(String(t.prior_terms_hash))) {
359
+ throw new ValunError('bad_terms', 'prior_terms_hash does not fit the version');
360
+ }
361
+ const prev = t.version > 1 && Array.isArray(d.versions) ? d.versions.find(x => x?.version === t.version - 1) : undefined;
362
+ if (prev && prev.terms_hash !== t.prior_terms_hash) {
363
+ throw new ValunError('bad_terms', 'the offer does not continue the version before it (prior_terms_hash)');
364
+ }
365
+ return {terms_hash: h, proposer_signature_valid: true, terms: t};
366
+ }
367
+
368
+ /** The agreed terms of a deal view, checked: hash, and both parties' signatures against the keys named in them. */
369
+ verifiedAgreement(d) {
370
+ const a = isObject(d) ? d.agreement : null;
371
+ if (!isObject(a)) throw new ValunError('not_agreed', 'no agreement on this deal');
372
+ const cur = d.current;
373
+ if (!isObject(cur)) throw new ValunError('not_agreed', 'the agreement has no terms in this view');
374
+ const t = cur.terms;
375
+ checkTermsShape(t);
376
+ const h = termsHash(t);
377
+ if (h !== a.terms_hash) throw new ValunError('hash_mismatch', 'agreement hash does not match its terms');
378
+ const keys = {[t.payer.id]: t.payer.pubkey, [t.payee.id]: t.payee.pubkey};
379
+ const prop = cur.by;
380
+ if (prop !== t.payer.id && prop !== t.payee.id) throw new ValunError('bad_signature', 'the agreement was not proposed by a party to its terms');
381
+ const acc = prop === t.payer.id ? t.payee.id : t.payer.id;
382
+ if (!verifyHex(a.proposer_signature, utf8(statements.propose(h)), keys[prop])) {
383
+ throw new ValunError('bad_signature', 'proposer signature on the agreement does not verify');
384
+ }
385
+ if (!verifyHex(a.acceptor_signature, utf8(statements.accept(h)), keys[acc])) {
386
+ throw new ValunError('bad_signature', 'acceptor signature on the agreement does not verify');
387
+ }
388
+ return t;
389
+ }
390
+
391
+ // ---------- acts (all signed locally) ----------
392
+ /** valun-deal/1 terms for a new proposal (or, with dealId/version/prior, a counter-offer). Signs nothing. */
393
+ async buildTerms({counterpartyId, role, title, description, deliverable, price, deliverByHours = 48, offerTtlHours = 24,
394
+ dealId = undefined, version = 1, prior = null}) {
395
+ if (role !== 'payer' && role !== 'payee') {
396
+ throw new ValunError('bad_role', 'role must be payer (you pay with your credit) or payee (you do the work)');
397
+ }
398
+ if (![title, description, deliverable].every(x => typeof x === 'string')) {
399
+ throw new ValunError('bad_terms', 'title, description and deliverable must be strings');
400
+ }
401
+ const amount = toPrice(price);
402
+ const dueH = toHours(deliverByHours, 'deliverByHours'), ttlH = toHours(offerTtlHours, 'offerTtlHours');
403
+ this.#secretBytes().fill(0); // fail early (not_connected) rather than after the reads
404
+ const info = await this.#networkInfo();
405
+ const other = {id: counterpartyId, pubkey: await this.#registeredKey(counterpartyId)};
406
+ const me = {id: this.participantId, pubkey: this.pubkey};
407
+ const now = this.#now();
408
+ return {
409
+ protocol: PROTOCOL, network: NETWORK, tracker_nft_id: info.tracker_nft_id, denomination: info.denomination,
410
+ deal_id: dealId || ('vd_' + bytesToHex(this.#randomBytes(8))), version, prior_terms_hash: prior,
411
+ nonce: bytesToHex(this.#randomBytes(16)), proposer: this.participantId,
412
+ payer: role === 'payer' ? me : other, payee: role === 'payer' ? other : me,
413
+ service: {title, description, deliverable},
414
+ price: {amount, unit: UNIT}, consideration: CONSIDERATION,
415
+ deliver_by: isoSeconds(now + dueH * HOUR), offer_expires_at: isoSeconds(now + ttlH * HOUR),
416
+ };
417
+ }
418
+
419
+ /** Propose a new deal (version 1), signed by this agent. */
420
+ async propose({counterpartyId, role, title, description, deliverable, price, deliverByHours = 48, offerTtlHours = 24}) {
421
+ const t = await this.buildTerms({counterpartyId, role, title, description, deliverable, price, deliverByHours, offerTtlHours});
422
+ const h = termsHash(t);
423
+ return this.request('POST', '/pilot/v1/deals', {terms: t, terms_hash: h, signature: this.#sign(statements.propose(h))});
424
+ }
425
+
426
+ /** Counter the current offer: verify it, then sign new terms (version + 1, prior = its hash). Unset fields carry over. */
427
+ async counter(dealId, {price = undefined, title = undefined, description = undefined, deliverable = undefined,
428
+ deliverByHours = undefined, offerTtlHours = 24} = {}) {
429
+ const d = await this.deal(dealId);
430
+ const chk = await this.verifyOffer(d);
431
+ const t0 = chk.terms;
432
+ const role = t0.payer.id === this.participantId ? 'payer' : 'payee';
433
+ const other = role === 'payer' ? t0.payee.id : t0.payer.id;
434
+ const dueH = deliverByHours ?? Math.max(1, Math.floor((Date.parse(t0.deliver_by) - this.#now()) / HOUR));
435
+ const t = await this.buildTerms({
436
+ counterpartyId: other, role,
437
+ title: title || t0.service.title, description: description || t0.service.description,
438
+ deliverable: deliverable || t0.service.deliverable, price: price ?? t0.price.amount,
439
+ deliverByHours: dueH, offerTtlHours, dealId, version: t0.version + 1, prior: chk.terms_hash,
440
+ });
441
+ const h = termsHash(t);
442
+ return this.request('POST', `/pilot/v1/deals/${seg(dealId)}/counter`,
443
+ {terms: t, terms_hash: h, signature: this.#sign(statements.propose(h))});
444
+ }
445
+
446
+ /** Accept the current offer after verifyOffer(); refuses if it changed since `expectedTermsHash` was inspected. */
447
+ async accept(dealId, {expectedTermsHash = undefined} = {}) {
448
+ const d = await this.deal(dealId);
449
+ const chk = await this.verifyOffer(d);
450
+ if (expectedTermsHash && expectedTermsHash !== chk.terms_hash) {
451
+ throw new ValunError('stale_terms', 'the offer changed since you inspected it; inspect it again before accepting');
452
+ }
453
+ const h = chk.terms_hash;
454
+ return this.request('POST', `/pilot/v1/deals/${seg(dealId)}/accept`, {terms_hash: h, signature: this.#sign(statements.accept(h))});
455
+ }
456
+
457
+ /** Decline the current offer. The signature covers sha256(reason) of exactly the reason sent (spec 8.3). */
458
+ async decline(dealId, reason = '') {
459
+ reason ??= '';
460
+ refuseReason('decline', reason);
461
+ const d = await this.deal(dealId);
462
+ const h = this.#offerHash(d?.current);
463
+ return this.request('POST', `/pilot/v1/deals/${seg(dealId)}/decline`,
464
+ {terms_hash: h, reason, signature: this.#sign(statements.decline(h, reason))});
465
+ }
466
+
467
+ /** Withdraw this agent's open offer, or its counter-offer held for its owner. */
468
+ async withdraw(dealId) {
469
+ const d = await this.deal(dealId);
470
+ const h = this.#offerHash(d?.pending_counter || d?.current);
471
+ return this.request('POST', `/pilot/v1/deals/${seg(dealId)}/withdraw`, {terms_hash: h, signature: this.#sign(statements.withdraw(h))});
472
+ }
473
+
474
+ /** The terms_hash of an offer in a view, recomputed from its terms when the view carries them. */
475
+ #offerHash(offer) {
476
+ if (!isObject(offer) || typeof offer.terms_hash !== 'string') throw new ValunError('no_offer', 'this deal has no visible offer');
477
+ if (offer.terms !== undefined && termsHash(offer.terms) !== offer.terms_hash) {
478
+ throw new ValunError('hash_mismatch', 'the terms do not hash to the stated terms_hash; refusing to sign');
479
+ }
480
+ return offer.terms_hash;
481
+ }
482
+
483
+ /** Deliver the work (payee): verifies the agreement first, then signs sha256(content) exactly as sent. */
484
+ async deliver(dealId, content, {title = ''} = {}) {
485
+ if (typeof content !== 'string') throw new ValunError('bad_content', 'content must be a string');
486
+ const d = await this.deal(dealId);
487
+ if (!isObject(d?.agreement)) throw new ValunError('not_agreed', 'there is no agreement to deliver against');
488
+ const h = termsHash(this.verifiedAgreement(d));
489
+ const dh = sha256Hex(content);
490
+ return this.request('POST', `/pilot/v1/deals/${seg(dealId)}/deliver`,
491
+ {terms_hash: h, content, title, sha256: dh, signature: this.#sign(statements.deliver(h, content))});
492
+ }
493
+
494
+ /** Accept the delivery and pay: sign the Basis note for prior + price, after checking the template independently. */
495
+ async complete(dealId, {expectedDeliverableSha256 = undefined} = {}) {
496
+ const d = await this.deal(dealId);
497
+ if (!isObject(d?.delivery) || !isObject(d?.agreement)) throw new ValunError('not_delivered', 'nothing has been delivered on this deal');
498
+ const dh = d.delivery.sha256;
499
+ if (typeof d.delivery.content !== 'string' || sha256Hex(d.delivery.content) !== dh) {
500
+ throw new ValunError('hash_mismatch', 'the delivered content does not match its hash');
501
+ }
502
+ if (expectedDeliverableSha256 && expectedDeliverableSha256 !== dh) {
503
+ throw new ValunError('stale_delivery', 'the delivery changed since you reviewed it');
504
+ }
505
+ const terms = this.verifiedAgreement(d);
506
+ const tpl = await this.request('GET', `/pilot/v1/deals/${seg(dealId)}/credit-note-template`);
507
+ if (!isObject(tpl) || !['total', 'prior_total', 'price', 'timestamp'].every(k => Number.isSafeInteger(tpl[k]))) {
508
+ throw new ValunError('bad_template', 'credit-note template is malformed');
509
+ }
510
+ // The note is between the parties' CURRENT keys (an owner may have replaced an agent key after the deal was
511
+ // agreed): it must be signed by this agent's key and credit the payee's key as the directory lists it now.
512
+ const payee = await this.#registeredKey(terms.payee.id);
513
+ if (tpl.issuer_pubkey !== this.pubkey || tpl.recipient_pubkey !== payee) {
514
+ throw new ValunError('bad_template', 'credit-note template names the wrong parties');
515
+ }
516
+ const amount = terms.price.amount;
517
+ if (tpl.price !== amount || tpl.total !== tpl.prior_total + amount) {
518
+ throw new ValunError('bad_template', 'credit-note template does not add exactly the agreed price');
519
+ }
520
+ const pair = `${this.pubkey}>${payee}`;
521
+ const known = this.state.issued?.[pair];
522
+ if (known !== undefined && known !== null && known !== tpl.prior_total) {
523
+ throw new ValunError('ledger_mismatch', `the server says you have issued this payee ${tpl.prior_total} PC of credit notes so far; your own record says ${known}. Ask your operator.`);
524
+ }
525
+ if (tpl.tracker_nft_id !== terms.tracker_nft_id || tpl.denomination !== terms.denomination) {
526
+ throw new ValunError('bad_template', 'credit-note template names a different tracker or unit');
527
+ }
528
+ if (Math.abs(tpl.timestamp - this.#now()) > NOTE_CLOCK_TOLERANCE_MS) {
529
+ throw new ValunError('bad_template', 'credit-note template timestamp is not current');
530
+ }
531
+ // Recompute the 80-byte message from the checked values; never sign the template's message_hex.
532
+ let msg;
533
+ try {
534
+ msg = noteMessage(this.pubkey, payee, tpl.total, tpl.timestamp, tpl.tracker_nft_id, tpl.denomination);
535
+ } catch (e) {
536
+ throw new ValunError('bad_template', `credit-note template cannot form a Basis note: ${e.message}`);
537
+ }
538
+ const h = termsHash(terms);
539
+ const body = {terms_hash: h, deliverable_sha256: dh, signature: this.#sign(statements.complete(h, dh)),
540
+ credit_note: {total: tpl.total, timestamp: tpl.timestamp, signature: this.#signBytes(msg)}};
541
+ const r = await this.request('POST', `/pilot/v1/deals/${seg(dealId)}/complete`, body);
542
+ if (['note_issued', 'completion_awaiting_owner', 'issuing_note'].includes(r?.status)) {
543
+ if (!isObject(this.state.issued)) this.state.issued = {};
544
+ this.state.issued[pair] = tpl.total;
545
+ await this.#save();
546
+ }
547
+ return r;
548
+ }
549
+
550
+ /** Dispute the delivery (payer). The signature covers sha256(reason) of exactly the reason sent (spec 8.3). */
551
+ async dispute(dealId, reason) {
552
+ reason ??= '';
553
+ refuseReason('dispute', reason);
554
+ const d = await this.deal(dealId);
555
+ this.verifiedAgreement(d); // throws unless d.agreement.terms_hash is the hash of the terms, signed by both parties
556
+ if (!isObject(d.delivery) || typeof d.delivery.sha256 !== 'string') throw new ValunError('not_delivered', 'nothing has been delivered on this deal');
557
+ const dh = d.delivery.sha256;
558
+ if (typeof d.delivery.content === 'string' && sha256Hex(d.delivery.content) !== dh) {
559
+ throw new ValunError('hash_mismatch', 'the delivered content does not match its hash');
560
+ }
561
+ const h = d.agreement.terms_hash;
562
+ return this.request('POST', `/pilot/v1/deals/${seg(dealId)}/dispute`,
563
+ {terms_hash: h, deliverable_sha256: dh, reason, signature: this.#sign(statements.dispute(h, dh, reason))});
564
+ }
565
+ }
package/src/dns.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Types for "@fitzdoud/valun/dns" (Node only).
2
+
3
+ /** Looks the host name up with the system resolver; 'public' if any address is globally reachable. No connection is made. */
4
+ export function resolveHost(host: string): Promise<'public' | 'private' | 'not_found'>;
package/src/dns.js ADDED
@@ -0,0 +1,12 @@
1
+ // Node-only host checker for delivery measurements: resolveHost(host) -> 'public' | 'private' | 'not_found', from the
2
+ // addresses the system resolver returns (getaddrinfo, as the Python client's socket.getaddrinfo). DNS only: no page is
3
+ // fetched and no connection is opened to the host. Imported separately ("@fitzdoud/valun/dns") so that browser bundles of the
4
+ // main entry never pull in node:dns.
5
+ //
6
+ // import {resolveHost} from '@fitzdoud/valun/dns';
7
+ // const agent = new ValunAgent(client, {resolveHost});
8
+
9
+ import {lookup} from 'node:dns/promises';
10
+ import {hostResolver} from './measure.js';
11
+
12
+ export const resolveHost = hostResolver(async host => (await lookup(host, {all: true})).map(a => a.address));
package/src/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ /** Every refusal of the client, and every HTTP error of the server, is a ValunError with a stable `code`. */
2
+ export class ValunError extends Error {
3
+ constructor(code, message, status = undefined, extra = {}) {
4
+ super(message);
5
+ this.name = 'ValunError';
6
+ this.code = code;
7
+ this.status = status;
8
+ this.extra = extra || {};
9
+ }
10
+ }
11
+
12
+ /** Raised by the Basis signer for malformed keys, points or note fields (the Python client's SignatureError). */
13
+ export class SignatureError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'SignatureError';
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ // Types for "@fitzdoud/valun/file-store" (Node only).
2
+ import type {ClientState, ValunStore} from './index.js';
3
+
4
+ /**
5
+ * One private JSON file holding the client's state, the agent's secret key and its agent token, in the same layout as
6
+ * the Python client's FileStore. Written atomically through a fresh 0600 temporary file; the folder is created 0700.
7
+ */
8
+ export class FileStore implements ValunStore {
9
+ constructor(filePath: string);
10
+ readonly path: string;
11
+ state: ClientState;
12
+ secret(): string | null;
13
+ token(): string | null;
14
+ save(): void;
15
+ saveCredentials(secretHex: string, token: string): void;
16
+ }