@otskit/client 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@
32
32
 
33
33
  ### Developer Experience
34
34
  - **TypeScript-first** — Strict types throughout; full IntelliSense for every option and error
35
- - **Multi-runtime** — Node.js 18+, browsers, and edge runtimes (uses the standard `fetch` API)
35
+ - **Node.js 20+** Requires Node.js; uses native `crypto`, `dns`, and `net` APIs not available in browsers or edge runtimes
36
36
  - **Tree-shakeable** — Dual ESM/CJS build, zero runtime dependencies
37
37
  - **`AbortController` support** — Cancel any in-flight operation at any level
38
38
  - **Observable** — Drop-in `Logger` interface compatible with `console`, `pino`, `winston`, etc.
package/dist/index.cjs CHANGED
@@ -85,7 +85,7 @@ var OpenTimestampsClientError = class extends Error {
85
85
  constructor(message, options) {
86
86
  super(message);
87
87
  this.name = this.constructor.name;
88
- this.cause = options?.cause;
88
+ if (options?.cause !== void 0) this.cause = options.cause;
89
89
  Error.captureStackTrace?.(this, this.constructor);
90
90
  }
91
91
  };
@@ -103,11 +103,11 @@ var StampError = class extends OpenTimestampsClientError {
103
103
  var UpgradeError = class extends OpenTimestampsClientError {
104
104
  };
105
105
  var NetworkError = class extends OpenTimestampsClientError {
106
- /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
106
+ /** HTTP status code when the failure originates from an HTTP response. */
107
107
  status;
108
108
  constructor(message, options) {
109
109
  super(message, options);
110
- this.status = options?.status;
110
+ if (options?.status !== void 0) this.status = options.status;
111
111
  }
112
112
  };
113
113
  var CircuitBreakerError = class extends NetworkError {
@@ -130,7 +130,7 @@ var SizeLimitExceededError = class extends NetworkError {
130
130
  options
131
131
  );
132
132
  this.maxBytes = maxBytes;
133
- this.actualBytes = actualBytes;
133
+ if (actualBytes !== void 0) this.actualBytes = actualBytes;
134
134
  }
135
135
  };
136
136
 
@@ -182,7 +182,10 @@ var CircuitBreaker = class {
182
182
  this.onSuccess(key, circuit);
183
183
  return result;
184
184
  } catch (error) {
185
- this.onFailure(key, circuit);
185
+ const is4xx = error instanceof Error && error.retryable === false;
186
+ if (!is4xx) {
187
+ this.onFailure(key, circuit);
188
+ }
186
189
  throw error;
187
190
  }
188
191
  }
@@ -279,11 +282,15 @@ function sleep(ms, signal) {
279
282
  reject(new Error("Aborted"));
280
283
  return;
281
284
  }
282
- const timeout = setTimeout(resolve, ms);
283
- signal?.addEventListener("abort", () => {
285
+ const onAbort = () => {
284
286
  clearTimeout(timeout);
285
287
  reject(new Error("Aborted"));
286
- });
288
+ };
289
+ const timeout = setTimeout(() => {
290
+ signal?.removeEventListener("abort", onAbort);
291
+ resolve();
292
+ }, ms);
293
+ signal?.addEventListener("abort", onAbort, { once: true });
287
294
  });
288
295
  }
