@otskit/client 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -29,7 +29,7 @@ __export(index_exports, {
29
29
  DEFAULT_CALENDARS: () => DEFAULT_CALENDARS,
30
30
  DEFAULT_CALENDAR_WHITELIST: () => DEFAULT_CALENDAR_WHITELIST,
31
31
  DEFAULT_RESILIENCE: () => DEFAULT_RESILIENCE,
32
- DetachedTimestampFile: () => import_core5.DetachedTimestampFile,
32
+ DetachedTimestampFile: () => import_core7.DetachedTimestampFile,
33
33
  EsploraClient: () => EsploraClient,
34
34
  EsploraResponseError: () => EsploraResponseError,
35
35
  MAX_CALENDAR_RESPONSE_SIZE: () => MAX_CALENDAR_RESPONSE_SIZE,
@@ -41,7 +41,7 @@ __export(index_exports, {
41
41
  ResilientNetworkLayer: () => ResilientNetworkLayer,
42
42
  SizeLimitExceededError: () => SizeLimitExceededError,
43
43
  StampError: () => StampError,
44
- Timestamp: () => import_core5.Timestamp,
44
+ Timestamp: () => import_core7.Timestamp,
45
45
  UpgradeError: () => UpgradeError,
46
46
  UrlWhitelist: () => UrlWhitelist,
47
47
  ValidationError: () => ValidationError,
@@ -49,7 +49,7 @@ __export(index_exports, {
49
49
  hashBuffer: () => hashBuffer,
50
50
  hashFile: () => hashFile,
51
51
  isVerified: () => isVerified,
52
- verifyAgainstBlockheader: () => import_core6.verifyAgainstBlockheader,
52
+ verifyAgainstBlockheader: () => import_core8.verifyAgainstBlockheader,
53
53
  verifyTimestampAttestation: () => verifyTimestampAttestation
54
54
  });
55
55
  module.exports = __toCommonJS(index_exports);
@@ -85,7 +85,8 @@ 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)
89
+ this.cause = options.cause;
89
90
  Error.captureStackTrace?.(this, this.constructor);
90
91
  }
91
92
  };
@@ -103,11 +104,11 @@ var StampError = class extends OpenTimestampsClientError {
103
104
  var UpgradeError = class extends OpenTimestampsClientError {
104
105
  };
105
106
  var NetworkError = class extends OpenTimestampsClientError {
106
- /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
107
+ /** HTTP status code when the failure originates from an HTTP response. */
107
108
  status;
108
109
  constructor(message, options) {
109
110
  super(message, options);
110
- this.status = options?.status;
111
+ if (options?.status !== void 0) this.status = options.status;
111
112
  }
112
113
  };
113
114
  var CircuitBreakerError = class extends NetworkError {
@@ -130,7 +131,7 @@ var SizeLimitExceededError = class extends NetworkError {
130
131
  options
131
132
  );
132
133
  this.maxBytes = maxBytes;
133
- this.actualBytes = actualBytes;
134
+ if (actualBytes !== void 0) this.actualBytes = actualBytes;
134
135
  }
135
136
  };
136
137
 
@@ -182,7 +183,10 @@ var CircuitBreaker = class {
182
183
  this.onSuccess(key, circuit);
183
184
  return result;
184
185
  } catch (error) {
185
- this.onFailure(key, circuit);
186
+ const is4xx = error instanceof Error && error.retryable === false;
187
+ if (!is4xx) {
188
+ this.onFailure(key, circuit);
189
+ }
186
190
  throw error;
187
191
  }
188
192
  }
@@ -279,11 +283,15 @@ function sleep(ms, signal) {
279
283
  reject(new Error("Aborted"));
280
284
  return;
281
285
  }
282
- const timeout = setTimeout(resolve, ms);
283
- signal?.addEventListener("abort", () => {
286
+ const onAbort = () => {
284
287
  clearTimeout(timeout);
285
288
  reject(new Error("Aborted"));
286
- });
289
+ };
290
+ const timeout = setTimeout(() => {
291
+ signal?.removeEventListener("abort", onAbort);
292
+ resolve();
293
+ }, ms);
294
+ signal?.addEventListener("abort", onAbort, { once: true });
287
295
  });
288
296
  }
