@orangecheck/stamp-ots 0.1.1 → 0.2.1

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.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import { sha256 } from '@noble/hashes/sha2';
2
+ import { sha1, ripemd160 } from '@noble/hashes/legacy';
3
+ import { keccak_256 } from '@noble/hashes/sha3';
4
+
1
5
  // src/types.ts
2
6
  function toStampOts(p) {
3
7
  return {
@@ -115,7 +119,6 @@ function createCalendarClient(url, opts = {}) {
115
119
  const ac = withTimeout(signal, timeoutMs);
116
120
  const resp = await fetchImpl(`${base}/digest`, {
117
121
  method: "POST",
118
- headers: { "Content-Type": "application/vnd.opentimestamps.v1" },
119
122
  body: digest,
120
123
  signal: ac.signal
121
124
  });
@@ -126,12 +129,12 @@ function createCalendarClient(url, opts = {}) {
126
129
  const buf = await resp.arrayBuffer();
127
130
  return new Uint8Array(buf);
128
131
  },
129
- async fetchProof(digest, signal) {
130
- if (digest.byteLength !== 32) {
131
- throw new Error("OTS: digest must be exactly 32 bytes");
132
+ async fetchProof(commitment, signal) {
133
+ if (commitment.byteLength === 0 || commitment.byteLength > 64) {
134
+ throw new Error("OTS: commitment must be 1-64 bytes");
132
135
  }
133
136
  const ac = withTimeout(signal, timeoutMs);
134
- const resp = await fetchImpl(`${base}/timestamp/${hexEncode(digest)}`, {
137
+ const resp = await fetchImpl(`${base}/timestamp/${hexEncode(commitment)}`, {
135
138
  method: "GET",
136
139
  headers: { Accept: "application/vnd.opentimestamps.v1" },
137
140
  signal: ac.signal
@@ -200,71 +203,300 @@ async function submitToCalendars(idHex, opts = {}) {
200
203
  upgradedAt: null
201
204
  };
202
205
  }
203
-
204
- // src/upgrade.ts
205
- async function upgradeProof(current, idHex, opts) {
206
- if (current.status === "confirmed") return current;
207
- const digest = hexDecode(idHex);
208
- const clients = current.calendars.map(
209
- (url) => createCalendarClient(url, { fetch: opts.fetch, timeoutMs: opts.timeoutMs })
210
- );
211
- if (clients.length === 0) {
212
- throw new Error("upgradeProof: no calendars listed in current proof");
206
+ var HEADER_MAGIC = new Uint8Array([
207
+ 0,
208
+ 79,
209
+ 112,
210
+ 101,
211
+ 110,
212
+ 84,
213
+ 105,
214
+ 109,
215
+ 101,
216
+ 115,
217
+ 116,
218
+ 97,
219
+ 109,
220
+ 112,
221
+ 115,
222
+ 0,
223
+ 0,
224
+ 80,
225
+ 114,
226
+ 111,
227
+ 111,
228
+ 102,
229
+ 0,
230
+ 191,
231
+ 137,
232
+ 226,
233
+ 232,
234
+ 132,
235
+ 232,
236
+ 146,
237
+ 148
238
+ ]);
239
+ var TAG_BITCOIN = "0588960d73d71901";
240
+ var TAG_PENDING = "83dfe30d2ef90c8e";
241
+ var MAX_DEPTH = 256;
242
+ var MAX_MSG = 4096;
243
+ var Reader = class {
244
+ constructor(buf) {
245
+ this.buf = buf;
246
+ this.pos = 0;
213
247
  }
214
- const attempts = await Promise.allSettled(
215
- clients.map(async (c) => {
216
- const proof = await c.fetchProof(digest, opts.signal);
217
- if (!proof) return null;
218
- const anchor = await opts.parseAnchor(proof);
219
- return { url: c.url, proof, anchor };
220
- })
221
- );
222
- for (const r of attempts) {
223
- if (r.status !== "fulfilled" || r.value === null) continue;
224
- const { url, proof, anchor } = r.value;
225
- if (anchor) {
226
- return {
227
- status: "confirmed",
228
- proof: base64Encode(proof),
229
- calendars: uniqueFirst(current.calendars, url),
230
- blockHeight: anchor.blockHeight,
231
- blockHash: anchor.blockHash,
232
- upgradedAt: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "Z")
233
- };
248
+ byte() {
249
+ if (this.pos >= this.buf.length) throw new Error("ots: unexpected end of proof");
250
+ return this.buf[this.pos++];
251
+ }
252
+ bytes(n) {
253
+ if (n < 0 || this.pos + n > this.buf.length) throw new Error("ots: unexpected end of proof");
254
+ const out = this.buf.subarray(this.pos, this.pos + n);
255
+ this.pos += n;
256
+ return out;
257
+ }
258
+ varuint() {
259
+ let value = 0;
260
+ let shift = 0;
261
+ for (; ; ) {
262
+ const b = this.byte();
263
+ value += (b & 127) * 2 ** shift;
264
+ if (!(b & 128)) return value;
265
+ shift += 7;
266
+ if (shift > 49) throw new Error("ots: varuint too large");
234
267
  }
235
268
  }
236
- let longest = null;
237
- for (const r of attempts) {
238
- if (r.status !== "fulfilled" || r.value === null) continue;
239
- if (!longest || r.value.proof.byteLength > longest.proof.byteLength) {
240
- longest = { url: r.value.url, proof: r.value.proof };
269
+ varbytes(max) {
270
+ const n = this.varuint();
271
+ if (n > max) throw new Error("ots: varbytes too long");
272
+ return this.bytes(n);
273
+ }
274
+ get done() {
275
+ return this.pos === this.buf.length;
276
+ }
277
+ };
278
+ var UNARY = {
279
+ 2: "sha1",
280
+ 3: "ripemd160",
281
+ 8: "sha256",
282
+ 103: "keccak256",
283
+ 242: "reverse",
284
+ 243: "hexlify"
285
+ };
286
+ function applyOp(op, msg) {
287
+ switch (op.kind) {
288
+ case "sha256":
289
+ return sha256(msg);
290
+ case "ripemd160":
291
+ return ripemd160(msg);
292
+ case "sha1":
293
+ return sha1(msg);
294
+ case "keccak256":
295
+ return keccak_256(msg);
296
+ case "reverse":
297
+ return new Uint8Array(msg).reverse();
298
+ case "hexlify":
299
+ return new TextEncoder().encode(hexEncode(msg));
300
+ case "append":
301
+ return concat(msg, op.arg);
302
+ case "prepend":
303
+ return concat(op.arg, msg);
304
+ }
305
+ }
306
+ function readOp(r, tag) {
307
+ const unary = UNARY[tag];
308
+ if (unary) return { kind: unary };
309
+ if (tag === 240) return { kind: "append", arg: r.varbytes(MAX_MSG) };
310
+ if (tag === 241) return { kind: "prepend", arg: r.varbytes(MAX_MSG) };
311
+ throw new Error(`ots: unknown op tag 0x${tag.toString(16)}`);
312
+ }
313
+ function readAttestation(r) {
314
+ const tag = hexEncode(r.bytes(8));
315
+ const payload = r.varbytes(8192);
316
+ const p = new Reader(payload);
317
+ if (tag === TAG_BITCOIN) return { kind: "bitcoin", height: p.varuint() };
318
+ if (tag === TAG_PENDING) return { kind: "pending", uri: new TextDecoder().decode(p.varbytes(1e3)) };
319
+ return { kind: "unknown", tag, payload };
320
+ }
321
+ function readTimestamp(r, msg, depth) {
322
+ if (depth > MAX_DEPTH) throw new Error("ots: proof too deep");
323
+ const node = { msg, attestations: [], ops: [] };
324
+ const item = (tag2) => {
325
+ if (tag2 === 0) {
326
+ node.attestations.push(readAttestation(r));
327
+ return;
241
328
  }
329
+ const op = readOp(r, tag2);
330
+ const next = applyOp(op, msg);
331
+ if (next.length > MAX_MSG) throw new Error("ots: message too long");
332
+ node.ops.push({ op, child: readTimestamp(r, next, depth + 1) });
333
+ };
334
+ let tag = r.byte();
335
+ while (tag === 255) {
336
+ item(r.byte());
337
+ tag = r.byte();
242
338
  }
243
- if (longest) {
244
- const currentBytes = base64Decode(current.proof);
245
- if (longest.proof.byteLength > currentBytes.byteLength) {
246
- return {
247
- status: "pending",
248
- proof: base64Encode(longest.proof),
249
- calendars: uniqueFirst(current.calendars, longest.url),
250
- blockHeight: null,
251
- blockHash: null,
252
- upgradedAt: null
253
- };
339
+ item(tag);
340
+ return node;
341
+ }
342
+ function parseTimestamp(bytes, digest) {
343
+ const r = new Reader(bytes);
344
+ const ts = readTimestamp(r, digest, 0);
345
+ if (!r.done) throw new Error("ots: trailing bytes after timestamp");
346
+ return ts;
347
+ }
348
+ function isDetached(bytes) {
349
+ return bytes.length > HEADER_MAGIC.length && bytesEqual(bytes.subarray(0, HEADER_MAGIC.length), HEADER_MAGIC);
350
+ }
351
+ function parseDetached(bytes) {
352
+ if (!isDetached(bytes)) throw new Error("ots: missing detached-file header");
353
+ const r = new Reader(bytes.subarray(HEADER_MAGIC.length));
354
+ const major = r.varuint();
355
+ if (major !== 1) throw new Error(`ots: unsupported file version ${major}`);
356
+ if (r.byte() !== 8) throw new Error("ots: only sha256 file digests are supported");
357
+ const digest = new Uint8Array(r.bytes(32));
358
+ const timestamp = readTimestamp(r, digest, 0);
359
+ if (!r.done) throw new Error("ots: trailing bytes after timestamp");
360
+ return { digest, timestamp };
361
+ }
362
+ function parseProof(bytes, digest) {
363
+ if (!isDetached(bytes)) return parseTimestamp(bytes, digest);
364
+ const d = parseDetached(bytes);
365
+ if (!bytesEqual(d.digest, digest)) throw new Error("ots: proof commits to a different digest");
366
+ return d.timestamp;
367
+ }
368
+ function attestations(ts) {
369
+ const out = [];
370
+ const walk = (n) => {
371
+ for (const a of n.attestations) out.push({ msg: n.msg, attestation: a });
372
+ for (const { child } of n.ops) walk(child);
373
+ };
374
+ walk(ts);
375
+ return out;
376
+ }
377
+ function bitcoinAnchors(ts) {
378
+ return attestations(ts).flatMap(
379
+ ({ msg, attestation }) => attestation.kind === "bitcoin" ? [{ blockHeight: attestation.height, merkleRoot: msg }] : []
380
+ ).sort((a, b) => a.blockHeight - b.blockHeight);
381
+ }
382
+ function pendingCommitments(ts) {
383
+ return attestations(ts).flatMap(
384
+ ({ msg, attestation }) => attestation.kind === "pending" ? [{ uri: attestation.uri, commitment: msg }] : []
385
+ );
386
+ }
387
+ function mergeAt(ts, upgrade) {
388
+ if (bytesEqual(ts.msg, upgrade.msg)) {
389
+ for (const a of upgrade.attestations) {
390
+ if (!ts.attestations.some((b) => attestationKey(b) === attestationKey(a))) ts.attestations.push(a);
254
391
  }
392
+ for (const { op, child } of upgrade.ops) {
393
+ const same = ts.ops.find((o) => opKey(o.op) === opKey(op));
394
+ if (same) mergeAt(same.child, child);
395
+ else ts.ops.push({ op, child });
396
+ }
397
+ return true;
255
398
  }
256
- return current;
399
+ return ts.ops.some(({ child }) => mergeAt(child, upgrade));
400
+ }
401
+ function writeVaruint(out, n) {
402
+ do {
403
+ let b = n % 128;
404
+ n = Math.floor(n / 128);
405
+ if (n > 0) b |= 128;
406
+ out.push(b);
407
+ } while (n > 0);
408
+ }
409
+ function writeVarbytes(out, bytes) {
410
+ writeVaruint(out, bytes.length);
411
+ out.push(...bytes);
412
+ }
413
+ var OP_TAG = {
414
+ sha1: 2,
415
+ ripemd160: 3,
416
+ sha256: 8,
417
+ keccak256: 103,
418
+ append: 240,
419
+ prepend: 241,
420
+ reverse: 242,
421
+ hexlify: 243
422
+ };
423
+ function writeAttestation(out, a) {
424
+ const payload = [];
425
+ let tag;
426
+ if (a.kind === "bitcoin") {
427
+ tag = TAG_BITCOIN;
428
+ writeVaruint(payload, a.height);
429
+ } else if (a.kind === "pending") {
430
+ tag = TAG_PENDING;
431
+ writeVarbytes(payload, new TextEncoder().encode(a.uri));
432
+ } else {
433
+ tag = a.tag;
434
+ payload.push(...a.payload);
435
+ }
436
+ out.push(0);
437
+ for (let i = 0; i < 16; i += 2) out.push(parseInt(tag.slice(i, i + 2), 16));
438
+ writeVarbytes(out, new Uint8Array(payload));
439
+ }
440
+ function writeTimestamp(out, ts) {
441
+ const items = [
442
+ ...ts.attestations.map((a) => () => writeAttestation(out, a)),
443
+ ...ts.ops.map(({ op, child }) => () => {
444
+ out.push(OP_TAG[op.kind]);
445
+ if (op.kind === "append" || op.kind === "prepend") writeVarbytes(out, op.arg);
446
+ writeTimestamp(out, child);
447
+ })
448
+ ];
449
+ if (items.length === 0) throw new Error("ots: cannot serialize an empty timestamp node");
450
+ items.forEach((write, i) => {
451
+ if (i < items.length - 1) out.push(255);
452
+ write();
453
+ });
454
+ }
455
+ function serializeTimestamp(ts) {
456
+ const out = [];
457
+ writeTimestamp(out, ts);
458
+ return new Uint8Array(out);
459
+ }
460
+ function attestationKey(a) {
461
+ if (a.kind === "bitcoin") return `b:${a.height}`;
462
+ if (a.kind === "pending") return `p:${a.uri}`;
463
+ return `u:${a.tag}:${hexEncode(a.payload)}`;
464
+ }
465
+ function opKey(op) {
466
+ return "arg" in op ? `${op.kind}:${hexEncode(op.arg)}` : op.kind;
257
467
  }
258
- function uniqueFirst(arr, first) {
259
- const out = [first];
260
- for (const x of arr) if (x !== first && !out.includes(x)) out.push(x);
468
+ function concat(a, b) {
469
+ const out = new Uint8Array(a.length + b.length);
470
+ out.set(a, 0);
471
+ out.set(b, a.length);
261
472
  return out;
262
473
  }
474
+ function bytesEqual(a, b) {
475
+ if (a.byteLength !== b.byteLength) return false;
476
+ for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
477
+ return true;
478
+ }
263
479
 
264
480
  // src/anchor.ts
481
+ async function walkOtsProof(input) {
482
+ try {
483
+ const anchors = bitcoinAnchors(parseProof(input.proofBytes, input.digest));
484
+ return anchors.find((a) => a.blockHeight === input.blockHeight) ?? anchors[0] ?? null;
485
+ } catch {
486
+ return null;
487
+ }
488
+ }
489
+ function blockHashOf(header) {
490
+ return hexEncode(sha256(sha256(header)).reverse());
491
+ }
265
492
  function makeAnchorVerifier(cfg) {
493
+ const walkProof = cfg.walkProof ?? walkOtsProof;
266
494
  const verifier = async (input) => {
267
- const walked = await cfg.walkProof({ proofBytes: input.proofBytes, digest: input.digest });
495
+ const walked = await walkProof({
496
+ proofBytes: input.proofBytes,
497
+ digest: input.digest,
498
+ blockHeight: input.blockHeight
499
+ });
268
500
  if (!walked) {
269
501
  return { ok: false, reason: "proof could not be walked to a Bitcoin commitment" };
270
502
  }
@@ -275,76 +507,100 @@ function makeAnchorVerifier(cfg) {
275
507
  };
276
508
  }
277
509
  const header = await cfg.headerSource.getHeaderAt(input.blockHeight);
278
- if (!header) {
510
+ if (!header || header.byteLength !== 80) {
279
511
  return { ok: false, reason: `no block header available at height ${input.blockHeight}` };
280
512
  }
281
- if (header.byteLength < 80) {
282
- return { ok: false, reason: "malformed Bitcoin header (too short)" };
513
+ if (blockHashOf(header) !== input.blockHash.toLowerCase()) {
514
+ return { ok: false, reason: "block header does not hash to the declared block hash" };
283
515
  }
284
- const headerMerkleLE = header.subarray(36, 68);
285
- const headerMerkleBE = new Uint8Array(headerMerkleLE).reverse();
286
- const matchesLE = bytesEqual(headerMerkleLE, walked.merkleRoot);
287
- const matchesBE = bytesEqual(headerMerkleBE, walked.merkleRoot);
288
- if (!matchesLE && !matchesBE) {
516
+ const headerMerkle = header.subarray(36, 68);
517
+ if (!bytesEqual(headerMerkle, walked.merkleRoot)) {
289
518
  return { ok: false, reason: "block header Merkle root does not match walked proof root" };
290
519
  }
291
- return { ok: true, merkleRoot: hexEncode(matchesBE ? headerMerkleBE : headerMerkleLE) };
520
+ return { ok: true, merkleRoot: hexEncode(headerMerkle) };
292
521
  };
293
522
  return async (proofB64, blockHeight, blockHash, envelopeId) => {
294
523
  const r = await verifier({
295
524
  proofBytes: base64Decode(proofB64),
296
525
  digest: hexDecode(envelopeId),
297
- blockHeight});
526
+ blockHeight,
527
+ blockHash
528
+ });
298
529
  return r.ok;
299
530
  };
300
531
  }
301
532
  async function makeDefaultAnchorVerifier(opts) {
302
- const walkProof = await loadOpenTimestampsWalker();
303
- return makeAnchorVerifier({ walkProof, headerSource: opts.headerSource });
533
+ return makeAnchorVerifier({ headerSource: opts.headerSource });
304
534
  }
305
- async function loadOpenTimestampsWalker() {
306
- let ots;
307
- try {
308
- ots = await import(
309
- /* webpackIgnore: true */
310
- 'opentimestamps'
311
- );
312
- } catch (e) {
313
- throw new Error(
314
- `@orangecheck/stamp-ots: makeDefaultAnchorVerifier requires the optional peer dependency \`opentimestamps\`. Install it with \`npm i opentimestamps\` or supply your own walkProof via makeAnchorVerifier. Underlying error: ${e instanceof Error ? e.message : String(e)}`
315
- );
316
- }
317
- const mod = ots.default ?? ots;
318
- const modAny = mod;
319
- const DetachedCtor = modAny.DetachedTimestampFile ?? modAny.default?.DetachedTimestampFile;
320
- if (!DetachedCtor || typeof DetachedCtor.deserialize !== "function") {
321
- throw new Error(
322
- "@orangecheck/stamp-ots: unexpected shape from `opentimestamps` package \u2014 expected DetachedTimestampFile.deserialize. If you are using a non-standard build, pass your own walkProof via makeAnchorVerifier instead."
323
- );
324
- }
325
- return async ({ proofBytes }) => {
326
- try {
327
- const detached = DetachedCtor.deserialize(proofBytes);
328
- const ts = detached.timestamp;
329
- const attestations = await ts.verify();
330
- const first = attestations.values().next();
331
- if (first.done) return null;
332
- const { height, timestamp } = first.value;
333
- if (typeof height !== "number" || !timestamp) return null;
334
- const merkleRoot = timestamp.msg;
335
- if (!merkleRoot) return null;
336
- return { merkleRoot, blockHeight: height };
337
- } catch {
338
- return null;
535
+ function mempoolHeaderSource(opts = {}) {
536
+ const base = (opts.baseUrl ?? "https://mempool.space/api").replace(/\/+$/, "");
537
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
538
+ return {
539
+ async getHeaderAt(height) {
540
+ const h = await fetchImpl(`${base}/block-height/${height}`);
541
+ if (!h.ok) return null;
542
+ const hash = (await h.text()).trim();
543
+ if (!/^[0-9a-f]{64}$/.test(hash)) return null;
544
+ const r = await fetchImpl(`${base}/block/${hash}/header`);
545
+ if (!r.ok) return null;
546
+ const hex = (await r.text()).trim();
547
+ return /^[0-9a-f]{160}$/.test(hex) ? hexDecode(hex) : null;
339
548
  }
340
549
  };
341
550
  }
342
- function bytesEqual(a, b) {
343
- if (a.byteLength !== b.byteLength) return false;
344
- for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
345
- return true;
551
+
552
+ // src/upgrade.ts
553
+ var norm = (u) => u.replace(/\/+$/, "");
554
+ async function upgradeProof(current, idHex, opts) {
555
+ if (current.status === "confirmed") return current;
556
+ if (!opts.headerSource && !opts.parseAnchor) {
557
+ throw new Error("upgradeProof: pass headerSource or parseAnchor");
558
+ }
559
+ const before = base64Decode(current.proof);
560
+ const tree = parseProof(before, hexDecode(idHex));
561
+ const listed = new Set(current.calendars.map(norm));
562
+ const pending = pendingCommitments(tree).filter((p) => listed.has(norm(p.uri)));
563
+ if (pending.length === 0 && bitcoinAnchors(tree).length === 0) {
564
+ throw new Error("upgradeProof: proof has no pending attestation from a listed calendar");
565
+ }
566
+ const answered = [];
567
+ await Promise.allSettled(
568
+ pending.map(async ({ uri, commitment }) => {
569
+ const client = createCalendarClient(uri, { fetch: opts.fetch, timeoutMs: opts.timeoutMs });
570
+ const bytes = await client.fetchProof(commitment, opts.signal);
571
+ if (!bytes) return;
572
+ if (mergeAt(tree, parseTimestamp(bytes, commitment))) answered.push(norm(uri));
573
+ })
574
+ );
575
+ const merged = serializeTimestamp(tree);
576
+ const anchor = await resolveAnchor(merged, tree, opts);
577
+ const calendars = [...answered, ...current.calendars.filter((c) => !answered.includes(norm(c)))];
578
+ if (anchor) {
579
+ return {
580
+ status: "confirmed",
581
+ proof: base64Encode(merged),
582
+ calendars,
583
+ blockHeight: anchor.blockHeight,
584
+ blockHash: anchor.blockHash,
585
+ upgradedAt: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "Z")
586
+ };
587
+ }
588
+ if (!bytesEqual(merged, before)) {
589
+ return { ...current, proof: base64Encode(merged), calendars };
590
+ }
591
+ return current;
592
+ }
593
+ async function resolveAnchor(merged, tree, opts) {
594
+ if (opts.parseAnchor) return opts.parseAnchor(merged);
595
+ for (const a of bitcoinAnchors(tree)) {
596
+ const header = await opts.headerSource.getHeaderAt(a.blockHeight);
597
+ if (header && header.byteLength === 80 && bytesEqual(header.subarray(36, 68), a.merkleRoot)) {
598
+ return { blockHeight: a.blockHeight, blockHash: blockHashOf(header) };
599
+ }
600
+ }
601
+ return null;
346
602
  }
347
603
 
348
- export { DEFAULT_CALENDARS, base64Decode, base64Encode, createCalendarClient, fromStampOts, hexDecode, hexEncode, makeAnchorVerifier, makeDefaultAnchorVerifier, submitToCalendars, toStampOts, upgradeProof };
604
+ export { DEFAULT_CALENDARS, attestations, base64Decode, base64Encode, bitcoinAnchors, blockHashOf, createCalendarClient, fromStampOts, hexDecode, hexEncode, makeAnchorVerifier, makeDefaultAnchorVerifier, mempoolHeaderSource, mergeAt, parseDetached, parseProof, parseTimestamp, pendingCommitments, serializeTimestamp, submitToCalendars, toStampOts, upgradeProof, walkOtsProof };
349
605
  //# sourceMappingURL=index.mjs.map
350
606
  //# sourceMappingURL=index.mjs.map