289
296
  async function withRetry(fn, options, logger, signal) {
@@ -366,8 +373,9 @@ async function executeRequest(request, maxBytes) {
366
373
  const response = await globalThis.fetch(request.url, {
367
374
  method: request.method,
368
375
  headers: { "Content-Type": "application/octet-stream", ...request.headers },
369
- body: request.body,
370
- signal: request.signal
376
+ ...request.body !== void 0 ? { body: request.body } : {},
377
+ ...request.signal !== void 0 ? { signal: request.signal } : {},
378
+ redirect: "error"
371
379
  });
372
380
  const data = await readResponseBody(response, maxBytes);
373
381
  return { ok: response.ok, status: response.status, statusText: response.statusText, data };
@@ -515,7 +523,7 @@ var CalendarClient = class {
515
523
  url;
516
524
  networkLayer;
517
525
  logger;
518
- /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
526
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
519
527
  async submit(digest, signal) {
520
528
  assertCommitment(digest);
521
529
  this.logger?.debug(`Submitting digest to ${this.url}/digest`);
@@ -526,7 +534,7 @@ var CalendarClient = class {
526
534
  );
527
535
  return this.#parseTimestamp(response.data, digest);
528
536
  }
529
- /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
537
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
530
538
  async getTimestamp(commitment, signal) {
531
539
  assertCommitment(commitment);
532
540
  const path = `/timestamp/${(0, import_core2.bytesToHex)(commitment)}`;
@@ -548,7 +556,7 @@ var CalendarClient = class {
548
556
  }
549
557
  return this.#parseTimestamp(response.data, commitment);
550
558
  }
551
- /** Deserializa la respuesta del calendario como un Timestamp commit-eado a `commitment`. */
559
+ /** Deserializes the calendar response as a Timestamp committed to `commitment`. */
552
560
  #parseTimestamp(data, commitment) {
553
561
  if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
554
562
  throw new CalendarResponseTooLargeError(
@@ -578,7 +586,7 @@ function parseWhitelistPattern(raw) {
578
586
  hostname,
579
587
  port: parsed.port,
580
588
  pathname: parsed.pathname,
581
- wildcardSuffix
589
+ ...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
582
590
  };
583
591
  }
584
592
  function hostnameMatchesPattern(hostname, pattern) {
@@ -594,20 +602,26 @@ var UrlWhitelist = class {
594
602
  for (const u of urls) this.add(u);
595
603
  }
596
604
  }
597
- /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
605
+ /**
606
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
607
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
608
+ */
598
609
  add(url) {
599
610
  if (typeof url !== "string") {
600
611
  throw new TypeError("UrlWhitelist: URL must be a string");
601
612
  }
602
613
  if (url.startsWith("http://") || url.startsWith("https://")) {
603
614
  const pattern = parseWhitelistPattern(url);
604
- if (pattern !== void 0) this.#patterns.set(url, pattern);
615
+ if (pattern === void 0) {
616
+ throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
617
+ }
618
+ this.#patterns.set(url, pattern);
605
619
  } else {
606
620
  this.add("http://" + url);
607
621
  this.add("https://" + url);
608
622
  }
609
623
  }
610
- /** Verdadero si `url` casa con algun patron de la whitelist. */
624
+ /** Returns true if `url` matches any pattern in the allowlist. */
611
625
  contains(url) {
612
626
  let parsed;
613
627
  try {
@@ -633,10 +647,17 @@ var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...import_core2.TRUSTED_CALEN
633
647
  var DEFAULT_AGGREGATORS = [...import_core2.DEFAULT_AGGREGATOR_URLS];
634
648
 
635
649
  // src/network/esplora.ts
650
+ var import_node_crypto = require("crypto");
636
651
  var import_core3 = require("@otskit/core");
637
652
  var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
638
653
  var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
654
+ var RAW_HEADER_SIZE = 80;
639
655
  var HEX64_RE = /^[0-9a-f]{64}$/i;
656
+ function sha256dDisplayHex(data) {
657
+ const first = (0, import_node_crypto.createHash)("sha256").update(data).digest();
658
+ const second = (0, import_node_crypto.createHash)("sha256").update(first).digest();
659
+ return Buffer.from(second).reverse().toString("hex");
660
+ }
640
661
  var EsploraClient = class {
641
662
  #url;
642
663
  #networkLayer;
@@ -656,7 +677,7 @@ var EsploraClient = class {
656
677
  this.#url = raw.replace(/\/+$/, "");
657
678
  this.#logger = options.logger;
658
679
  }
659
- /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
680
+ /** Returns the block hash (64-char hex, lowercase) at the given height. */
660
681
  async blockHash(height, signal) {
661
682
  if (!Number.isSafeInteger(height) || height < 0) {
662
683
  throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
@@ -673,7 +694,7 @@ var EsploraClient = class {
673
694
  }
674
695
  return text.toLowerCase();
675
696
  }
676
- /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
697
+ /** Returns the block header (merkle root + timestamp) for the given hash. */
677
698
  async block(hash, signal) {
678
699
  if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
679
700
  throw new ValidationError("block hash must be a 64-char hex string");
@@ -691,7 +712,7 @@ var EsploraClient = class {
691
712
  } catch (err) {
692
713
  throw new EsploraResponseError("esplora returned a non-JSON block response", {
693
714
  /* v8 ignore next */
694
- cause: err instanceof Error ? err : void 0
715
+ ...err instanceof Error ? { cause: err } : {}
695
716
  });
696
717
  }
697
718
  if (typeof body !== "object" || body === null) {
@@ -706,7 +727,36 @@ var EsploraClient = class {
706
727
  }
707
728
  return { merkleroot, time };
708
729
  }
709
- /** Decodifica el cuerpo a texto aplicando el límite de tamaño (fail-closed). */
730
+ /**
731
+ * Fetches the raw 80-byte block header for `hash` and self-authenticates it:
732
+ * sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
733
+ * JSON layer — the raw header is cryptographically bound to the block hash we requested.
734
+ */
735
+ async rawBlockHeader(hash, signal) {
736
+ if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
737
+ throw new ValidationError("block hash must be a 64-char hex string");
738
+ }
739
+ this.#logger?.debug(`Esplora raw header ${hash}`);
740
+ const response = await this.#networkLayer.request(
741
+ this.#url,
742
+ { url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "application/octet-stream" } },
743
+ signal
744
+ );
745
+ const data = response.data;
746
+ if (data.length !== RAW_HEADER_SIZE) {
747
+ throw new EsploraResponseError(
748
+ `raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
749
+ );
750
+ }
751
+ const actualHash = sha256dDisplayHex(data);
752
+ if (actualHash !== hash.toLowerCase()) {
753
+ throw new EsploraResponseError(
754
+ `raw block header hash mismatch: expected ${hash.toLowerCase()}, got ${actualHash}`
755
+ );
756
+ }
757
+ return data;
758
+ }
759
+ /** Decodes the response body as text, enforcing the size limit (fail-closed). */
710
760
  #decode(data) {
711
761
  if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
712
762
  throw new EsploraResponseError(
@@ -717,7 +767,7 @@ var EsploraClient = class {
717
767
  return new TextDecoder("utf-8", { fatal: true }).decode(data);
718
768
  } catch (cause) {
719
769
  throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
720
- cause: cause instanceof Error ? cause : void 0
770
+ ...cause instanceof Error ? { cause } : {}
721
771
  });
722
772
  }
723
773
  }
@@ -727,12 +777,12 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
727
777
  throw new import_core3.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
728
778
  }
729
779
  const hash = await explorer.blockHash(attestation.height, signal);
730
- const header = await explorer.block(hash, signal);
731
- return (0, import_core3.verifyAgainstBlockheader)(digest, header);
780
+ const rawHeader = await explorer.rawBlockHeader(hash, signal);
781
+ return (0, import_core3.verifyAgainstRawHeader)(digest, rawHeader);
732
782
  }
733
783
 
734
784
  // src/core/orchestration.ts
735
- var import_node_crypto = require("crypto");
785
+ var import_node_crypto2 = require("crypto");
736
786
 
737
787
  // src/security/ssrf.ts
738
788
  var import_promises = require("dns/promises");
@@ -855,7 +905,7 @@ function secureNonce(n) {
855
905
  }
856
906
  function timingSafeEq(a, b) {
857
907
  if (a.length !== b.length) return false;
858
- return (0, import_node_crypto.timingSafeEqual)(a, b);
908
+ return (0, import_node_crypto2.timingSafeEqual)(a, b);
859
909
  }
860
910
  var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
861
911
  async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
@@ -912,7 +962,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
912
962
  } catch (error) {
913
963
  throw new ValidationError("Invalid .ots proof format", {
914
964
  /* v8 ignore next */
915
- cause: error instanceof Error ? error : void 0
965
+ ...error instanceof Error ? { cause: error } : {}
916
966
  });
917
967
  }
918
968
  if (detached.timestamp.isTimestampComplete()) {
@@ -949,15 +999,21 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
949
999
  }
950
1000
  return Buffer.from(after);
951
1001
  }
952
- async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
1002
+ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
953
1003
  let detached;
954
1004
  try {
955
1005
  detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(proof));
956
1006
  } catch (cause) {
957
1007
  throw new ValidationError("Invalid .ots proof format", {
958
- cause: cause instanceof Error ? cause : void 0
1008
+ ...cause instanceof Error ? { cause } : {}
959
1009
  });
960
1010
  }
1011
+ if (detached.fileHashOp instanceof import_core4.OpSHA1 || detached.fileHashOp instanceof import_core4.OpRIPEMD160) {
1012
+ return {
1013
+ status: "invalid",
1014
+ reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
1015
+ };
1016
+ }
961
1017
  if (originalDataHash !== void 0) {
962
1018
  let expected;
963
1019
  try {
@@ -965,7 +1021,7 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
965
1021
  } catch (err) {
966
1022
  throw new ValidationError(
967
1023
  err instanceof Error ? err.message : "Invalid hash format",
968
- { cause: err instanceof Error ? err : void 0 }
1024
+ { ...err instanceof Error ? { cause: err } : {} }
969
1025
  );
970
1026
  }
971
1027
  if (!timingSafeEq(expected, detached.fileDigest())) {
@@ -996,7 +1052,10 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
996
1052
  reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
997
1053
  };
998
1054
  }
999
- const explorer = new EsploraClient(networkLayer);
1055
+ const explorer = new EsploraClient(networkLayer, {
1056
+ ...esploraUrl !== void 0 ? { url: esploraUrl } : {},
1057
+ ...logger !== void 0 ? { logger } : {}
1058
+ });
1000
1059
  let lastNetworkError;
1001
1060
  let lastCryptoError;
1002
1061
  for (const { msg, attestation } of bitcoinAtts) {
@@ -1038,20 +1097,32 @@ var OpenTimestampsClient = class {
1038
1097
  globalSignal;
1039
1098
  minimumSuccessfulSubmissions;
1040
1099
  allowPrivateCalendars;
1100
+ esploraUrl;
1041
1101
  /**
1042
1102
  * Create a new OpenTimestamps client
1043
1103
  *
1044
1104
  * @param options Client configuration options
1045
1105
  */
1046
1106
  constructor(options = {}) {
1107
+ this.logger = options.logger;
1047
1108
  if (!options.calendars || options.calendars.length === 0) {
1048
1109
  this.calendars = DEFAULT_CALENDARS;
1049
1110
  this.logger?.info("No calendars provided, using defaults");
1050
1111
  } else {
1051
1112
  this.calendars = options.calendars;
1052
1113
  }
1053
- this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
1114
+ const minSubs = options.minimumSuccessfulSubmissions ?? 2;
1115
+ if (!Number.isInteger(minSubs) || minSubs < 1) {
1116
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
1117
+ }
1118
+ if (minSubs > this.calendars.length) {
1119
+ throw new ValidationError(
1120
+ `minimumSuccessfulSubmissions (${minSubs}) cannot exceed the number of calendars (${this.calendars.length})`
1121
+ );
1122
+ }
1123
+ this.minimumSuccessfulSubmissions = minSubs;
1054
1124
  this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
1125
+ this.esploraUrl = options.esploraUrl;
1055
1126
  const resilienceConfig = {
1056
1127
  ...DEFAULT_RESILIENCE,
1057
1128
  ...options.resilience,
@@ -1068,8 +1139,7 @@ var OpenTimestampsClient = class {
1068
1139
  ...options.resilience?.circuitBreaker
1069
1140
  }
1070
1141
  };
1071
- this.logger = options.logger;
1072
- this.globalSignal = options.signal;
1142
+ if (options.signal !== void 0) this.globalSignal = options.signal;
1073
1143
  const internalOptions = options;
1074
1144
  this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
1075
1145
  this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
@@ -1154,7 +1224,7 @@ var OpenTimestampsClient = class {
1154
1224
  * ```
1155
1225
  */
1156
1226
  async verify(proof, originalDataHash) {
1157
- return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
1227
+ return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal, this.esploraUrl);
1158
1228
  }
1159
1229
  /**
1160
1230
  * Get the current state of the circuit breaker for a calendar
package/dist/index.d.cts CHANGED
@@ -4,7 +4,7 @@ export { Attestation, BitcoinAttestation, DetachedTimestampFile, PendingAttestat
4
4
  /**
5
5
  * Type definitions for the OpenTimestamps Client SDK
6
6
  */
7
- /** Logger interface for observability */
7
+ /** Logger interface for observability. */
8
8
  interface Logger {
9
9
  debug(message: string, ...args: unknown[]): void;
10
10
  info(message: string, ...args: unknown[]): void;
@@ -39,7 +39,7 @@ interface ResilienceOptions {
39
39
  connectTimeoutMs: number;
40
40
  retries: RetryOptions;
41
41
  circuitBreaker: CircuitBreakerOptions;
42
- /** Límite de bytes para el body de la respuesta. Default 100 KB. */
42
+ /** Maximum bytes allowed in the response body. Defaults to 100 KB. */
43
43
  maxResponseBytes?: number;
44
44
  }
45
45
  /** Client configuration options */
@@ -55,12 +55,18 @@ interface ClientOptions {
55
55
  /** Minimum successful calendar submissions required (default: 2) */
56
56
  minimumSuccessfulSubmissions?: number;
57
57
  /**
58
- * Permite URLs de calendario que resuelven a IPs privadas/reservadas.
58
+ * Base URL for the Esplora block explorer used by verify().
59
+ * Defaults to Blockstream (https://blockstream.info/api).
60
+ * Override to use a self-hosted or alternative explorer.
61
+ */
62
+ esploraUrl?: string;
63
+ /**
64
+ * Allows calendar URLs that resolve to private/reserved IPs.
59
65
  *
60
- * **No activar en producción.** Útil para testing local o redes corporativas.
66
+ * **Do not enable in production.** Useful for local testing or corporate networks.
61
67
  *
62
- * Incluso con `false`, la protección es best-effort contra DNS rebinding (TOCTOU).
63
- * Para alta seguridad, complementar con egress filtering a nivel de red.
68
+ * Even when `false`, protection is best-effort against DNS rebinding (TOCTOU).
69
+ * For high-security environments, complement with network-level egress filtering.
64
70
  *
65
71
  * @default false
66
72
  */
@@ -139,6 +145,7 @@ declare class OpenTimestampsClient {
139
145
  private globalSignal?;
140
146
  private minimumSuccessfulSubmissions;
141
147
  private allowPrivateCalendars;
148
+ private esploraUrl;
142
149
  /**
143
150
  * Create a new OpenTimestamps client
144
151
  *
@@ -265,7 +272,7 @@ declare class UpgradeError extends OpenTimestampsClientError {
265
272
  }
266
273
  /** Network-related error (timeout, all retries failed, etc.) */
267
274
  declare class NetworkError extends OpenTimestampsClientError {
268
- /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
275
+ /** HTTP status code when the failure originates from an HTTP response. */
269
276
  readonly status?: number;
270
277
  constructor(message: string, options?: {
271
278
  cause?: Error;
@@ -276,16 +283,16 @@ declare class NetworkError extends OpenTimestampsClientError {
276
283
  declare class CircuitBreakerError extends NetworkError {
277
284
  constructor(calendar: string);
278
285
  }
279
- /** El calendario no conoce (todavía) el commitment consultado (HTTP 404). */
286
+ /** The calendar does not yet know the queried commitment (HTTP 404). */
280
287
  declare class CommitmentNotFoundError extends NetworkError {
281
288
  }
282
- /** La respuesta del calendario supera el límite de tamaño permitido (defensa DoS). */
289
+ /** The calendar response exceeds the allowed size limit (DoS defense). */
283
290
  declare class CalendarResponseTooLargeError extends NetworkError {
284
291
  }
285
- /** Respuesta del explorador Esplora inválida: vacía, no-JSON, malformada o demasiado grande (defensa DoS). */
292
+ /** Invalid Esplora response: empty, non-JSON, malformed, or too large (DoS defense). */
286
293
  declare class EsploraResponseError extends NetworkError {
287
294
  }
288
- /** La respuesta supera el límite de bytes permitido (defensa DoS). */
295
+ /** Response exceeds the allowed byte limit (DoS defense). */
289
296
  declare class SizeLimitExceededError extends NetworkError {
290
297
  readonly maxBytes: number;
291
298
  readonly actualBytes?: number;
@@ -306,8 +313,7 @@ declare enum CircuitState {
306
313
  }
307
314
 
308
315
  /**
309
- * Universal fetch adapter para compatibilidad multi-runtime.
310
- * Funciona en Node.js 18+, browsers y edge runtimes.
316
+ * Fetch adapter for Node.js 20+.
311
317
  */
312
318
  interface FetchRequest {
313
319
  url: string;
@@ -341,30 +347,33 @@ declare class ResilientNetworkLayer {
341
347
  }
342
348
 
343
349
  /**
344
- * Cliente de un calendario remoto OpenTimestamps (protocolo OTS real).
350
+ * Remote OpenTimestamps calendar client (real OTS protocol).
345
351
  */
346
352
 
347
- /** Limite de tamano de la respuesta de un calendario (defensa DoS). */
353
+ /** Maximum response size from a calendar server (DoS defense). */
348
354
  declare const MAX_CALENDAR_RESPONSE_SIZE = 10000;
349
- /** Interfaz con un servidor de calendario remoto. */
355
+ /** Interface to a remote calendar server. */
350
356
  declare class CalendarClient {
351
357
  #private;
352
358
  private readonly url;
353
359
  private readonly networkLayer;
354
360
  private readonly logger?;
355
361
  constructor(url: string, networkLayer: ResilientNetworkLayer, logger?: Logger | undefined);
356
- /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
362
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
357
363
  submit(digest: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
358
- /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
364
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
359
365
  getTimestamp(commitment: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
360
366
  }
361
- /** Lista blanca de URLs de calendario de confianza. */
367
+ /** Allowlist of trusted calendar URLs. */
362
368
  declare class UrlWhitelist {
363
369
  #private;
364
370
  constructor(urls?: readonly string[]);
365
- /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
371
+ /**
372
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
373
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
374
+ */
366
375
  add(url: string): void;
367
- /** Verdadero si `url` casa con algun patron de la whitelist. */
376
+ /** Returns true if `url` matches any pattern in the allowlist. */
368
377
  contains(url: string): boolean;
369
378
  toString(): string;
370
379
  }
@@ -379,32 +388,38 @@ declare const DEFAULT_CALENDAR_WHITELIST: UrlWhitelist;
379
388
  */
380
389
  declare const DEFAULT_AGGREGATORS: readonly string[];
381
390
 
382
- /** Explorador Esplora público por defecto (Bitcoin mainnet). */
391
+ /** Default public Esplora explorer (Bitcoin mainnet). */
383
392
  declare const PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
384
- /** Límite de tamaño de una respuesta de Esplora (defensa DoS). Una cabecera JSON ronda los cientos de bytes. */
393
+ /** Maximum size of an Esplora response (DoS defense). A JSON block header is a few hundred bytes. */
385
394
  declare const MAX_ESPLORA_RESPONSE_SIZE = 100000;
386
395
  interface EsploraClientOptions {
387
- /** URL base del explorador (por defecto Blockstream). Útil para apuntar a un Esplora de Litecoin. */
396
+ /** Base URL of the explorer (defaults to Blockstream). Useful for pointing to a Litecoin Esplora. */
388
397
  url?: string;
389
398
  logger?: Logger;
390
399
  }
391
- /** Cliente de un explorador Esplora remoto. */
400
+ /** Client for a remote Esplora explorer. */
392
401
  declare class EsploraClient {
393
402
  #private;
394
403
  constructor(networkLayer: ResilientNetworkLayer, options?: EsploraClientOptions);
395
- /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
404
+ /** Returns the block hash (64-char hex, lowercase) at the given height. */
396
405
  blockHash(height: number, signal?: AbortSignal): Promise<string>;
397
- /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
406
+ /** Returns the block header (merkle root + timestamp) for the given hash. */
398
407
  block(hash: string, signal?: AbortSignal): Promise<BlockHeader>;
408
+ /**
409
+ * Fetches the raw 80-byte block header for `hash` and self-authenticates it:
410
+ * sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
411
+ * JSON layer — the raw header is cryptographically bound to the block hash we requested.
412
+ */
413
+ rawBlockHeader(hash: string, signal?: AbortSignal): Promise<Uint8Array>;
399
414
  }
400
415
  /**
401
- * Verifica una atestación Bitcoin/Litecoin contra la cabecera del bloque correspondiente.
416
+ * Verifies a Bitcoin/Litecoin attestation against the corresponding block header.
402
417
  *
403
- * `digest` es el commitment final del árbol del timestamp en el punto de la atestación
404
- * (32 bytes, debe ser el merkleroot del bloque). `explorer` debe apuntar a la cadena de la
405
- * atestación (Blockstream para Bitcoin; un Esplora de Litecoin para Litecoin). Devuelve el
406
- * tiempo del bloque (epoch s) en éxito; lanza `VerificationError` si no coincide o si la
407
- * atestación no es verificable en cadena (`pending`/`unknown`). Fail-closed.
418
+ * `digest` is the final tree commitment at the attestation point (32 bytes, must equal the
419
+ * block's merkle root). `explorer` must point to the correct chain (Blockstream for Bitcoin;
420
+ * a Litecoin Esplora for Litecoin). Returns the block time (epoch seconds) on success;
421
+ * throws `VerificationError` if the digest does not match or the attestation is not
422
+ * on-chain verifiable (`pending`/`unknown`). Fail-closed.
408
423
  */
409
424
  declare function verifyTimestampAttestation(digest: Uint8Array, attestation: Attestation, explorer: EsploraClient, signal?: AbortSignal): Promise<number>;
410
425
 
@@ -412,21 +427,21 @@ declare function hashBuffer(data: Buffer | Uint8Array): Buffer;
412
427
  declare function hashFile(path: string): Promise<Buffer>;
413
428
 
414
429
  /**
415
- * SSRF protection para calendarios configurables por el usuario.
430
+ * SSRF protection for user-configurable calendar URLs.
416
431
  *
417
- * LIMITACIONES (documentadas intencionalmente):
418
- * - TOCTOU/DNS rebinding: la validación DNS ocurre ANTES de la conexión.
419
- * Un servidor con TTL=0 puede cambiar la IP entre la validación y el fetch.
420
- * Mitigación real requiere egress filtering a nivel de red.
421
- * - IPv4-mapped IPv6 (::ffff:x.x.x.x): se bloquea el prefijo ::ffff:
422
- * pero la validación del componente IPv4 depende del formato que devuelva Node.js.
432
+ * KNOWN LIMITATIONS (documented intentionally):
433
+ * - TOCTOU / DNS rebinding: DNS validation happens BEFORE the connection.
434
+ * A server with TTL=0 can change its IP between validation and the actual fetch.
435
+ * Real mitigation requires network-level egress filtering.
436
+ * - IPv4-mapped IPv6 (::ffff:x.x.x.x): the ::ffff: prefix is blocked but the
437
+ * IPv4 component validation depends on the format Node.js returns.
423
438
  */
424
439
  /**
425
- * Valida que una URL de calendario es segura para hacer outbound HTTP.
426
- * Bloquea IPs privadas/reservadas por defecto.
440
+ * Validates that a calendar URL is safe for outbound HTTP requests.
441
+ * Blocks private/reserved IPs by default.
427
442
  *
428
- * @param allowPrivate Si true, omite la comprobación de rangos IP.
429
- * Útil para testing local o redes corporativas internas.
443
+ * @param allowPrivate When true, skips the IP range check.
444
+ * Useful for local testing or internal corporate networks.
430
445
  */
431
446
  declare function assertSafeCalendarUrl(url: string, options: {
432
447
  allowPrivate: boolean;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { Attestation, BitcoinAttestation, DetachedTimestampFile, PendingAttestat
4
4
  /**
5
5
  * Type definitions for the OpenTimestamps Client SDK
6
6
  */
7
- /** Logger interface for observability */
7
+ /** Logger interface for observability. */
8
8
  interface Logger {
9
9
  debug(message: string, ...args: unknown[]): void;
10
10
  info(message: string, ...args: unknown[]): void;
@@ -39,7 +39,7 @@ interface ResilienceOptions {
39
39
  connectTimeoutMs: number;
40
40
  retries: RetryOptions;
41
41
  circuitBreaker: CircuitBreakerOptions;
42
- /** Límite de bytes para el body de la respuesta. Default 100 KB. */
42
+ /** Maximum bytes allowed in the response body. Defaults to 100 KB. */
43
43
  maxResponseBytes?: number;
44
44
  }
45
45
  /** Client configuration options */
@@ -55,12 +55,18 @@ interface ClientOptions {
55
55
  /** Minimum successful calendar submissions required (default: 2) */
56
56
  minimumSuccessfulSubmissions?: number;
57
57
  /**
58
- * Permite URLs de calendario que resuelven a IPs privadas/reservadas.
58
+ * Base URL for the Esplora block explorer used by verify().
59
+ * Defaults to Blockstream (https://blockstream.info/api).
60
+ * Override to use a self-hosted or alternative explorer.
61
+ */
62
+ esploraUrl?: string;
63
+ /**
64
+ * Allows calendar URLs that resolve to private/reserved IPs.
59
65
  *
60
- * **No activar en producción.** Útil para testing local o redes corporativas.
66
+ * **Do not enable in production.** Useful for local testing or corporate networks.
61
67
  *
62
- * Incluso con `false`, la protección es best-effort contra DNS rebinding (TOCTOU).
63
- * Para alta seguridad, complementar con egress filtering a nivel de red.
68
+ * Even when `false`, protection is best-effort against DNS rebinding (TOCTOU).
69
+ * For high-security environments, complement with network-level egress filtering.
64
70
  *
65
71
  * @default false
66
72
  */
@@ -139,6 +145,7 @@ declare class OpenTimestampsClient {
139
145
  private globalSignal?;
140
146
  private minimumSuccessfulSubmissions;
141
147
  private allowPrivateCalendars;
148
+ private esploraUrl;
142
149
  /**
143
150
  * Create a new OpenTimestamps client
144
151
  *
@@ -265,7 +272,7 @@ declare class UpgradeError extends OpenTimestampsClientError {
265
272
  }
266
273
  /** Network-related error (timeout, all retries failed, etc.) */
267
274
  declare class NetworkError extends OpenTimestampsClientError {
268
- /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
275
+ /** HTTP status code when the failure originates from an HTTP response. */
269
276
  readonly status?: number;
270
277
  constructor(message: string, options?: {
271
278
  cause?: Error;
@@ -276,16 +283,16 @@ declare class NetworkError extends OpenTimestampsClientError {
276
283
  declare class CircuitBreakerError extends NetworkError {
277
284
  constructor(calendar: string);
278
285
  }
279
- /** El calendario no conoce (todavía) el commitment consultado (HTTP 404). */
286
+ /** The calendar does not yet know the queried commitment (HTTP 404). */
280
287
  declare class CommitmentNotFoundError extends NetworkError {
281
288
  }
282
- /** La respuesta del calendario supera el límite de tamaño permitido (defensa DoS). */
289
+ /** The calendar response exceeds the allowed size limit (DoS defense). */
283
290
  declare class CalendarResponseTooLargeError extends NetworkError {
284
291
  }
285
- /** Respuesta del explorador Esplora inválida: vacía, no-JSON, malformada o demasiado grande (defensa DoS). */
292
+ /** Invalid Esplora response: empty, non-JSON, malformed, or too large (DoS defense). */
286
293
  declare class EsploraResponseError extends NetworkError {
287
294
  }
288
- /** La respuesta supera el límite de bytes permitido (defensa DoS). */
295
+ /** Response exceeds the allowed byte limit (DoS defense). */
289
296
  declare class SizeLimitExceededError extends NetworkError {
290
297
  readonly maxBytes: number;
291
298
  readonly actualBytes?: number;
@@ -306,8 +313,7 @@ declare enum CircuitState {
306
313
  }
307
314
 
308
315
  /**
309
- * Universal fetch adapter para compatibilidad multi-runtime.
310
- * Funciona en Node.js 18+, browsers y edge runtimes.
316
+ * Fetch adapter for Node.js 20+.
311
317
  */
312
318
  interface FetchRequest {
313
319
  url: string;
@@ -341,30 +347,33 @@ declare class ResilientNetworkLayer {
341
347
  }
342
348
 
343
349
  /**
344
- * Cliente de un calendario remoto OpenTimestamps (protocolo OTS real).
350
+ * Remote OpenTimestamps calendar client (real OTS protocol).
345
351
  */
346
352
 
347
- /** Limite de tamano de la respuesta de un calendario (defensa DoS). */
353
+ /** Maximum response size from a calendar server (DoS defense). */
348
354
  declare const MAX_CALENDAR_RESPONSE_SIZE = 10000;
349
- /** Interfaz con un servidor de calendario remoto. */
355
+ /** Interface to a remote calendar server. */
350
356
  declare class CalendarClient {
351
357
  #private;
352
358
  private readonly url;
353
359
  private readonly networkLayer;
354
360
  private readonly logger?;
355
361
  constructor(url: string, networkLayer: ResilientNetworkLayer, logger?: Logger | undefined);
356
- /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
362
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
357
363
  submit(digest: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
358
- /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
364
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
359
365
  getTimestamp(commitment: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
360
366
  }
361
- /** Lista blanca de URLs de calendario de confianza. */
367
+ /** Allowlist of trusted calendar URLs. */
362
368
  declare class UrlWhitelist {
363
369
  #private;
364
370
  constructor(urls?: readonly string[]);
365
- /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
371
+ /**
372
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
373
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
374
+ */
366
375
  add(url: string): void;
367
- /** Verdadero si `url` casa con algun patron de la whitelist. */
376
+ /** Returns true if `url` matches any pattern in the allowlist. */
368
377
  contains(url: string): boolean;
369
378
  toString(): string;
370
379
  }
@@ -379,32 +388,38 @@ declare const DEFAULT_CALENDAR_WHITELIST: UrlWhitelist;
379
388
  */
380
389
  declare const DEFAULT_AGGREGATORS: readonly string[];
381
390
 
382
- /** Explorador Esplora público por defecto (Bitcoin mainnet). */
391
+ /** Default public Esplora explorer (Bitcoin mainnet). */
383
392
  declare const PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
384
- /** Límite de tamaño de una respuesta de Esplora (defensa DoS). Una cabecera JSON ronda los cientos de bytes. */
393
+ /** Maximum size of an Esplora response (DoS defense). A JSON block header is a few hundred bytes. */
385
394
  declare const MAX_ESPLORA_RESPONSE_SIZE = 100000;
386
395
  interface EsploraClientOptions {
387
- /** URL base del explorador (por defecto Blockstream). Útil para apuntar a un Esplora de Litecoin. */
396
+ /** Base URL of the explorer (defaults to Blockstream). Useful for pointing to a Litecoin Esplora. */
388
397
  url?: string;
389
398
  logger?: Logger;
390
399
  }
391
- /** Cliente de un explorador Esplora remoto. */
400
+ /** Client for a remote Esplora explorer. */
392
401
  declare class EsploraClient {
393
402
  #private;
394
403
  constructor(networkLayer: ResilientNetworkLayer, options?: EsploraClientOptions);
395
- /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
404
+ /** Returns the block hash (64-char hex, lowercase) at the given height. */
396
405
  blockHash(height: number, signal?: AbortSignal): Promise<string>;
397
- /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
406
+ /** Returns the block header (merkle root + timestamp) for the given hash. */
398
407
  block(hash: string, signal?: AbortSignal): Promise<BlockHeader>;
408
+ /**
409
+ * Fetches the raw 80-byte block header for `hash` and self-authenticates it:
410
+ * sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
411
+ * JSON layer — the raw header is cryptographically bound to the block hash we requested.
412
+ */
413
+ rawBlockHeader(hash: string, signal?: AbortSignal): Promise<Uint8Array>;
399
414
  }
400
415
  /**
401
- * Verifica una atestación Bitcoin/Litecoin contra la cabecera del bloque correspondiente.
416
+ * Verifies a Bitcoin/Litecoin attestation against the corresponding block header.
402
417
  *
403
- * `digest` es el commitment final del árbol del timestamp en el punto de la atestación
404
- * (32 bytes, debe ser el merkleroot del bloque). `explorer` debe apuntar a la cadena de la
405
- * atestación (Blockstream para Bitcoin; un Esplora de Litecoin para Litecoin). Devuelve el
406
- * tiempo del bloque (epoch s) en éxito; lanza `VerificationError` si no coincide o si la
407
- * atestación no es verificable en cadena (`pending`/`unknown`). Fail-closed.
418
+ * `digest` is the final tree commitment at the attestation point (32 bytes, must equal the
419
+ * block's merkle root). `explorer` must point to the correct chain (Blockstream for Bitcoin;
420
+ * a Litecoin Esplora for Litecoin). Returns the block time (epoch seconds) on success;
421
+ * throws `VerificationError` if the digest does not match or the attestation is not
422
+ * on-chain verifiable (`pending`/`unknown`). Fail-closed.
408
423
  */
409
424
  declare function verifyTimestampAttestation(digest: Uint8Array, attestation: Attestation, explorer: EsploraClient, signal?: AbortSignal): Promise<number>;
410
425
 
@@ -412,21 +427,21 @@ declare function hashBuffer(data: Buffer | Uint8Array): Buffer;
412
427
  declare function hashFile(path: string): Promise<Buffer>;
413
428
 
414
429
  /**
415
- * SSRF protection para calendarios configurables por el usuario.
430
+ * SSRF protection for user-configurable calendar URLs.
416
431
  *
417
- * LIMITACIONES (documentadas intencionalmente):
418
- * - TOCTOU/DNS rebinding: la validación DNS ocurre ANTES de la conexión.
419
- * Un servidor con TTL=0 puede cambiar la IP entre la validación y el fetch.
420
- * Mitigación real requiere egress filtering a nivel de red.
421
- * - IPv4-mapped IPv6 (::ffff:x.x.x.x): se bloquea el prefijo ::ffff:
422
- * pero la validación del componente IPv4 depende del formato que devuelva Node.js.
432
+ * KNOWN LIMITATIONS (documented intentionally):
433
+ * - TOCTOU / DNS rebinding: DNS validation happens BEFORE the connection.
434
+ * A server with TTL=0 can change its IP between validation and the actual fetch.
435
+ * Real mitigation requires network-level egress filtering.
436
+ * - IPv4-mapped IPv6 (::ffff:x.x.x.x): the ::ffff: prefix is blocked but the
437
+ * IPv4 component validation depends on the format Node.js returns.
423
438
  */
424
439
  /**
425
- * Valida que una URL de calendario es segura para hacer outbound HTTP.
426
- * Bloquea IPs privadas/reservadas por defecto.
440
+ * Validates that a calendar URL is safe for outbound HTTP requests.
441
+ * Blocks private/reserved IPs by default.
427
442
  *
428
- * @param allowPrivate Si true, omite la comprobación de rangos IP.
429
- * Útil para testing local o redes corporativas internas.
443
+ * @param allowPrivate When true, skips the IP range check.
444
+ * Useful for local testing or internal corporate networks.
430
445
  */
431
446
  declare function assertSafeCalendarUrl(url: string, options: {
432
447
  allowPrivate: boolean;
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ var OpenTimestampsClientError = class extends Error {
29
29
  constructor(message, options) {
30
30
  super(message);
31
31
  this.name = this.constructor.name;
32
- this.cause = options?.cause;
32
+ if (options?.cause !== void 0) this.cause = options.cause;
33
33
  Error.captureStackTrace?.(this, this.constructor);
34
34
  }
35
35
  };
@@ -47,11 +47,11 @@ var StampError = class extends OpenTimestampsClientError {
47
47
  var UpgradeError = class extends OpenTimestampsClientError {
48
48
  };
49
49
  var NetworkError = class extends OpenTimestampsClientError {
50
- /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
50
+ /** HTTP status code when the failure originates from an HTTP response. */
51
51
  status;
52
52
  constructor(message, options) {
53
53
  super(message, options);
54
- this.status = options?.status;
54
+ if (options?.status !== void 0) this.status = options.status;
55
55
  }
56
56
  };
57
57
  var CircuitBreakerError = class extends NetworkError {
@@ -74,7 +74,7 @@ var SizeLimitExceededError = class extends NetworkError {
74
74
  options
75
75
  );
76
76
  this.maxBytes = maxBytes;
77
- this.actualBytes = actualBytes;
77
+ if (actualBytes !== void 0) this.actualBytes = actualBytes;
78
78
  }
79
79
  };
80
80
 
@@ -126,7 +126,10 @@ var CircuitBreaker = class {
126
126
  this.onSuccess(key, circuit);
127
127
  return result;
128
128
  } catch (error) {
129
- this.onFailure(key, circuit);
129
+ const is4xx = error instanceof Error && error.retryable === false;
130
+ if (!is4xx) {
131
+ this.onFailure(key, circuit);
132
+ }
130
133
  throw error;
131
134
  }
132
135
  }
@@ -223,11 +226,15 @@ function sleep(ms, signal) {
223
226
  reject(new Error("Aborted"));
224
227
  return;
225
228
  }
226
- const timeout = setTimeout(resolve, ms);
227
- signal?.addEventListener("abort", () => {
229
+ const onAbort = () => {
228
230
  clearTimeout(timeout);
229
231
  reject(new Error("Aborted"));
230
- });
232
+ };
233
+ const timeout = setTimeout(() => {
234
+ signal?.removeEventListener("abort", onAbort);
235
+ resolve();
236
+ }, ms);
237
+ signal?.addEventListener("abort", onAbort, { once: true });
231
238
  });
232
239
  }
233
240
  async function withRetry(fn, options, logger, signal) {
@@ -310,8 +317,9 @@ async function executeRequest(request, maxBytes) {
310
317
  const response = await globalThis.fetch(request.url, {
311
318
  method: request.method,
312
319
  headers: { "Content-Type": "application/octet-stream", ...request.headers },
313
- body: request.body,
314
- signal: request.signal
320
+ ...request.body !== void 0 ? { body: request.body } : {},
321
+ ...request.signal !== void 0 ? { signal: request.signal } : {},
322
+ redirect: "error"
315
323
  });
316
324
  const data = await readResponseBody(response, maxBytes);
317
325
  return { ok: response.ok, status: response.status, statusText: response.statusText, data };
@@ -434,6 +442,8 @@ import {
434
442
  DetachedTimestampFile,
435
443
  OpSHA256,
436
444
  OpAppend,
445
+ OpSHA1,
446
+ OpRIPEMD160,
437
447
  makeMerkleTree
438
448
  } from "@otskit/core";
439
449
 
@@ -470,7 +480,7 @@ var CalendarClient = class {
470
480
  url;
471
481
  networkLayer;
472
482
  logger;
473
- /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
483
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
474
484
  async submit(digest, signal) {
475
485
  assertCommitment(digest);
476
486
  this.logger?.debug(`Submitting digest to ${this.url}/digest`);
@@ -481,7 +491,7 @@ var CalendarClient = class {
481
491
  );
482
492
  return this.#parseTimestamp(response.data, digest);
483
493
  }
484
- /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
494
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
485
495
  async getTimestamp(commitment, signal) {
486
496
  assertCommitment(commitment);
487
497
  const path = `/timestamp/${bytesToHex(commitment)}`;
@@ -503,7 +513,7 @@ var CalendarClient = class {
503
513
  }
504
514
  return this.#parseTimestamp(response.data, commitment);
505
515
  }
506
- /** Deserializa la respuesta del calendario como un Timestamp commit-eado a `commitment`. */
516
+ /** Deserializes the calendar response as a Timestamp committed to `commitment`. */
507
517
  #parseTimestamp(data, commitment) {
508
518
  if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
509
519
  throw new CalendarResponseTooLargeError(
@@ -533,7 +543,7 @@ function parseWhitelistPattern(raw) {
533
543
  hostname,
534
544
  port: parsed.port,
535
545
  pathname: parsed.pathname,
536
- wildcardSuffix
546
+ ...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
537
547
  };
538
548
  }
539
549
  function hostnameMatchesPattern(hostname, pattern) {
@@ -549,20 +559,26 @@ var UrlWhitelist = class {
549
559
  for (const u of urls) this.add(u);
550
560
  }
551
561
  }
552
- /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
562
+ /**
563
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
564
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
565
+ */
553
566
  add(url) {
554
567
  if (typeof url !== "string") {
555
568
  throw new TypeError("UrlWhitelist: URL must be a string");
556
569
  }
557
570
  if (url.startsWith("http://") || url.startsWith("https://")) {
558
571
  const pattern = parseWhitelistPattern(url);
559
- if (pattern !== void 0) this.#patterns.set(url, pattern);
572
+ if (pattern === void 0) {
573
+ throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
574
+ }
575
+ this.#patterns.set(url, pattern);
560
576
  } else {
561
577
  this.add("http://" + url);
562
578
  this.add("https://" + url);
563
579
  }
564
580
  }
565
- /** Verdadero si `url` casa con algun patron de la whitelist. */
581
+ /** Returns true if `url` matches any pattern in the allowlist. */
566
582
  contains(url) {
567
583
  let parsed;
568
584
  try {
@@ -588,10 +604,17 @@ var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...TRUSTED_CALENDAR_WHITELIST
588
604
  var DEFAULT_AGGREGATORS = [...DEFAULT_AGGREGATOR_URLS];
589
605
 
590
606
  // src/network/esplora.ts
591
- import { verifyAgainstBlockheader, VerificationError } from "@otskit/core";
607
+ import { createHash } from "crypto";
608
+ import { verifyAgainstRawHeader, VerificationError } from "@otskit/core";
592
609
  var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
593
610
  var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
611
+ var RAW_HEADER_SIZE = 80;
594
612
  var HEX64_RE = /^[0-9a-f]{64}$/i;
613
+ function sha256dDisplayHex(data) {
614
+ const first = createHash("sha256").update(data).digest();
615
+ const second = createHash("sha256").update(first).digest();
616
+ return Buffer.from(second).reverse().toString("hex");
617
+ }
595
618
  var EsploraClient = class {
596
619
  #url;
597
620
  #networkLayer;
@@ -611,7 +634,7 @@ var EsploraClient = class {
611
634
  this.#url = raw.replace(/\/+$/, "");
612
635
  this.#logger = options.logger;
613
636
  }
614
- /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
637
+ /** Returns the block hash (64-char hex, lowercase) at the given height. */
615
638
  async blockHash(height, signal) {
616
639
  if (!Number.isSafeInteger(height) || height < 0) {
617
640
  throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
@@ -628,7 +651,7 @@ var EsploraClient = class {
628
651
  }
629
652
  return text.toLowerCase();
630
653
  }
631
- /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
654
+ /** Returns the block header (merkle root + timestamp) for the given hash. */
632
655
  async block(hash, signal) {
633
656
  if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
634
657
  throw new ValidationError("block hash must be a 64-char hex string");
@@ -646,7 +669,7 @@ var EsploraClient = class {
646
669
  } catch (err) {
647
670
  throw new EsploraResponseError("esplora returned a non-JSON block response", {
648
671
  /* v8 ignore next */
649
- cause: err instanceof Error ? err : void 0
672
+ ...err instanceof Error ? { cause: err } : {}
650
673
  });
651
674
  }
652
675
  if (typeof body !== "object" || body === null) {
@@ -661,7 +684,36 @@ var EsploraClient = class {
661
684
  }
662
685
  return { merkleroot, time };
663
686
  }
664
- /** Decodifica el cuerpo a texto aplicando el límite de tamaño (fail-closed). */
687
+ /**
688
+ * Fetches the raw 80-byte block header for `hash` and self-authenticates it:
689
+ * sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
690
+ * JSON layer — the raw header is cryptographically bound to the block hash we requested.
691
+ */
692
+ async rawBlockHeader(hash, signal) {
693
+ if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
694
+ throw new ValidationError("block hash must be a 64-char hex string");
695
+ }
696
+ this.#logger?.debug(`Esplora raw header ${hash}`);
697
+ const response = await this.#networkLayer.request(
698
+ this.#url,
699
+ { url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "application/octet-stream" } },
700
+ signal
701
+ );
702
+ const data = response.data;
703
+ if (data.length !== RAW_HEADER_SIZE) {
704
+ throw new EsploraResponseError(
705
+ `raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
706
+ );
707
+ }
708
+ const actualHash = sha256dDisplayHex(data);
709
+ if (actualHash !== hash.toLowerCase()) {
710
+ throw new EsploraResponseError(
711
+ `raw block header hash mismatch: expected ${hash.toLowerCase()}, got ${actualHash}`
712
+ );
713
+ }
714
+ return data;
715
+ }
716
+ /** Decodes the response body as text, enforcing the size limit (fail-closed). */
665
717
  #decode(data) {
666
718
  if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
667
719
  throw new EsploraResponseError(
@@ -672,7 +724,7 @@ var EsploraClient = class {
672
724
  return new TextDecoder("utf-8", { fatal: true }).decode(data);
673
725
  } catch (cause) {
674
726
  throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
675
- cause: cause instanceof Error ? cause : void 0
727
+ ...cause instanceof Error ? { cause } : {}
676
728
  });
677
729
  }
678
730
  }
@@ -682,8 +734,8 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
682
734
  throw new VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
683
735
  }
684
736
  const hash = await explorer.blockHash(attestation.height, signal);
685
- const header = await explorer.block(hash, signal);
686
- return verifyAgainstBlockheader(digest, header);
737
+ const rawHeader = await explorer.rawBlockHeader(hash, signal);
738
+ return verifyAgainstRawHeader(digest, rawHeader);
687
739
  }
688
740
 
689
741
  // src/core/orchestration.ts
@@ -867,7 +919,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
867
919
  } catch (error) {
868
920
  throw new ValidationError("Invalid .ots proof format", {
869
921
  /* v8 ignore next */
870
- cause: error instanceof Error ? error : void 0
922
+ ...error instanceof Error ? { cause: error } : {}
871
923
  });
872
924
  }
873
925
  if (detached.timestamp.isTimestampComplete()) {
@@ -904,15 +956,21 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
904
956
  }
905
957
  return Buffer.from(after);
906
958
  }
907
- async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
959
+ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
908
960
  let detached;
909
961
  try {
910
962
  detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
911
963
  } catch (cause) {
912
964
  throw new ValidationError("Invalid .ots proof format", {
913
- cause: cause instanceof Error ? cause : void 0
965
+ ...cause instanceof Error ? { cause } : {}
914
966
  });
915
967
  }
968
+ if (detached.fileHashOp instanceof OpSHA1 || detached.fileHashOp instanceof OpRIPEMD160) {
969
+ return {
970
+ status: "invalid",
971
+ reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
972
+ };
973
+ }
916
974
  if (originalDataHash !== void 0) {
917
975
  let expected;
918
976
  try {
@@ -920,7 +978,7 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
920
978
  } catch (err) {
921
979
  throw new ValidationError(
922
980
  err instanceof Error ? err.message : "Invalid hash format",
923
- { cause: err instanceof Error ? err : void 0 }
981
+ { ...err instanceof Error ? { cause: err } : {} }
924
982
  );
925
983
  }
926
984
  if (!timingSafeEq(expected, detached.fileDigest())) {
@@ -951,7 +1009,10 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
951
1009
  reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
952
1010
  };
953
1011
  }
954
- const explorer = new EsploraClient(networkLayer);
1012
+ const explorer = new EsploraClient(networkLayer, {
1013
+ ...esploraUrl !== void 0 ? { url: esploraUrl } : {},
1014
+ ...logger !== void 0 ? { logger } : {}
1015
+ });
955
1016
  let lastNetworkError;
956
1017
  let lastCryptoError;
957
1018
  for (const { msg, attestation } of bitcoinAtts) {
@@ -993,20 +1054,32 @@ var OpenTimestampsClient = class {
993
1054
  globalSignal;
994
1055
  minimumSuccessfulSubmissions;
995
1056
  allowPrivateCalendars;
1057
+ esploraUrl;
996
1058
  /**
997
1059
  * Create a new OpenTimestamps client
998
1060
  *
999
1061
  * @param options Client configuration options
1000
1062
  */
1001
1063
  constructor(options = {}) {
1064
+ this.logger = options.logger;
1002
1065
  if (!options.calendars || options.calendars.length === 0) {
1003
1066
  this.calendars = DEFAULT_CALENDARS;
1004
1067
  this.logger?.info("No calendars provided, using defaults");
1005
1068
  } else {
1006
1069
  this.calendars = options.calendars;
1007
1070
  }
1008
- this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
1071
+ const minSubs = options.minimumSuccessfulSubmissions ?? 2;
1072
+ if (!Number.isInteger(minSubs) || minSubs < 1) {
1073
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
1074
+ }
1075
+ if (minSubs > this.calendars.length) {
1076
+ throw new ValidationError(
1077
+ `minimumSuccessfulSubmissions (${minSubs}) cannot exceed the number of calendars (${this.calendars.length})`
1078
+ );
1079
+ }
1080
+ this.minimumSuccessfulSubmissions = minSubs;
1009
1081
  this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
1082
+ this.esploraUrl = options.esploraUrl;
1010
1083
  const resilienceConfig = {
1011
1084
  ...DEFAULT_RESILIENCE,
1012
1085
  ...options.resilience,
@@ -1023,8 +1096,7 @@ var OpenTimestampsClient = class {
1023
1096
  ...options.resilience?.circuitBreaker
1024
1097
  }
1025
1098
  };
1026
- this.logger = options.logger;
1027
- this.globalSignal = options.signal;
1099
+ if (options.signal !== void 0) this.globalSignal = options.signal;
1028
1100
  const internalOptions = options;
1029
1101
  this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
1030
1102
  this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
@@ -1109,7 +1181,7 @@ var OpenTimestampsClient = class {
1109
1181
  * ```
1110
1182
  */
1111
1183
  async verify(proof, originalDataHash) {
1112
- return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
1184
+ return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal, this.esploraUrl);
1113
1185
  }
1114
1186
  /**
1115
1187
  * Get the current state of the circuit breaker for a calendar
@@ -1143,17 +1215,17 @@ var OpenTimestampsClient = class {
1143
1215
 
1144
1216
  // src/index.ts
1145
1217
  import { DetachedTimestampFile as DetachedTimestampFile2, Timestamp as Timestamp2 } from "@otskit/core";
1146
- import { verifyAgainstBlockheader as verifyAgainstBlockheader2 } from "@otskit/core";
1218
+ import { verifyAgainstBlockheader } from "@otskit/core";
1147
1219
 
1148
1220
  // src/utils/hash.ts
1149
- import { createHash } from "crypto";
1221
+ import { createHash as createHash2 } from "crypto";
1150
1222
  import { createReadStream } from "fs";
1151
1223
  function hashBuffer(data) {
1152
- return createHash("sha256").update(data).digest();
1224
+ return createHash2("sha256").update(data).digest();
1153
1225
  }
1154
1226
  function hashFile(path) {
1155
1227
  return new Promise((resolve, reject) => {
1156
- const hash = createHash("sha256");
1228
+ const hash = createHash2("sha256");
1157
1229
  createReadStream(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
1158
1230
  });
1159
1231
  }
@@ -1187,6 +1259,6 @@ export {
1187
1259
  hashBuffer,
1188
1260
  hashFile,
1189
1261
  isVerified,
1190
- verifyAgainstBlockheader2 as verifyAgainstBlockheader,
1262
+ verifyAgainstBlockheader,
1191
1263
  verifyTimestampAttestation
1192
1264
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otskit/client",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Official client SDK for OpenTimestamps calendars with resilience patterns",
5
5
  "author": "alexalves87",
6
6
  "license": "MIT",
@@ -24,12 +24,13 @@
24
24
  "dist"
25
25
  ],
26
26
  "engines": {
27
- "node": ">=18.0.0"
27
+ "node": ">=20.0.0"
28
28
  },
29
29
  "scripts": {
30
30
  "build": "tsup src/index.ts --format esm,cjs --dts --clean",
31
31
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
32
32
  "test": "vitest run",
33
+ "coverage": "vitest run --coverage",
33
34
  "test:watch": "vitest",
34
35
  "lint": "eslint .",
35
36
  "format": "prettier --write \"src/**/*.ts\"",
@@ -71,7 +72,7 @@
71
72
  "prettier": "^3.3.3",
72
73
  "semantic-release": "^25.0.3",
73
74
  "tsup": "^8.3.5",
74
- "typescript": "^5.6.3",
75
+ "typescript": "^6.0.3",
75
76
  "vitest": "^4.1.8"
76
77
  }
77
78
  }