@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.js CHANGED
@@ -29,7 +29,8 @@ 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)
33
+ this.cause = options.cause;
33
34
  Error.captureStackTrace?.(this, this.constructor);
34
35
  }
35
36
  };
@@ -47,11 +48,11 @@ var StampError = class extends OpenTimestampsClientError {
47
48
  var UpgradeError = class extends OpenTimestampsClientError {
48
49
  };
49
50
  var NetworkError = class extends OpenTimestampsClientError {
50
- /** HTTP status code, cuando el fallo viene de una respuesta HTTP. */
51
+ /** HTTP status code when the failure originates from an HTTP response. */
51
52
  status;
52
53
  constructor(message, options) {
53
54
  super(message, options);
54
- this.status = options?.status;
55
+ if (options?.status !== void 0) this.status = options.status;
55
56
  }
56
57
  };
57
58
  var CircuitBreakerError = class extends NetworkError {
@@ -74,7 +75,7 @@ var SizeLimitExceededError = class extends NetworkError {
74
75
  options
75
76
  );
76
77
  this.maxBytes = maxBytes;
77
- this.actualBytes = actualBytes;
78
+ if (actualBytes !== void 0) this.actualBytes = actualBytes;
78
79
  }
79
80
  };
80
81
 
@@ -126,7 +127,10 @@ var CircuitBreaker = class {
126
127
  this.onSuccess(key, circuit);
127
128
  return result;
128
129
  } catch (error) {
129
- this.onFailure(key, circuit);
130
+ const is4xx = error instanceof Error && error.retryable === false;
131
+ if (!is4xx) {
132
+ this.onFailure(key, circuit);
133
+ }
130
134
  throw error;
131
135
  }
132
136
  }
@@ -223,11 +227,15 @@ function sleep(ms, signal) {
223
227
  reject(new Error("Aborted"));
224
228
  return;
225
229
  }
226
- const timeout = setTimeout(resolve, ms);
227
- signal?.addEventListener("abort", () => {
230
+ const onAbort = () => {
228
231
  clearTimeout(timeout);
229
232
  reject(new Error("Aborted"));
230
- });
233
+ };
234
+ const timeout = setTimeout(() => {
235
+ signal?.removeEventListener("abort", onAbort);
236
+ resolve();
237
+ }, ms);
238
+ signal?.addEventListener("abort", onAbort, { once: true });
231
239
  });
232
240
  }
