@otskit/client 0.5.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 +8 -6
- package/dist/browser.d.ts +36 -0
- package/dist/browser.js +742 -0
- package/dist/index.cjs +292 -251
- package/dist/index.d.cts +13 -13
- package/dist/index.d.ts +13 -13
- package/dist/index.js +281 -247
- package/package.json +15 -6
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
|
-
if (options?.cause !== void 0)
|
|
32
|
+
if (options?.cause !== void 0)
|
|
33
|
+
this.cause = options.cause;
|
|
33
34
|
Error.captureStackTrace?.(this, this.constructor);
|
|
34
35
|
}
|
|
35
36
|
};
|
|
@@ -328,7 +329,8 @@ async function executeRequest(request, maxBytes) {
|
|
|
328
329
|
if (error instanceof NetworkError) throw error;
|
|
329
330
|
if (error instanceof Error) {
|
|
330
331
|
if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
|
|
331
|
-
if (error.message.includes("timeout"))
|
|
332
|
+
if (error.message.includes("timeout"))
|
|
333
|
+
throw new NetworkError("Request timeout", { cause: error });
|
|
332
334
|
throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
|
|
333
335
|
}
|
|
334
336
|
throw new NetworkError("Unknown network error");
|
|
@@ -342,10 +344,14 @@ function createTimeoutController(timeoutMs, parentSignal) {
|
|
|
342
344
|
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
343
345
|
controller.abort(parentSignal?.reason);
|
|
344
346
|
};
|
|
345
|
-
controller.signal.addEventListener(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
controller.signal.addEventListener(
|
|
348
|
+
"abort",
|
|
349
|
+
() => {
|
|
350
|
+
clearTimeout(timeout);
|
|
351
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
352
|
+
},
|
|
353
|
+
{ once: true }
|
|
354
|
+
);
|
|
349
355
|
if (parentSignal) {
|
|
350
356
|
if (parentSignal.aborted) {
|
|
351
357
|
clearTimeout(timeout);
|
|
@@ -437,15 +443,8 @@ var ResilientNetworkLayer = class {
|
|
|
437
443
|
}
|
|
438
444
|
};
|
|
439
445
|
|
|
440
|
-
// src/core/
|
|
441
|
-
import {
|
|
442
|
-
DetachedTimestampFile,
|
|
443
|
-
OpSHA256,
|
|
444
|
-
OpAppend,
|
|
445
|
-
OpSHA1,
|
|
446
|
-
OpRIPEMD160,
|
|
447
|
-
makeMerkleTree
|
|
448
|
-
} from "@otskit/core";
|
|
446
|
+
// src/core/stamp.ts
|
|
447
|
+
import { DetachedTimestampFile, OpSHA256, OpAppend, makeMerkleTree } from "@otskit/core";
|
|
449
448
|
|
|
450
449
|
// src/network/calendar.ts
|
|
451
450
|
import {
|
|
@@ -603,6 +602,146 @@ var UrlWhitelist = class {
|
|
|
603
602
|
var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...TRUSTED_CALENDAR_WHITELIST_PATTERNS]);
|
|
604
603
|
var DEFAULT_AGGREGATORS = [...DEFAULT_AGGREGATOR_URLS];
|
|
605
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
|
+
|
|
606
745
|
// src/network/esplora.ts
|
|
607
746
|
import { createHash } from "crypto";
|
|
608
747
|
import { verifyAgainstRawHeader, VerificationError } from "@otskit/core";
|
|
@@ -738,8 +877,103 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
738
877
|
return verifyAgainstRawHeader(digest, rawHeader);
|
|
739
878
|
}
|
|
740
879
|
|
|
741
|
-
// src/core/
|
|
742
|
-
|
|
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
|
+
}
|
|
743
977
|
|
|
744
978
|
// src/security/ssrf.ts
|
|
745
979
|
import { lookup } from "dns/promises";
|
|
@@ -758,7 +992,7 @@ var BLOCKED_CIDRS_V4 = [
|
|
|
758
992
|
];
|
|
759
993
|
function ipv4ToUint32(ip) {
|
|
760
994
|
const parts = ip.split(".");
|
|
761
|
-
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;
|
|
762
996
|
}
|
|
763
997
|
function assertNotPrivateIPv4(ip, calendarUrl) {
|
|
764
998
|
const n = ipv4ToUint32(ip);
|
|
@@ -837,215 +1071,6 @@ async function assertSafeCalendarUrl(url, options) {
|
|
|
837
1071
|
}
|
|
838
1072
|
}
|
|
839
1073
|
|
|
840
|
-
// src/core/orchestration.ts
|
|
841
|
-
var MAX_BITCOIN_ATTESTATIONS = 10;
|
|
842
|
-
function validateHash(hash) {
|
|
843
|
-
if (typeof hash === "string") {
|
|
844
|
-
const hex = hash.trim().toLowerCase();
|
|
845
|
-
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
846
|
-
throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
|
|
847
|
-
}
|
|
848
|
-
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
849
|
-
}
|
|
850
|
-
if (hash.length !== 32) {
|
|
851
|
-
throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
|
|
852
|
-
}
|
|
853
|
-
return Uint8Array.from(hash);
|
|
854
|
-
}
|
|
855
|
-
function secureNonce(n) {
|
|
856
|
-
const bytes = new Uint8Array(n);
|
|
857
|
-
if (!globalThis.crypto?.getRandomValues) {
|
|
858
|
-
throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
|
|
859
|
-
}
|
|
860
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
861
|
-
return bytes;
|
|
862
|
-
}
|
|
863
|
-
function timingSafeEq(a, b) {
|
|
864
|
-
if (a.length !== b.length) return false;
|
|
865
|
-
return timingSafeEqual(a, b);
|
|
866
|
-
}
|
|
867
|
-
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
868
|
-
async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
|
|
869
|
-
if (calendars.length === 0) {
|
|
870
|
-
throw new ValidationError("at least one calendar is required to stamp");
|
|
871
|
-
}
|
|
872
|
-
if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
|
|
873
|
-
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
874
|
-
}
|
|
875
|
-
if (minimumSuccessfulSubmissions > calendars.length) {
|
|
876
|
-
throw new ValidationError(
|
|
877
|
-
`minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
await Promise.all(
|
|
881
|
-
calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
|
|
882
|
-
);
|
|
883
|
-
const digest = validateHash(hash);
|
|
884
|
-
logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
|
|
885
|
-
const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
|
|
886
|
-
const nonceAppended = detached.timestamp.add(new OpAppend(secureNonce(16)));
|
|
887
|
-
const merkleRoot = nonceAppended.add(new OpSHA256());
|
|
888
|
-
const merkleTip = makeMerkleTree([merkleRoot]);
|
|
889
|
-
const results = await Promise.allSettled(
|
|
890
|
-
calendars.map((url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal))
|
|
891
|
-
);
|
|
892
|
-
const successful = [];
|
|
893
|
-
const failed = [];
|
|
894
|
-
results.forEach((r, i) => {
|
|
895
|
-
const calendar = calendars[i];
|
|
896
|
-
if (r.status === "fulfilled") {
|
|
897
|
-
merkleTip.merge(r.value);
|
|
898
|
-
successful.push({ calendar });
|
|
899
|
-
logger?.info(`Submitted to ${calendar}`);
|
|
900
|
-
} else {
|
|
901
|
-
const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
902
|
-
failed.push({ calendar, error });
|
|
903
|
-
logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
|
|
904
|
-
}
|
|
905
|
-
});
|
|
906
|
-
if (successful.length < minimumSuccessfulSubmissions) {
|
|
907
|
-
throw new StampError(
|
|
908
|
-
`Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
|
|
909
|
-
successful,
|
|
910
|
-
failed
|
|
911
|
-
);
|
|
912
|
-
}
|
|
913
|
-
return Buffer.from(detached.serializeToBytes());
|
|
914
|
-
}
|
|
915
|
-
async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, logger, signal) {
|
|
916
|
-
let detached;
|
|
917
|
-
try {
|
|
918
|
-
detached = DetachedTimestampFile.deserialize(new Uint8Array(incompleteProof));
|
|
919
|
-
} catch (error) {
|
|
920
|
-
throw new ValidationError("Invalid .ots proof format", {
|
|
921
|
-
/* v8 ignore next */
|
|
922
|
-
...error instanceof Error ? { cause: error } : {}
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
if (detached.timestamp.isTimestampComplete()) {
|
|
926
|
-
logger?.info("Proof already complete; nothing to upgrade");
|
|
927
|
-
return Buffer.from(incompleteProof);
|
|
928
|
-
}
|
|
929
|
-
const before = detached.serializeToBytes();
|
|
930
|
-
for (const subStamp of detached.timestamp.directlyVerified()) {
|
|
931
|
-
if (subStamp.isTimestampComplete()) continue;
|
|
932
|
-
for (const att of subStamp.attestations) {
|
|
933
|
-
if (att.kind !== "pending") continue;
|
|
934
|
-
if (!DEFAULT_CALENDAR_WHITELIST.contains(att.uri)) {
|
|
935
|
-
logger?.warn(`Ignoring attestation from non-whitelisted calendar ${att.uri}`);
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
try {
|
|
939
|
-
const upgraded = await new CalendarClient(att.uri, networkLayer, logger).getTimestamp(
|
|
940
|
-
subStamp.getDigest(),
|
|
941
|
-
signal
|
|
942
|
-
);
|
|
943
|
-
subStamp.merge(upgraded);
|
|
944
|
-
} catch (err) {
|
|
945
|
-
if (err instanceof CommitmentNotFoundError) {
|
|
946
|
-
logger?.debug(`Calendar ${att.uri} has not confirmed yet`);
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
logger?.warn(`Failed to query ${att.uri}: ${err instanceof Error ? err.message : String(err)}`);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
const after = detached.serializeToBytes();
|
|
954
|
-
if (bytesEqFast(before, after)) {
|
|
955
|
-
throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
|
|
956
|
-
}
|
|
957
|
-
return Buffer.from(after);
|
|
958
|
-
}
|
|
959
|
-
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
960
|
-
let detached;
|
|
961
|
-
try {
|
|
962
|
-
detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
963
|
-
} catch (cause) {
|
|
964
|
-
throw new ValidationError("Invalid .ots proof format", {
|
|
965
|
-
...cause instanceof Error ? { cause } : {}
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
if (detached.fileHashOp instanceof OpSHA1 || detached.fileHashOp instanceof OpRIPEMD160) {
|
|
969
|
-
return {
|
|
970
|
-
status: "invalid",
|
|
971
|
-
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
if (originalDataHash !== void 0) {
|
|
975
|
-
let expected;
|
|
976
|
-
try {
|
|
977
|
-
expected = validateHash(originalDataHash);
|
|
978
|
-
} catch (err) {
|
|
979
|
-
throw new ValidationError(
|
|
980
|
-
err instanceof Error ? err.message : "Invalid hash format",
|
|
981
|
-
{ ...err instanceof Error ? { cause: err } : {} }
|
|
982
|
-
);
|
|
983
|
-
}
|
|
984
|
-
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
985
|
-
return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
|
|
989
|
-
const seenHeights = /* @__PURE__ */ new Set();
|
|
990
|
-
const deduped = allBitcoin.filter(({ attestation }) => {
|
|
991
|
-
if (attestation.kind !== "bitcoin") return false;
|
|
992
|
-
if (seenHeights.has(attestation.height)) {
|
|
993
|
-
logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
|
|
994
|
-
return false;
|
|
995
|
-
}
|
|
996
|
-
seenHeights.add(attestation.height);
|
|
997
|
-
return true;
|
|
998
|
-
});
|
|
999
|
-
const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
|
|
1000
|
-
if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
|
|
1001
|
-
logger?.warn(
|
|
1002
|
-
`Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
if (bitcoinAtts.length === 0) {
|
|
1006
|
-
const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
|
|
1007
|
-
return {
|
|
1008
|
-
status: "pending",
|
|
1009
|
-
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
1010
|
-
};
|
|
1011
|
-
}
|
|
1012
|
-
const explorer = new EsploraClient(networkLayer, {
|
|
1013
|
-
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
1014
|
-
...logger !== void 0 ? { logger } : {}
|
|
1015
|
-
});
|
|
1016
|
-
let lastNetworkError;
|
|
1017
|
-
let lastCryptoError;
|
|
1018
|
-
for (const { msg, attestation } of bitcoinAtts) {
|
|
1019
|
-
if (attestation.kind !== "bitcoin") continue;
|
|
1020
|
-
try {
|
|
1021
|
-
const blockTime = await verifyTimestampAttestation(
|
|
1022
|
-
Uint8Array.from(msg).reverse(),
|
|
1023
|
-
attestation,
|
|
1024
|
-
explorer,
|
|
1025
|
-
signal
|
|
1026
|
-
);
|
|
1027
|
-
logger?.info(`Verified against Bitcoin block ${attestation.height}`);
|
|
1028
|
-
return { status: "verified", blockHeight: attestation.height, blockTime };
|
|
1029
|
-
} catch (err) {
|
|
1030
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1031
|
-
if (err instanceof NetworkError || err instanceof EsploraResponseError) {
|
|
1032
|
-
lastNetworkError = message;
|
|
1033
|
-
logger?.warn(`Network error at block ${attestation.height}: ${message}`);
|
|
1034
|
-
} else {
|
|
1035
|
-
lastCryptoError = message;
|
|
1036
|
-
logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
if (lastCryptoError !== void 0) {
|
|
1041
|
-
return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
|
|
1042
|
-
}
|
|
1043
|
-
return {
|
|
1044
|
-
status: "network_error",
|
|
1045
|
-
reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
1074
|
// src/client.ts
|
|
1050
1075
|
var OpenTimestampsClient = class {
|
|
1051
1076
|
calendars;
|
|
@@ -1057,7 +1082,7 @@ var OpenTimestampsClient = class {
|
|
|
1057
1082
|
esploraUrl;
|
|
1058
1083
|
/**
|
|
1059
1084
|
* Create a new OpenTimestamps client
|
|
1060
|
-
*
|
|
1085
|
+
*
|
|
1061
1086
|
* @param options Client configuration options
|
|
1062
1087
|
*/
|
|
1063
1088
|
constructor(options = {}) {
|
|
@@ -1103,15 +1128,15 @@ var OpenTimestampsClient = class {
|
|
|
1103
1128
|
}
|
|
1104
1129
|
/**
|
|
1105
1130
|
* Create a timestamp by submitting a hash to calendar servers
|
|
1106
|
-
*
|
|
1131
|
+
*
|
|
1107
1132
|
* @param hash SHA-256 hash of the data to timestamp (as Buffer or hex string)
|
|
1108
1133
|
* @param options Operation-specific options
|
|
1109
1134
|
* @returns Initial .ots proof with pending attestations
|
|
1110
|
-
*
|
|
1135
|
+
*
|
|
1111
1136
|
* @throws {ValidationError} If the hash is invalid
|
|
1112
1137
|
* @throws {StampError} If submission fails to all calendars
|
|
1113
1138
|
* @throws {NetworkError} If network errors occur
|
|
1114
|
-
*
|
|
1139
|
+
*
|
|
1115
1140
|
* @example
|
|
1116
1141
|
* ```typescript
|
|
1117
1142
|
* const hash = crypto.createHash('sha256').update('my data').digest()
|
|
@@ -1121,34 +1146,36 @@ var OpenTimestampsClient = class {
|
|
|
1121
1146
|
*/
|
|
1122
1147
|
async stamp(hash, options) {
|
|
1123
1148
|
const signal = options?.signal || this.globalSignal;
|
|
1124
|
-
|
|
1149
|
+
const allowPrivate = this.allowPrivateCalendars;
|
|
1150
|
+
const bytes = await orchestrateStamp(
|
|
1125
1151
|
hash,
|
|
1126
1152
|
this.calendars,
|
|
1127
1153
|
this.networkLayer,
|
|
1154
|
+
(url) => assertSafeCalendarUrl(url, { allowPrivate }),
|
|
1128
1155
|
this.logger,
|
|
1129
1156
|
signal,
|
|
1130
|
-
this.minimumSuccessfulSubmissions
|
|
1131
|
-
this.allowPrivateCalendars
|
|
1157
|
+
this.minimumSuccessfulSubmissions
|
|
1132
1158
|
);
|
|
1159
|
+
return Buffer.from(bytes);
|
|
1133
1160
|
}
|
|
1134
1161
|
/**
|
|
1135
1162
|
* Upgrade an incomplete timestamp proof by querying calendars for Bitcoin confirmation
|
|
1136
|
-
*
|
|
1163
|
+
*
|
|
1137
1164
|
* @param incompleteProof The initial .ots proof returned by stamp()
|
|
1138
1165
|
* @param options Operation-specific options
|
|
1139
1166
|
* @returns Upgraded .ots proof with Bitcoin attestation (if available)
|
|
1140
|
-
*
|
|
1167
|
+
*
|
|
1141
1168
|
* @throws {ValidationError} If the proof format is invalid
|
|
1142
1169
|
* @throws {UpgradeError} If no calendar has confirmed the timestamp yet
|
|
1143
1170
|
* @throws {NetworkError} If network errors occur
|
|
1144
|
-
*
|
|
1171
|
+
*
|
|
1145
1172
|
* @example
|
|
1146
1173
|
* ```typescript
|
|
1147
1174
|
* // Proof already has pending attestations from stamp()
|
|
1148
1175
|
* const upgradedProof = await client.upgrade(incompleteProof)
|
|
1149
|
-
*
|
|
1176
|
+
*
|
|
1150
1177
|
* // If upgrade throws UpgradeError, Bitcoin hasn't confirmed yet
|
|
1151
|
-
* // Retry later (typically
|
|
1178
|
+
* // Retry later (typically ~60 minutes after stamp)
|
|
1152
1179
|
* ```
|
|
1153
1180
|
*/
|
|
1154
1181
|
async upgrade(incompleteProof, options) {
|
|
@@ -1163,15 +1190,15 @@ var OpenTimestampsClient = class {
|
|
|
1163
1190
|
}
|
|
1164
1191
|
/**
|
|
1165
1192
|
* Verify a complete timestamp proof against the Bitcoin blockchain
|
|
1166
|
-
*
|
|
1193
|
+
*
|
|
1167
1194
|
* @param proof The complete .ots proof with Bitcoin attestation
|
|
1168
1195
|
* @param originalDataHash Optional: the original data hash to verify against
|
|
1169
1196
|
* @returns Verification result with block details
|
|
1170
|
-
*
|
|
1197
|
+
*
|
|
1171
1198
|
* @example
|
|
1172
1199
|
* ```typescript
|
|
1173
1200
|
* const result = await client.verify(completeProof, originalHash)
|
|
1174
|
-
*
|
|
1201
|
+
*
|
|
1175
1202
|
* if (result.valid) {
|
|
1176
1203
|
* console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
|
|
1177
1204
|
* console.log(`Block timestamp: ${new Date(result.timestamp! * 1000)}`)
|
|
@@ -1181,12 +1208,19 @@ var OpenTimestampsClient = class {
|
|
|
1181
1208
|
* ```
|
|
1182
1209
|
*/
|
|
1183
1210
|
async verify(proof, originalDataHash) {
|
|
1184
|
-
return orchestrateVerify(
|
|
1211
|
+
return orchestrateVerify(
|
|
1212
|
+
proof,
|
|
1213
|
+
this.networkLayer,
|
|
1214
|
+
originalDataHash,
|
|
1215
|
+
this.logger,
|
|
1216
|
+
this.globalSignal,
|
|
1217
|
+
this.esploraUrl
|
|
1218
|
+
);
|
|
1185
1219
|
}
|
|
1186
1220
|
/**
|
|
1187
1221
|
* Get the current state of the circuit breaker for a calendar
|
|
1188
1222
|
* Useful for monitoring and debugging
|
|
1189
|
-
*
|
|
1223
|
+
*
|
|
1190
1224
|
* @param calendarUrl The calendar URL to check
|
|
1191
1225
|
* @returns Circuit state: 'CLOSED', 'OPEN', or 'HALF_OPEN' (undefined if not yet initialized)
|
|
1192
1226
|
*/
|
|
@@ -1196,7 +1230,7 @@ var OpenTimestampsClient = class {
|
|
|
1196
1230
|
/**
|
|
1197
1231
|
* Reset the circuit breaker for a specific calendar
|
|
1198
1232
|
* Use this to manually recover a calendar that has been marked as failing
|
|
1199
|
-
*
|
|
1233
|
+
*
|
|
1200
1234
|
* @param calendarUrl The calendar URL to reset
|
|
1201
1235
|
*/
|
|
1202
1236
|
resetCircuit(calendarUrl) {
|
|
@@ -1214,7 +1248,7 @@ var OpenTimestampsClient = class {
|
|
|
1214
1248
|
};
|
|
1215
1249
|
|
|
1216
1250
|
// src/index.ts
|
|
1217
|
-
import { DetachedTimestampFile as
|
|
1251
|
+
import { DetachedTimestampFile as DetachedTimestampFile4, Timestamp as Timestamp2 } from "@otskit/core";
|
|
1218
1252
|
import { verifyAgainstBlockheader } from "@otskit/core";
|
|
1219
1253
|
|
|
1220
1254
|
// src/utils/hash.ts
|
|
@@ -1239,7 +1273,7 @@ export {
|
|
|
1239
1273
|
DEFAULT_CALENDARS,
|
|
1240
1274
|
DEFAULT_CALENDAR_WHITELIST,
|
|
1241
1275
|
DEFAULT_RESILIENCE,
|
|
1242
|
-
|
|
1276
|
+
DetachedTimestampFile4 as DetachedTimestampFile,
|
|
1243
1277
|
EsploraClient,
|
|
1244
1278
|
EsploraResponseError,
|
|
1245
1279
|
MAX_CALENDAR_RESPONSE_SIZE,
|