@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/README.md +17 -19
- package/dist/index.d.mts +66 -4
- package/dist/index.d.ts +66 -4
- package/dist/index.js +373 -106
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +363 -107
- package/dist/index.mjs.map +1 -1
- package/package.json +60 -64
- package/src/anchor.test.ts +72 -0
- package/src/anchor.ts +78 -135
- package/src/calendar.test.ts +37 -0
- package/src/calendar.ts +8 -9
- package/src/index.ts +19 -2
- package/src/ots-fixture.test-helper.ts +24 -0
- package/src/ots.test.ts +34 -0
- package/src/ots.ts +320 -0
- package/src/submit.test.ts +0 -17
- package/src/types.ts +7 -6
- package/src/upgrade.test.ts +110 -0
- package/src/upgrade.ts +72 -67
package/src/ots.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// OpenTimestamps proof format: parse, walk, serialize, merge.
|
|
2
|
+
//
|
|
3
|
+
// Follows python-opentimestamps (core/timestamp.py, core/op.py,
|
|
4
|
+
// core/notary.py). Two encodings are accepted:
|
|
5
|
+
//
|
|
6
|
+
// - a bare timestamp rooted at the digest (what an OTS calendar returns from
|
|
7
|
+
// POST /digest, and what OC Stamp envelopes carry in `ots.proof`);
|
|
8
|
+
// - a detached `.ots` file (magic header + file-hash op + digest + timestamp).
|
|
9
|
+
|
|
10
|
+
import { ripemd160, sha1 } from '@noble/hashes/legacy';
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
12
|
+
import { keccak_256 } from '@noble/hashes/sha3';
|
|
13
|
+
|
|
14
|
+
import { hexEncode } from './base64.js';
|
|
15
|
+
|
|
16
|
+
const HEADER_MAGIC = new Uint8Array([
|
|
17
|
+
0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00,
|
|
18
|
+
0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TAG_BITCOIN = '0588960d73d71901';
|
|
22
|
+
const TAG_PENDING = '83dfe30d2ef90c8e';
|
|
23
|
+
|
|
24
|
+
const MAX_DEPTH = 256;
|
|
25
|
+
const MAX_MSG = 4096;
|
|
26
|
+
|
|
27
|
+
export type OtsOp =
|
|
28
|
+
| { kind: 'sha256' | 'ripemd160' | 'sha1' | 'keccak256' | 'reverse' | 'hexlify' }
|
|
29
|
+
| { kind: 'append' | 'prepend'; arg: Uint8Array };
|
|
30
|
+
|
|
31
|
+
export type OtsAttestation =
|
|
32
|
+
| { kind: 'bitcoin'; height: number }
|
|
33
|
+
| { kind: 'pending'; uri: string }
|
|
34
|
+
| { kind: 'unknown'; tag: string; payload: Uint8Array };
|
|
35
|
+
|
|
36
|
+
export interface OtsTimestamp {
|
|
37
|
+
/** The message at this node: the digest after every op on the path to it. */
|
|
38
|
+
msg: Uint8Array;
|
|
39
|
+
attestations: OtsAttestation[];
|
|
40
|
+
ops: { op: OtsOp; child: OtsTimestamp }[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Reader {
|
|
44
|
+
private pos = 0;
|
|
45
|
+
constructor(private readonly buf: Uint8Array) {}
|
|
46
|
+
byte(): number {
|
|
47
|
+
if (this.pos >= this.buf.length) throw new Error('ots: unexpected end of proof');
|
|
48
|
+
return this.buf[this.pos++]!;
|
|
49
|
+
}
|
|
50
|
+
bytes(n: number): Uint8Array {
|
|
51
|
+
if (n < 0 || this.pos + n > this.buf.length) throw new Error('ots: unexpected end of proof');
|
|
52
|
+
const out = this.buf.subarray(this.pos, this.pos + n);
|
|
53
|
+
this.pos += n;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
varuint(): number {
|
|
57
|
+
let value = 0;
|
|
58
|
+
let shift = 0;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const b = this.byte();
|
|
61
|
+
value += (b & 0x7f) * 2 ** shift;
|
|
62
|
+
if (!(b & 0x80)) return value;
|
|
63
|
+
shift += 7;
|
|
64
|
+
if (shift > 49) throw new Error('ots: varuint too large');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
varbytes(max: number): Uint8Array {
|
|
68
|
+
const n = this.varuint();
|
|
69
|
+
if (n > max) throw new Error('ots: varbytes too long');
|
|
70
|
+
return this.bytes(n);
|
|
71
|
+
}
|
|
72
|
+
get done(): boolean {
|
|
73
|
+
return this.pos === this.buf.length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const UNARY: Record<number, OtsOp['kind']> = {
|
|
78
|
+
0x02: 'sha1',
|
|
79
|
+
0x03: 'ripemd160',
|
|
80
|
+
0x08: 'sha256',
|
|
81
|
+
0x67: 'keccak256',
|
|
82
|
+
0xf2: 'reverse',
|
|
83
|
+
0xf3: 'hexlify',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function applyOp(op: OtsOp, msg: Uint8Array): Uint8Array {
|
|
87
|
+
switch (op.kind) {
|
|
88
|
+
case 'sha256':
|
|
89
|
+
return sha256(msg);
|
|
90
|
+
case 'ripemd160':
|
|
91
|
+
return ripemd160(msg);
|
|
92
|
+
case 'sha1':
|
|
93
|
+
return sha1(msg);
|
|
94
|
+
case 'keccak256':
|
|
95
|
+
return keccak_256(msg);
|
|
96
|
+
case 'reverse':
|
|
97
|
+
return new Uint8Array(msg).reverse();
|
|
98
|
+
case 'hexlify':
|
|
99
|
+
return new TextEncoder().encode(hexEncode(msg));
|
|
100
|
+
case 'append':
|
|
101
|
+
return concat(msg, op.arg);
|
|
102
|
+
case 'prepend':
|
|
103
|
+
return concat(op.arg, msg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readOp(r: Reader, tag: number): OtsOp {
|
|
108
|
+
const unary = UNARY[tag];
|
|
109
|
+
if (unary) return { kind: unary } as OtsOp;
|
|
110
|
+
if (tag === 0xf0) return { kind: 'append', arg: r.varbytes(MAX_MSG) };
|
|
111
|
+
if (tag === 0xf1) return { kind: 'prepend', arg: r.varbytes(MAX_MSG) };
|
|
112
|
+
throw new Error(`ots: unknown op tag 0x${tag.toString(16)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readAttestation(r: Reader): OtsAttestation {
|
|
116
|
+
const tag = hexEncode(r.bytes(8));
|
|
117
|
+
const payload = r.varbytes(8192);
|
|
118
|
+
const p = new Reader(payload);
|
|
119
|
+
if (tag === TAG_BITCOIN) return { kind: 'bitcoin', height: p.varuint() };
|
|
120
|
+
if (tag === TAG_PENDING) return { kind: 'pending', uri: new TextDecoder().decode(p.varbytes(1000)) };
|
|
121
|
+
return { kind: 'unknown', tag, payload };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readTimestamp(r: Reader, msg: Uint8Array, depth: number): OtsTimestamp {
|
|
125
|
+
if (depth > MAX_DEPTH) throw new Error('ots: proof too deep');
|
|
126
|
+
const node: OtsTimestamp = { msg, attestations: [], ops: [] };
|
|
127
|
+
const item = (tag: number) => {
|
|
128
|
+
if (tag === 0x00) {
|
|
129
|
+
node.attestations.push(readAttestation(r));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const op = readOp(r, tag);
|
|
133
|
+
const next = applyOp(op, msg);
|
|
134
|
+
if (next.length > MAX_MSG) throw new Error('ots: message too long');
|
|
135
|
+
node.ops.push({ op, child: readTimestamp(r, next, depth + 1) });
|
|
136
|
+
};
|
|
137
|
+
let tag = r.byte();
|
|
138
|
+
while (tag === 0xff) {
|
|
139
|
+
item(r.byte());
|
|
140
|
+
tag = r.byte();
|
|
141
|
+
}
|
|
142
|
+
item(tag);
|
|
143
|
+
return node;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Parse a bare timestamp whose root message is `digest`. */
|
|
147
|
+
export function parseTimestamp(bytes: Uint8Array, digest: Uint8Array): OtsTimestamp {
|
|
148
|
+
const r = new Reader(bytes);
|
|
149
|
+
const ts = readTimestamp(r, digest, 0);
|
|
150
|
+
if (!r.done) throw new Error('ots: trailing bytes after timestamp');
|
|
151
|
+
return ts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isDetached(bytes: Uint8Array): boolean {
|
|
155
|
+
return bytes.length > HEADER_MAGIC.length && bytesEqual(bytes.subarray(0, HEADER_MAGIC.length), HEADER_MAGIC);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parse a detached `.ots` file. Only sha256 file digests are accepted. */
|
|
159
|
+
export function parseDetached(bytes: Uint8Array): { digest: Uint8Array; timestamp: OtsTimestamp } {
|
|
160
|
+
if (!isDetached(bytes)) throw new Error('ots: missing detached-file header');
|
|
161
|
+
const r = new Reader(bytes.subarray(HEADER_MAGIC.length));
|
|
162
|
+
const major = r.varuint();
|
|
163
|
+
if (major !== 1) throw new Error(`ots: unsupported file version ${major}`);
|
|
164
|
+
if (r.byte() !== 0x08) throw new Error('ots: only sha256 file digests are supported');
|
|
165
|
+
const digest = new Uint8Array(r.bytes(32));
|
|
166
|
+
const timestamp = readTimestamp(r, digest, 0);
|
|
167
|
+
if (!r.done) throw new Error('ots: trailing bytes after timestamp');
|
|
168
|
+
return { digest, timestamp };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse either encoding and require that it commits to `digest`. A detached
|
|
173
|
+
* file for some other digest is rejected rather than walked.
|
|
174
|
+
*/
|
|
175
|
+
export function parseProof(bytes: Uint8Array, digest: Uint8Array): OtsTimestamp {
|
|
176
|
+
if (!isDetached(bytes)) return parseTimestamp(bytes, digest);
|
|
177
|
+
const d = parseDetached(bytes);
|
|
178
|
+
if (!bytesEqual(d.digest, digest)) throw new Error('ots: proof commits to a different digest');
|
|
179
|
+
return d.timestamp;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every attestation in the tree with the message it attests to. */
|
|
183
|
+
export function attestations(ts: OtsTimestamp): { msg: Uint8Array; attestation: OtsAttestation }[] {
|
|
184
|
+
const out: { msg: Uint8Array; attestation: OtsAttestation }[] = [];
|
|
185
|
+
const walk = (n: OtsTimestamp) => {
|
|
186
|
+
for (const a of n.attestations) out.push({ msg: n.msg, attestation: a });
|
|
187
|
+
for (const { child } of n.ops) walk(child);
|
|
188
|
+
};
|
|
189
|
+
walk(ts);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Bitcoin attestations, lowest height first. `merkleRoot` is the message at
|
|
195
|
+
* the attestation, which equals the block header's Merkle root in header
|
|
196
|
+
* byte order.
|
|
197
|
+
*/
|
|
198
|
+
export function bitcoinAnchors(ts: OtsTimestamp): { blockHeight: number; merkleRoot: Uint8Array }[] {
|
|
199
|
+
return attestations(ts)
|
|
200
|
+
.flatMap(({ msg, attestation }) =>
|
|
201
|
+
attestation.kind === 'bitcoin' ? [{ blockHeight: attestation.height, merkleRoot: msg }] : []
|
|
202
|
+
)
|
|
203
|
+
.sort((a, b) => a.blockHeight - b.blockHeight);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Pending calendar attestations: where to ask, and the commitment to ask for. */
|
|
207
|
+
export function pendingCommitments(ts: OtsTimestamp): { uri: string; commitment: Uint8Array }[] {
|
|
208
|
+
return attestations(ts).flatMap(({ msg, attestation }) =>
|
|
209
|
+
attestation.kind === 'pending' ? [{ uri: attestation.uri, commitment: msg }] : []
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Graft `upgrade` (a timestamp rooted at some node's message) onto the node
|
|
215
|
+
* with that message, as python-opentimestamps' Timestamp.merge does. Returns
|
|
216
|
+
* false when no node carries that message.
|
|
217
|
+
*/
|
|
218
|
+
export function mergeAt(ts: OtsTimestamp, upgrade: OtsTimestamp): boolean {
|
|
219
|
+
if (bytesEqual(ts.msg, upgrade.msg)) {
|
|
220
|
+
for (const a of upgrade.attestations) {
|
|
221
|
+
if (!ts.attestations.some((b) => attestationKey(b) === attestationKey(a))) ts.attestations.push(a);
|
|
222
|
+
}
|
|
223
|
+
for (const { op, child } of upgrade.ops) {
|
|
224
|
+
const same = ts.ops.find((o) => opKey(o.op) === opKey(op));
|
|
225
|
+
if (same) mergeAt(same.child, child);
|
|
226
|
+
else ts.ops.push({ op, child });
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
return ts.ops.some(({ child }) => mergeAt(child, upgrade));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function writeVaruint(out: number[], n: number) {
|
|
234
|
+
do {
|
|
235
|
+
let b = n % 128;
|
|
236
|
+
n = Math.floor(n / 128);
|
|
237
|
+
if (n > 0) b |= 0x80;
|
|
238
|
+
out.push(b);
|
|
239
|
+
} while (n > 0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function writeVarbytes(out: number[], bytes: Uint8Array) {
|
|
243
|
+
writeVaruint(out, bytes.length);
|
|
244
|
+
out.push(...bytes);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const OP_TAG: Record<OtsOp['kind'], number> = {
|
|
248
|
+
sha1: 0x02,
|
|
249
|
+
ripemd160: 0x03,
|
|
250
|
+
sha256: 0x08,
|
|
251
|
+
keccak256: 0x67,
|
|
252
|
+
append: 0xf0,
|
|
253
|
+
prepend: 0xf1,
|
|
254
|
+
reverse: 0xf2,
|
|
255
|
+
hexlify: 0xf3,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function writeAttestation(out: number[], a: OtsAttestation) {
|
|
259
|
+
const payload: number[] = [];
|
|
260
|
+
let tag: string;
|
|
261
|
+
if (a.kind === 'bitcoin') {
|
|
262
|
+
tag = TAG_BITCOIN;
|
|
263
|
+
writeVaruint(payload, a.height);
|
|
264
|
+
} else if (a.kind === 'pending') {
|
|
265
|
+
tag = TAG_PENDING;
|
|
266
|
+
writeVarbytes(payload, new TextEncoder().encode(a.uri));
|
|
267
|
+
} else {
|
|
268
|
+
tag = a.tag;
|
|
269
|
+
payload.push(...a.payload);
|
|
270
|
+
}
|
|
271
|
+
out.push(0x00);
|
|
272
|
+
for (let i = 0; i < 16; i += 2) out.push(parseInt(tag.slice(i, i + 2), 16));
|
|
273
|
+
writeVarbytes(out, new Uint8Array(payload));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function writeTimestamp(out: number[], ts: OtsTimestamp) {
|
|
277
|
+
const items: (() => void)[] = [
|
|
278
|
+
...ts.attestations.map((a) => () => writeAttestation(out, a)),
|
|
279
|
+
...ts.ops.map(({ op, child }) => () => {
|
|
280
|
+
out.push(OP_TAG[op.kind]);
|
|
281
|
+
if (op.kind === 'append' || op.kind === 'prepend') writeVarbytes(out, op.arg);
|
|
282
|
+
writeTimestamp(out, child);
|
|
283
|
+
}),
|
|
284
|
+
];
|
|
285
|
+
if (items.length === 0) throw new Error('ots: cannot serialize an empty timestamp node');
|
|
286
|
+
items.forEach((write, i) => {
|
|
287
|
+
if (i < items.length - 1) out.push(0xff);
|
|
288
|
+
write();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Serialize as a bare timestamp (the encoding carried in `ots.proof`). */
|
|
293
|
+
export function serializeTimestamp(ts: OtsTimestamp): Uint8Array {
|
|
294
|
+
const out: number[] = [];
|
|
295
|
+
writeTimestamp(out, ts);
|
|
296
|
+
return new Uint8Array(out);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function attestationKey(a: OtsAttestation): string {
|
|
300
|
+
if (a.kind === 'bitcoin') return `b:${a.height}`;
|
|
301
|
+
if (a.kind === 'pending') return `p:${a.uri}`;
|
|
302
|
+
return `u:${a.tag}:${hexEncode(a.payload)}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function opKey(op: OtsOp): string {
|
|
306
|
+
return 'arg' in op ? `${op.kind}:${hexEncode(op.arg)}` : op.kind;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
310
|
+
const out = new Uint8Array(a.length + b.length);
|
|
311
|
+
out.set(a, 0);
|
|
312
|
+
out.set(b, a.length);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
317
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
318
|
+
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
|
319
|
+
return true;
|
|
320
|
+
}
|
package/src/submit.test.ts
CHANGED
|
@@ -73,23 +73,6 @@ describe('upgradeProof', () => {
|
|
|
73
73
|
upgradedAt: null,
|
|
74
74
|
};
|
|
75
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
76
|
it('is idempotent when given a confirmed proof', async () => {
|
|
94
77
|
const confirmed: OtsProof = {
|
|
95
78
|
...basePending,
|
package/src/types.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type OtsProofStatus = 'pending' | 'confirmed';
|
|
|
6
6
|
|
|
7
7
|
export interface OtsProof {
|
|
8
8
|
status: OtsProofStatus;
|
|
9
|
-
/**
|
|
9
|
+
/** OTS timestamp rooted at the envelope id (or a detached .ots file), base64. */
|
|
10
10
|
proof: string;
|
|
11
11
|
/** Calendar URLs that contain this proof, in submission order. */
|
|
12
12
|
calendars: string[];
|
|
@@ -33,13 +33,14 @@ export interface CalendarClient {
|
|
|
33
33
|
submit(digest: Uint8Array, signal?: AbortSignal): Promise<Uint8Array>;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Fetch the
|
|
37
|
-
*
|
|
36
|
+
* Fetch the continuation of a pending proof. `commitment` is the message
|
|
37
|
+
* at the calendar's pending attestation (see `pendingCommitments`), not
|
|
38
|
+
* the submitted digest. Returns null if the calendar has nothing yet.
|
|
38
39
|
*
|
|
39
|
-
* OTS calendar HTTP API: GET <url>/timestamp/<hex(
|
|
40
|
-
*
|
|
40
|
+
* OTS calendar HTTP API: GET <url>/timestamp/<hex(commitment)>, returns a
|
|
41
|
+
* timestamp rooted at the commitment, or 404.
|
|
41
42
|
*/
|
|
42
|
-
fetchProof(
|
|
43
|
+
fetchProof(commitment: Uint8Array, signal?: AbortSignal): Promise<Uint8Array | null>;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface AnchorVerificationInput {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { makeAnchorVerifier } from './anchor.js';
|
|
4
|
+
import { base64Encode, hexDecode, hexEncode } from './base64.js';
|
|
5
|
+
import { parseDetached, serializeTimestamp, type OtsTimestamp } from './ots.js';
|
|
6
|
+
import {
|
|
7
|
+
BLOCK_HASH,
|
|
8
|
+
BLOCK_HEADER_HEX,
|
|
9
|
+
BLOCK_HEIGHT,
|
|
10
|
+
HELLO_DIGEST,
|
|
11
|
+
HELLO_OTS_FILE,
|
|
12
|
+
} from './ots-fixture.test-helper.js';
|
|
13
|
+
import type { BlockHeaderSource, OtsProof } from './types.js';
|
|
14
|
+
import { upgradeProof } from './upgrade.js';
|
|
15
|
+
|
|
16
|
+
const CAL = 'https://cal.test';
|
|
17
|
+
const chain: BlockHeaderSource = {
|
|
18
|
+
async getHeaderAt(h) {
|
|
19
|
+
return h === BLOCK_HEIGHT ? hexDecode(BLOCK_HEADER_HEX) : null;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Split the real proof the way a calendar does: the client holds the path to
|
|
24
|
+
// a pending attestation, the calendar later serves the rest from that point.
|
|
25
|
+
function split(depth: number): { pending: OtsTimestamp; rest: OtsTimestamp } {
|
|
26
|
+
const full = parseDetached(HELLO_OTS_FILE).timestamp;
|
|
27
|
+
const toBitcoin = (n: OtsTimestamp): boolean =>
|
|
28
|
+
n.attestations.some((a) => a.kind === 'bitcoin') || n.ops.some((o) => toBitcoin(o.child));
|
|
29
|
+
const copy: OtsTimestamp = { msg: full.msg, attestations: [], ops: [] };
|
|
30
|
+
let src = full;
|
|
31
|
+
let dst = copy;
|
|
32
|
+
for (let i = 0; i < depth; i++) {
|
|
33
|
+
const next = src.ops.find((o) => toBitcoin(o.child))!;
|
|
34
|
+
const child: OtsTimestamp = { msg: next.child.msg, attestations: [], ops: [] };
|
|
35
|
+
dst.ops.push({ op: next.op, child });
|
|
36
|
+
src = next.child;
|
|
37
|
+
dst = child;
|
|
38
|
+
}
|
|
39
|
+
dst.attestations.push({ kind: 'pending', uri: CAL });
|
|
40
|
+
return { pending: copy, rest: src };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pendingProof(tree: OtsTimestamp, calendars = [CAL]): OtsProof {
|
|
44
|
+
return {
|
|
45
|
+
status: 'pending',
|
|
46
|
+
proof: base64Encode(serializeTimestamp(tree)),
|
|
47
|
+
calendars,
|
|
48
|
+
blockHeight: null,
|
|
49
|
+
blockHash: null,
|
|
50
|
+
upgradedAt: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function calendarServing(commitment: Uint8Array, body: Uint8Array, seen: string[]) {
|
|
55
|
+
return (async (url: string) => {
|
|
56
|
+
seen.push(url);
|
|
57
|
+
return url === `${CAL}/timestamp/${hexEncode(commitment)}`
|
|
58
|
+
? new Response(body)
|
|
59
|
+
: new Response(null, { status: 404 });
|
|
60
|
+
}) as unknown as typeof fetch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('upgradeProof', () => {
|
|
64
|
+
it('asks the calendar for its commitment and returns a proof that verifies', async () => {
|
|
65
|
+
const { pending, rest } = split(6);
|
|
66
|
+
const seen: string[] = [];
|
|
67
|
+
const out = await upgradeProof(pendingProof(pending), HELLO_DIGEST, {
|
|
68
|
+
headerSource: chain,
|
|
69
|
+
fetch: calendarServing(rest.msg, serializeTimestamp(rest), seen),
|
|
70
|
+
});
|
|
71
|
+
expect(seen).toEqual([`${CAL}/timestamp/${hexEncode(rest.msg)}`]);
|
|
72
|
+
expect(out.status).toBe('confirmed');
|
|
73
|
+
expect(out.blockHeight).toBe(BLOCK_HEIGHT);
|
|
74
|
+
expect(out.blockHash).toBe(BLOCK_HASH);
|
|
75
|
+
const verify = makeAnchorVerifier({ headerSource: chain });
|
|
76
|
+
expect(await verify(out.proof, out.blockHeight!, out.blockHash!, HELLO_DIGEST)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stays pending when the calendar answer does not reach the block header', async () => {
|
|
80
|
+
const { pending, rest } = split(6);
|
|
81
|
+
const bogus: OtsTimestamp = { msg: rest.msg, attestations: [{ kind: 'bitcoin', height: BLOCK_HEIGHT }], ops: [] };
|
|
82
|
+
const out = await upgradeProof(pendingProof(pending), HELLO_DIGEST, {
|
|
83
|
+
headerSource: chain,
|
|
84
|
+
fetch: calendarServing(rest.msg, serializeTimestamp(bogus), []),
|
|
85
|
+
});
|
|
86
|
+
expect(out.status).toBe('pending');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not contact calendars the proof was not submitted to', async () => {
|
|
90
|
+
const { pending, rest } = split(6);
|
|
91
|
+
const seen: string[] = [];
|
|
92
|
+
await expect(
|
|
93
|
+
upgradeProof(pendingProof(pending, ['https://other.test']), HELLO_DIGEST, {
|
|
94
|
+
headerSource: chain,
|
|
95
|
+
fetch: calendarServing(rest.msg, serializeTimestamp(rest), seen),
|
|
96
|
+
})
|
|
97
|
+
).rejects.toThrow(/no pending attestation/);
|
|
98
|
+
expect(seen).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns the proof unchanged while the calendar has nothing new', async () => {
|
|
102
|
+
const { pending } = split(6);
|
|
103
|
+
const current = pendingProof(pending);
|
|
104
|
+
const out = await upgradeProof(current, HELLO_DIGEST, {
|
|
105
|
+
headerSource: chain,
|
|
106
|
+
fetch: (async () => new Response(null, { status: 404 })) as unknown as typeof fetch,
|
|
107
|
+
});
|
|
108
|
+
expect(out).toEqual(current);
|
|
109
|
+
});
|
|
110
|
+
});
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// proof
|
|
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
|
|
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
|
-
*
|
|
20
|
-
* the proof
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
83
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|