@otskit/client 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/dist/index.cjs ADDED
@@ -0,0 +1,1012 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CalendarClient: () => CalendarClient,
24
+ CalendarResponseTooLargeError: () => CalendarResponseTooLargeError,
25
+ CircuitBreakerError: () => CircuitBreakerError,
26
+ CircuitState: () => CircuitState,
27
+ CommitmentNotFoundError: () => CommitmentNotFoundError,
28
+ DEFAULT_AGGREGATORS: () => DEFAULT_AGGREGATORS,
29
+ DEFAULT_CALENDARS: () => DEFAULT_CALENDARS,
30
+ DEFAULT_CALENDAR_WHITELIST: () => DEFAULT_CALENDAR_WHITELIST,
31
+ DEFAULT_RESILIENCE: () => DEFAULT_RESILIENCE,
32
+ DetachedTimestampFile: () => import_core4.DetachedTimestampFile,
33
+ EsploraClient: () => EsploraClient,
34
+ EsploraResponseError: () => EsploraResponseError,
35
+ MAX_CALENDAR_RESPONSE_SIZE: () => MAX_CALENDAR_RESPONSE_SIZE,
36
+ MAX_ESPLORA_RESPONSE_SIZE: () => MAX_ESPLORA_RESPONSE_SIZE,
37
+ NetworkError: () => NetworkError,
38
+ OpenTimestampsClient: () => OpenTimestampsClient,
39
+ OpenTimestampsClientError: () => OpenTimestampsClientError,
40
+ PUBLIC_ESPLORA_URL: () => PUBLIC_ESPLORA_URL,
41
+ ResilientNetworkLayer: () => ResilientNetworkLayer,
42
+ StampError: () => StampError,
43
+ Timestamp: () => import_core4.Timestamp,
44
+ UpgradeError: () => UpgradeError,
45
+ UrlWhitelist: () => UrlWhitelist,
46
+ ValidationError: () => ValidationError,
47
+ verifyAgainstBlockheader: () => import_core5.verifyAgainstBlockheader,
48
+ verifyTimestampAttestation: () => verifyTimestampAttestation
49
+ });
50
+ module.exports = __toCommonJS(index_exports);
51
+
52
+ // src/types.ts
53
+ var DEFAULT_CALENDARS = [
54
+ "https://alice.btc.calendar.opentimestamps.org",
55
+ "https://bob.btc.calendar.opentimestamps.org",
56
+ "https://finney.calendar.eternitywall.com",
57
+ "https://btc.calendar.catallaxy.com"
58
+ ];
59
+ var DEFAULT_RESILIENCE = {
60
+ totalTimeoutMs: 3e4,
61
+ connectTimeoutMs: 5e3,
62
+ retries: {
63
+ enabled: true,
64
+ maxAttempts: 3,
65
+ backoff: {
66
+ strategy: "exponential",
67
+ initialDelayMs: 200,
68
+ maxDelayMs: 5e3,
69
+ jitter: "full"
70
+ }
71
+ },
72
+ circuitBreaker: {
73
+ enabled: true,
74
+ failureThreshold: 5,
75
+ recoveryTimeoutMs: 15e3,
76
+ halfOpenMaxAttempts: 1
77
+ }
78
+ };
79
+
80
+ // src/errors.ts
81
+ var OpenTimestampsClientError = class extends Error {
82
+ cause;
83
+ constructor(message, options) {
84
+ super(message);
85
+ this.name = this.constructor.name;
86
+ this.cause = options?.cause;
87
+ Error.captureStackTrace?.(this, this.constructor);
88
+ }
89
+ };
90
+ var ValidationError = class extends OpenTimestampsClientError {
91
+ };
92
+ var StampError = class extends OpenTimestampsClientError {
93
+ successfulSubmissions;
94
+ failedSubmissions;
95
+ constructor(message, successful, failed, options) {
96
+ super(message, options);
97
+ this.successfulSubmissions = successful;
98
+ this.failedSubmissions = failed;
99
+ }
100
+ };
101
+ var UpgradeError = class extends OpenTimestampsClientError {
102
+ };
103
+ var NetworkError = class extends OpenTimestampsClientError {
104
+ /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
105
+ status;
106
+ constructor(message, options) {
107
+ super(message, options);
108
+ this.status = options?.status;
109
+ }
110
+ };
111
+ var CircuitBreakerError = class extends NetworkError {
112
+ constructor(calendar) {
113
+ super(`Circuit breaker open for calendar: ${calendar}`);
114
+ }
115
+ };
116
+ var CommitmentNotFoundError = class extends NetworkError {
117
+ };
118
+ var CalendarResponseTooLargeError = class extends NetworkError {
119
+ };
120
+ var EsploraResponseError = class extends NetworkError {
121
+ };
122
+
123
+ // src/network/circuit-breaker.ts
124
+ var CircuitState = /* @__PURE__ */ ((CircuitState2) => {
125
+ CircuitState2["CLOSED"] = "CLOSED";
126
+ CircuitState2["OPEN"] = "OPEN";
127
+ CircuitState2["HALF_OPEN"] = "HALF_OPEN";
128
+ return CircuitState2;
129
+ })(CircuitState || {});
130
+ var CircuitBreaker = class {
131
+ constructor(options, logger) {
132
+ this.options = options;
133
+ this.logger = logger;
134
+ }
135
+ options;
136
+ logger;
137
+ circuits = /* @__PURE__ */ new Map();
138
+ /**
139
+ * Execute a request through the circuit breaker
140
+ */
141
+ async execute(key, fn) {
142
+ if (!this.options.enabled) {
143
+ return fn();
144
+ }
145
+ const circuit = this.getOrCreateCircuit(key);
146
+ if (circuit.state === "OPEN" /* OPEN */) {
147
+ const shouldAttemptRecovery = this.shouldAttemptRecovery(circuit);
148
+ if (shouldAttemptRecovery) {
149
+ this.logger?.info(`Circuit breaker for ${key} entering HALF_OPEN state`);
150
+ circuit.state = "HALF_OPEN" /* HALF_OPEN */;
151
+ circuit.stats.halfOpenAttempts = 0;
152
+ } else {
153
+ throw new CircuitBreakerError(key);
154
+ }
155
+ }
156
+ if (circuit.state === "HALF_OPEN" /* HALF_OPEN */) {
157
+ const maxAttempts = this.options.halfOpenMaxAttempts || 1;
158
+ if (circuit.stats.halfOpenAttempts >= maxAttempts) {
159
+ this.logger?.warn(`Circuit breaker for ${key} reopening after failed HALF_OPEN attempts`);
160
+ circuit.state = "OPEN" /* OPEN */;
161
+ circuit.stats.lastFailureTime = Date.now();
162
+ throw new CircuitBreakerError(key);
163
+ }
164
+ circuit.stats.halfOpenAttempts++;
165
+ }
166
+ try {
167
+ const result = await fn();
168
+ this.onSuccess(key, circuit);
169
+ return result;
170
+ } catch (error) {
171
+ this.onFailure(key, circuit);
172
+ throw error;
173
+ }
174
+ }
175
+ getOrCreateCircuit(key) {
176
+ let circuit = this.circuits.get(key);
177
+ if (!circuit) {
178
+ circuit = {
179
+ state: "CLOSED" /* CLOSED */,
180
+ stats: {
181
+ consecutiveFailures: 0,
182
+ halfOpenAttempts: 0
183
+ }
184
+ };
185
+ this.circuits.set(key, circuit);
186
+ }
187
+ return circuit;
188
+ }
189
+ shouldAttemptRecovery(circuit) {
190
+ if (!circuit.stats.lastFailureTime) return false;
191
+ const elapsed = Date.now() - circuit.stats.lastFailureTime;
192
+ return elapsed >= this.options.recoveryTimeoutMs;
193
+ }
194
+ onSuccess(key, circuit) {
195
+ if (circuit.state === "HALF_OPEN" /* HALF_OPEN */) {
196
+ this.logger?.info(`Circuit breaker for ${key} closing after successful HALF_OPEN attempt`);
197
+ circuit.state = "CLOSED" /* CLOSED */;
198
+ }
199
+ circuit.stats.consecutiveFailures = 0;
200
+ circuit.stats.halfOpenAttempts = 0;
201
+ }
202
+ onFailure(key, circuit) {
203
+ circuit.stats.consecutiveFailures++;
204
+ circuit.stats.lastFailureTime = Date.now();
205
+ if (circuit.state === "HALF_OPEN" /* HALF_OPEN */) {
206
+ this.logger?.warn(`Circuit breaker for ${key} reopening after failed HALF_OPEN attempt`);
207
+ circuit.state = "OPEN" /* OPEN */;
208
+ return;
209
+ }
210
+ if (circuit.stats.consecutiveFailures >= this.options.failureThreshold) {
211
+ this.logger?.warn(
212
+ `Circuit breaker for ${key} opening after ${circuit.stats.consecutiveFailures} consecutive failures`
213
+ );
214
+ circuit.state = "OPEN" /* OPEN */;
215
+ }
216
+ }
217
+ /** Get current state for debugging/monitoring */
218
+ getState(key) {
219
+ return this.circuits.get(key)?.state;
220
+ }
221
+ /** Reset a specific circuit */
222
+ reset(key) {
223
+ this.circuits.delete(key);
224
+ }
225
+ /** Reset all circuits */
226
+ resetAll() {
227
+ this.circuits.clear();
228
+ }
229
+ };
230
+
231
+ // src/network/retry.ts
232
+ function calculateDelay(attempt, options) {
233
+ const { strategy, initialDelayMs, maxDelayMs, jitter } = options.backoff;
234
+ let delay;
235
+ switch (strategy) {
236
+ case "exponential":
237
+ delay = initialDelayMs * Math.pow(2, attempt - 1);
238
+ break;
239
+ case "linear":
240
+ delay = initialDelayMs * attempt;
241
+ break;
242
+ case "constant":
243
+ delay = initialDelayMs;
244
+ break;
245
+ }
246
+ if (maxDelayMs && delay > maxDelayMs) {
247
+ delay = maxDelayMs;
248
+ }
249
+ switch (jitter) {
250
+ case "full":
251
+ delay = Math.random() * delay;
252
+ break;
253
+ case "equal":
254
+ delay = delay / 2 + Math.random() * (delay / 2);
255
+ break;
256
+ case "none":
257
+ default:
258
+ break;
259
+ }
260
+ return Math.floor(delay);
261
+ }
262
+ function sleep(ms, signal) {
263
+ return new Promise((resolve, reject) => {
264
+ if (signal?.aborted) {
265
+ reject(new Error("Aborted"));
266
+ return;
267
+ }
268
+ const timeout = setTimeout(resolve, ms);
269
+ signal?.addEventListener("abort", () => {
270
+ clearTimeout(timeout);
271
+ reject(new Error("Aborted"));
272
+ });
273
+ });
274
+ }
275
+ async function withRetry(fn, options, logger, signal) {
276
+ if (!options.enabled) {
277
+ return fn();
278
+ }
279
+ let lastError;
280
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
281
+ try {
282
+ logger?.debug(`Attempt ${attempt}/${options.maxAttempts}`);
283
+ return await fn();
284
+ } catch (error) {
285
+ lastError = error instanceof Error ? error : new Error(String(error));
286
+ if (signal?.aborted) {
287
+ throw lastError;
288
+ }
289
+ if (error.retryable === false) {
290
+ logger?.debug("Error is not retryable (4xx client error), failing immediately");
291
+ throw lastError;
292
+ }
293
+ if (attempt === options.maxAttempts) {
294
+ logger?.warn(`All ${options.maxAttempts} attempts failed`);
295
+ throw lastError;
296
+ }
297
+ const delay = calculateDelay(attempt, options);
298
+ logger?.debug(`Retry attempt ${attempt} failed, waiting ${delay}ms before next attempt`);
299
+ try {
300
+ await sleep(delay, signal);
301
+ } catch {
302
+ throw lastError;
303
+ }
304
+ }
305
+ }
306
+ throw lastError || new Error("Retry failed");
307
+ }
308
+
309
+ // src/adapters/fetch-adapter.ts
310
+ async function executeRequest(request) {
311
+ try {
312
+ const response = await globalThis.fetch(request.url, {
313
+ method: request.method,
314
+ headers: {
315
+ "Content-Type": "application/octet-stream",
316
+ ...request.headers
317
+ },
318
+ body: request.body,
319
+ signal: request.signal
320
+ });
321
+ const arrayBuffer = await response.arrayBuffer();
322
+ const data = new Uint8Array(arrayBuffer);
323
+ return {
324
+ ok: response.ok,
325
+ status: response.status,
326
+ statusText: response.statusText,
327
+ data
328
+ };
329
+ } catch (error) {
330
+ if (error instanceof Error) {
331
+ if (error.name === "AbortError") {
332
+ throw new NetworkError("Request aborted", { cause: error });
333
+ }
334
+ if (error.message.includes("timeout")) {
335
+ throw new NetworkError("Request timeout", { cause: error });
336
+ }
337
+ throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
338
+ }
339
+ throw new NetworkError("Unknown network error");
340
+ }
341
+ }
342
+ function createTimeoutController(timeoutMs, parentSignal) {
343
+ const controller = new AbortController();
344
+ const timeout = setTimeout(() => {
345
+ controller.abort(new Error("Timeout"));
346
+ }, timeoutMs);
347
+ if (parentSignal) {
348
+ if (parentSignal.aborted) {
349
+ clearTimeout(timeout);
350
+ controller.abort(parentSignal.reason);
351
+ } else {
352
+ parentSignal.addEventListener("abort", () => {
353
+ clearTimeout(timeout);
354
+ controller.abort(parentSignal.reason);
355
+ });
356
+ }
357
+ }
358
+ controller.signal.addEventListener("abort", () => {
359
+ clearTimeout(timeout);
360
+ });
361
+ return controller;
362
+ }
363
+
364
+ // src/network/resilience.ts
365
+ var ResilientNetworkLayer = class {
366
+ constructor(options, logger) {
367
+ this.options = options;
368
+ this.logger = logger;
369
+ this.circuitBreaker = new CircuitBreaker(options.circuitBreaker, logger);
370
+ }
371
+ options;
372
+ logger;
373
+ circuitBreaker;
374
+ /**
375
+ * Execute a request with full resilience pipeline
376
+ */
377
+ async request(calendarUrl, request, parentSignal) {
378
+ const startTime = Date.now();
379
+ const totalController = createTimeoutController(
380
+ this.options.totalTimeoutMs,
381
+ parentSignal
382
+ );
383
+ try {
384
+ return await this.circuitBreaker.execute(calendarUrl, async () => {
385
+ return await withRetry(
386
+ async () => {
387
+ const attemptController = createTimeoutController(
388
+ this.options.connectTimeoutMs,
389
+ totalController.signal
390
+ );
391
+ try {
392
+ const response = await executeRequest({
393
+ ...request,
394
+ signal: attemptController.signal
395
+ });
396
+ const elapsed = Date.now() - startTime;
397
+ this.logger?.debug(`Request to ${calendarUrl} succeeded in ${elapsed}ms`);
398
+ if (!response.ok) {
399
+ if (response.status >= 400 && response.status < 500) {
400
+ const error = new NetworkError(
401
+ `HTTP ${response.status}: ${response.statusText}`,
402
+ { status: response.status }
403
+ );
404
+ error.retryable = false;
405
+ throw error;
406
+ }
407
+ throw new NetworkError(
408
+ `HTTP ${response.status}: ${response.statusText}`,
409
+ { status: response.status }
410
+ );
411
+ }
412
+ return response;
413
+ } finally {
414
+ attemptController.signal.removeEventListener("abort", () => {
415
+ });
416
+ }
417
+ },
418
+ this.options.retries,
419
+ this.logger,
420
+ totalController.signal
421
+ );
422
+ });
423
+ } catch (error) {
424
+ const elapsed = Date.now() - startTime;
425
+ this.logger?.error(`Request to ${calendarUrl} failed after ${elapsed}ms`, error);
426
+ throw error;
427
+ } finally {
428
+ totalController.signal.removeEventListener("abort", () => {
429
+ });
430
+ }
431
+ }
432
+ /** Get circuit breaker state for a calendar */
433
+ getCircuitState(calendarUrl) {
434
+ return this.circuitBreaker.getState(calendarUrl);
435
+ }
436
+ /** Reset circuit breaker for a calendar */
437
+ resetCircuit(calendarUrl) {
438
+ this.circuitBreaker.reset(calendarUrl);
439
+ }
440
+ /** Reset all circuit breakers */
441
+ resetAllCircuits() {
442
+ this.circuitBreaker.resetAll();
443
+ }
444
+ };
445
+
446
+ // src/core/orchestration.ts
447
+ var import_core3 = require("@otskit/core");
448
+
449
+ // src/network/calendar.ts
450
+ var import_core = require("@otskit/core");
451
+ var MAX_CALENDAR_RESPONSE_SIZE = 1e4;
452
+ function assertCommitment(bytes) {
453
+ if (!(bytes instanceof Uint8Array)) {
454
+ throw new TypeError("commitment must be a Uint8Array");
455
+ }
456
+ if (bytes.length === 0 || bytes.length > 64) {
457
+ throw new RangeError(`commitment length ${bytes.length} is out of range (1..64)`);
458
+ }
459
+ }
460
+ var OTS_HEADERS = {
461
+ Accept: "application/vnd.opentimestamps.v1",
462
+ "Content-Type": "application/x-www-form-urlencoded"
463
+ };
464
+ function joinUrl(base, path) {
465
+ return base.replace(/\/+$/, "") + path;
466
+ }
467
+ var CalendarClient = class {
468
+ constructor(url, networkLayer, logger) {
469
+ this.url = url;
470
+ this.networkLayer = networkLayer;
471
+ this.logger = logger;
472
+ }
473
+ url;
474
+ networkLayer;
475
+ logger;
476
+ /** Envía un digest al calendario y devuelve el Timestamp que lo commit-ea. */
477
+ async submit(digest, signal) {
478
+ assertCommitment(digest);
479
+ this.logger?.debug(`Submitting digest to ${this.url}/digest`);
480
+ const response = await this.networkLayer.request(
481
+ this.url,
482
+ { url: joinUrl(this.url, "/digest"), method: "POST", headers: OTS_HEADERS, body: digest },
483
+ signal
484
+ );
485
+ return this.#parseTimestamp(response.data, digest);
486
+ }
487
+ /** Pregunta al calendario si tiene un Timestamp más completo para `commitment` (upgrade). */
488
+ async getTimestamp(commitment, signal) {
489
+ assertCommitment(commitment);
490
+ const path = `/timestamp/${(0, import_core.bytesToHex)(commitment)}`;
491
+ this.logger?.debug(`Querying ${this.url}${path}`);
492
+ let response;
493
+ try {
494
+ response = await this.networkLayer.request(
495
+ this.url,
496
+ { url: joinUrl(this.url, path), method: "GET", headers: OTS_HEADERS },
497
+ signal
498
+ );
499
+ } catch (err) {
500
+ if (err instanceof NetworkError && err.status === 404) {
501
+ throw new CommitmentNotFoundError(`calendar ${this.url} has no timestamp for the commitment yet`, {
502
+ cause: err
503
+ });
504
+ }
505
+ throw err;
506
+ }
507
+ return this.#parseTimestamp(response.data, commitment);
508
+ }
509
+ /** Deserializa la respuesta del calendario como un Timestamp commit-eado a `commitment`. */
510
+ #parseTimestamp(data, commitment) {
511
+ if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
512
+ throw new CalendarResponseTooLargeError(
513
+ `calendar response of ${data.length} bytes exceeds limit ${MAX_CALENDAR_RESPONSE_SIZE}`
514
+ );
515
+ }
516
+ const ctx = new import_core.StreamDeserializationContext(data);
517
+ const timestamp = import_core.Timestamp.deserialize(ctx, commitment);
518
+ ctx.assertEof();
519
+ return timestamp;
520
+ }
521
+ };
522
+ function wildcardToRegExp(pattern) {
523
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "[^/]*");
524
+ return new RegExp(`^${escaped}$`, "i");
525
+ }
526
+ var UrlWhitelist = class {
527
+ #patterns = /* @__PURE__ */ new Set();
528
+ constructor(urls) {
529
+ if (urls) {
530
+ for (const u of urls) this.add(u);
531
+ }
532
+ }
533
+ /** Añade un patrón; si no trae esquema, se añaden las variantes http y https. */
534
+ add(url) {
535
+ if (typeof url !== "string") {
536
+ throw new TypeError("UrlWhitelist: URL must be a string");
537
+ }
538
+ if (url.startsWith("http://") || url.startsWith("https://")) {
539
+ this.#patterns.add(url);
540
+ } else {
541
+ this.#patterns.add("http://" + url);
542
+ this.#patterns.add("https://" + url);
543
+ }
544
+ }
545
+ /** Verdadero si `url` casa con algún patrón de la whitelist. */
546
+ contains(url) {
547
+ for (const pattern of this.#patterns) {
548
+ if (wildcardToRegExp(pattern).test(url)) return true;
549
+ }
550
+ return false;
551
+ }
552
+ toString() {
553
+ return `UrlWhitelist([${[...this.#patterns].join(", ")}])`;
554
+ }
555
+ };
556
+ var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([
557
+ "https://*.calendar.opentimestamps.org",
558
+ // Peter Todd
559
+ "https://*.calendar.eternitywall.com",
560
+ // Eternity Wall
561
+ "https://*.calendar.catallaxy.com"
562
+ // Catallaxy
563
+ ]);
564
+ var DEFAULT_AGGREGATORS = [
565
+ "https://a.pool.opentimestamps.org",
566
+ "https://b.pool.opentimestamps.org",
567
+ "https://a.pool.eternitywall.com",
568
+ "https://ots.btc.catallaxy.com"
569
+ ];
570
+
571
+ // src/network/esplora.ts
572
+ var import_core2 = require("@otskit/core");
573
+ var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
574
+ var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
575
+ var HEX64_RE = /^[0-9a-f]{64}$/i;
576
+ var EsploraClient = class {
577
+ #url;
578
+ #networkLayer;
579
+ #logger;
580
+ constructor(networkLayer, options = {}) {
581
+ const raw = options.url ?? PUBLIC_ESPLORA_URL;
582
+ let parsed;
583
+ try {
584
+ parsed = new URL(raw);
585
+ } catch {
586
+ throw new ValidationError(`invalid Esplora URL: ${raw}`);
587
+ }
588
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
589
+ throw new ValidationError(`Esplora URL must use http(s): ${raw}`);
590
+ }
591
+ this.#networkLayer = networkLayer;
592
+ this.#url = raw.replace(/\/+$/, "");
593
+ this.#logger = options.logger;
594
+ }
595
+ /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
596
+ async blockHash(height, signal) {
597
+ if (!Number.isSafeInteger(height) || height < 0) {
598
+ throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
599
+ }
600
+ this.#logger?.debug(`Esplora block-height ${height}`);
601
+ const response = await this.#networkLayer.request(
602
+ this.#url,
603
+ { url: `${this.#url}/block-height/${height}`, method: "GET", headers: { Accept: "text/plain" } },
604
+ signal
605
+ );
606
+ const text = this.#decode(response.data).trim();
607
+ if (!HEX64_RE.test(text)) {
608
+ throw new EsploraResponseError(`esplora returned an invalid block hash for height ${height}`);
609
+ }
610
+ return text.toLowerCase();
611
+ }
612
+ /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
613
+ async block(hash, signal) {
614
+ if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
615
+ throw new ValidationError("block hash must be a 64-char hex string");
616
+ }
617
+ this.#logger?.debug(`Esplora block ${hash}`);
618
+ const response = await this.#networkLayer.request(
619
+ this.#url,
620
+ { url: `${this.#url}/block/${hash}`, method: "GET", headers: { Accept: "application/json" } },
621
+ signal
622
+ );
623
+ const text = this.#decode(response.data);
624
+ let body;
625
+ try {
626
+ body = JSON.parse(text);
627
+ } catch (err) {
628
+ throw new EsploraResponseError("esplora returned a non-JSON block response", {
629
+ /* v8 ignore next */
630
+ cause: err instanceof Error ? err : void 0
631
+ });
632
+ }
633
+ if (typeof body !== "object" || body === null) {
634
+ throw new EsploraResponseError("esplora block response is not an object");
635
+ }
636
+ const { merkle_root: merkleroot, timestamp: time } = body;
637
+ if (typeof merkleroot !== "string" || !HEX64_RE.test(merkleroot)) {
638
+ throw new EsploraResponseError("esplora block merkle_root is not a 64-char hex string");
639
+ }
640
+ if (typeof time !== "number" || !Number.isInteger(time) || time <= 0) {
641
+ throw new EsploraResponseError("esplora block timestamp is not a positive integer");
642
+ }
643
+ return { merkleroot, time };
644
+ }
645
+ /** Decodifica el cuerpo a texto aplicando el límite de tamaño (fail-closed). */
646
+ #decode(data) {
647
+ if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
648
+ throw new EsploraResponseError(
649
+ `esplora response of ${data.length} bytes exceeds limit ${MAX_ESPLORA_RESPONSE_SIZE}`
650
+ );
651
+ }
652
+ return new TextDecoder("utf-8", { fatal: false }).decode(data);
653
+ }
654
+ };
655
+ async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
656
+ if (attestation.kind !== "bitcoin" && attestation.kind !== "litecoin") {
657
+ throw new import_core2.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
658
+ }
659
+ const hash = await explorer.blockHash(attestation.height, signal);
660
+ const header = await explorer.block(hash, signal);
661
+ return (0, import_core2.verifyAgainstBlockheader)(digest, header);
662
+ }
663
+
664
+ // src/core/orchestration.ts
665
+ function validateHash(hash) {
666
+ if (typeof hash === "string") {
667
+ const hex = hash.trim().toLowerCase();
668
+ if (!/^[0-9a-f]{64}$/.test(hex)) {
669
+ throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
670
+ }
671
+ return Uint8Array.from(Buffer.from(hex, "hex"));
672
+ }
673
+ if (hash.length !== 32) {
674
+ throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
675
+ }
676
+ return Uint8Array.from(hash);
677
+ }
678
+ function secureNonce(n) {
679
+ const bytes = new Uint8Array(n);
680
+ if (!globalThis.crypto?.getRandomValues) {
681
+ throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
682
+ }
683
+ globalThis.crypto.getRandomValues(bytes);
684
+ return bytes;
685
+ }
686
+ var bytesEq = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
687
+ function assertHttpUrl(url, label) {
688
+ let parsed;
689
+ try {
690
+ parsed = new URL(url);
691
+ } catch {
692
+ throw new ValidationError(`${label} is not a valid URL: ${url}`);
693
+ }
694
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
695
+ throw new ValidationError(`${label} must use http(s): ${url}`);
696
+ }
697
+ }
698
+ async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2) {
699
+ if (calendars.length === 0) {
700
+ throw new ValidationError("at least one calendar is required to stamp");
701
+ }
702
+ if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
703
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
704
+ }
705
+ if (minimumSuccessfulSubmissions > calendars.length) {
706
+ throw new ValidationError(
707
+ `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
708
+ );
709
+ }
710
+ for (const url of calendars) assertHttpUrl(url, "calendar");
711
+ const digest = validateHash(hash);
712
+ logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
713
+ const detached = import_core3.DetachedTimestampFile.fromHash(new import_core3.OpSHA256(), digest);
714
+ const nonceAppended = detached.timestamp.add(new import_core3.OpAppend(secureNonce(16)));
715
+ const merkleRoot = nonceAppended.add(new import_core3.OpSHA256());
716
+ const merkleTip = (0, import_core3.makeMerkleTree)([merkleRoot]);
717
+ const results = await Promise.allSettled(
718
+ calendars.map((url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal))
719
+ );
720
+ const successful = [];
721
+ const failed = [];
722
+ results.forEach((r, i) => {
723
+ const calendar = calendars[i];
724
+ if (r.status === "fulfilled") {
725
+ merkleTip.merge(r.value);
726
+ successful.push({ calendar });
727
+ logger?.info(`Submitted to ${calendar}`);
728
+ } else {
729
+ const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
730
+ failed.push({ calendar, error });
731
+ logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
732
+ }
733
+ });
734
+ if (successful.length < minimumSuccessfulSubmissions) {
735
+ throw new StampError(
736
+ `Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
737
+ successful,
738
+ failed
739
+ );
740
+ }
741
+ return Buffer.from(detached.serializeToBytes());
742
+ }
743
+ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
744
+ let detached;
745
+ try {
746
+ detached = import_core3.DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
747
+ } catch (error) {
748
+ throw new ValidationError("Invalid .ots proof format", {
749
+ /* v8 ignore next */
750
+ cause: error instanceof Error ? error : void 0
751
+ });
752
+ }
753
+ if (detached.timestamp.isTimestampComplete()) {
754
+ logger?.info("Proof already complete; nothing to upgrade");
755
+ return Buffer.from(incompleteProof);
756
+ }
757
+ const before = detached.serializeToBytes();
758
+ for (const subStamp of detached.timestamp.directlyVerified()) {
759
+ if (subStamp.isTimestampComplete()) continue;
760
+ for (const att of subStamp.attestations) {
761
+ if (att.kind !== "pending") continue;
762
+ if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
763
+ logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
764
+ continue;
765
+ }
766
+ try {
767
+ const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
768
+ subStamp.getDigest(),
769
+ signal
770
+ );
771
+ subStamp.merge(upgraded);
772
+ } catch (err) {
773
+ if (err instanceof CommitmentNotFoundError) {
774
+ logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
775
+ continue;
776
+ }
777
+ logger?.warn(`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`);
778
+ }
779
+ }
780
+ }
781
+ const after = detached.serializeToBytes();
782
+ if (bytesEq(before, after)) {
783
+ throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
784
+ }
785
+ return Buffer.from(after);
786
+ }
787
+ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
788
+ let detached;
789
+ try {
790
+ detached = import_core3.DetachedTimestampFile.deserialize(new Uint8Array(proof));
791
+ } catch {
792
+ return { valid: false, error: "Invalid .ots proof format" };
793
+ }
794
+ if (originalDataHash !== void 0) {
795
+ let expected;
796
+ try {
797
+ expected = validateHash(originalDataHash);
798
+ } catch (err) {
799
+ return { valid: false, error: err instanceof Error ? err.message : "Invalid hash format" };
800
+ }
801
+ if (!bytesEq(expected, detached.fileDigest())) {
802
+ return { valid: false, error: "File hash does not match proof" };
803
+ }
804
+ }
805
+ const bitcoinAtts = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
806
+ if (bitcoinAtts.length === 0) {
807
+ const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
808
+ if (hasLitecoin) {
809
+ return { valid: false, error: "Litecoin verification is not supported by this client" };
810
+ }
811
+ return { valid: false, error: "No Bitcoin attestation found (timestamp not yet confirmed)" };
812
+ }
813
+ const explorer = new EsploraClient(networkLayer);
814
+ let lastError = "";
815
+ for (const { msg, attestation } of bitcoinAtts) {
816
+ if (attestation.kind !== "bitcoin") continue;
817
+ try {
818
+ const time = await verifyTimestampAttestation(Uint8Array.from(msg).reverse(), attestation, explorer, signal);
819
+ logger?.info(`Verified against Bitcoin block ${attestation.height}`);
820
+ return { valid: true, blockHeight: attestation.height, timestamp: time };
821
+ } catch (err) {
822
+ lastError = err instanceof Error ? err.message : String(err);
823
+ logger?.warn(`Bitcoin attestation at height ${attestation.height} failed: ${lastError}`);
824
+ }
825
+ }
826
+ return { valid: false, error: `Could not verify against the Bitcoin blockchain: ${lastError}` };
827
+ }
828
+
829
+ // src/client.ts
830
+ var OpenTimestampsClient = class {
831
+ calendars;
832
+ networkLayer;
833
+ logger;
834
+ globalSignal;
835
+ minimumSuccessfulSubmissions;
836
+ /**
837
+ * Create a new OpenTimestamps client
838
+ *
839
+ * @param options Client configuration options
840
+ */
841
+ constructor(options = {}) {
842
+ if (!options.calendars || options.calendars.length === 0) {
843
+ this.calendars = DEFAULT_CALENDARS;
844
+ this.logger?.info("No calendars provided, using defaults");
845
+ } else {
846
+ this.calendars = options.calendars;
847
+ }
848
+ this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
849
+ const resilienceConfig = {
850
+ ...DEFAULT_RESILIENCE,
851
+ ...options.resilience,
852
+ retries: {
853
+ ...DEFAULT_RESILIENCE.retries,
854
+ ...options.resilience?.retries,
855
+ backoff: {
856
+ ...DEFAULT_RESILIENCE.retries.backoff,
857
+ ...options.resilience?.retries?.backoff
858
+ }
859
+ },
860
+ circuitBreaker: {
861
+ ...DEFAULT_RESILIENCE.circuitBreaker,
862
+ ...options.resilience?.circuitBreaker
863
+ }
864
+ };
865
+ this.logger = options.logger;
866
+ this.globalSignal = options.signal;
867
+ this.networkLayer = options.networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
868
+ this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
869
+ }
870
+ /**
871
+ * Create a timestamp by submitting a hash to calendar servers
872
+ *
873
+ * @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
874
+ * @param options Operation-specific options
875
+ * @returns Initial .ots proof with pending attestations
876
+ *
877
+ * @throws {ValidationError} If the hash is invalid
878
+ * @throws {StampError} If submission fails to all calendars
879
+ * @throws {NetworkError} If network errors occur
880
+ *
881
+ * @example
882
+ * ```typescript
883
+ * const hash = crypto.createHash('sha256').update('my data').digest()
884
+ * const otsProof = await client.stamp(hash)
885
+ * // Save otsProof to database as Buffer
886
+ * ```
887
+ */
888
+ async stamp(hash, options) {
889
+ const signal = options?.signal || this.globalSignal;
890
+ return orchestrateStamp(
891
+ hash,
892
+ this.calendars,
893
+ this.networkLayer,
894
+ this.logger,
895
+ signal,
896
+ this.minimumSuccessfulSubmissions
897
+ );
898
+ }
899
+ /**
900
+ * Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
901
+ *
902
+ * @param incompleteProof The initial .ots proof returned by stamp()
903
+ * @param options Operation-specific options
904
+ * @returns Upgraded .ots proof with Bitcoin attestation (if available)
905
+ *
906
+ * @throws {ValidationError} If the proof format is invalid
907
+ * @throws {UpgradeError} If no calendar has confirmed the timestamp yet
908
+ * @throws {NetworkError} If network errors occur
909
+ *
910
+ * @example
911
+ * ```typescript
912
+ * // Proof already has pending attestations from stamp()
913
+ * const upgradedProof = await client.upgrade(incompleteProof)
914
+ *
915
+ * // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
916
+ * // Retry later (typically 10-60 minutes after stamp)
917
+ * ```
918
+ */
919
+ async upgrade(incompleteProof, options) {
920
+ const signal = options?.signal || this.globalSignal;
921
+ return orchestrateUpgrade(
922
+ incompleteProof,
923
+ this.calendars,
924
+ this.networkLayer,
925
+ this.logger,
926
+ signal
927
+ );
928
+ }
929
+ /**
930
+ * Verify a complete timestamp proof against the Bitcoin blockchain
931
+ *
932
+ * @param proof The complete .ots proof with Bitcoin attestation
933
+ * @param originalDataHash Optional: the original data hash to verify against
934
+ * @returns Verification result with block details
935
+ *
936
+ * @example
937
+ * ```typescript
938
+ * const result = await client.verify(completeProof, originalHash)
939
+ *
940
+ * if (result.valid) {
941
+ * console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
942
+ * console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
943
+ * } else {
944
+ * console.error(`Verification failed: ${result.error}`)
945
+ * }
946
+ * ```
947
+ */
948
+ async verify(proof, originalDataHash) {
949
+ return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
950
+ }
951
+ /**
952
+ * Get the current state of the circuit breaker for a calendar
953
+ * Useful for monitoring and debugging
954
+ *
955
+ * @param calendarUrl The calendar URL to check
956
+ * @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
957
+ */
958
+ getCircuitState(calendarUrl) {
959
+ return this.networkLayer.getCircuitState(calendarUrl);
960
+ }
961
+ /**
962
+ * Reset the circuit breaker for a specific calendar
963
+ * Use this to manually recover a calendar that has been marked as failing
964
+ *
965
+ * @param calendarUrl The calendar URL to reset
966
+ */
967
+ resetCircuit(calendarUrl) {
968
+ this.logger?.info(`Manually resetting circuit breaker for ${calendarUrl}`);
969
+ this.networkLayer.resetCircuit(calendarUrl);
970
+ }
971
+ /**
972
+ * Reset all circuit breakers
973
+ * Use this to clear all failure states
974
+ */
975
+ resetAllCircuits() {
976
+ this.logger?.info("Manually resetting all circuit breakers");
977
+ this.networkLayer.resetAllCircuits();
978
+ }
979
+ };
980
+
981
+ // src/index.ts
982
+ var import_core4 = require("@otskit/core");
983
+ var import_core5 = require("@otskit/core");
984
+ // Annotate the CommonJS export names for ESM import in node:
985
+ 0 && (module.exports = {
986
+ CalendarClient,
987
+ CalendarResponseTooLargeError,
988
+ CircuitBreakerError,
989
+ CircuitState,
990
+ CommitmentNotFoundError,
991
+ DEFAULT_AGGREGATORS,
992
+ DEFAULT_CALENDARS,
993
+ DEFAULT_CALENDAR_WHITELIST,
994
+ DEFAULT_RESILIENCE,
995
+ DetachedTimestampFile,
996
+ EsploraClient,
997
+ EsploraResponseError,
998
+ MAX_CALENDAR_RESPONSE_SIZE,
999
+ MAX_ESPLORA_RESPONSE_SIZE,
1000
+ NetworkError,
1001
+ OpenTimestampsClient,
1002
+ OpenTimestampsClientError,
1003
+ PUBLIC_ESPLORA_URL,
1004
+ ResilientNetworkLayer,
1005
+ StampError,
1006
+ Timestamp,
1007
+ UpgradeError,
1008
+ UrlWhitelist,
1009
+ ValidationError,
1010
+ verifyAgainstBlockheader,
1011
+ verifyTimestampAttestation
1012
+ });