@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/README.md +9 -7
- package/dist/browser.d.ts +36 -0
- package/dist/browser.js +742 -0
- package/dist/index.cjs +378 -267
- package/dist/index.d.cts +71 -56
- package/dist/index.d.ts +71 -56
- package/dist/index.js +374 -268
- package/package.json +18 -8
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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"))
|
|
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(
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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/
|
|
489
|
-
var
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
731
|
-
return (0,
|
|
926
|
+
const rawHeader = await explorer.rawBlockHeader(hash, signal);
|
|
927
|
+
return (0, import_core5.verifyAgainstRawHeader)(digest, rawHeader);
|
|
732
928
|
}
|
|
733
929
|
|
|
734
|
-
// src/core/
|
|
735
|
-
var
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
1191
|
-
var
|
|
1301
|
+
var import_core7 = require("@otskit/core");
|
|
1302
|
+
var import_core8 = require("@otskit/core");
|
|
1192
1303
|
|
|
1193
1304
|
// src/utils/hash.ts
|
|
1194
|
-
var
|
|
1195
|
-
var
|
|
1305
|
+
var import_node_crypto3 = require("crypto");
|
|
1306
|
+
var import_node_fs = require("fs");
|
|
1196
1307
|
function hashBuffer(data) {
|
|
1197
|
-
return (0,
|
|
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,
|
|
1202
|
-
(0,
|
|
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:
|