@orangecheck/stamp-ots 0.1.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/base64.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Minimal, dependency-free base64 <-> Uint8Array helpers. OTS proof bytes
2
+ // are carried as base64 inside OC Stamp envelopes so they survive any text
3
+ // transport (URL fragment, JSON, Nostr event content).
4
+
5
+ const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
6
+ const B64_DECODE_TABLE = (() => {
7
+ const t = new Int16Array(128).fill(-1);
8
+ for (let i = 0; i < B64_CHARS.length; i++) t[B64_CHARS.charCodeAt(i)] = i;
9
+ t['='.charCodeAt(0)] = -2;
10
+ return t;
11
+ })();
12
+
13
+ export function base64Encode(bytes: Uint8Array): string {
14
+ let out = '';
15
+ let i = 0;
16
+ for (; i + 2 < bytes.length; i += 3) {
17
+ const a = bytes[i]!;
18
+ const b = bytes[i + 1]!;
19
+ const c = bytes[i + 2]!;
20
+ out +=
21
+ B64_CHARS[a >> 2]! +
22
+ B64_CHARS[((a & 0x03) << 4) | (b >> 4)]! +
23
+ B64_CHARS[((b & 0x0f) << 2) | (c >> 6)]! +
24
+ B64_CHARS[c & 0x3f]!;
25
+ }
26
+ if (i < bytes.length) {
27
+ const a = bytes[i]!;
28
+ const b = i + 1 < bytes.length ? bytes[i + 1]! : 0;
29
+ out += B64_CHARS[a >> 2]!;
30
+ out += B64_CHARS[((a & 0x03) << 4) | (b >> 4)]!;
31
+ out += i + 1 < bytes.length ? B64_CHARS[(b & 0x0f) << 2]! : '=';
32
+ out += '=';
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function base64Decode(s: string): Uint8Array {
38
+ const str = s.replace(/\s+/g, '');
39
+ const len = str.length;
40
+ if (len === 0) return new Uint8Array(0);
41
+ if (len % 4 !== 0) throw new Error('base64: bad length');
42
+ let padding = 0;
43
+ if (str[len - 1] === '=') padding++;
44
+ if (str[len - 2] === '=') padding++;
45
+ const outLen = (len / 4) * 3 - padding;
46
+ const out = new Uint8Array(outLen);
47
+ let o = 0;
48
+ for (let i = 0; i < len; i += 4) {
49
+ const c0 = B64_DECODE_TABLE[str.charCodeAt(i)]!;
50
+ const c1 = B64_DECODE_TABLE[str.charCodeAt(i + 1)]!;
51
+ const c2 = B64_DECODE_TABLE[str.charCodeAt(i + 2)]!;
52
+ const c3 = B64_DECODE_TABLE[str.charCodeAt(i + 3)]!;
53
+ if (c0 < 0 || c1 < 0 || c2 < -2 || c3 < -2) throw new Error('base64: bad char');
54
+ out[o++] = (c0 << 2) | (c1 >> 4);
55
+ if (c2 >= 0) out[o++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
56
+ if (c3 >= 0) out[o++] = ((c2 & 0x03) << 6) | c3;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export function hexDecode(s: string): Uint8Array {
62
+ if (s.length % 2 !== 0) throw new Error('hex: odd length');
63
+ const out = new Uint8Array(s.length / 2);
64
+ for (let i = 0; i < out.length; i++) {
65
+ const hi = parseInt(s[i * 2]!, 16);
66
+ const lo = parseInt(s[i * 2 + 1]!, 16);
67
+ if (Number.isNaN(hi) || Number.isNaN(lo)) throw new Error('hex: bad char');
68
+ out[i] = (hi << 4) | lo;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ export function hexEncode(bytes: Uint8Array): string {
74
+ const HEX = '0123456789abcdef';
75
+ let out = '';
76
+ for (let i = 0; i < bytes.length; i++) {
77
+ const b = bytes[i]!;
78
+ out += HEX[(b >> 4) & 0x0f]! + HEX[b & 0x0f]!;
79
+ }
80
+ return out;
81
+ }
@@ -0,0 +1,101 @@
1
+ // HTTP client for an OpenTimestamps calendar. Implements the minimal subset
2
+ // of the calendar protocol that an OC Stamp implementation needs:
3
+ //
4
+ // POST <url>/digest body = raw digest bytes -> pending proof bytes
5
+ // GET <url>/timestamp/<hex> -> upgraded proof bytes, or 404
6
+ //
7
+ // The client is thin by design. It does not parse proof bytes; that is a
8
+ // separate concern addressed by whichever OTS proof library the consumer
9
+ // plugs in (see verifyOtsAnchor in ./anchor.ts).
10
+
11
+ import type { CalendarClient } from './types.js';
12
+ import { hexEncode } from './base64.js';
13
+
14
+ export const DEFAULT_CALENDARS: readonly string[] = [
15
+ 'https://alice.btc.calendar.opentimestamps.org',
16
+ 'https://bob.btc.calendar.opentimestamps.org',
17
+ 'https://finney.calendar.eternitywall.com',
18
+ ];
19
+
20
+ export interface HttpCalendarOptions {
21
+ /**
22
+ * Custom fetch implementation. Defaults to global `fetch`. Useful in
23
+ * environments that need a specific adapter (Node < 18, Cloudflare
24
+ * Workers, proxies).
25
+ */
26
+ fetch?: typeof fetch;
27
+ /** Request timeout in milliseconds. Default 30_000. */
28
+ timeoutMs?: number;
29
+ }
30
+
31
+ export function createCalendarClient(url: string, opts: HttpCalendarOptions = {}): CalendarClient {
32
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
33
+ if (typeof fetchImpl !== 'function') {
34
+ throw new Error(
35
+ '@orangecheck/stamp-ots: no fetch implementation available. Pass one via { fetch }.'
36
+ );
37
+ }
38
+ const timeoutMs = opts.timeoutMs ?? 30_000;
39
+ const base = url.replace(/\/$/, '');
40
+
41
+ return {
42
+ url: base,
43
+ async submit(digest, signal) {
44
+ if (digest.byteLength !== 32) {
45
+ throw new Error('OTS: digest must be exactly 32 bytes');
46
+ }
47
+ const ac = withTimeout(signal, timeoutMs);
48
+ // Uint8Array is a valid fetch body at runtime in Node 18+ and all
49
+ // modern browsers, but TypeScript's DOM typings don't accept it
50
+ // directly under `strict` + `noUncheckedIndexedAccess`. The cast is
51
+ // narrow and intentional.
52
+ const resp = await fetchImpl(`${base}/digest`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/vnd.opentimestamps.v1' },
55
+ body: digest as unknown as BodyInit,
56
+ signal: ac.signal,
57
+ });
58
+ ac.clear();
59
+ if (!resp.ok) {
60
+ throw new Error(`OTS calendar ${base} submit failed: HTTP ${resp.status}`);
61
+ }
62
+ const buf = await resp.arrayBuffer();
63
+ return new Uint8Array(buf);
64
+ },
65
+ async fetchProof(digest, signal) {
66
+ if (digest.byteLength !== 32) {
67
+ throw new Error('OTS: digest must be exactly 32 bytes');
68
+ }
69
+ const ac = withTimeout(signal, timeoutMs);
70
+ const resp = await fetchImpl(`${base}/timestamp/${hexEncode(digest)}`, {
71
+ method: 'GET',
72
+ headers: { Accept: 'application/vnd.opentimestamps.v1' },
73
+ signal: ac.signal,
74
+ });
75
+ ac.clear();
76
+ if (resp.status === 404) return null;
77
+ if (!resp.ok) {
78
+ throw new Error(`OTS calendar ${base} fetchProof failed: HTTP ${resp.status}`);
79
+ }
80
+ const buf = await resp.arrayBuffer();
81
+ return new Uint8Array(buf);
82
+ },
83
+ };
84
+ }
85
+
86
+ function withTimeout(parent: AbortSignal | undefined, ms: number): { signal: AbortSignal; clear: () => void } {
87
+ const ac = new AbortController();
88
+ const onParent = () => ac.abort((parent as AbortSignal & { reason?: unknown }).reason);
89
+ if (parent) {
90
+ if (parent.aborted) ac.abort((parent as AbortSignal & { reason?: unknown }).reason);
91
+ else parent.addEventListener('abort', onParent, { once: true });
92
+ }
93
+ const t = setTimeout(() => ac.abort(new Error(`OTS request timed out after ${ms}ms`)), ms);
94
+ return {
95
+ signal: ac.signal,
96
+ clear: () => {
97
+ clearTimeout(t);
98
+ if (parent) parent.removeEventListener('abort', onParent);
99
+ },
100
+ };
101
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './types.js';
2
+ export { base64Decode, base64Encode, hexDecode, hexEncode } from './base64.js';
3
+ export { createCalendarClient, DEFAULT_CALENDARS } from './calendar.js';
4
+ export type { HttpCalendarOptions } from './calendar.js';
5
+ export { submitToCalendars } from './submit.js';
6
+ export type { SubmitOptions } from './submit.js';
7
+ export { upgradeProof } from './upgrade.js';
8
+ export type { UpgradeOptions } from './upgrade.js';
9
+ export { makeAnchorVerifier, adaptAnchorVerifier } from './anchor.js';
10
+ export type { AnchorVerifierConfig } from './anchor.js';
@@ -0,0 +1,108 @@
1
+ // submit() / upgrade() tests using a fake CalendarClient — avoids hitting the
2
+ // real OTS calendar network from CI. Verifies the composition logic only.
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { submitToCalendars } from './submit.js';
7
+ import { upgradeProof } from './upgrade.js';
8
+ import type { CalendarClient, OtsProof } from './types.js';
9
+
10
+ const FIXED_ID = 'ad30c983dfc872a8c53cd70eb4d84e1869967a4b8433586beec7ba468b03c3de';
11
+
12
+ function fakeCalendar(opts: {
13
+ url: string;
14
+ submitResult?: Uint8Array | (() => Promise<Uint8Array>);
15
+ fetchResult?: Uint8Array | null | (() => Promise<Uint8Array | null>);
16
+ submitShouldFail?: boolean;
17
+ }): CalendarClient {
18
+ return {
19
+ url: opts.url,
20
+ async submit(digest) {
21
+ if (opts.submitShouldFail) throw new Error(`calendar ${opts.url} down`);
22
+ if (!opts.submitResult) return new Uint8Array([0xde, 0xad, ...digest.subarray(0, 4)]);
23
+ const r = typeof opts.submitResult === 'function' ? await opts.submitResult() : opts.submitResult;
24
+ return r;
25
+ },
26
+ async fetchProof() {
27
+ if (!opts.fetchResult && opts.fetchResult !== null)
28
+ return new Uint8Array([0xfe, 0xed, 0xfa, 0xce]);
29
+ const r = typeof opts.fetchResult === 'function' ? await opts.fetchResult() : opts.fetchResult;
30
+ return r;
31
+ },
32
+ };
33
+ }
34
+
35
+ describe('submitToCalendars', () => {
36
+ it('returns a pending proof with all successful calendars listed', async () => {
37
+ const cal1 = fakeCalendar({ url: 'https://a.example', submitResult: new Uint8Array([1, 2, 3]) });
38
+ const cal2 = fakeCalendar({ url: 'https://b.example', submitResult: new Uint8Array([4, 5, 6, 7]) });
39
+ const out = await submitToCalendars(FIXED_ID, { calendars: [cal1, cal2] });
40
+ expect(out.status).toBe('pending');
41
+ expect(out.calendars).toEqual(['https://a.example', 'https://b.example']);
42
+ expect(out.blockHeight).toBeNull();
43
+ expect(out.proof.length).toBeGreaterThan(0);
44
+ });
45
+
46
+ it('tolerates one calendar failure when minSuccesses defaults to 1', async () => {
47
+ const bad = fakeCalendar({ url: 'https://bad.example', submitShouldFail: true });
48
+ const good = fakeCalendar({ url: 'https://good.example', submitResult: new Uint8Array([9]) });
49
+ const out = await submitToCalendars(FIXED_ID, { calendars: [bad, good] });
50
+ expect(out.calendars).toEqual(['https://good.example']);
51
+ });
52
+
53
+ it('throws when minSuccesses is not met', async () => {
54
+ const bad1 = fakeCalendar({ url: 'https://x.example', submitShouldFail: true });
55
+ const bad2 = fakeCalendar({ url: 'https://y.example', submitShouldFail: true });
56
+ await expect(
57
+ submitToCalendars(FIXED_ID, { calendars: [bad1, bad2], minSuccesses: 1 })
58
+ ).rejects.toThrow(/did not reach/);
59
+ });
60
+
61
+ it('rejects invalid id hex', async () => {
62
+ await expect(submitToCalendars('deadbeef', { calendars: ['https://x'] })).rejects.toThrow();
63
+ });
64
+ });
65
+
66
+ describe('upgradeProof', () => {
67
+ const basePending: OtsProof = {
68
+ status: 'pending',
69
+ proof: 'AAEC',
70
+ calendars: ['https://a.example', 'https://b.example'],
71
+ blockHeight: null,
72
+ blockHash: null,
73
+ upgradedAt: null,
74
+ };
75
+
76
+ it('returns current proof unchanged when parseAnchor returns null from all calendars', async () => {
77
+ // Fake the fetch so calendars return same-length pending proofs.
78
+ // We have to monkey-patch createCalendarClient indirectly — instead,
79
+ // use a minimal harness: submit() isn't used here, upgradeProof reads
80
+ // calendars from the proof and creates new clients. Rather than
81
+ // rewire the module, we test the no-op path by making parseAnchor
82
+ // return null and ensure the function returns the input.
83
+ // The real network fetches will fail in test; we swallow via opts.fetch.
84
+ const fakeFetch = async () => new Response(null, { status: 404 });
85
+ const out = await upgradeProof(basePending, FIXED_ID, {
86
+ parseAnchor: async () => null,
87
+ fetch: fakeFetch as unknown as typeof fetch,
88
+ });
89
+ // Both calendars returned 404 => nothing to update.
90
+ expect(out).toEqual(basePending);
91
+ });
92
+
93
+ it('is idempotent when given a confirmed proof', async () => {
94
+ const confirmed: OtsProof = {
95
+ ...basePending,
96
+ status: 'confirmed',
97
+ blockHeight: 890123,
98
+ blockHash: '0'.repeat(64),
99
+ upgradedAt: '2026-04-24T19:00:00Z',
100
+ };
101
+ const out = await upgradeProof(confirmed, FIXED_ID, {
102
+ parseAnchor: async () => {
103
+ throw new Error('should not be called');
104
+ },
105
+ });
106
+ expect(out).toEqual(confirmed);
107
+ });
108
+ });
package/src/submit.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Submit an envelope id to one or more OTS calendars. Returns a pending proof.
2
+ // See SPEC.md §6.1.
3
+ //
4
+ // The submission strategy: try each calendar in parallel, take the first
5
+ // success, report which calendar produced it. A stamp that only reaches one
6
+ // calendar is a single point of failure for upgrade; the reference app
7
+ // submits to at least two calendars for redundancy.
8
+
9
+ import { base64Encode, hexDecode } from './base64.js';
10
+ import { createCalendarClient, DEFAULT_CALENDARS } from './calendar.js';
11
+ import type { CalendarClient, OtsProof } from './types.js';
12
+
13
+ export interface SubmitOptions {
14
+ /**
15
+ * Calendars to submit to. Defaults to DEFAULT_CALENDARS. Can be strings
16
+ * (URLs) or pre-built CalendarClient instances.
17
+ */
18
+ calendars?: readonly (string | CalendarClient)[];
19
+ /**
20
+ * Minimum number of calendars that must succeed. Defaults to 1.
21
+ * If fewer than `minSuccesses` calendars respond with a proof,
22
+ * submit() throws an aggregate error.
23
+ */
24
+ minSuccesses?: number;
25
+ /** Optional custom fetch passed through to calendar clients. */
26
+ fetch?: typeof fetch;
27
+ /** Request timeout per calendar. Default 30_000ms. */
28
+ timeoutMs?: number;
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ /**
33
+ * Submit the envelope `id` (64-hex) to OTS calendars and return an OtsProof in
34
+ * pending state. The caller merges this into the envelope's `ots` field.
35
+ *
36
+ * Implementation note: the raw proof bytes returned by OTS calendars are not
37
+ * a superset of the digest alone — they encode the calendar's commitment.
38
+ * Because different calendars return different pending-proof bytes for the
39
+ * same digest, we carry the FIRST successful calendar's bytes in the
40
+ * envelope. Upgrade (see upgrade.ts) can fetch richer proofs from any of the
41
+ * submitted-to calendars later.
42
+ */
43
+ export async function submitToCalendars(idHex: string, opts: SubmitOptions = {}): Promise<OtsProof> {
44
+ const digest = hexDecode(idHex);
45
+ if (digest.byteLength !== 32) {
46
+ throw new Error('submitToCalendars: idHex must be 32 bytes (64 hex chars)');
47
+ }
48
+
49
+ const clients = (opts.calendars ?? DEFAULT_CALENDARS).map((c) =>
50
+ typeof c === 'string'
51
+ ? createCalendarClient(c, { fetch: opts.fetch, timeoutMs: opts.timeoutMs })
52
+ : c
53
+ );
54
+ if (clients.length === 0) {
55
+ throw new Error('submitToCalendars: at least one calendar is required');
56
+ }
57
+ const minSuccesses = Math.max(1, opts.minSuccesses ?? 1);
58
+
59
+ const settled = await Promise.allSettled(
60
+ clients.map(async (c) => {
61
+ const proof = await c.submit(digest, opts.signal);
62
+ return { url: c.url, proof };
63
+ })
64
+ );
65
+ const successes = settled
66
+ .filter((r): r is PromiseFulfilledResult<{ url: string; proof: Uint8Array }> => r.status === 'fulfilled')
67
+ .map((r) => r.value);
68
+ const failures = settled
69
+ .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
70
+ .map((r) => (r.reason instanceof Error ? r.reason.message : String(r.reason)));
71
+
72
+ if (successes.length < minSuccesses) {
73
+ // AggregateError would be nicer but targets ES2021; Error keeps us at ES2020.
74
+ const msg = `OTS submission did not reach ${minSuccesses} calendar(s). Errors: ${failures.join('; ')}`;
75
+ const err = new Error(msg) as Error & { errors: string[] };
76
+ err.errors = failures;
77
+ throw err;
78
+ }
79
+
80
+ const first = successes[0]!;
81
+ return {
82
+ status: 'pending',
83
+ proof: base64Encode(first.proof),
84
+ calendars: successes.map((s) => s.url),
85
+ blockHeight: null,
86
+ blockHash: null,
87
+ upgradedAt: null,
88
+ };
89
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { fromStampOts, toStampOts } from './types.js';
4
+
5
+ describe('toStampOts / fromStampOts', () => {
6
+ it('round-trips a pending proof', () => {
7
+ const original = {
8
+ status: 'pending' as const,
9
+ proof: 'xxxx',
10
+ calendars: ['https://a.example', 'https://b.example'],
11
+ blockHeight: null,
12
+ blockHash: null,
13
+ upgradedAt: null,
14
+ };
15
+ const stamp = toStampOts(original);
16
+ expect(stamp.status).toBe('pending');
17
+ expect(stamp.block_height).toBeNull();
18
+ const back = fromStampOts(stamp);
19
+ expect(back).toEqual(original);
20
+ });
21
+
22
+ it('round-trips a confirmed proof', () => {
23
+ const original = {
24
+ status: 'confirmed' as const,
25
+ proof: 'yyyy',
26
+ calendars: ['https://a.example'],
27
+ blockHeight: 890123,
28
+ blockHash: '0'.repeat(64),
29
+ upgradedAt: '2026-04-24T19:00:00Z',
30
+ };
31
+ const stamp = toStampOts(original);
32
+ expect(stamp.block_height).toBe(890123);
33
+ expect(stamp.block_hash).toBe('0'.repeat(64));
34
+ const back = fromStampOts(stamp);
35
+ expect(back).toEqual(original);
36
+ });
37
+ });
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ // Types for @orangecheck/stamp-ots.
2
+
3
+ import type { StampOts } from '@orangecheck/stamp-core';
4
+
5
+ export type OtsProofStatus = 'pending' | 'confirmed';
6
+
7
+ export interface OtsProof {
8
+ status: OtsProofStatus;
9
+ /** Raw proof bytes as base64. Opaque at this layer; parsers plug in. */
10
+ proof: string;
11
+ /** Calendar URLs that contain this proof, in submission order. */
12
+ calendars: string[];
13
+ /** Bitcoin block height at which this proof is anchored (null if pending). */
14
+ blockHeight: number | null;
15
+ /** Bitcoin block hash (hex, lowercase) at the anchor block (null if pending). */
16
+ blockHash: string | null;
17
+ /** ISO 8601 UTC of the upgrade (null if pending). */
18
+ upgradedAt: string | null;
19
+ }
20
+
21
+ export interface CalendarClient {
22
+ /** Base URL of the calendar, e.g. "https://alice.btc.calendar.opentimestamps.org". */
23
+ readonly url: string;
24
+
25
+ /**
26
+ * Submit a 32-byte digest to the calendar for aggregation. Returns the raw
27
+ * pending proof bytes.
28
+ *
29
+ * OTS calendar HTTP API: POST <url>/digest with the raw bytes as the body,
30
+ * Content-Type: application/vnd.opentimestamps.v1, returns the proof bytes
31
+ * as the response body.
32
+ */
33
+ submit(digest: Uint8Array, signal?: AbortSignal): Promise<Uint8Array>;
34
+
35
+ /**
36
+ * Fetch the current (possibly upgraded) proof for a previously-submitted
37
+ * digest. Returns null if the calendar has no proof for this digest yet.
38
+ *
39
+ * OTS calendar HTTP API: GET <url>/timestamp/<hex(digest)>, returns the
40
+ * proof bytes or 404.
41
+ */
42
+ fetchProof(digest: Uint8Array, signal?: AbortSignal): Promise<Uint8Array | null>;
43
+ }
44
+
45
+ export interface AnchorVerificationInput {
46
+ /** Raw OTS proof bytes (base64-decoded by the caller). */
47
+ proofBytes: Uint8Array;
48
+ /** 32-byte digest that the proof commits to. */
49
+ digest: Uint8Array;
50
+ /** Declared block height from the envelope. */
51
+ blockHeight: number;
52
+ /** Declared block hash (hex) from the envelope. */
53
+ blockHash: string;
54
+ /**
55
+ * Optional block header source. If provided, the verifier uses this to
56
+ * fetch the header at `blockHeight` and compare its Merkle root to the
57
+ * one derived from walking the proof.
58
+ *
59
+ * If omitted, the verifier MAY still parse the proof and report
60
+ * structural validity but CANNOT report a bound anchor.
61
+ */
62
+ headerSource?: BlockHeaderSource;
63
+ }
64
+
65
+ export interface BlockHeaderSource {
66
+ /** Return the 80-byte raw block header at the given height, or null if unknown. */
67
+ getHeaderAt(height: number): Promise<Uint8Array | null>;
68
+ }
69
+
70
+ export type AnchorVerifier = (input: AnchorVerificationInput) => Promise<AnchorVerificationResult>;
71
+
72
+ export type AnchorVerificationResult =
73
+ | { ok: true; merkleRoot: string }
74
+ | { ok: false; reason: string };
75
+
76
+ /**
77
+ * Convert an OtsProof (library type) to the StampOts field shape that the
78
+ * envelope carries. The two are isomorphic; this function exists to keep
79
+ * stamp-core's type surface narrow.
80
+ */
81
+ export function toStampOts(p: OtsProof): StampOts {
82
+ return {
83
+ status: p.status,
84
+ proof: p.proof,
85
+ calendars: p.calendars,
86
+ block_height: p.blockHeight,
87
+ block_hash: p.blockHash,
88
+ upgraded_at: p.upgradedAt,
89
+ };
90
+ }
91
+
92
+ export function fromStampOts(s: StampOts): OtsProof {
93
+ return {
94
+ status: s.status,
95
+ proof: s.proof,
96
+ calendars: s.calendars,
97
+ blockHeight: s.block_height,
98
+ blockHash: s.block_hash,
99
+ upgradedAt: s.upgraded_at,
100
+ };
101
+ }
package/src/upgrade.ts ADDED
@@ -0,0 +1,102 @@
1
+ // Upgrade a pending OTS proof to a confirmed one. See SPEC.md §6.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).
12
+
13
+ import { base64Decode, base64Encode, hexDecode } from './base64.js';
14
+ import { createCalendarClient } from './calendar.js';
15
+ import type { CalendarClient, OtsProof } from './types.js';
16
+
17
+ export interface UpgradeOptions {
18
+ /**
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.
24
+ */
25
+ parseAnchor: (proofBytes: Uint8Array) => Promise<{ blockHeight: number; blockHash: string } | null>;
26
+ /** Optional custom fetch passed through to calendar clients. */
27
+ fetch?: typeof fetch;
28
+ /** Request timeout per calendar. Default 30_000ms. */
29
+ timeoutMs?: number;
30
+ signal?: AbortSignal;
31
+ }
32
+
33
+ export async function upgradeProof(
34
+ current: OtsProof,
35
+ idHex: string,
36
+ opts: UpgradeOptions
37
+ ): Promise<OtsProof> {
38
+ if (current.status === 'confirmed') return current; // idempotent
39
+
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');
46
+ }
47
+
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 };
54
+ })
55
+ );
56
+
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
+ }
72
+
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
+ }
81
+ }
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
+ }
94
+ }
95
+ return current;
96
+ }
97
+
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;
102
+ }