289
297
  async function withRetry(fn, options, logger, signal) {
@@ -366,8 +374,9 @@ async function executeRequest(request, maxBytes) {
366
374
  const response = await globalThis.fetch(request.url, {
367
375
  method: request.method,
368
376
  headers: { "Content-Type": "application/octet-stream", ...request.headers },
369
- body: request.body,
370
- signal: request.signal
377
+ ...request.body !== void 0 ? { body: request.body } : {},
378
+ ...request.signal !== void 0 ? { signal: request.signal } : {},
379
+ redirect: "error"
371
380
  });
372
381
  const data = await readResponseBody(response, maxBytes);
373
382
  return { ok: response.ok, status: response.status, statusText: response.statusText, data };
@@ -376,7 +385,8 @@ async function executeRequest(request, maxBytes) {
376
385
  if (error instanceof NetworkError) throw error;
377
386
  if (error instanceof Error) {
378
387
  if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
379
- if (error.message.includes("timeout")) throw new NetworkError("Request timeout", { cause: error });
388
+ if (error.message.includes("timeout"))
389
+ throw new NetworkError("Request timeout", { cause: error });
380
390
  throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
381
391
  }
382
392
  throw new NetworkError("Unknown network error");
@@ -390,10 +400,14 @@ function createTimeoutController(timeoutMs, parentSignal) {
390
400
  parentSignal?.removeEventListener("abort", onParentAbort);
391
401
  controller.abort(parentSignal?.reason);
392
402
  };
393
- controller.signal.addEventListener("abort", () => {
394
- clearTimeout(timeout);
395
- parentSignal?.removeEventListener("abort", onParentAbort);
396
- }, { once: true });
403
+ controller.signal.addEventListener(
404
+ "abort",
405
+ () => {
406
+ clearTimeout(timeout);
407
+ parentSignal?.removeEventListener("abort", onParentAbort);
408
+ },
409
+ { once: true }
410
+ );
397
411
  if (parentSignal) {
398
412
  if (parentSignal.aborted) {
399
413
  clearTimeout(timeout);
@@ -485,8 +499,8 @@ var ResilientNetworkLayer = class {
485
499
  }
486
500
  };
487
501
 
488
- // src/core/orchestration.ts
489
- var import_core4 = require("@otskit/core");
502
+ // src/core/stamp.ts
503
+ var import_core3 = require("@otskit/core");
490
504
 
491
505
  // src/network/calendar.ts
492
506
  var import_core2 = require("@otskit/core");
@@ -515,7 +529,7 @@ var CalendarClient = class {
515
529
  url;
516
530
  networkLayer;
517
531
  logger;
518
- /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
532
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
519
533
  async submit(digest, signal) {
520
534
  assertCommitment(digest);
521
535
  this.logger?.debug(`Submitting digest to ${this.url}/digest`);
@@ -526,7 +540,7 @@ var CalendarClient = class {
526
540
  );
527
541
  return this.#parseTimestamp(response.data, digest);
528
542
  }
529
- /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
543
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
530
544
  async getTimestamp(commitment, signal) {
531
545
  assertCommitment(commitment);
532
546
  const path = `/timestamp/${(0, import_core2.bytesToHex)(commitment)}`;
@@ -548,7 +562,7 @@ var CalendarClient = class {
548
562
  }
549
563
  return this.#parseTimestamp(response.data, commitment);
550
564
  }
551
- /** Deserializa la respuesta del calendario como un Timestamp commit-eado a `commitment`. */
565
+ /** Deserializes the calendar response as a Timestamp committed to `commitment`. */
552
566
  #parseTimestamp(data, commitment) {
553
567
  if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
554
568
  throw new CalendarResponseTooLargeError(
@@ -578,7 +592,7 @@ function parseWhitelistPattern(raw) {
578
592
  hostname,
579
593
  port: parsed.port,
580
594
  pathname: parsed.pathname,
581
- wildcardSuffix
595
+ ...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
582
596
  };
583
597
  }
584
598
  function hostnameMatchesPattern(hostname, pattern) {
@@ -594,20 +608,26 @@ var UrlWhitelist = class {
594
608
  for (const u of urls) this.add(u);
595
609
  }
596
610
  }
597
- /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
611
+ /**
612
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
613
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
614
+ */
598
615
  add(url) {
599
616
  if (typeof url !== "string") {
600
617
  throw new TypeError("UrlWhitelist: URL must be a string");
601
618
  }
602
619
  if (url.startsWith("http://") || url.startsWith("https://")) {
603
620
  const pattern = parseWhitelistPattern(url);
604
- if (pattern !== void 0) this.#patterns.set(url, pattern);
621
+ if (pattern === void 0) {
622
+ throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
623
+ }
624
+ this.#patterns.set(url, pattern);
605
625
  } else {
606
626
  this.add("http://" + url);
607
627
  this.add("https://" + url);
608
628
  }
609
629
  }
610
- /** Verdadero si `url` casa con algun patron de la whitelist. */
630
+ /** Returns true if `url` matches any pattern in the allowlist. */
611
631
  contains(url) {
612
632
  let parsed;
613
633
  try {
@@ -632,11 +652,158 @@ var UrlWhitelist = class {
632
652
  var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...import_core2.TRUSTED_CALENDAR_WHITELIST_PATTERNS]);
633
653
  var DEFAULT_AGGREGATORS = [...import_core2.DEFAULT_AGGREGATOR_URLS];
634
654
 
655
+ // src/utils/hex.ts
656
+ function hexToBytes(hex) {
657
+ const clean = hex.trim().toLowerCase();
658
+ if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) throw new Error("Invalid hex string");
659
+ const out = new Uint8Array(clean.length / 2);
660
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
661
+ return out;
662
+ }
663
+ function bytesToHex2(bytes) {
664
+ let s = "";
665
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
666
+ return s;
667
+ }
668
+
669
+ // src/core/shared.ts
670
+ function validateHash(hash) {
671
+ if (typeof hash === "string") {
672
+ const hex = hash.trim().toLowerCase();
673
+ if (!/^[0-9a-f]{64}$/.test(hex)) {
674
+ throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
675
+ }
676
+ return hexToBytes(hex);
677
+ }
678
+ if (hash.length !== 32) {
679
+ throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
680
+ }
681
+ return Uint8Array.from(hash);
682
+ }
683
+ function secureNonce(n) {
684
+ const bytes = new Uint8Array(n);
685
+ if (!globalThis.crypto?.getRandomValues) {
686
+ throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
687
+ }
688
+ globalThis.crypto.getRandomValues(bytes);
689
+ return bytes;
690
+ }
691
+
692
+ // src/core/stamp.ts
693
+ async function orchestrateStamp(hash, calendars, networkLayer, validateCalendarUrl, logger, signal, minimumSuccessfulSubmissions = 2) {
694
+ if (calendars.length === 0) {
695
+ throw new ValidationError("at least one calendar is required to stamp");
696
+ }
697
+ if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
698
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
699
+ }
700
+ if (minimumSuccessfulSubmissions > calendars.length) {
701
+ throw new ValidationError(
702
+ `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
703
+ );
704
+ }
705
+ await Promise.all(calendars.map((url) => validateCalendarUrl(url)));
706
+ const digest = validateHash(hash);
707
+ logger?.info(`Starting stamp for ${bytesToHex2(digest)}`);
708
+ const detached = import_core3.DetachedTimestampFile.fromHash(new import_core3.OpSHA256(), digest);
709
+ const nonceAppended = detached.timestamp.add(new import_core3.OpAppend(secureNonce(16)));
710
+ const merkleRoot = nonceAppended.add(new import_core3.OpSHA256());
711
+ const merkleTip = (0, import_core3.makeMerkleTree)([merkleRoot]);
712
+ const results = await Promise.allSettled(
713
+ calendars.map(
714
+ (url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal)
715
+ )
716
+ );
717
+ const successful = [];
718
+ const failed = [];
719
+ results.forEach((r, i) => {
720
+ const calendar = calendars[i];
721
+ if (r.status === "fulfilled") {
722
+ merkleTip.merge(r.value);
723
+ successful.push({ calendar });
724
+ logger?.info(`Submitted to ${calendar}`);
725
+ } else {
726
+ const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
727
+ failed.push({ calendar, error });
728
+ logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
729
+ }
730
+ });
731
+ if (successful.length < minimumSuccessfulSubmissions) {
732
+ throw new StampError(
733
+ `Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
734
+ successful,
735
+ failed
736
+ );
737
+ }
738
+ return detached.serializeToBytes();
739
+ }
740
+
741
+ // src/core/upgrade.ts
742
+ var import_core4 = require("@otskit/core");
743
+ var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
744
+ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
745
+ let detached;
746
+ try {
747
+ detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
748
+ } catch (error) {
749
+ throw new ValidationError("Invalid .ots proof format", {
750
+ /* v8 ignore next */
751
+ ...error instanceof Error ? { cause: error } : {}
752
+ });
753
+ }
754
+ if (detached.timestamp.isTimestampComplete()) {
755
+ logger?.info("Proof already complete; nothing to upgrade");
756
+ return Buffer.from(incompleteProof);
757
+ }
758
+ const before = detached.serializeToBytes();
759
+ for (const subStamp of detached.timestamp.directlyVerified()) {
760
+ if (subStamp.isTimestampComplete()) continue;
761
+ for (const att of subStamp.attestations) {
762
+ if (att.kind !== "pending") continue;
763
+ if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
764
+ logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
765
+ continue;
766
+ }
767
+ try {
768
+ const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
769
+ subStamp.getDigest(),
770
+ signal
771
+ );
772
+ subStamp.merge(upgraded);
773
+ } catch (err) {
774
+ if (err instanceof CommitmentNotFoundError) {
775
+ logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
776
+ continue;
777
+ }
778
+ logger?.warn(
779
+ `Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`
780
+ );
781
+ }
782
+ }
783
+ }
784
+ const after = detached.serializeToBytes();
785
+ if (bytesEqFast(before, after)) {
786
+ throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
787
+ }
788
+ return Buffer.from(after);
789
+ }
790
+
791
+ // src/core/verify.ts
792
+ var import_core6 = require("@otskit/core");
793
+ var import_node_crypto2 = require("crypto");
794
+
635
795
  // src/network/esplora.ts
636
- var import_core3 = require("@otskit/core");
796
+ var import_node_crypto = require("crypto");
797
+ var import_core5 = require("@otskit/core");
637
798
  var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
638
799
  var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
800
+ var RAW_HEADER_SIZE = 80;
639
801
  var HEX64_RE = /^[0-9a-f]{64}$/i;
802
+ function sha256dDisplayHex(data) {
803
+ const first = (0, import_node_crypto.createHash)("sha256").update(data).digest();
804
+ const second = (0, import_node_crypto.createHash)("sha256").update(first).digest();
805
+ return Buffer.from(second).reverse().toString("hex");
806
+ }
640
807
  var EsploraClient = class {
641
808
  #url;
642
809
  #networkLayer;
@@ -656,7 +823,7 @@ var EsploraClient = class {
656
823
  this.#url = raw.replace(/\/+$/, "");
657
824
  this.#logger = options.logger;
658
825
  }
659
- /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
826
+ /** Returns the block hash (64-char hex, lowercase) at the given height. */
660
827
  async blockHash(height, signal) {
661
828
  if (!Number.isSafeInteger(height) || height < 0) {
662
829
  throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
@@ -673,7 +840,7 @@ var EsploraClient = class {
673
840
  }
674
841
  return text.toLowerCase();
675
842
  }
676
- /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
843
+ /** Returns the block header (merkle root + timestamp) for the given hash. */
677
844
  async block(hash, signal) {
678
845
  if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
679
846
  throw new ValidationError("block hash must be a 64-char hex string");
@@ -691,7 +858,7 @@ var EsploraClient = class {
691
858
  } catch (err) {
692
859
  throw new EsploraResponseError("esplora returned a non-JSON block response", {
693
860
  /* v8 ignore next */
694
- cause: err instanceof Error ? err : void 0
861
+ ...err instanceof Error ? { cause: err } : {}
695
862
  });
696
863
  }
697
864
  if (typeof body !== "object" || body === null) {
@@ -706,7 +873,36 @@ var EsploraClient = class {
706
873
  }
707
874
  return { merkleroot, time };
708
875
  }
709
- /** Decodifica el cuerpo a texto aplicando el límite de tamaño (fail-closed). */
876
+ /**
877
+ * Fetches the raw 80-byte block header for `hash` and self-authenticates it:
878
+ * sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
879
+ * JSON layer — the raw header is cryptographically bound to the block hash we requested.
880
+ */
881
+ async rawBlockHeader(hash, signal) {
882
+ if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
883
+ throw new ValidationError("block hash must be a 64-char hex string");
884
+ }
885
+ this.#logger?.debug(`Esplora raw header ${hash}`);
886
+ const response = await this.#networkLayer.request(
887
+ this.#url,
888
+ { url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "application/octet-stream" } },
889
+ signal
890
+ );
891
+ const data = response.data;
892
+ if (data.length !== RAW_HEADER_SIZE) {
893
+ throw new EsploraResponseError(
894
+ `raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
895
+ );
896
+ }
897
+ const actualHash = sha256dDisplayHex(data);
898
+ if (actualHash !== hash.toLowerCase()) {
899
+ throw new EsploraResponseError(
900
+ `raw block header hash mismatch: expected ${hash.toLowerCase()}, got ${actualHash}`
901
+ );
902
+ }
903
+ return data;
904
+ }
905
+ /** Decodes the response body as text, enforcing the size limit (fail-closed). */
710
906
  #decode(data) {
711
907
  if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
712
908
  throw new EsploraResponseError(
@@ -717,22 +913,117 @@ var EsploraClient = class {
717
913
  return new TextDecoder("utf-8", { fatal: true }).decode(data);
718
914
  } catch (cause) {
719
915
  throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
720
- cause: cause instanceof Error ? cause : void 0
916
+ ...cause instanceof Error ? { cause } : {}
721
917
  });
722
918
  }
723
919
  }
724
920
  };
725
921
  async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
726
922
  if (attestation.kind !== "bitcoin" && attestation.kind !== "litecoin") {
727
- throw new import_core3.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
923
+ throw new import_core5.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
728
924
  }
729
925
  const hash = await explorer.blockHash(attestation.height, signal);
730
- const header = await explorer.block(hash, signal);
731
- return (0, import_core3.verifyAgainstBlockheader)(digest, header);
926
+ const rawHeader = await explorer.rawBlockHeader(hash, signal);
927
+ return (0, import_core5.verifyAgainstRawHeader)(digest, rawHeader);
732
928
  }
733
929
 
734
- // src/core/orchestration.ts
735
- var import_node_crypto = require("crypto");
930
+ // src/core/verify.ts
931
+ var MAX_BITCOIN_ATTESTATIONS = 10;
932
+ function timingSafeEq(a, b) {
933
+ if (a.length !== b.length) return false;
934
+ return (0, import_node_crypto2.timingSafeEqual)(a, b);
935
+ }
936
+ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
937
+ let detached;
938
+ try {
939
+ detached = import_core6.DetachedTimestampFile.deserialize(new Uint8Array(proof));
940
+ } catch (cause) {
941
+ throw new ValidationError("Invalid .ots proof format", {
942
+ ...cause instanceof Error ? { cause } : {}
943
+ });
944
+ }
945
+ if (detached.fileHashOp instanceof import_core6.OpSHA1 || detached.fileHashOp instanceof import_core6.OpRIPEMD160) {
946
+ return {
947
+ status: "invalid",
948
+ reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
949
+ };
950
+ }
951
+ if (originalDataHash !== void 0) {
952
+ let expected;
953
+ try {
954
+ expected = validateHash(originalDataHash);
955
+ } catch (err) {
956
+ throw new ValidationError(err instanceof Error ? err.message : "Invalid hash format", {
957
+ ...err instanceof Error ? { cause: err } : {}
958
+ });
959
+ }
960
+ if (!timingSafeEq(expected, detached.fileDigest())) {
961
+ return {
962
+ status: "invalid",
963
+ reason: "File hash does not match proof \u2014 file may have been modified"
964
+ };
965
+ }
966
+ }
967
+ const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
968
+ const seenHeights = /* @__PURE__ */ new Set();
969
+ const deduped = allBitcoin.filter(({ attestation }) => {
970
+ if (attestation.kind !== "bitcoin") return false;
971
+ if (seenHeights.has(attestation.height)) {
972
+ logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
973
+ return false;
974
+ }
975
+ seenHeights.add(attestation.height);
976
+ return true;
977
+ });
978
+ const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
979
+ if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
980
+ logger?.warn(
981
+ `Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
982
+ );
983
+ }
984
+ if (bitcoinAtts.length === 0) {
985
+ const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
986
+ return {
987
+ status: "pending",
988
+ reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
989
+ };
990
+ }
991
+ const explorer = new EsploraClient(networkLayer, {
992
+ ...esploraUrl !== void 0 ? { url: esploraUrl } : {},
993
+ ...logger !== void 0 ? { logger } : {}
994
+ });
995
+ let lastNetworkError;
996
+ let lastCryptoError;
997
+ for (const { msg, attestation } of bitcoinAtts) {
998
+ if (attestation.kind !== "bitcoin") continue;
999
+ try {
1000
+ const blockTime = await verifyTimestampAttestation(
1001
+ Uint8Array.from(msg).reverse(),
1002
+ attestation,
1003
+ explorer,
1004
+ signal
1005
+ );
1006
+ logger?.info(`Verified against Bitcoin block ${attestation.height}`);
1007
+ return { status: "verified", blockHeight: attestation.height, blockTime };
1008
+ } catch (err) {
1009
+ const message = err instanceof Error ? err.message : String(err);
1010
+ if (err instanceof NetworkError || err instanceof EsploraResponseError) {
1011
+ lastNetworkError = message;
1012
+ logger?.warn(`Network error at block ${attestation.height}: ${message}`);
1013
+ } else {
1014
+ lastCryptoError = message;
1015
+ logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
1016
+ }
1017
+ }
1018
+ }
1019
+ if (lastCryptoError !== void 0) {
1020
+ return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
1021
+ }
1022
+ return {
1023
+ status: "network_error",
1024
+ reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
1025
+ };
1026
+ }
736
1027
 
737
1028
  // src/security/ssrf.ts
738
1029
  var import_promises = require("dns/promises");
@@ -751,7 +1042,7 @@ var BLOCKED_CIDRS_V4 = [
751
1042
  ];
752
1043
  function ipv4ToUint32(ip) {
753
1044
  const parts = ip.split(".");
754
- return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
1045
+ return (Number.parseInt(parts[0], 10) << 24 | Number.parseInt(parts[1], 10) << 16 | Number.parseInt(parts[2], 10) << 8 | Number.parseInt(parts[3], 10)) >>> 0;
755
1046
  }
756
1047
  function assertNotPrivateIPv4(ip, calendarUrl) {
757
1048
  const n = ipv4ToUint32(ip);
@@ -830,206 +1121,6 @@ async function assertSafeCalendarUrl(url, options) {
830
1121
  }
831
1122
  }
832
1123
 
833
- // src/core/orchestration.ts
834
- var MAX_BITCOIN_ATTESTATIONS = 10;
835
- function validateHash(hash) {
836
- if (typeof hash === "string") {
837
- const hex = hash.trim().toLowerCase();
838
- if (!/^[0-9a-f]{64}$/.test(hex)) {
839
- throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
840
- }
841
- return Uint8Array.from(Buffer.from(hex, "hex"));
842
- }
843
- if (hash.length !== 32) {
844
- throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
845
- }
846
- return Uint8Array.from(hash);
847
- }
848
- function secureNonce(n) {
849
- const bytes = new Uint8Array(n);
850
- if (!globalThis.crypto?.getRandomValues) {
851
- throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
852
- }
853
- globalThis.crypto.getRandomValues(bytes);
854
- return bytes;
855
- }
856
- function timingSafeEq(a, b) {
857
- if (a.length !== b.length) return false;
858
- return (0, import_node_crypto.timingSafeEqual)(a, b);
859
- }
860
- var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
861
- async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
862
- if (calendars.length === 0) {
863
- throw new ValidationError("at least one calendar is required to stamp");
864
- }
865
- if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
866
- throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
867
- }
868
- if (minimumSuccessfulSubmissions > calendars.length) {
869
- throw new ValidationError(
870
- `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
871
- );
872
- }
873
- await Promise.all(
874
- calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
875
- );
876
- const digest = validateHash(hash);
877
- logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
878
- const detached = import_core4.DetachedTimestampFile.fromHash(new import_core4.OpSHA256(), digest);
879
- const nonceAppended = detached.timestamp.add(new import_core4.OpAppend(secureNonce(16)));
880
- const merkleRoot = nonceAppended.add(new import_core4.OpSHA256());
881
- const merkleTip = (0, import_core4.makeMerkleTree)([merkleRoot]);
882
- const results = await Promise.allSettled(
883
- calendars.map((url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal))
884
- );
885
- const successful = [];
886
- const failed = [];
887
- results.forEach((r, i) => {
888
- const calendar = calendars[i];
889
- if (r.status === "fulfilled") {
890
- merkleTip.merge(r.value);
891
- successful.push({ calendar });
892
- logger?.info(`Submitted to ${calendar}`);
893
- } else {
894
- const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
895
- failed.push({ calendar, error });
896
- logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
897
- }
898
- });
899
- if (successful.length < minimumSuccessfulSubmissions) {
900
- throw new StampError(
901
- `Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
902
- successful,
903
- failed
904
- );
905
- }
906
- return Buffer.from(detached.serializeToBytes());
907
- }
908
- async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
909
- let detached;
910
- try {
911
- detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
912
- } catch (error) {
913
- throw new ValidationError("Invalid .ots proof format", {
914
- /* v8 ignore next */
915
- cause: error instanceof Error ? error : void 0
916
- });
917
- }
918
- if (detached.timestamp.isTimestampComplete()) {
919
- logger?.info("Proof already complete; nothing to upgrade");
920
- return Buffer.from(incompleteProof);
921
- }
922
- const before = detached.serializeToBytes();
923
- for (const subStamp of detached.timestamp.directlyVerified()) {
924
- if (subStamp.isTimestampComplete()) continue;
925
- for (const att of subStamp.attestations) {
926
- if (att.kind !== "pending") continue;
927
- if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
928
- logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
929
- continue;
930
- }
931
- try {
932
- const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
933
- subStamp.getDigest(),
934
- signal
935
- );
936
- subStamp.merge(upgraded);
937
- } catch (err) {
938
- if (err instanceof CommitmentNotFoundError) {
939
- logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
940
- continue;
941
- }
942
- logger?.warn(`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`);
943
- }
944
- }
945
- }
946
- const after = detached.serializeToBytes();
947
- if (bytesEqFast(before, after)) {
948
- throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
949
- }
950
- return Buffer.from(after);
951
- }
952
- async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
953
- let detached;
954
- try {
955
- detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(proof));
956
- } catch (cause) {
957
- throw new ValidationError("Invalid .ots proof format", {
958
- cause: cause instanceof Error ? cause : void 0
959
- });
960
- }
961
- if (originalDataHash !== void 0) {
962
- let expected;
963
- try {
964
- expected = validateHash(originalDataHash);
965
- } catch (err) {
966
- throw new ValidationError(
967
- err instanceof Error ? err.message : "Invalid hash format",
968
- { cause: err instanceof Error ? err : void 0 }
969
- );
970
- }
971
- if (!timingSafeEq(expected, detached.fileDigest())) {
972
- return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
973
- }
974
- }
975
- const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
976
- const seenHeights = /* @__PURE__ */ new Set();
977
- const deduped = allBitcoin.filter(({ attestation }) => {
978
- if (attestation.kind !== "bitcoin") return false;
979
- if (seenHeights.has(attestation.height)) {
980
- logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
981
- return false;
982
- }
983
- seenHeights.add(attestation.height);
984
- return true;
985
- });
986
- const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
987
- if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
988
- logger?.warn(
989
- `Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
990
- );
991
- }
992
- if (bitcoinAtts.length === 0) {
993
- const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
994
- return {
995
- status: "pending",
996
- reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
997
- };
998
- }
999
- const explorer = new EsploraClient(networkLayer);
1000
- let lastNetworkError;
1001
- let lastCryptoError;
1002
- for (const { msg, attestation } of bitcoinAtts) {
1003
- if (attestation.kind !== "bitcoin") continue;
1004
- try {
1005
- const blockTime = await verifyTimestampAttestation(
1006
- Uint8Array.from(msg).reverse(),
1007
- attestation,
1008
- explorer,
1009
- signal
1010
- );
1011
- logger?.info(`Verified against Bitcoin block ${attestation.height}`);
1012
- return { status: "verified", blockHeight: attestation.height, blockTime };
1013
- } catch (err) {
1014
- const message = err instanceof Error ? err.message : String(err);
1015
- if (err instanceof NetworkError || err instanceof EsploraResponseError) {
1016
- lastNetworkError = message;
1017
- logger?.warn(`Network error at block ${attestation.height}: ${message}`);
1018
- } else {
1019
- lastCryptoError = message;
1020
- logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
1021
- }
1022
- }
1023
- }
1024
- if (lastCryptoError !== void 0) {
1025
- return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
1026
- }
1027
- return {
1028
- status: "network_error",
1029
- reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
1030
- };
1031
- }
1032
-
1033
1124
  // src/client.ts
1034
1125
  var OpenTimestampsClient = class {
1035
1126
  calendars;
@@ -1038,20 +1129,32 @@ var OpenTimestampsClient = class {
1038
1129
  globalSignal;
1039
1130
  minimumSuccessfulSubmissions;
1040
1131
  allowPrivateCalendars;
1132
+ esploraUrl;
1041
1133
  /**
1042
1134
  * Create a new OpenTimestamps client
1043
- *
1135
+ *
1044
1136
  * @param options Client configuration options
1045
1137
  */
1046
1138
  constructor(options = {}) {
1139
+ this.logger = options.logger;
1047
1140
  if (!options.calendars || options.calendars.length === 0) {
1048
1141
  this.calendars = DEFAULT_CALENDARS;
1049
1142
  this.logger?.info("No calendars provided, using defaults");
1050
1143
  } else {
1051
1144
  this.calendars = options.calendars;
1052
1145
  }
1053
- this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
1146
+ const minSubs = options.minimumSuccessfulSubmissions ?? 2;
1147
+ if (!Number.isInteger(minSubs) || minSubs < 1) {
1148
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
1149
+ }
1150
+ if (minSubs > this.calendars.length) {
1151
+ throw new ValidationError(
1152
+ `minimumSuccessfulSubmissions (${minSubs}) cannot exceed the number of calendars (${this.calendars.length})`
1153
+ );
1154
+ }
1155
+ this.minimumSuccessfulSubmissions = minSubs;
1054
1156
  this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
1157
+ this.esploraUrl = options.esploraUrl;
1055
1158
  const resilienceConfig = {
1056
1159
  ...DEFAULT_RESILIENCE,
1057
1160
  ...options.resilience,
@@ -1068,23 +1171,22 @@ var OpenTimestampsClient = class {
1068
1171
  ...options.resilience?.circuitBreaker
1069
1172
  }
1070
1173
  };
1071
- this.logger = options.logger;
1072
- this.globalSignal = options.signal;
1174
+ if (options.signal !== void 0) this.globalSignal = options.signal;
1073
1175
  const internalOptions = options;
1074
1176
  this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
1075
1177
  this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
1076
1178
  }
1077
1179
  /**
1078
1180
  * Create a timestamp by submitting a hash to calendar servers
1079
- *
1181
+ *
1080
1182
  * @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
1081
1183
  * @param options Operation-specific options
1082
1184
  * @returns Initial .ots proof with pending attestations
1083
- *
1185
+ *
1084
1186
  * @throws {ValidationError} If the hash is invalid
1085
1187
  * @throws {StampError} If submission fails to all calendars
1086
1188
  * @throws {NetworkError} If network errors occur
1087
- *
1189
+ *
1088
1190
  * @example
1089
1191
  * ```typescript
1090
1192
  * const hash = crypto.createHash('sha256').update('my data').digest()
@@ -1094,34 +1196,36 @@ var OpenTimestampsClient = class {
1094
1196
  */
1095
1197
  async stamp(hash, options) {
1096
1198
  const signal = options?.signal || this.globalSignal;
1097
- return orchestrateStamp(
1199
+ const allowPrivate = this.allowPrivateCalendars;
1200
+ const bytes = await orchestrateStamp(
1098
1201
  hash,
1099
1202
  this.calendars,
1100
1203
  this.networkLayer,
1204
+ (url) => assertSafeCalendarUrl(url, { allowPrivate }),
1101
1205
  this.logger,
1102
1206
  signal,
1103
- this.minimumSuccessfulSubmissions,
1104
- this.allowPrivateCalendars
1207
+ this.minimumSuccessfulSubmissions
1105
1208
  );
1209
+ return Buffer.from(bytes);
1106
1210
  }
1107
1211
  /**
1108
1212
  * Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
1109
- *
1213
+ *
1110
1214
  * @param incompleteProof The initial .ots proof returned by stamp()
1111
1215
  * @param options Operation-specific options
1112
1216
  * @returns Upgraded .ots proof with Bitcoin attestation (if available)
1113
- *
1217
+ *
1114
1218
  * @throws {ValidationError} If the proof format is invalid
1115
1219
  * @throws {UpgradeError} If no calendar has confirmed the timestamp yet
1116
1220
  * @throws {NetworkError} If network errors occur
1117
- *
1221
+ *
1118
1222
  * @example
1119
1223
  * ```typescript
1120
1224
  * // Proof already has pending attestations from stamp()
1121
1225
  * const upgradedProof = await client.upgrade(incompleteProof)
1122
- *
1226
+ *
1123
1227
  * // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
1124
- * // Retry later (typically 10-60 minutes after stamp)
1228
+ * // Retry later (typically ~60 minutes after stamp)
1125
1229
  * ```
1126
1230
  */
1127
1231
  async upgrade(incompleteProof, options) {
@@ -1136,15 +1240,15 @@ var OpenTimestampsClient = class {
1136
1240
  }
1137
1241
  /**
1138
1242
  * Verify a complete timestamp proof against the Bitcoin blockchain
1139
- *
1243
+ *
1140
1244
  * @param proof The complete .ots proof with Bitcoin attestation
1141
1245
  * @param originalDataHash Optional: the original data hash to verify against
1142
1246
  * @returns Verification result with block details
1143
- *
1247
+ *
1144
1248
  * @example
1145
1249
  * ```typescript
1146
1250
  * const result = await client.verify(completeProof, originalHash)
1147
- *
1251
+ *
1148
1252
  * if (result.valid) {
1149
1253
  * console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
1150
1254
  * console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
@@ -1154,12 +1258,19 @@ var OpenTimestampsClient = class {
1154
1258
  * ```
1155
1259
  */
1156
1260
  async verify(proof, originalDataHash) {
1157
- return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
1261
+ return orchestrateVerify(
1262
+ proof,
1263
+ this.networkLayer,
1264
+ originalDataHash,
1265
+ this.logger,
1266
+ this.globalSignal,
1267
+ this.esploraUrl
1268
+ );
1158
1269
  }
1159
1270
  /**
1160
1271
  * Get the current state of the circuit breaker for a calendar
1161
1272
  * Useful for monitoring and debugging
1162
- *
1273
+ *
1163
1274
  * @param calendarUrl The calendar URL to check
1164
1275
  * @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
1165
1276
  */
@@ -1169,7 +1280,7 @@ var OpenTimestampsClient = class {
1169
1280
  /**
1170
1281
  * Reset the circuit breaker for a specific calendar
1171
1282
  * Use this to manually recover a calendar that has been marked as failing
1172
- *
1283
+ *
1173
1284
  * @param calendarUrl The calendar URL to reset
1174
1285
  */
1175
1286
  resetCircuit(calendarUrl) {
@@ -1187,19 +1298,19 @@ var OpenTimestampsClient = class {
1187
1298
  };
1188
1299
 
1189
1300
  // src/index.ts
1190
- var import_core5 = require("@otskit/core");
1191
- var import_core6 = require("@otskit/core");
1301
+ var import_core7 = require("@otskit/core");
1302
+ var import_core8 = require("@otskit/core");
1192
1303
 
1193
1304
  // src/utils/hash.ts
1194
- var import_crypto = require("crypto");
1195
- var import_fs = require("fs");
1305
+ var import_node_crypto3 = require("crypto");
1306
+ var import_node_fs = require("fs");
1196
1307
  function hashBuffer(data) {
1197
- return (0, import_crypto.createHash)("sha256").update(data).digest();
1308
+ return (0, import_node_crypto3.createHash)("sha256").update(data).digest();
1198
1309
  }
1199
1310
  function hashFile(path) {
1200
1311
  return new Promise((resolve, reject) => {
1201
- const hash = (0, import_crypto.createHash)("sha256");
1202
- (0, import_fs.createReadStream)(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
1312
+ const hash = (0, import_node_crypto3.createHash)("sha256");
1313
+ (0, import_node_fs.createReadStream)(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
1203
1314
  });
1204
1315
  }
1205
1316
  // Annotate the CommonJS export names for ESM import in node: