@orangecheck/stamp-ots 0.1.0 → 0.2.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/src/upgrade.ts CHANGED
@@ -1,28 +1,37 @@
1
1
  // Upgrade a pending OTS proof to a confirmed one. See SPEC.md §6.2.
2
2
  //
3
- // Upgrade strategy: for each listed calendar, ask whether it has an upgraded
4
- // proof for this digest. If at least one calendar returns a proof longer than
5
- // the current pending one (heuristic for "more commitments included"),
6
- // return an upgraded OtsProof. The caller is responsible for verifying the
7
- // proof chains to a real Bitcoin block header (see anchor.ts).
8
- //
9
- // This package does NOT parse the proof bytes to extract block_height /
10
- // block_hash — that requires a full OTS proof parser. We provide an anchor
11
- // verifier callback pattern instead (see AnchorVerifier in ./types.ts).
3
+ // A pending proof ends in calendar attestations, each naming a calendar and
4
+ // the commitment (the message at that leaf) the calendar will later anchor.
5
+ // Calendars index upgrades by that commitment, not by the submitted digest.
6
+ // For each pending leaf we ask its calendar for the continuation, graft it on,
7
+ // and report the proof as confirmed once it reaches a Bitcoin attestation
8
+ // whose block header checks out.
12
9
 
10
+ import { blockHashOf } from './anchor.js';
13
11
  import { base64Decode, base64Encode, hexDecode } from './base64.js';
14
12
  import { createCalendarClient } from './calendar.js';
15
- import type { CalendarClient, OtsProof } from './types.js';
13
+ import {
14
+ bitcoinAnchors,
15
+ bytesEqual,
16
+ mergeAt,
17
+ parseProof,
18
+ parseTimestamp,
19
+ pendingCommitments,
20
+ serializeTimestamp,
21
+ } from './ots.js';
22
+ import type { BlockHeaderSource, OtsProof } from './types.js';
16
23
 
17
24
  export interface UpgradeOptions {
18
25
  /**
19
- * A parser that, given proof bytes, returns `{blockHeight, blockHash}` if
20
- * the proof is fully anchored, or null if it's still pending.
21
- *
22
- * Callers plug in their own parser (e.g., via the `opentimestamps` npm
23
- * package). This keeps @orangecheck/stamp-ots free of heavy parser deps.
26
+ * Header source used to resolve the anchor's block hash and to check that
27
+ * the proof's Merkle root is in that block.
28
+ */
29
+ headerSource?: BlockHeaderSource;
30
+ /**
31
+ * Alternative to `headerSource`: given the merged proof bytes, return the
32
+ * anchor if the proof is confirmed, else null.
24
33
  */
25
- parseAnchor: (proofBytes: Uint8Array) => Promise<{ blockHeight: number; blockHash: string } | null>;
34
+ parseAnchor?: (proofBytes: Uint8Array) => Promise<{ blockHeight: number; blockHash: string } | null>;
26
35
  /** Optional custom fetch passed through to calendar clients. */
27
36
  fetch?: typeof fetch;
28
37
  /** Request timeout per calendar. Default 30_000ms. */
@@ -30,73 +39,69 @@ export interface UpgradeOptions {
30
39
  signal?: AbortSignal;
31
40
  }
32
41
 
42
+ const norm = (u: string) => u.replace(/\/+$/, '');
43
+
33
44
  export async function upgradeProof(
34
45
  current: OtsProof,
35
46
  idHex: string,
36
47
  opts: UpgradeOptions
37
48
  ): Promise<OtsProof> {
38
49
  if (current.status === 'confirmed') return current; // idempotent
50
+ if (!opts.headerSource && !opts.parseAnchor) {
51
+ throw new Error('upgradeProof: pass headerSource or parseAnchor');
52
+ }
39
53
 
40
- const digest = hexDecode(idHex);
41
- const clients: CalendarClient[] = current.calendars.map((url) =>
42
- createCalendarClient(url, { fetch: opts.fetch, timeoutMs: opts.timeoutMs })
43
- );
44
- if (clients.length === 0) {
45
- throw new Error('upgradeProof: no calendars listed in current proof');
54
+ const before = base64Decode(current.proof);
55
+ const tree = parseProof(before, hexDecode(idHex));
56
+
57
+ // Only ask calendars this proof was submitted to.
58
+ const listed = new Set(current.calendars.map(norm));
59
+ const pending = pendingCommitments(tree).filter((p) => listed.has(norm(p.uri)));
60
+ if (pending.length === 0 && bitcoinAnchors(tree).length === 0) {
61
+ throw new Error('upgradeProof: proof has no pending attestation from a listed calendar');
46
62
  }
47
63
 
48
- const attempts = await Promise.allSettled(
49
- clients.map(async (c) => {
50
- const proof = await c.fetchProof(digest, opts.signal);
51
- if (!proof) return null;
52
- const anchor = await opts.parseAnchor(proof);
53
- return { url: c.url, proof, anchor };
64
+ const answered: string[] = [];
65
+ await Promise.allSettled(
66
+ pending.map(async ({ uri, commitment }) => {
67
+ const client = createCalendarClient(uri, { fetch: opts.fetch, timeoutMs: opts.timeoutMs });
68
+ const bytes = await client.fetchProof(commitment, opts.signal);
69
+ if (!bytes) return;
70
+ if (mergeAt(tree, parseTimestamp(bytes, commitment))) answered.push(norm(uri));
54
71
  })
55
72
  );
56
73
 
57
- // Prefer a fully-confirmed proof from any calendar.
58
- for (const r of attempts) {
59
- if (r.status !== 'fulfilled' || r.value === null) continue;
60
- const { url, proof, anchor } = r.value;
61
- if (anchor) {
62
- return {
63
- status: 'confirmed',
64
- proof: base64Encode(proof),
65
- calendars: uniqueFirst(current.calendars, url),
66
- blockHeight: anchor.blockHeight,
67
- blockHash: anchor.blockHash,
68
- upgradedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
69
- };
70
- }
71
- }
74
+ const merged = serializeTimestamp(tree);
75
+ const anchor = await resolveAnchor(merged, tree, opts);
76
+ const calendars = [...answered, ...current.calendars.filter((c) => !answered.includes(norm(c)))];
72
77
 
73
- // No confirmation yet. Some calendars may still have returned updated
74
- // pending bytes (more commitments included). Prefer the longest.
75
- let longest: { url: string; proof: Uint8Array } | null = null;
76
- for (const r of attempts) {
77
- if (r.status !== 'fulfilled' || r.value === null) continue;
78
- if (!longest || r.value.proof.byteLength > longest.proof.byteLength) {
79
- longest = { url: r.value.url, proof: r.value.proof };
80
- }
78
+ if (anchor) {
79
+ return {
80
+ status: 'confirmed',
81
+ proof: base64Encode(merged),
82
+ calendars,
83
+ blockHeight: anchor.blockHeight,
84
+ blockHash: anchor.blockHash,
85
+ upgradedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
86
+ };
81
87
  }
82
- if (longest) {
83
- const currentBytes = base64Decode(current.proof);
84
- if (longest.proof.byteLength > currentBytes.byteLength) {
85
- return {
86
- status: 'pending',
87
- proof: base64Encode(longest.proof),
88
- calendars: uniqueFirst(current.calendars, longest.url),
89
- blockHeight: null,
90
- blockHash: null,
91
- upgradedAt: null,
92
- };
93
- }
88
+ if (!bytesEqual(merged, before)) {
89
+ return { ...current, proof: base64Encode(merged), calendars };
94
90
  }
95
91
  return current;
96
92
  }
97
93
 
98
- function uniqueFirst(arr: readonly string[], first: string): string[] {
99
- const out = [first];
100
- for (const x of arr) if (x !== first && !out.includes(x)) out.push(x);
101
- return out;
94
+ async function resolveAnchor(
95
+ merged: Uint8Array,
96
+ tree: ReturnType<typeof parseTimestamp>,
97
+ opts: UpgradeOptions
98
+ ): Promise<{ blockHeight: number; blockHash: string } | null> {
99
+ if (opts.parseAnchor) return opts.parseAnchor(merged);
100
+ for (const a of bitcoinAnchors(tree)) {
101
+ const header = await opts.headerSource!.getHeaderAt(a.blockHeight);
102
+ if (header && header.byteLength === 80 && bytesEqual(header.subarray(36, 68), a.merkleRoot)) {
103
+ return { blockHeight: a.blockHeight, blockHash: blockHashOf(header) };
104
+ }
105
+ }
106
+ return null;
102
107
  }