233
241
  async function withRetry(fn, options, logger, signal) {
@@ -310,8 +318,9 @@ async function executeRequest(request, maxBytes) {
310
318
  const response = await globalThis.fetch(request.url, {
311
319
  method: request.method,
312
320
  headers: { "Content-Type": "application/octet-stream", ...request.headers },
313
- body: request.body,
314
- signal: request.signal
321
+ ...request.body !== void 0 ? { body: request.body } : {},
322
+ ...request.signal !== void 0 ? { signal: request.signal } : {},
323
+ redirect: "error"
315
324
  });
316
325
  const data = await readResponseBody(response, maxBytes);
317
326
  return { ok: response.ok, status: response.status, statusText: response.statusText, data };
@@ -320,7 +329,8 @@ async function executeRequest(request, maxBytes) {
320
329
  if (error instanceof NetworkError) throw error;
321
330
  if (error instanceof Error) {
322
331
  if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
323
- if (error.message.includes("timeout")) throw new NetworkError("Request timeout", { cause: error });
332
+ if (error.message.includes("timeout"))
333
+ throw new NetworkError("Request timeout", { cause: error });
324
334
  throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
325
335
  }
326
336
  throw new NetworkError("Unknown network error");
@@ -334,10 +344,14 @@ function createTimeoutController(timeoutMs, parentSignal) {
334
344
  parentSignal?.removeEventListener("abort", onParentAbort);
335
345
  controller.abort(parentSignal?.reason);
336
346
  };
337
- controller.signal.addEventListener("abort", () => {
338
- clearTimeout(timeout);
339
- parentSignal?.removeEventListener("abort", onParentAbort);
340
- }, { once: true });
347
+ controller.signal.addEventListener(
348
+ "abort",
349
+ () => {
350
+ clearTimeout(timeout);
351
+ parentSignal?.removeEventListener("abort", onParentAbort);
352
+ },
353
+ { once: true }
354
+ );
341
355
  if (parentSignal) {
342
356
  if (parentSignal.aborted) {
343
357
  clearTimeout(timeout);
@@ -429,13 +443,8 @@ var ResilientNetworkLayer = class {
429
443
  }
430
444
  };
431
445
 
432
- // src/core/orchestration.ts
433
- import {
434
- DetachedTimestampFile,
435
- OpSHA256,
436
- OpAppend,
437
- makeMerkleTree
438
- } from "@otskit/core";
446
+ // src/core/stamp.ts
447
+ import { DetachedTimestampFile, OpSHA256, OpAppend, makeMerkleTree } from "@otskit/core";
439
448
 
440
449
  // src/network/calendar.ts
441
450
  import {
@@ -470,7 +479,7 @@ var CalendarClient = class {
470
479
  url;
471
480
  networkLayer;
472
481
  logger;
473
- /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
482
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
474
483
  async submit(digest, signal) {
475
484
  assertCommitment(digest);
476
485
  this.logger?.debug(`Submitting digest to ${this.url}/digest`);
@@ -481,7 +490,7 @@ var CalendarClient = class {
481
490
  );
482
491
  return this.#parseTimestamp(response.data, digest);
483
492
  }
484
- /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
493
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
485
494
  async getTimestamp(commitment, signal) {
486
495
  assertCommitment(commitment);
487
496
  const path = `/timestamp/${bytesToHex(commitment)}`;
@@ -503,7 +512,7 @@ var CalendarClient = class {
503
512
  }
504
513
  return this.#parseTimestamp(response.data, commitment);
505
514
  }
506
- /** Deserializa la respuesta del calendario como un Timestamp commit-eado a `commitment`. */
515
+ /** Deserializes the calendar response as a Timestamp committed to `commitment`. */
507
516
  #parseTimestamp(data, commitment) {
508
517
  if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
509
518
  throw new CalendarResponseTooLargeError(
@@ -533,7 +542,7 @@ function parseWhitelistPattern(raw) {
533
542
  hostname,
534
543
  port: parsed.port,
535
544
  pathname: parsed.pathname,
536
- wildcardSuffix
545
+ ...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
537
546
  };
538
547
  }
539
548
  function hostnameMatchesPattern(hostname, pattern) {
@@ -549,20 +558,26 @@ var UrlWhitelist = class {
549
558
  for (const u of urls) this.add(u);
550
559
  }
551
560
  }
552
- /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
561
+ /**
562
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
563
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
564
+ */
553
565
  add(url) {
554
566
  if (typeof url !== "string") {
555
567
  throw new TypeError("UrlWhitelist: URL must be a string");
556
568
  }
557
569
  if (url.startsWith("http://") || url.startsWith("https://")) {
558
570
  const pattern = parseWhitelistPattern(url);
559
- if (pattern !== void 0) this.#patterns.set(url, pattern);
571
+ if (pattern === void 0) {
572
+ throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
573
+ }
574
+ this.#patterns.set(url, pattern);
560
575
  } else {
561
576
  this.add("http://" + url);
562
577
  this.add("https://" + url);
563
578
  }
564
579
  }
565
- /** Verdadero si `url` casa con algun patron de la whitelist. */
580
+ /** Returns true if `url` matches any pattern in the allowlist. */
566
581
  contains(url) {
567
582
  let parsed;
568
583
  try {
@@ -587,11 +602,158 @@ var UrlWhitelist = class {
587
602
  var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...TRUSTED_CALENDAR_WHITELIST_PATTERNS]);
588
603
  var DEFAULT_AGGREGATORS = [...DEFAULT_AGGREGATOR_URLS];
589
604
 
605
+ // src/utils/hex.ts
606
+ function hexToBytes(hex) {
607
+ const clean = hex.trim().toLowerCase();
608
+ if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) throw new Error("Invalid hex string");
609
+ const out = new Uint8Array(clean.length / 2);
610
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
611
+ return out;
612
+ }
613
+ function bytesToHex2(bytes) {
614
+ let s = "";
615
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
616
+ return s;
617
+ }
618
+
619
+ // src/core/shared.ts
620
+ function validateHash(hash) {
621
+ if (typeof hash === "string") {
622
+ const hex = hash.trim().toLowerCase();
623
+ if (!/^[0-9a-f]{64}$/.test(hex)) {
624
+ throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
625
+ }
626
+ return hexToBytes(hex);
627
+ }
628
+ if (hash.length !== 32) {
629
+ throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
630
+ }
631
+ return Uint8Array.from(hash);
632
+ }
633
+ function secureNonce(n) {
634
+ const bytes = new Uint8Array(n);
635
+ if (!globalThis.crypto?.getRandomValues) {
636
+ throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
637
+ }
638
+ globalThis.crypto.getRandomValues(bytes);
639
+ return bytes;
640
+ }
641
+
642
+ // src/core/stamp.ts
643
+ async function orchestrateStamp(hash, calendars, networkLayer, validateCalendarUrl, logger, signal, minimumSuccessfulSubmissions = 2) {
644
+ if (calendars.length === 0) {
645
+ throw new ValidationError("at least one calendar is required to stamp");
646
+ }
647
+ if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
648
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
649
+ }
650
+ if (minimumSuccessfulSubmissions > calendars.length) {
651
+ throw new ValidationError(
652
+ `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
653
+ );
654
+ }
655
+ await Promise.all(calendars.map((url) => validateCalendarUrl(url)));
656
+ const digest = validateHash(hash);
657
+ logger?.info(`Starting stamp for ${bytesToHex2(digest)}`);
658
+ const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
659
+ const nonceAppended = detached.timestamp.add(new OpAppend(secureNonce(16)));
660
+ const merkleRoot = nonceAppended.add(new OpSHA256());
661
+ const merkleTip = makeMerkleTree([merkleRoot]);
662
+ const results = await Promise.allSettled(
663
+ calendars.map(
664
+ (url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal)
665
+ )
666
+ );
667
+ const successful = [];
668
+ const failed = [];
669
+ results.forEach((r, i) => {
670
+ const calendar = calendars[i];
671
+ if (r.status === "fulfilled") {
672
+ merkleTip.merge(r.value);
673
+ successful.push({ calendar });
674
+ logger?.info(`Submitted to ${calendar}`);
675
+ } else {
676
+ const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
677
+ failed.push({ calendar, error });
678
+ logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
679
+ }
680
+ });
681
+ if (successful.length < minimumSuccessfulSubmissions) {
682
+ throw new StampError(
683
+ `Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
684
+ successful,
685
+ failed
686
+ );
687
+ }
688
+ return detached.serializeToBytes();
689
+ }
690
+
691
+ // src/core/upgrade.ts
692
+ import { DetachedTimestampFile as DetachedTimestampFile2 } from "@otskit/core";
693
+ var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
694
+ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
695
+ let detached;
696
+ try {
697
+ detached = DetachedTimestampFile2.deserialize(new Uint8Array(incompleteProof));
698
+ } catch (error) {
699
+ throw new ValidationError("Invalid .ots proof format", {
700
+ /* v8 ignore next */
701
+ ...error instanceof Error ? { cause: error } : {}
702
+ });
703
+ }
704
+ if (detached.timestamp.isTimestampComplete()) {
705
+ logger?.info("Proof already complete; nothing to upgrade");
706
+ return Buffer.from(incompleteProof);
707
+ }
708
+ const before = detached.serializeToBytes();
709
+ for (const subStamp of detached.timestamp.directlyVerified()) {
710
+ if (subStamp.isTimestampComplete()) continue;
711
+ for (const att of subStamp.attestations) {
712
+ if (att.kind !== "pending") continue;
713
+ if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
714
+ logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
715
+ continue;
716
+ }
717
+ try {
718
+ const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
719
+ subStamp.getDigest(),
720
+ signal
721
+ );
722
+ subStamp.merge(upgraded);
723
+ } catch (err) {
724
+ if (err instanceof CommitmentNotFoundError) {
725
+ logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
726
+ continue;
727
+ }
728
+ logger?.warn(
729
+ `Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`
730
+ );
731
+ }
732
+ }
733
+ }
734
+ const after = detached.serializeToBytes();
735
+ if (bytesEqFast(before, after)) {
736
+ throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
737
+ }
738
+ return Buffer.from(after);
739
+ }
740
+
741
+ // src/core/verify.ts
742
+ import { DetachedTimestampFile as DetachedTimestampFile3, OpSHA1, OpRIPEMD160 } from "@otskit/core";
743
+ import { timingSafeEqual } from "crypto";
744
+
590
745
  // src/network/esplora.ts
591
- import { verifyAgainstBlockheader, VerificationError } from "@otskit/core";
746
+ import { createHash } from "crypto";
747
+ import { verifyAgainstRawHeader, VerificationError } from "@otskit/core";
592
748
  var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
593
749
  var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
750
+ var RAW_HEADER_SIZE = 80;
594
751
  var HEX64_RE = /^[0-9a-f]{64}$/i;
752
+ function sha256dDisplayHex(data) {
753
+ const first = createHash("sha256").update(data).digest();
754
+ const second = createHash("sha256").update(first).digest();
755
+ return Buffer.from(second).reverse().toString("hex");
756
+ }
595
757
  var EsploraClient = class {
596
758
  #url;
597
759
  #networkLayer;
@@ -611,7 +773,7 @@ var EsploraClient = class {
611
773
  this.#url = raw.replace(/\/+$/, "");
612
774
  this.#logger = options.logger;
613
775
  }
614
- /** Devuelve el hash (hex 64, minúsculas) del bloque a la altura dada. */
776
+ /** Returns the block hash (64-char hex, lowercase) at the given height. */
615
777
  async blockHash(height, signal) {
616
778
  if (!Number.isSafeInteger(height) || height < 0) {
617
779
  throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
@@ -628,7 +790,7 @@ var EsploraClient = class {
628
790
  }
629
791
  return text.toLowerCase();
630
792
  }
631
- /** Devuelve la cabecera del bloque (merkleroot + time) dado su hash. */
793
+ /** Returns the block header (merkle root + timestamp) for the given hash. */
632
794
  async block(hash, signal) {
633
795
  if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
634
796
  throw new ValidationError("block hash must be a 64-char hex string");
@@ -646,7 +808,7 @@ var EsploraClient = class {
646
808
  } catch (err) {
647
809
  throw new EsploraResponseError("esplora returned a non-JSON block response", {
648
810
  /* v8 ignore next */
649
- cause: err instanceof Error ? err : void 0
811
+ ...err instanceof Error ? { cause: err } : {}
650
812
  });
651
813
  }
652
814
  if (typeof body !== "object" || body === null) {
@@ -661,7 +823,36 @@ var EsploraClient = class {
661
823
  }
662
824
  return { merkleroot, time };
663
825
  }
664
- /** Decodifica el cuerpo a texto aplicando el límite de tamaño (fail-closed). */
826
+ /**
827
+ * Fetches the raw 80-byte block header for `hash` and self-authenticates it:
828
+ * sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
829
+ * JSON layer — the raw header is cryptographically bound to the block hash we requested.
830
+ */
831
+ async rawBlockHeader(hash, signal) {
832
+ if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
833
+ throw new ValidationError("block hash must be a 64-char hex string");
834
+ }
835
+ this.#logger?.debug(`Esplora raw header ${hash}`);
836
+ const response = await this.#networkLayer.request(
837
+ this.#url,
838
+ { url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "application/octet-stream" } },
839
+ signal
840
+ );
841
+ const data = response.data;
842
+ if (data.length !== RAW_HEADER_SIZE) {
843
+ throw new EsploraResponseError(
844
+ `raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
845
+ );
846
+ }
847
+ const actualHash = sha256dDisplayHex(data);
848
+ if (actualHash !== hash.toLowerCase()) {
849
+ throw new EsploraResponseError(
850
+ `raw block header hash mismatch: expected ${hash.toLowerCase()}, got ${actualHash}`
851
+ );
852
+ }
853
+ return data;
854
+ }
855
+ /** Decodes the response body as text, enforcing the size limit (fail-closed). */
665
856
  #decode(data) {
666
857
  if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
667
858
  throw new EsploraResponseError(
@@ -672,7 +863,7 @@ var EsploraClient = class {
672
863
  return new TextDecoder("utf-8", { fatal: true }).decode(data);
673
864
  } catch (cause) {
674
865
  throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
675
- cause: cause instanceof Error ? cause : void 0
866
+ ...cause instanceof Error ? { cause } : {}
676
867
  });
677
868
  }
678
869
  }
@@ -682,12 +873,107 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
682
873
  throw new VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
683
874
  }
684
875
  const hash = await explorer.blockHash(attestation.height, signal);
685
- const header = await explorer.block(hash, signal);
686
- return verifyAgainstBlockheader(digest, header);
876
+ const rawHeader = await explorer.rawBlockHeader(hash, signal);
877
+ return verifyAgainstRawHeader(digest, rawHeader);
687
878
  }
688
879
 
689
- // src/core/orchestration.ts
690
- import { timingSafeEqual } from "crypto";
880
+ // src/core/verify.ts
881
+ var MAX_BITCOIN_ATTESTATIONS = 10;
882
+ function timingSafeEq(a, b) {
883
+ if (a.length !== b.length) return false;
884
+ return timingSafeEqual(a, b);
885
+ }
886
+ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
887
+ let detached;
888
+ try {
889
+ detached = DetachedTimestampFile3.deserialize(new Uint8Array(proof));
890
+ } catch (cause) {
891
+ throw new ValidationError("Invalid .ots proof format", {
892
+ ...cause instanceof Error ? { cause } : {}
893
+ });
894
+ }
895
+ if (detached.fileHashOp instanceof OpSHA1 || detached.fileHashOp instanceof OpRIPEMD160) {
896
+ return {
897
+ status: "invalid",
898
+ reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
899
+ };
900
+ }
901
+ if (originalDataHash !== void 0) {
902
+ let expected;
903
+ try {
904
+ expected = validateHash(originalDataHash);
905
+ } catch (err) {
906
+ throw new ValidationError(err instanceof Error ? err.message : "Invalid hash format", {
907
+ ...err instanceof Error ? { cause: err } : {}
908
+ });
909
+ }
910
+ if (!timingSafeEq(expected, detached.fileDigest())) {
911
+ return {
912
+ status: "invalid",
913
+ reason: "File hash does not match proof \u2014 file may have been modified"
914
+ };
915
+ }
916
+ }
917
+ const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
918
+ const seenHeights = /* @__PURE__ */ new Set();
919
+ const deduped = allBitcoin.filter(({ attestation }) => {
920
+ if (attestation.kind !== "bitcoin") return false;
921
+ if (seenHeights.has(attestation.height)) {
922
+ logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
923
+ return false;
924
+ }
925
+ seenHeights.add(attestation.height);
926
+ return true;
927
+ });
928
+ const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
929
+ if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
930
+ logger?.warn(
931
+ `Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
932
+ );
933
+ }
934
+ if (bitcoinAtts.length === 0) {
935
+ const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
936
+ return {
937
+ status: "pending",
938
+ reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
939
+ };
940
+ }
941
+ const explorer = new EsploraClient(networkLayer, {
942
+ ...esploraUrl !== void 0 ? { url: esploraUrl } : {},
943
+ ...logger !== void 0 ? { logger } : {}
944
+ });
945
+ let lastNetworkError;
946
+ let lastCryptoError;
947
+ for (const { msg, attestation } of bitcoinAtts) {
948
+ if (attestation.kind !== "bitcoin") continue;
949
+ try {
950
+ const blockTime = await verifyTimestampAttestation(
951
+ Uint8Array.from(msg).reverse(),
952
+ attestation,
953
+ explorer,
954
+ signal
955
+ );
956
+ logger?.info(`Verified against Bitcoin block ${attestation.height}`);
957
+ return { status: "verified", blockHeight: attestation.height, blockTime };
958
+ } catch (err) {
959
+ const message = err instanceof Error ? err.message : String(err);
960
+ if (err instanceof NetworkError || err instanceof EsploraResponseError) {
961
+ lastNetworkError = message;
962
+ logger?.warn(`Network error at block ${attestation.height}: ${message}`);
963
+ } else {
964
+ lastCryptoError = message;
965
+ logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
966
+ }
967
+ }
968
+ }
969
+ if (lastCryptoError !== void 0) {
970
+ return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
971
+ }
972
+ return {
973
+ status: "network_error",
974
+ reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
975
+ };
976
+ }
691
977
 
692
978
  // src/security/ssrf.ts
693
979
  import { lookup } from "dns/promises";
@@ -706,7 +992,7 @@ var BLOCKED_CIDRS_V4 = [
706
992
  ];
707
993
  function ipv4ToUint32(ip) {
708
994
  const parts = ip.split(".");
709
- return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
995
+ 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;
710
996
  }
711
997
  function assertNotPrivateIPv4(ip, calendarUrl) {
712
998
  const n = ipv4ToUint32(ip);
@@ -785,206 +1071,6 @@ async function assertSafeCalendarUrl(url, options) {
785
1071
  }
786
1072
  }
787
1073
 
788
- // src/core/orchestration.ts
789
- var MAX_BITCOIN_ATTESTATIONS = 10;
790
- function validateHash(hash) {
791
- if (typeof hash === "string") {
792
- const hex = hash.trim().toLowerCase();
793
- if (!/^[0-9a-f]{64}$/.test(hex)) {
794
- throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
795
- }
796
- return Uint8Array.from(Buffer.from(hex, "hex"));
797
- }
798
- if (hash.length !== 32) {
799
- throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
800
- }
801
- return Uint8Array.from(hash);
802
- }
803
- function secureNonce(n) {
804
- const bytes = new Uint8Array(n);
805
- if (!globalThis.crypto?.getRandomValues) {
806
- throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
807
- }
808
- globalThis.crypto.getRandomValues(bytes);
809
- return bytes;
810
- }
811
- function timingSafeEq(a, b) {
812
- if (a.length !== b.length) return false;
813
- return timingSafeEqual(a, b);
814
- }
815
- var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
816
- async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
817
- if (calendars.length === 0) {
818
- throw new ValidationError("at least one calendar is required to stamp");
819
- }
820
- if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
821
- throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
822
- }
823
- if (minimumSuccessfulSubmissions > calendars.length) {
824
- throw new ValidationError(
825
- `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
826
- );
827
- }
828
- await Promise.all(
829
- calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
830
- );
831
- const digest = validateHash(hash);
832
- logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
833
- const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
834
- const nonceAppended = detached.timestamp.add(new OpAppend(secureNonce(16)));
835
- const merkleRoot = nonceAppended.add(new OpSHA256());
836
- const merkleTip = makeMerkleTree([merkleRoot]);
837
- const results = await Promise.allSettled(
838
- calendars.map((url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal))
839
- );
840
- const successful = [];
841
- const failed = [];
842
- results.forEach((r, i) => {
843
- const calendar = calendars[i];
844
- if (r.status === "fulfilled") {
845
- merkleTip.merge(r.value);
846
- successful.push({ calendar });
847
- logger?.info(`Submitted to ${calendar}`);
848
- } else {
849
- const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
850
- failed.push({ calendar, error });
851
- logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
852
- }
853
- });
854
- if (successful.length < minimumSuccessfulSubmissions) {
855
- throw new StampError(
856
- `Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
857
- successful,
858
- failed
859
- );
860
- }
861
- return Buffer.from(detached.serializeToBytes());
862
- }
863
- async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
864
- let detached;
865
- try {
866
- detached = DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
867
- } catch (error) {
868
- throw new ValidationError("Invalid .ots proof format", {
869
- /* v8 ignore next */
870
- cause: error instanceof Error ? error : void 0
871
- });
872
- }
873
- if (detached.timestamp.isTimestampComplete()) {
874
- logger?.info("Proof already complete; nothing to upgrade");
875
- return Buffer.from(incompleteProof);
876
- }
877
- const before = detached.serializeToBytes();
878
- for (const subStamp of detached.timestamp.directlyVerified()) {
879
- if (subStamp.isTimestampComplete()) continue;
880
- for (const att of subStamp.attestations) {
881
- if (att.kind !== "pending") continue;
882
- if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
883
- logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
884
- continue;
885
- }
886
- try {
887
- const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
888
- subStamp.getDigest(),
889
- signal
890
- );
891
- subStamp.merge(upgraded);
892
- } catch (err) {
893
- if (err instanceof CommitmentNotFoundError) {
894
- logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
895
- continue;
896
- }
897
- logger?.warn(`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`);
898
- }
899
- }
900
- }
901
- const after = detached.serializeToBytes();
902
- if (bytesEqFast(before, after)) {
903
- throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
904
- }
905
- return Buffer.from(after);
906
- }
907
- async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
908
- let detached;
909
- try {
910
- detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
911
- } catch (cause) {
912
- throw new ValidationError("Invalid .ots proof format", {
913
- cause: cause instanceof Error ? cause : void 0
914
- });
915
- }
916
- if (originalDataHash !== void 0) {
917
- let expected;
918
- try {
919
- expected = validateHash(originalDataHash);
920
- } catch (err) {
921
- throw new ValidationError(
922
- err instanceof Error ? err.message : "Invalid hash format",
923
- { cause: err instanceof Error ? err : void 0 }
924
- );
925
- }
926
- if (!timingSafeEq(expected, detached.fileDigest())) {
927
- return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
928
- }
929
- }
930
- const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
931
- const seenHeights = /* @__PURE__ */ new Set();
932
- const deduped = allBitcoin.filter(({ attestation }) => {
933
- if (attestation.kind !== "bitcoin") return false;
934
- if (seenHeights.has(attestation.height)) {
935
- logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
936
- return false;
937
- }
938
- seenHeights.add(attestation.height);
939
- return true;
940
- });
941
- const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
942
- if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
943
- logger?.warn(
944
- `Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
945
- );
946
- }
947
- if (bitcoinAtts.length === 0) {
948
- const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
949
- return {
950
- status: "pending",
951
- reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
952
- };
953
- }
954
- const explorer = new EsploraClient(networkLayer);
955
- let lastNetworkError;
956
- let lastCryptoError;
957
- for (const { msg, attestation } of bitcoinAtts) {
958
- if (attestation.kind !== "bitcoin") continue;
959
- try {
960
- const blockTime = await verifyTimestampAttestation(
961
- Uint8Array.from(msg).reverse(),
962
- attestation,
963
- explorer,
964
- signal
965
- );
966
- logger?.info(`Verified against Bitcoin block ${attestation.height}`);
967
- return { status: "verified", blockHeight: attestation.height, blockTime };
968
- } catch (err) {
969
- const message = err instanceof Error ? err.message : String(err);
970
- if (err instanceof NetworkError || err instanceof EsploraResponseError) {
971
- lastNetworkError = message;
972
- logger?.warn(`Network error at block ${attestation.height}: ${message}`);
973
- } else {
974
- lastCryptoError = message;
975
- logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
976
- }
977
- }
978
- }
979
- if (lastCryptoError !== void 0) {
980
- return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
981
- }
982
- return {
983
- status: "network_error",
984
- reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
985
- };
986
- }
987
-
988
1074
  // src/client.ts
989
1075
  var OpenTimestampsClient = class {
990
1076
  calendars;
@@ -993,20 +1079,32 @@ var OpenTimestampsClient = class {
993
1079
  globalSignal;
994
1080
  minimumSuccessfulSubmissions;
995
1081
  allowPrivateCalendars;
1082
+ esploraUrl;
996
1083
  /**
997
1084
  * Create a new OpenTimestamps client
998
- *
1085
+ *
999
1086
  * @param options Client configuration options
1000
1087
  */
1001
1088
  constructor(options = {}) {
1089
+ this.logger = options.logger;
1002
1090
  if (!options.calendars || options.calendars.length === 0) {
1003
1091
  this.calendars = DEFAULT_CALENDARS;
1004
1092
  this.logger?.info("No calendars provided, using defaults");
1005
1093
  } else {
1006
1094
  this.calendars = options.calendars;
1007
1095
  }
1008
- this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
1096
+ const minSubs = options.minimumSuccessfulSubmissions ?? 2;
1097
+ if (!Number.isInteger(minSubs) || minSubs < 1) {
1098
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
1099
+ }
1100
+ if (minSubs > this.calendars.length) {
1101
+ throw new ValidationError(
1102
+ `minimumSuccessfulSubmissions (${minSubs}) cannot exceed the number of calendars (${this.calendars.length})`
1103
+ );
1104
+ }
1105
+ this.minimumSuccessfulSubmissions = minSubs;
1009
1106
  this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
1107
+ this.esploraUrl = options.esploraUrl;
1010
1108
  const resilienceConfig = {
1011
1109
  ...DEFAULT_RESILIENCE,
1012
1110
  ...options.resilience,
@@ -1023,23 +1121,22 @@ var OpenTimestampsClient = class {
1023
1121
  ...options.resilience?.circuitBreaker
1024
1122
  }
1025
1123
  };
1026
- this.logger = options.logger;
1027
- this.globalSignal = options.signal;
1124
+ if (options.signal !== void 0) this.globalSignal = options.signal;
1028
1125
  const internalOptions = options;
1029
1126
  this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
1030
1127
  this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
1031
1128
  }
1032
1129
  /**
1033
1130
  * Create a timestamp by submitting a hash to calendar servers
1034
- *
1131
+ *
1035
1132
  * @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
1036
1133
  * @param options Operation-specific options
1037
1134
  * @returns Initial .ots proof with pending attestations
1038
- *
1135
+ *
1039
1136
  * @throws {ValidationError} If the hash is invalid
1040
1137
  * @throws {StampError} If submission fails to all calendars
1041
1138
  * @throws {NetworkError} If network errors occur
1042
- *
1139
+ *
1043
1140
  * @example
1044
1141
  * ```typescript
1045
1142
  * const hash = crypto.createHash('sha256').update('my data').digest()
@@ -1049,34 +1146,36 @@ var OpenTimestampsClient = class {
1049
1146
  */
1050
1147
  async stamp(hash, options) {
1051
1148
  const signal = options?.signal || this.globalSignal;
1052
- return orchestrateStamp(
1149
+ const allowPrivate = this.allowPrivateCalendars;
1150
+ const bytes = await orchestrateStamp(
1053
1151
  hash,
1054
1152
  this.calendars,
1055
1153
  this.networkLayer,
1154
+ (url) => assertSafeCalendarUrl(url, { allowPrivate }),
1056
1155
  this.logger,
1057
1156
  signal,
1058
- this.minimumSuccessfulSubmissions,
1059
- this.allowPrivateCalendars
1157
+ this.minimumSuccessfulSubmissions
1060
1158
  );
1159
+ return Buffer.from(bytes);
1061
1160
  }
1062
1161
  /**
1063
1162
  * Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
1064
- *
1163
+ *
1065
1164
  * @param incompleteProof The initial .ots proof returned by stamp()
1066
1165
  * @param options Operation-specific options
1067
1166
  * @returns Upgraded .ots proof with Bitcoin attestation (if available)
1068
- *
1167
+ *
1069
1168
  * @throws {ValidationError} If the proof format is invalid
1070
1169
  * @throws {UpgradeError} If no calendar has confirmed the timestamp yet
1071
1170
  * @throws {NetworkError} If network errors occur
1072
- *
1171
+ *
1073
1172
  * @example
1074
1173
  * ```typescript
1075
1174
  * // Proof already has pending attestations from stamp()
1076
1175
  * const upgradedProof = await client.upgrade(incompleteProof)
1077
- *
1176
+ *
1078
1177
  * // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
1079
- * // Retry later (typically 10-60 minutes after stamp)
1178
+ * // Retry later (typically ~60 minutes after stamp)
1080
1179
  * ```
1081
1180
  */
1082
1181
  async upgrade(incompleteProof, options) {
@@ -1091,15 +1190,15 @@ var OpenTimestampsClient = class {
1091
1190
  }
1092
1191
  /**
1093
1192
  * Verify a complete timestamp proof against the Bitcoin blockchain
1094
- *
1193
+ *
1095
1194
  * @param proof The complete .ots proof with Bitcoin attestation
1096
1195
  * @param originalDataHash Optional: the original data hash to verify against
1097
1196
  * @returns Verification result with block details
1098
- *
1197
+ *
1099
1198
  * @example
1100
1199
  * ```typescript
1101
1200
  * const result = await client.verify(completeProof, originalHash)
1102
- *
1201
+ *
1103
1202
  * if (result.valid) {
1104
1203
  * console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
1105
1204
  * console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
@@ -1109,12 +1208,19 @@ var OpenTimestampsClient = class {
1109
1208
  * ```
1110
1209
  */
1111
1210
  async verify(proof, originalDataHash) {
1112
- return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
1211
+ return orchestrateVerify(
1212
+ proof,
1213
+ this.networkLayer,
1214
+ originalDataHash,
1215
+ this.logger,
1216
+ this.globalSignal,
1217
+ this.esploraUrl
1218
+ );
1113
1219
  }
1114
1220
  /**
1115
1221
  * Get the current state of the circuit breaker for a calendar
1116
1222
  * Useful for monitoring and debugging
1117
- *
1223
+ *
1118
1224
  * @param calendarUrl The calendar URL to check
1119
1225
  * @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
1120
1226
  */
@@ -1124,7 +1230,7 @@ var OpenTimestampsClient = class {
1124
1230
  /**
1125
1231
  * Reset the circuit breaker for a specific calendar
1126
1232
  * Use this to manually recover a calendar that has been marked as failing
1127
- *
1233
+ *
1128
1234
  * @param calendarUrl The calendar URL to reset
1129
1235
  */
1130
1236
  resetCircuit(calendarUrl) {
@@ -1142,18 +1248,18 @@ var OpenTimestampsClient = class {
1142
1248
  };
1143
1249
 
1144
1250
  // src/index.ts
1145
- import { DetachedTimestampFile as DetachedTimestampFile2, Timestamp as Timestamp2 } from "@otskit/core";
1146
- import { verifyAgainstBlockheader as verifyAgainstBlockheader2 } from "@otskit/core";
1251
+ import { DetachedTimestampFile as DetachedTimestampFile4, Timestamp as Timestamp2 } from "@otskit/core";
1252
+ import { verifyAgainstBlockheader } from "@otskit/core";
1147
1253
 
1148
1254
  // src/utils/hash.ts
1149
- import { createHash } from "crypto";
1255
+ import { createHash as createHash2 } from "crypto";
1150
1256
  import { createReadStream } from "fs";
1151
1257
  function hashBuffer(data) {
1152
- return createHash("sha256").update(data).digest();
1258
+ return createHash2("sha256").update(data).digest();
1153
1259
  }
1154
1260
  function hashFile(path) {
1155
1261
  return new Promise((resolve, reject) => {
1156
- const hash = createHash("sha256");
1262
+ const hash = createHash2("sha256");
1157
1263
  createReadStream(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
1158
1264
  });
1159
1265
  }
@@ -1167,7 +1273,7 @@ export {
1167
1273
  DEFAULT_CALENDARS,
1168
1274
  DEFAULT_CALENDAR_WHITELIST,
1169
1275
  DEFAULT_RESILIENCE,
1170
- DetachedTimestampFile2 as DetachedTimestampFile,
1276
+ DetachedTimestampFile4 as DetachedTimestampFile,
1171
1277
  EsploraClient,
1172
1278
  EsploraResponseError,
1173
1279
  MAX_CALENDAR_RESPONSE_SIZE,
@@ -1187,6 +1293,6 @@ export {
1187
1293
  hashBuffer,
1188
1294
  hashFile,
1189
1295
  isVerified,
1190
- verifyAgainstBlockheader2 as verifyAgainstBlockheader,
1296
+ verifyAgainstBlockheader,
1191
1297
  verifyTimestampAttestation
1192
1298
  };