@otskit/client 0.2.0 → 0.3.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
@@ -1,4 +1,5 @@
1
1
  // src/types.ts
2
+ var isVerified = (r) => r.status === "verified";
2
3
  var DEFAULT_CALENDARS = [
3
4
  "https://alice.btc.calendar.opentimestamps.org",
4
5
  "https://bob.btc.calendar.opentimestamps.org",
@@ -68,6 +69,18 @@ var CalendarResponseTooLargeError = class extends NetworkError {
68
69
  };
69
70
  var EsploraResponseError = class extends NetworkError {
70
71
  };
72
+ var SizeLimitExceededError = class extends NetworkError {
73
+ maxBytes;
74
+ actualBytes;
75
+ constructor(maxBytes, actualBytes, options) {
76
+ super(
77
+ actualBytes === void 0 ? `Response size exceeds limit of ${maxBytes} bytes` : `Response size ${actualBytes} bytes exceeds limit of ${maxBytes} bytes`,
78
+ options
79
+ );
80
+ this.maxBytes = maxBytes;
81
+ this.actualBytes = actualBytes;
82
+ }
83
+ };
71
84
 
72
85
  // src/network/circuit-breaker.ts
73
86
  var CircuitState = /* @__PURE__ */ ((CircuitState2) => {
@@ -256,33 +269,62 @@ async function withRetry(fn, options, logger, signal) {
256
269
  }
257
270
 
258
271
  // src/adapters/fetch-adapter.ts
259
- async function executeRequest(request) {
272
+ function getDeclaredContentLength(response) {
273
+ const value = response.headers.get("content-length");
274
+ if (value === null || !/^\d+$/.test(value)) return void 0;
275
+ const n = Number(value);
276
+ return Number.isSafeInteger(n) ? n : void 0;
277
+ }
278
+ async function readStreamLimited(body, maxBytes, status) {
279
+ const reader = body.getReader();
280
+ const buffer = new Uint8Array(maxBytes);
281
+ let received = 0;
282
+ try {
283
+ while (true) {
284
+ const { done, value } = await reader.read();
285
+ if (done) return buffer.subarray(0, received);
286
+ const next = received + value.byteLength;
287
+ if (next > maxBytes) {
288
+ await reader.cancel();
289
+ throw new SizeLimitExceededError(maxBytes, next, { status });
290
+ }
291
+ buffer.set(value, received);
292
+ received = next;
293
+ }
294
+ } finally {
295
+ reader.releaseLock();
296
+ }
297
+ }
298
+ async function readResponseBody(response, maxBytes) {
299
+ const contentLength = getDeclaredContentLength(response);
300
+ if (contentLength !== void 0 && contentLength > maxBytes) {
301
+ throw new SizeLimitExceededError(maxBytes, contentLength, { status: response.status });
302
+ }
303
+ if (response.body === null) {
304
+ const ab = await response.arrayBuffer();
305
+ if (ab.byteLength > maxBytes) {
306
+ throw new SizeLimitExceededError(maxBytes, ab.byteLength, { status: response.status });
307
+ }
308
+ return new Uint8Array(ab);
309
+ }
310
+ return readStreamLimited(response.body, maxBytes, response.status);
311
+ }
312
+ async function executeRequest(request, maxBytes) {
260
313
  try {
261
314
  const response = await globalThis.fetch(request.url, {
262
315
  method: request.method,
263
- headers: {
264
- "Content-Type": "application/octet-stream",
265
- ...request.headers
266
- },
316
+ headers: { "Content-Type": "application/octet-stream", ...request.headers },
267
317
  body: request.body,
268
318
  signal: request.signal
269
319
  });
270
- const arrayBuffer = await response.arrayBuffer();
271
- const data = new Uint8Array(arrayBuffer);
272
- return {
273
- ok: response.ok,
274
- status: response.status,
275
- statusText: response.statusText,
276
- data
277
- };
320
+ const data = await readResponseBody(response, maxBytes);
321
+ return { ok: response.ok, status: response.status, statusText: response.statusText, data };
278
322
  } catch (error) {
323
+ if (error instanceof SizeLimitExceededError) throw error;
324
+ if (error instanceof NetworkError) throw error;
279
325
  if (error instanceof Error) {
280
- if (error.name === "AbortError") {
281
- throw new NetworkError("Request aborted", { cause: error });
282
- }
283
- if (error.message.includes("timeout")) {
284
- throw new NetworkError("Request timeout", { cause: error });
285
- }
326
+ if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
327
+ if (error.message.includes("timeout")) throw new NetworkError("Request timeout", { cause: error });
286
328
  throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
287
329
  }
288
330
  throw new NetworkError("Unknown network error");
@@ -290,23 +332,24 @@ async function executeRequest(request) {
290
332
  }
291
333
  function createTimeoutController(timeoutMs, parentSignal) {
292
334
  const controller = new AbortController();
293
- const timeout = setTimeout(() => {
294
- controller.abort(new Error("Timeout"));
295
- }, timeoutMs);
335
+ const timeout = setTimeout(() => controller.abort(new Error("Timeout")), timeoutMs);
336
+ const onParentAbort = () => {
337
+ clearTimeout(timeout);
338
+ parentSignal?.removeEventListener("abort", onParentAbort);
339
+ controller.abort(parentSignal?.reason);
340
+ };
341
+ controller.signal.addEventListener("abort", () => {
342
+ clearTimeout(timeout);
343
+ parentSignal?.removeEventListener("abort", onParentAbort);
344
+ }, { once: true });
296
345
  if (parentSignal) {
297
346
  if (parentSignal.aborted) {
298
347
  clearTimeout(timeout);
299
348
  controller.abort(parentSignal.reason);
300
349
  } else {
301
- parentSignal.addEventListener("abort", () => {
302
- clearTimeout(timeout);
303
- controller.abort(parentSignal.reason);
304
- });
350
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
305
351
  }
306
352
  }
307
- controller.signal.addEventListener("abort", () => {
308
- clearTimeout(timeout);
309
- });
310
353
  return controller;
311
354
  }
312
355
 
@@ -338,10 +381,10 @@ var ResilientNetworkLayer = class {
338
381
  totalController.signal
339
382
  );
340
383
  try {
341
- const response = await executeRequest({
342
- ...request,
343
- signal: attemptController.signal
344
- });
384
+ const response = await executeRequest(
385
+ { ...request, signal: attemptController.signal },
386
+ this.options.maxResponseBytes ?? 1e5
387
+ );
345
388
  const elapsed = Date.now() - startTime;
346
389
  this.logger?.debug(`Request to ${calendarUrl} succeeded in ${elapsed}ms`);
347
390
  if (!response.ok) {
@@ -360,8 +403,7 @@ var ResilientNetworkLayer = class {
360
403
  }
361
404
  return response;
362
405
  } finally {
363
- attemptController.signal.removeEventListener("abort", () => {
364
- });
406
+ attemptController.abort(new Error("Attempt complete"));
365
407
  }
366
408
  },
367
409
  this.options.retries,
@@ -374,8 +416,7 @@ var ResilientNetworkLayer = class {
374
416
  this.logger?.error(`Request to ${calendarUrl} failed after ${elapsed}ms`, error);
375
417
  throw error;
376
418
  } finally {
377
- totalController.signal.removeEventListener("abort", () => {
378
- });
419
+ totalController.abort(new Error("Request complete"));
379
420
  }
380
421
  }
381
422
  /** Get circuit breaker state for a calendar */
@@ -1864,7 +1905,7 @@ var CalendarClient = class {
1864
1905
  url;
1865
1906
  networkLayer;
1866
1907
  logger;
1867
- /** Envía un digest al calendario y devuelve el Timestamp que lo commit-ea. */
1908
+ /** Envia un digest al calendario y devuelve el Timestamp que lo commit-ea. */
1868
1909
  async submit(digest, signal) {
1869
1910
  assertCommitment(digest);
1870
1911
  this.logger?.debug(`Submitting digest to ${this.url}/digest`);
@@ -1875,7 +1916,7 @@ var CalendarClient = class {
1875
1916
  );
1876
1917
  return this.#parseTimestamp(response.data, digest);
1877
1918
  }
1878
- /** Pregunta al calendario si tiene un Timestamp más completo para `commitment` (upgrade). */
1919
+ /** Pregunta al calendario si tiene un Timestamp mas completo para `commitment` (upgrade). */
1879
1920
  async getTimestamp(commitment, signal) {
1880
1921
  assertCommitment(commitment);
1881
1922
  const path = `/timestamp/${bytesToHex(commitment)}`;
@@ -1910,43 +1951,79 @@ var CalendarClient = class {
1910
1951
  return timestamp;
1911
1952
  }
1912
1953
  };
1913
- function wildcardToRegExp(pattern) {
1914
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "[^/]*");
1915
- return new RegExp(`^${escaped}$`, "i");
1954
+ function parseWhitelistPattern(raw) {
1955
+ let parsed;
1956
+ try {
1957
+ parsed = new URL(raw);
1958
+ } catch {
1959
+ return void 0;
1960
+ }
1961
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
1962
+ const hostname = parsed.hostname.toLowerCase();
1963
+ const wildcardSuffix = hostname.startsWith("*.") ? hostname.slice(2) : void 0;
1964
+ if (hostname.includes("*") && wildcardSuffix === void 0) return void 0;
1965
+ if (wildcardSuffix !== void 0 && (wildcardSuffix.length === 0 || wildcardSuffix.includes("*"))) return void 0;
1966
+ return {
1967
+ protocol: parsed.protocol,
1968
+ hostname,
1969
+ port: parsed.port,
1970
+ pathname: parsed.pathname,
1971
+ wildcardSuffix
1972
+ };
1973
+ }
1974
+ function hostnameMatchesPattern(hostname, pattern) {
1975
+ if (pattern.wildcardSuffix === void 0) return hostname === pattern.hostname;
1976
+ if (!hostname.endsWith("." + pattern.wildcardSuffix)) return false;
1977
+ const label = hostname.slice(0, -pattern.wildcardSuffix.length - 1);
1978
+ return label.length > 0 && !label.includes(".");
1916
1979
  }
1917
1980
  var UrlWhitelist = class {
1918
- #patterns = /* @__PURE__ */ new Set();
1981
+ #patterns = /* @__PURE__ */ new Map();
1919
1982
  constructor(urls) {
1920
1983
  if (urls) {
1921
1984
  for (const u of urls) this.add(u);
1922
1985
  }
1923
1986
  }
1924
- /** Añade un patrón; si no trae esquema, se añaden las variantes http y https. */
1987
+ /** Anade un patron; si no trae esquema, se anaden las variantes http y https. */
1925
1988
  add(url) {
1926
1989
  if (typeof url !== "string") {
1927
1990
  throw new TypeError("UrlWhitelist: URL must be a string");
1928
1991
  }
1929
1992
  if (url.startsWith("http://") || url.startsWith("https://")) {
1930
- this.#patterns.add(url);
1993
+ const pattern = parseWhitelistPattern(url);
1994
+ if (pattern !== void 0) this.#patterns.set(url, pattern);
1931
1995
  } else {
1932
- this.#patterns.add("http://" + url);
1933
- this.#patterns.add("https://" + url);
1996
+ this.add("http://" + url);
1997
+ this.add("https://" + url);
1934
1998
  }
1935
1999
  }
1936
- /** Verdadero si `url` casa con algún patrón de la whitelist. */
2000
+ /** Verdadero si `url` casa con algun patron de la whitelist. */
1937
2001
  contains(url) {
1938
- for (const pattern of this.#patterns) {
1939
- if (wildcardToRegExp(pattern).test(url)) return true;
2002
+ let parsed;
2003
+ try {
2004
+ parsed = new URL(url);
2005
+ } catch {
2006
+ return false;
2007
+ }
2008
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
2009
+ if (parsed.search !== "" || parsed.hash !== "") return false;
2010
+ const hostname = parsed.hostname.toLowerCase();
2011
+ for (const pattern of this.#patterns.values()) {
2012
+ if (parsed.protocol !== pattern.protocol || parsed.port !== pattern.port) continue;
2013
+ if (parsed.pathname !== pattern.pathname) continue;
2014
+ if (hostnameMatchesPattern(hostname, pattern)) return true;
1940
2015
  }
1941
2016
  return false;
1942
2017
  }
1943
2018
  toString() {
1944
- return `UrlWhitelist([${[...this.#patterns].join(", ")}])`;
2019
+ return `UrlWhitelist([${[...this.#patterns.keys()].join(", ")}])`;
1945
2020
  }
1946
2021
  };
1947
2022
  var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([
1948
2023
  "https://*.calendar.opentimestamps.org",
1949
2024
  // Peter Todd
2025
+ "https://*.btc.calendar.opentimestamps.org",
2026
+ // Peter Todd Bitcoin calendars
1950
2027
  "https://*.calendar.eternitywall.com",
1951
2028
  // Eternity Wall
1952
2029
  "https://*.calendar.catallaxy.com"
@@ -2039,7 +2116,13 @@ var EsploraClient = class {
2039
2116
  `esplora response of ${data.length} bytes exceeds limit ${MAX_ESPLORA_RESPONSE_SIZE}`
2040
2117
  );
2041
2118
  }
2042
- return new TextDecoder("utf-8", { fatal: false }).decode(data);
2119
+ try {
2120
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
2121
+ } catch (cause) {
2122
+ throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
2123
+ cause: cause instanceof Error ? cause : void 0
2124
+ });
2125
+ }
2043
2126
  }
2044
2127
  };
2045
2128
  async function verifyTimestampAttestation(digest, attestation, explorer, signal) {
@@ -2052,6 +2135,106 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
2052
2135
  }
2053
2136
 
2054
2137
  // src/core/orchestration.ts
2138
+ import { timingSafeEqual } from "crypto";
2139
+
2140
+ // src/security/ssrf.ts
2141
+ import { lookup } from "dns/promises";
2142
+ import { isIP } from "net";
2143
+ var BLOCKED_CIDRS_V4 = [
2144
+ { network: 0, mask: 4278190080, label: "0.0.0.0/8 (This Network)" },
2145
+ { network: 167772160, mask: 4278190080, label: "10.0.0.0/8 (RFC 1918)" },
2146
+ { network: 2130706432, mask: 4278190080, label: "127.0.0.0/8 (Loopback)" },
2147
+ { network: 2851995648, mask: 4294901760, label: "169.254.0.0/16 (Link-local/IMDS)" },
2148
+ { network: 2886729728, mask: 4293918720, label: "172.16.0.0/12 (RFC 1918)" },
2149
+ { network: 3232235520, mask: 4294901760, label: "192.168.0.0/16 (RFC 1918)" },
2150
+ { network: 3323068416, mask: 4294836224, label: "198.18.0.0/15 (Benchmarking)" },
2151
+ { network: 3758096384, mask: 4026531840, label: "224.0.0.0/4 (Multicast)" },
2152
+ { network: 4026531840, mask: 4026531840, label: "240.0.0.0/4 (Reserved)" },
2153
+ { network: 4294967295, mask: 4294967295, label: "255.255.255.255 (Broadcast)" }
2154
+ ];
2155
+ function ipv4ToUint32(ip) {
2156
+ const parts = ip.split(".");
2157
+ return (parseInt(parts[0], 10) << 24 | parseInt(parts[1], 10) << 16 | parseInt(parts[2], 10) << 8 | parseInt(parts[3], 10)) >>> 0;
2158
+ }
2159
+ function assertNotPrivateIPv4(ip, calendarUrl) {
2160
+ const n = ipv4ToUint32(ip);
2161
+ for (const cidr of BLOCKED_CIDRS_V4) {
2162
+ if ((n & cidr.mask) >>> 0 === cidr.network) {
2163
+ throw new ValidationError(
2164
+ `Calendar URL "${calendarUrl}" resolves to a private/reserved IPv4 address (${ip} \u2014 ${cidr.label}). Set allowPrivateCalendars: true to override.`
2165
+ );
2166
+ }
2167
+ }
2168
+ }
2169
+ var BLOCKED_IPV6_PREFIXES = [
2170
+ { prefix: "::1", label: "Loopback" },
2171
+ { prefix: "::", label: "Unspecified" },
2172
+ { prefix: "fc", label: "fc00::/7 (Unique Local)" },
2173
+ { prefix: "fd", label: "fd00::/8 (Unique Local)" },
2174
+ { prefix: "fe8", label: "fe80::/10 (Link-local)" },
2175
+ { prefix: "fe9", label: "fe80::/10 (Link-local)" },
2176
+ { prefix: "fea", label: "fe80::/10 (Link-local)" },
2177
+ { prefix: "feb", label: "fe80::/10 (Link-local)" },
2178
+ { prefix: "ff", label: "ff00::/8 (Multicast)" },
2179
+ { prefix: "::ffff:", label: "IPv4-mapped IPv6" },
2180
+ { prefix: "64:ff9b:", label: "64:ff9b::/96 (NAT64)" },
2181
+ { prefix: "2001:db8", label: "2001:db8::/32 (Documentation)" }
2182
+ ];
2183
+ function assertNotPrivateIPv6(ip, calendarUrl) {
2184
+ const lower = ip.toLowerCase();
2185
+ for (const { prefix, label } of BLOCKED_IPV6_PREFIXES) {
2186
+ if (lower === prefix || lower.startsWith(prefix)) {
2187
+ throw new ValidationError(
2188
+ `Calendar URL "${calendarUrl}" resolves to a private/reserved IPv6 address (${ip} \u2014 ${label}). Set allowPrivateCalendars: true to override.`
2189
+ );
2190
+ }
2191
+ }
2192
+ }
2193
+ async function assertSafeCalendarUrl(url, options) {
2194
+ let parsed;
2195
+ try {
2196
+ parsed = new URL(url);
2197
+ } catch {
2198
+ throw new ValidationError(`Calendar URL is not valid: "${url}"`);
2199
+ }
2200
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
2201
+ throw new ValidationError(`Calendar URL must use http or https: "${url}"`);
2202
+ }
2203
+ if (parsed.username || parsed.password) {
2204
+ throw new ValidationError(`Calendar URL must not contain embedded credentials: "${url}"`);
2205
+ }
2206
+ if (options.allowPrivate) return;
2207
+ const hostname = parsed.hostname;
2208
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
2209
+ const ipVersion = isIP(host);
2210
+ if (ipVersion === 4) {
2211
+ assertNotPrivateIPv4(host, url);
2212
+ return;
2213
+ }
2214
+ if (ipVersion === 6) {
2215
+ assertNotPrivateIPv6(host, url);
2216
+ return;
2217
+ }
2218
+ let addresses;
2219
+ try {
2220
+ addresses = await lookup(hostname, { all: true });
2221
+ } catch (err) {
2222
+ const message = err instanceof Error ? err.message : String(err);
2223
+ throw new ValidationError(
2224
+ `Calendar URL hostname "${hostname}" could not be resolved: ${message}`
2225
+ );
2226
+ }
2227
+ if (addresses.length === 0) {
2228
+ throw new ValidationError(`Calendar URL hostname "${hostname}" resolved to no addresses`);
2229
+ }
2230
+ for (const { address, family } of addresses) {
2231
+ if (family === 4) assertNotPrivateIPv4(address, url);
2232
+ else if (family === 6) assertNotPrivateIPv6(address, url);
2233
+ }
2234
+ }
2235
+
2236
+ // src/core/orchestration.ts
2237
+ var MAX_BITCOIN_ATTESTATIONS = 10;
2055
2238
  function validateHash(hash) {
2056
2239
  if (typeof hash === "string") {
2057
2240
  const hex = hash.trim().toLowerCase();
@@ -2073,19 +2256,12 @@ function secureNonce(n) {
2073
2256
  globalThis.crypto.getRandomValues(bytes);
2074
2257
  return bytes;
2075
2258
  }
2076
- var bytesEq = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
2077
- function assertHttpUrl(url, label) {
2078
- let parsed;
2079
- try {
2080
- parsed = new URL(url);
2081
- } catch {
2082
- throw new ValidationError(`${label} is not a valid URL: ${url}`);
2083
- }
2084
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
2085
- throw new ValidationError(`${label} must use http(s): ${url}`);
2086
- }
2259
+ function timingSafeEq(a, b) {
2260
+ if (a.length !== b.length) return false;
2261
+ return timingSafeEqual(a, b);
2087
2262
  }
2088
- async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2) {
2263
+ var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
2264
+ async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
2089
2265
  if (calendars.length === 0) {
2090
2266
  throw new ValidationError("at least one calendar is required to stamp");
2091
2267
  }
@@ -2097,7 +2273,9 @@ async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, m
2097
2273
  `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
2098
2274
  );
2099
2275
  }
2100
- for (const url of calendars) assertHttpUrl(url, "calendar");
2276
+ await Promise.all(
2277
+ calendars.map((url) => assertSafeCalendarUrl(url, { allowPrivate: allowPrivateCalendars }))
2278
+ );
2101
2279
  const digest = validateHash(hash);
2102
2280
  logger?.info(`Starting stamp for ${Buffer.from(digest).toString("hex")}`);
2103
2281
  const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
@@ -2169,7 +2347,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
2169
2347
  }
2170
2348
  }
2171
2349
  const after = detached.serializeToBytes();
2172
- if (bytesEq(before, after)) {
2350
+ if (bytesEqFast(before, after)) {
2173
2351
  throw new UpgradeError("No calendar has confirmed the timestamp yet (Bitcoin not yet mined)");
2174
2352
  }
2175
2353
  return Buffer.from(after);
@@ -2178,42 +2356,81 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
2178
2356
  let detached;
2179
2357
  try {
2180
2358
  detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
2181
- } catch {
2182
- return { valid: false, error: "Invalid .ots proof format" };
2359
+ } catch (cause) {
2360
+ throw new ValidationError("Invalid .ots proof format", {
2361
+ cause: cause instanceof Error ? cause : void 0
2362
+ });
2183
2363
  }
2184
2364
  if (originalDataHash !== void 0) {
2185
2365
  let expected;
2186
2366
  try {
2187
2367
  expected = validateHash(originalDataHash);
2188
2368
  } catch (err) {
2189
- return { valid: false, error: err instanceof Error ? err.message : "Invalid hash format" };
2369
+ throw new ValidationError(
2370
+ err instanceof Error ? err.message : "Invalid hash format",
2371
+ { cause: err instanceof Error ? err : void 0 }
2372
+ );
2373
+ }
2374
+ if (!timingSafeEq(expected, detached.fileDigest())) {
2375
+ return { status: "invalid", reason: "File hash does not match proof \u2014 file may have been modified" };
2190
2376
  }
2191
- if (!bytesEq(expected, detached.fileDigest())) {
2192
- return { valid: false, error: "File hash does not match proof" };
2377
+ }
2378
+ const allBitcoin = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
2379
+ const seenHeights = /* @__PURE__ */ new Set();
2380
+ const deduped = allBitcoin.filter(({ attestation }) => {
2381
+ if (attestation.kind !== "bitcoin") return false;
2382
+ if (seenHeights.has(attestation.height)) {
2383
+ logger?.debug(`Skipping duplicate Bitcoin attestation at height ${attestation.height}`);
2384
+ return false;
2193
2385
  }
2386
+ seenHeights.add(attestation.height);
2387
+ return true;
2388
+ });
2389
+ const bitcoinAtts = deduped.slice(0, MAX_BITCOIN_ATTESTATIONS);
2390
+ if (deduped.length > MAX_BITCOIN_ATTESTATIONS) {
2391
+ logger?.warn(
2392
+ `Proof has ${deduped.length} unique Bitcoin attestations; verifying only the first ${MAX_BITCOIN_ATTESTATIONS}`
2393
+ );
2194
2394
  }
2195
- const bitcoinAtts = detached.timestamp.allAttestations().filter(({ attestation }) => attestation.kind === "bitcoin");
2196
2395
  if (bitcoinAtts.length === 0) {
2197
2396
  const hasLitecoin = detached.timestamp.allAttestations().some(({ attestation }) => attestation.kind === "litecoin");
2198
- if (hasLitecoin) {
2199
- return { valid: false, error: "Litecoin verification is not supported by this client" };
2200
- }
2201
- return { valid: false, error: "No Bitcoin attestation found (timestamp not yet confirmed)" };
2397
+ return {
2398
+ status: "pending",
2399
+ reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
2400
+ };
2202
2401
  }
2203
2402
  const explorer = new EsploraClient(networkLayer);
2204
- let lastError = "";
2403
+ let lastNetworkError;
2404
+ let lastCryptoError;
2205
2405
  for (const { msg, attestation } of bitcoinAtts) {
2206
2406
  if (attestation.kind !== "bitcoin") continue;
2207
2407
  try {
2208
- const time = await verifyTimestampAttestation(Uint8Array.from(msg).reverse(), attestation, explorer, signal);
2408
+ const blockTime = await verifyTimestampAttestation(
2409
+ Uint8Array.from(msg).reverse(),
2410
+ attestation,
2411
+ explorer,
2412
+ signal
2413
+ );
2209
2414
  logger?.info(`Verified against Bitcoin block ${attestation.height}`);
2210
- return { valid: true, blockHeight: attestation.height, timestamp: time };
2415
+ return { status: "verified", blockHeight: attestation.height, blockTime };
2211
2416
  } catch (err) {
2212
- lastError = err instanceof Error ? err.message : String(err);
2213
- logger?.warn(`Bitcoin attestation at height ${attestation.height} failed: ${lastError}`);
2417
+ const message = err instanceof Error ? err.message : String(err);
2418
+ if (err instanceof NetworkError || err instanceof EsploraResponseError) {
2419
+ lastNetworkError = message;
2420
+ logger?.warn(`Network error at block ${attestation.height}: ${message}`);
2421
+ } else {
2422
+ lastCryptoError = message;
2423
+ logger?.warn(`Crypto verification failed at block ${attestation.height}: ${message}`);
2424
+ }
2214
2425
  }
2215
2426
  }
2216
- return { valid: false, error: `Could not verify against the Bitcoin blockchain: ${lastError}` };
2427
+ if (lastCryptoError !== void 0) {
2428
+ return { status: "invalid", reason: `Cryptographic verification failed: ${lastCryptoError}` };
2429
+ }
2430
+ return {
2431
+ status: "network_error",
2432
+ reason: `Could not reach Bitcoin blockchain: ${lastNetworkError ?? "unknown error"}`
2433
+ };
2217
2434
  }
2218
2435
 
2219
2436
  // src/client.ts
@@ -2223,6 +2440,7 @@ var OpenTimestampsClient = class {
2223
2440
  logger;
2224
2441
  globalSignal;
2225
2442
  minimumSuccessfulSubmissions;
2443
+ allowPrivateCalendars;
2226
2444
  /**
2227
2445
  * Create a new OpenTimestamps client
2228
2446
  *
@@ -2236,6 +2454,7 @@ var OpenTimestampsClient = class {
2236
2454
  this.calendars = options.calendars;
2237
2455
  }
2238
2456
  this.minimumSuccessfulSubmissions = options.minimumSuccessfulSubmissions ?? 2;
2457
+ this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
2239
2458
  const resilienceConfig = {
2240
2459
  ...DEFAULT_RESILIENCE,
2241
2460
  ...options.resilience,
@@ -2254,7 +2473,8 @@ var OpenTimestampsClient = class {
2254
2473
  };
2255
2474
  this.logger = options.logger;
2256
2475
  this.globalSignal = options.signal;
2257
- this.networkLayer = options.networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
2476
+ const internalOptions = options;
2477
+ this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
2258
2478
  this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
2259
2479
  }
2260
2480
  /**
@@ -2283,7 +2503,8 @@ var OpenTimestampsClient = class {
2283
2503
  this.networkLayer,
2284
2504
  this.logger,
2285
2505
  signal,
2286
- this.minimumSuccessfulSubmissions
2506
+ this.minimumSuccessfulSubmissions,
2507
+ this.allowPrivateCalendars
2287
2508
  );
2288
2509
  }
2289
2510
  /**
@@ -2400,13 +2621,16 @@ export {
2400
2621
  OpenTimestampsClientError,
2401
2622
  PUBLIC_ESPLORA_URL,
2402
2623
  ResilientNetworkLayer,
2624
+ SizeLimitExceededError,
2403
2625
  StampError,
2404
2626
  Timestamp,
2405
2627
  UpgradeError,
2406
2628
  UrlWhitelist,
2407
2629
  ValidationError,
2630
+ assertSafeCalendarUrl,
2408
2631
  hashBuffer,
2409
2632
  hashFile,
2633
+ isVerified,
2410
2634
  verifyAgainstBlockheader,
2411
2635
  verifyTimestampAttestation
2412
2636
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otskit/client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official client SDK for OpenTimestamps calendars with resilience patterns",
5
5
  "author": "alexalves87",
6
6
  "license": "MIT",