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